diff --git a/package.json b/package.json index ce92bef..a277ce8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-segmented-choice", - "version": "1.0.4", + "version": "1.0.5", "description": "Accessible React segmented control with CSS-first customization, native radio semantics, drag-to-select interaction and customizable indicator geometry.", "keywords": [ "a11y", diff --git a/src/SegmentedChoice/SegmentedChoice.css b/src/SegmentedChoice/SegmentedChoice.css index a49320e..0a2d2bd 100644 --- a/src/SegmentedChoice/SegmentedChoice.css +++ b/src/SegmentedChoice/SegmentedChoice.css @@ -202,12 +202,13 @@ border-width 160ms ease; } -.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator { - transition: - opacity 120ms ease, - background-color 160ms ease, - border-color 160ms ease, - border-width 160ms ease; +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator, +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator::before, +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator::after, +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator-content, +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator-content * { + animation: none !important; + transition: none !important; } .rsc-root[data-rsc-indicator-style='fill'] .rsc-indicator { diff --git a/src/SegmentedChoice/SegmentedChoice.tsx b/src/SegmentedChoice/SegmentedChoice.tsx index dda1bdb..0a9f7c7 100644 --- a/src/SegmentedChoice/SegmentedChoice.tsx +++ b/src/SegmentedChoice/SegmentedChoice.tsx @@ -28,7 +28,11 @@ import { stringifyValue, validateOptionsStructure, } from './internal/validation'; -import type { SegmentedChoiceProps, SegmentedChoiceValue } from './SegmentedChoice.types'; +import type { + SegmentedChoiceOptionSizing, + SegmentedChoiceProps, + SegmentedChoiceValue, +} from './SegmentedChoice.types'; import { warnDuplicateValues, warnInvalidControlledValue, @@ -63,20 +67,20 @@ function setForwardedRef(ref: React.ForwardedRef, value: T) { function resolveIndicatorInsetPx({ indicatorInset, - resolvedTrackStyle, selectionMode, + trackStyle, unstyled, }: { indicatorInset: number | undefined; - resolvedTrackStyle: string; selectionMode: string; + trackStyle: string; unstyled: boolean; }) { if (indicatorInset !== undefined) { return indicatorInset; } - if (!unstyled && selectionMode === 'underlay' && resolvedTrackStyle === 'surface') { + if (!unstyled && selectionMode === 'underlay' && trackStyle === 'surface') { return 1; } @@ -87,27 +91,31 @@ function resolveShouldRenderAnchor({ anchorHeight, anchorWidth, hasExplicitAnchorSlot, - resolvedIndicatorStyle, - resolvedTrackLayout, + indicatorStyle, selectionMode, + trackLayout, }: { anchorHeight: number | undefined; anchorWidth: number | undefined; hasExplicitAnchorSlot: boolean; - resolvedIndicatorStyle: string; - resolvedTrackLayout: string; + indicatorStyle: string; selectionMode: string; + trackLayout: string; }) { return ( hasExplicitAnchorSlot || anchorWidth !== undefined || anchorHeight !== undefined || selectionMode === 'overlay' || - resolvedTrackLayout === 'center-span' || - resolvedIndicatorStyle === 'ring' + trackLayout === 'center-span' || + indicatorStyle === 'ring' ); } +function isNearLayoutSize(measured: number, expected: number) { + return Math.abs(measured - expected) < 0.5; +} + function InnerSegmentedChoice( { ariaDescribedby, @@ -203,21 +211,31 @@ function InnerSegmentedChoice( const resolvedGeometry = useMemo(() => resolveGeometryConfig(geometry), [geometry]); const normalizedSlotProps = useMemo(() => normalizeSlotProps(slotProps), [slotProps]); const selectionMode = resolvedGeometry.mode; - const resolvedTrackLayout = resolvedGeometry.trackLayout; - const resolvedTrackStyle = resolvedGeometry.trackStyle; - const resolvedIndicatorStyle = resolvedGeometry.indicatorStyle; - const resolvedIndicatorContentMode = resolvedGeometry.indicatorContentMode; - const resolvedIndicatorTransition = resolvedGeometry.indicatorTransition; - const anchorWidth = resolvedGeometry.anchorWidth; - const anchorHeight = resolvedGeometry.anchorHeight; + const trackConfig = { + layout: resolvedGeometry.trackLayout, + style: resolvedGeometry.trackStyle, + }; + const anchorConfig = { + width: resolvedGeometry.anchorWidth, + height: resolvedGeometry.anchorHeight, + }; + const indicatorConfig = { + borderWidth: resolvedGeometry.indicatorBorderWidth, + contentMode: resolvedGeometry.indicatorContentMode, + height: resolvedGeometry.indicatorHeight, + inset: resolvedGeometry.indicatorInset, + style: resolvedGeometry.indicatorStyle, + transition: resolvedGeometry.indicatorTransition, + width: resolvedGeometry.indicatorWidth, + }; + const optionLayoutSizing: SegmentedChoiceOptionSizing | 'fixed' = + resolvedGeometry.optionSize !== undefined ? 'fixed' : optionSizing; + const optionLayoutConfig = { + distribution: optionDistribution, + size: resolvedGeometry.optionSize, + sizing: optionLayoutSizing, + }; const dragScale = resolvedGeometry.dragScale ?? false; - const indicatorBorderWidth = resolvedGeometry.indicatorBorderWidth; - const indicatorInset = resolvedGeometry.indicatorInset; - const optionSize = resolvedGeometry.optionSize; - const resolvedOptionSizing = optionSize !== undefined ? 'fixed' : optionSizing; - const resolvedOptionDistribution = optionDistribution; - const indicatorWidth = resolvedGeometry.indicatorWidth; - const indicatorHeight = resolvedGeometry.indicatorHeight; // Current selection state, including uncontrolled fallback behavior. const { currentValue, commitValue, isControlled, resetValue } = useControllableValue({ @@ -244,28 +262,34 @@ function InnerSegmentedChoice( const anchorRefs = useRef>([]); // Layout flags keep the later hooks/render logic readable without changing behavior. - const indicatorBorderWidthPx = indicatorBorderWidth ?? 0; + const indicatorBorderWidthPx = indicatorConfig.borderWidth ?? 0; const indicatorInsetPx = resolveIndicatorInsetPx({ - indicatorInset, - resolvedTrackStyle, + indicatorInset: indicatorConfig.inset, selectionMode, + trackStyle: trackConfig.style, unstyled, }); const indicatorSizeAdjustment = - indicatorInsetPx * 2 + (resolvedIndicatorStyle === 'ring' ? indicatorBorderWidthPx * 2 : 0); - const hasSelectionWidth = indicatorWidth !== undefined; - const hasSelectionHeight = indicatorHeight !== undefined; - const hasSelectionSize = hasSelectionWidth || hasSelectionHeight; - const centerToOption = selectionMode === 'overlay' || hasSelectionSize; - const useRenderedIndicatorSize = hasSelectionSize; + indicatorInsetPx * 2 + (indicatorConfig.style === 'ring' ? indicatorBorderWidthPx * 2 : 0); + const hasExplicitIndicatorSize = + indicatorConfig.width !== undefined || indicatorConfig.height !== undefined; + const indicatorCentersOnOption = selectionMode === 'overlay' || hasExplicitIndicatorSize; const shouldRenderAnchor = resolveShouldRenderAnchor({ - anchorHeight, - anchorWidth, + anchorHeight: anchorConfig.height, + anchorWidth: anchorConfig.width, hasExplicitAnchorSlot: slotProps?.optionAnchor !== undefined, - resolvedIndicatorStyle, - resolvedTrackLayout, + indicatorStyle: indicatorConfig.style, selectionMode, + trackLayout: trackConfig.layout, }); + const expectedFixedIndicatorSize = + optionLayoutConfig.size !== undefined && !hasExplicitIndicatorSize + ? Math.max( + optionLayoutConfig.size + + (indicatorCentersOnOption ? indicatorSizeAdjustment : -indicatorInsetPx * 2), + 0 + ) + : undefined; const { commitIndex, @@ -298,11 +322,11 @@ function InnerSegmentedChoice( handlePointerMove, handlePointerUp, } = useDragSelection({ - centerToOption, + centerToOption: indicatorCentersOnOption, disabled, draggable, indicatorRef, - inset: centerToOption ? 0 : indicatorInsetPx, + inset: indicatorCentersOnOption ? 0 : indicatorInsetPx, listRef, measureRefs: anchorRefs, onCommitIndex: commitIndex, @@ -312,23 +336,23 @@ function InnerSegmentedChoice( selectionMode, selectedIndex, sizeAdjustment: indicatorSizeAdjustment, - useRenderedIndicatorSize, + useRenderedIndicatorSize: hasExplicitIndicatorSize, }); const activeIndex = selectionMode === 'underlay' ? (previewIndex ?? committedIndex) : committedIndex; const indicatorLayout = useIndicatorLayout({ activeIndex, - centerToOption, + centerToOption: indicatorCentersOnOption, indicatorRef, - inset: centerToOption ? 0 : indicatorInsetPx, + inset: indicatorCentersOnOption ? 0 : indicatorInsetPx, listRef, measureRefs: anchorRefs, optionCount: options.length, optionRefs, overrideLayout: dragLayout, sizeAdjustment: indicatorSizeAdjustment, - useRenderedIndicatorSize, + useRenderedIndicatorSize: hasExplicitIndicatorSize, }); const [indicatorMotionState, setIndicatorMotionState] = useState<'initial' | 'ready'>('initial'); const trackLayout = useTrackLayout({ @@ -338,10 +362,10 @@ function InnerSegmentedChoice( optionRefs, options, orientation, - trackLayout: resolvedTrackLayout, + trackLayout: trackConfig.layout, }); const equalDistributionLayout = useEqualDistributionLayout({ - optionSizing: resolvedOptionSizing, + optionSizing: optionLayoutConfig.sizing, optionContentRefs, optionCount: options.length, }); @@ -354,7 +378,7 @@ function InnerSegmentedChoice( const indicatorScale = dragging ? dragScaleValue : 1; const shouldCloneIndicatorContent = selectionMode === 'overlay' && - resolvedIndicatorContentMode === 'clone-active' && + indicatorConfig.contentMode === 'clone-active' && indicatorOption !== undefined; const interactiveCursor = disabled ? undefined @@ -363,8 +387,6 @@ function InnerSegmentedChoice( : draggable ? 'grab' : 'pointer'; - const indicatorCursor = interactiveCursor; - const listCursor = interactiveCursor; const listTouchAction = !disabled && draggable ? 'none' : undefined; useLayoutEffect(() => { @@ -375,27 +397,46 @@ function InnerSegmentedChoice( }, [inputRefs, options.length]); useEffect(() => { + const hasMeasuredIndicatorLayout = + indicatorLayout.isVisible && indicatorLayout.width > 0 && indicatorLayout.height > 0; + const hasSettledFixedIndicatorSize = + expectedFixedIndicatorSize === undefined || + (isNearLayoutSize(indicatorLayout.width, expectedFixedIndicatorSize) && + isNearLayoutSize(indicatorLayout.height, expectedFixedIndicatorSize)); + if ( indicatorMotionState !== 'initial' || - !indicatorLayout.isVisible || - indicatorLayout.width <= 0 || - indicatorLayout.height <= 0 || + !hasMeasuredIndicatorLayout || + !hasSettledFixedIndicatorSize || typeof window === 'undefined' ) { return; } - const frame = window.requestAnimationFrame(() => { - setIndicatorMotionState('ready'); - }); + let frame = 0; + const releaseAfterPaint = (remainingFrames: number) => { + frame = window.requestAnimationFrame(() => { + if (remainingFrames <= 1) { + setIndicatorMotionState('ready'); + return; + } + + releaseAfterPaint(remainingFrames - 1); + }); + }; + + releaseAfterPaint(2); return () => { window.cancelAnimationFrame(frame); }; }, [ + expectedFixedIndicatorSize, indicatorLayout.height, indicatorLayout.isVisible, indicatorLayout.width, + indicatorLayout.x, + indicatorLayout.y, indicatorMotionState, ]); @@ -423,22 +464,22 @@ function InnerSegmentedChoice( }; const instanceStyleText = buildSegmentedChoiceRuntimeRule({ - anchorHeight, - anchorWidth, + anchorHeight: anchorConfig.height, + anchorWidth: anchorConfig.width, equalDistributionLayout, - indicatorBorderWidth, + indicatorBorderWidth: indicatorConfig.borderWidth, indicatorColor: indicatorOption?.accentColor, - indicatorCursor, - indicatorHeight: hasSelectionHeight ? indicatorHeight : undefined, + indicatorCursor: interactiveCursor, + indicatorHeight: indicatorConfig.height !== undefined ? indicatorConfig.height : undefined, indicatorLayout, indicatorScale, - indicatorWidth: hasSelectionWidth ? indicatorWidth : undefined, + indicatorWidth: indicatorConfig.width !== undefined ? indicatorConfig.width : undefined, instanceId, - listCursor, + listCursor: interactiveCursor, listTouchAction, - optionSize, - resolvedOptionSizing, - resolvedTrackLayout, + optionSize: optionLayoutConfig.size, + resolvedOptionSizing: optionLayoutConfig.sizing, + resolvedTrackLayout: trackConfig.layout, trackLayout, }); @@ -471,19 +512,19 @@ function InnerSegmentedChoice( data-dragging={dragging ? 'true' : 'false'} data-orientation={orientation} data-rsc-anchor-sizing={ - anchorWidth !== undefined || anchorHeight !== undefined ? 'explicit' : 'fill' + anchorConfig.width !== undefined || anchorConfig.height !== undefined ? 'explicit' : 'fill' } data-rsc-drag-previewing={dragPreviewing ? 'true' : 'false'} - data-rsc-indicator-content-mode={resolvedIndicatorContentMode} + data-rsc-indicator-content-mode={indicatorConfig.contentMode} data-rsc-indicator-motion={indicatorMotionState === 'initial' ? 'initial' : undefined} - data-rsc-indicator-style={resolvedIndicatorStyle} - data-rsc-indicator-transition={resolvedIndicatorTransition} + data-rsc-indicator-style={indicatorConfig.style} + data-rsc-indicator-transition={indicatorConfig.transition} data-rsc-instance={instanceId} - data-rsc-option-distribution={resolvedOptionDistribution} - data-rsc-option-sizing={resolvedOptionSizing} + data-rsc-option-distribution={optionLayoutConfig.distribution} + data-rsc-option-sizing={optionLayoutConfig.sizing} data-rsc-selection-mode={selectionMode} - data-rsc-track-layout={resolvedTrackLayout} - data-rsc-track-style={resolvedTrackStyle} + data-rsc-track-layout={trackConfig.layout} + data-rsc-track-style={trackConfig.style} data-size={size} data-unstyled={unstyled ? 'true' : 'false'} > diff --git a/src/SegmentedChoice/tests/SegmentedChoice.behavior.suite.tsx b/src/SegmentedChoice/tests/SegmentedChoice.behavior.suite.tsx index c98a4e4..a92be91 100644 --- a/src/SegmentedChoice/tests/SegmentedChoice.behavior.suite.tsx +++ b/src/SegmentedChoice/tests/SegmentedChoice.behavior.suite.tsx @@ -2840,6 +2840,51 @@ export function registerSegmentedChoiceBehaviorSuite() { }); }); + it('keeps initial motion suppressed while fixed option geometry settles', async () => { + const { container } = render( + + ); + + const root = container.querySelector('.rsc-root') as HTMLDivElement; + const list = container.querySelector('.rsc-list') as HTMLDivElement; + const labels = Array.from(container.querySelectorAll('.rsc-option')); + + setElementRect(list, { left: 0, top: 0, width: 320, height: 104 }); + setElementRect(labels[0] as Element, { left: 0, top: 0, width: 57, height: 32 }); + setElementRect(labels[1] as Element, { left: 84, top: 8, width: 57, height: 32 }); + setElementRect(labels[2] as Element, { left: 168, top: 0, width: 57, height: 32 }); + triggerResizeObservers(); + + await waitFor(() => { + expect(getCssVar(root, '--_rsc-indicator-width')).toBe('57px'); + expect(root.dataset.rscIndicatorMotion).toBe('initial'); + }); + + setElementRect(labels[0] as Element, { left: 0, top: 0, width: 88, height: 88 }); + setElementRect(labels[1] as Element, { left: 96, top: 8, width: 88, height: 88 }); + setElementRect(labels[2] as Element, { left: 192, top: 0, width: 88, height: 88 }); + triggerResizeObservers(); + + await waitFor(() => { + expect(getCssVar(root, '--_rsc-indicator-width')).toBe('88px'); + expect(root.dataset.rscIndicatorMotion).toBe('initial'); + }); + + await waitFor(() => { + expect(root.dataset.rscIndicatorMotion).toBeUndefined(); + }); + }); + it('moves the underlay content-width indicator from old geometry to clicked geometry', async () => { const options = [ { value: 'deep', label: 'Deep Focus' }, diff --git a/src/SegmentedChoice/tests/SegmentedChoice.stateContract.suite.tsx b/src/SegmentedChoice/tests/SegmentedChoice.stateContract.suite.tsx index aef06ac..3d09bfa 100644 --- a/src/SegmentedChoice/tests/SegmentedChoice.stateContract.suite.tsx +++ b/src/SegmentedChoice/tests/SegmentedChoice.stateContract.suite.tsx @@ -466,19 +466,59 @@ export function registerSegmentedChoiceStateContractSuite() { }); it('contains the initial indicator placement transition override in CSS', () => { - expect(segmentedChoiceCss).toContain( - ".rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator" - ); expect(segmentedChoiceCss) - .toContain(`.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator { - transition: - opacity 120ms ease, - background-color 160ms ease, - border-color 160ms ease, - border-width 160ms ease; + .toContain(`.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator, +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator::before, +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator::after, +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator-content, +.rsc-root[data-rsc-indicator-motion='initial'] .rsc-indicator-content * { + animation: none !important; + transition: none !important; }`); }); + it('keeps initial indicator suppression stronger than custom indicator transitions', () => { + const { container } = render( + <> + + + + + ); + + const root = container.querySelector('.rsc-root') as HTMLDivElement; + const indicator = container.querySelector('.rsc-indicator') as HTMLSpanElement; + const clonedLabel = container.querySelector( + '.rsc-indicator-content .rsc-option-label' + ) as HTMLSpanElement; + + expect(root.dataset.rscIndicatorMotion).toBe('initial'); + expect(root.dataset.rscIndicatorTransition).toBe('smooth'); + expect(window.getComputedStyle(indicator).animationName).toBe('none'); + expect(window.getComputedStyle(indicator).transitionDuration).toBe('0s'); + expect(window.getComputedStyle(clonedLabel).animationName).toBe('none'); + expect(window.getComputedStyle(clonedLabel).transitionDuration).toBe('0s'); + }); + it('exposes the stable root and option data attributes', () => { const { container } = render(