diff --git a/API.md b/API.md index ee7400e..8544ac1 100644 --- a/API.md +++ b/API.md @@ -1,8 +1,8 @@ # SegmentedChoice API Reference -The README gets you to a working control. This file is for the parts that are easier to understand once the quick start is out of the way: value ownership, geometry, slot hooks, CSS customization and the few sharp edges that matter in real apps. +The README gets you to a working control. This file is for the parts that matter once you start fitting it into a real interface: value ownership, geometry, slot hooks, CSS customization and the few sharp edges worth knowing. -If you only need the default segmented control, the README is probably enough. If you are changing layout mechanics, wiring analytics attrs, building a custom skin or using the component in forms, this reference should answer the "what owns this?" questions. +If you only need the default segmented control, the README is probably enough. If you are changing layout mechanics, wiring analytics attrs, building a custom skin or using the component in forms, this reference is meant to answer the "what owns this?" questions. ## Component Signature @@ -51,12 +51,12 @@ type SegmentedChoiceOption = { Field details: -- `value`: unique string identifier used in selection state. +- `value`: unique string identifier used for selection state. - `label`: rendered content for the option. - `ariaLabel`: strongly recommended for icon-only labels. -- `description`: secondary content under/alongside label depending on styles. +- `description`: secondary content beside or under the label, depending on styles. - `disabled`: disables only this option. -- `accentColor`: optional per-option accent used by indicator color logic. +- `accentColor`: optional per-option accent used by the indicator color logic. Supported formats are hex, alphabetic named colors, `rgb()/rgba()`, `hsl()/hsla()` and `var(--token)`. Unsupported values are ignored and warn in development. @@ -64,7 +64,7 @@ Field details: ### Selection and State -- `value?: T`: controlled value. If set, the parent must pass the committed value back after `onValueChange`. +- `value?: T`: controlled value. If you pass it, the parent must pass the committed value back after `onValueChange`. - `defaultValue?: T`: initial value for uncontrolled mode. - `onValueChange?: (value: T) => void`: called when selection commits. @@ -86,18 +86,18 @@ Field details: - `optionDistribution?: "space-between" | "space-around"` (default: `"space-between"`) - `size?: "sm" | "md" | "lg"` (default: `"md"`) -`optionSizing` controls option box dimensions: +`optionSizing` decides how wide each option box should be: - `equal` measures the widest option content and uses that width for every option box. - `content` lets each option box follow its own content width. - `geometry.optionSize` resolves sizing to fixed square option boxes and overrides `optionSizing`. -`optionDistribution` controls how option boxes sit inside the surface when the surface has extra space: +`optionDistribution` only becomes visible when the surface has extra room: - `space-between` places spare space between option boxes. - `space-around` also adds spare space before the first and after the last option. -Surface width itself comes from normal CSS layout. Without an explicit width, the root remains compact around its options. +The surface width itself still comes from normal CSS layout. Without an explicit width, the root stays compact around its options. ### Accessibility @@ -105,7 +105,7 @@ Surface width itself comes from normal CSS layout. Without an explicit width, th - `ariaLabelledby?: string` - `ariaDescribedby?: string` -Use at least one group labelling strategy (`ariaLabel` or `ariaLabelledby`). +Use at least one group labelling strategy: `ariaLabel` or `ariaLabelledby`. ### Styling Entry Points @@ -121,11 +121,11 @@ Rendered slots do not receive `style={...}` from the component. What this means: -- public theming stays overrideable from external CSS +- public theming stays overrideable from app CSS - `slotProps.style` is not public API and is ignored at runtime - dynamic layout is written through an internal scoped stylesheet, not through element inline styles -This separation is intentional: +The split is deliberate: - public `--rsc-*` variables are for consumers - internal `--_rsc-*` variables are runtime mechanics and are not public API @@ -139,11 +139,11 @@ This separation is intentional: - invalid structures return `null` and emit a dev warning - radiogroup labelling requires `ariaLabel` or `ariaLabelledby` -This keeps runtime behavior deterministic for external consumers. +That keeps runtime behavior predictable for consumers and avoids half-valid controls. ## CSP and Runtime Styles -Dynamic geometry is written into a shared document stylesheet rather than inline slot styles. +Dynamic geometry is written into a shared document stylesheet instead of inline slot styles. - pass `styleNonce` to attach a nonce to that stylesheet - instances in one document reuse the same stylesheet when they share a nonce @@ -153,20 +153,20 @@ Dynamic geometry is written into a shared document stylesheet rather than inline Server rendering does not emit the internal runtime stylesheet. -- rendering on the server does not access browser globals during render +- server rendering does not access browser globals during render - the runtime stylesheet is attached on the client during the first layout effect after hydration - indicator geometry and measured layout settle on the client after hydration ## Mental Model -Think about the component in four layers: +The component is easier to reason about in four layers: 1. `options`, selection props and interaction props define state and semantics. -2. `geometry` defines the measured layout contract: where the indicator moves, how the track is measured, whether anchors exist and whether explicit option or indicator sizing is active. +2. `geometry` defines what the component measures: where the indicator moves, how the track is measured, whether anchors exist and whether explicit option or indicator sizing is active. 3. Public CSS variables, stable classes and stable data attributes define appearance. 4. `slotProps` lets an app attach integration metadata, event observers and extra class names to the rendered slots. -Use `geometry` when the layout math itself should change. Use CSS when the same mechanics should look different. Use `slotProps` when an outside system needs attributes, class names or event handlers on a specific slot. +Use `geometry` when the component should measure or move differently. Use CSS when the same mechanics should look different. Use `slotProps` when another system needs attributes, class names or event handlers on a specific slot. ## `geometry` @@ -207,15 +207,15 @@ Defaults: - `indicator.content: "none"` - `indicator.transition: "smooth"` -During an active drag gesture, changing `options`, `orientation` or `geometry` cancels the in-flight drag. -The control resets the preview state and the user can start a new drag after the update. +During an active drag, changing `options`, `orientation` or `geometry` cancels the in-flight gesture. +The control clears the preview state and the user can start a new drag after the update. ### `mode` - `"underlay"`: indicator participates as under-selection background. - `"overlay"`: indicator behaves like moving handle above options. -Use `"underlay"` for classic segmented controls where selected state feels like a highlighted background behind the active option. Use `"overlay"` when the selected state should behave like a handle or capsule moving above the option labels. +Use `"underlay"` for classic segmented controls where selection feels like a highlighted background behind the active option. Use `"overlay"` when selection should behave like a handle or capsule moving above the option labels. ### `dragScale` @@ -230,7 +230,7 @@ Use `"underlay"` for classic segmented controls where selected state feels like - Sets fixed square size for each option box. - Uses internal runtime layout values; no additional public styling hook is required. -Use this for icon grids, compact tool pickers or any control where every option needs the same square target independent of label length. `optionSize` defines option-box dimensions; `optionDistribution` still controls how those boxes spread when the surface is wider than the boxes. +Use this for icon grids, compact tool pickers or any control where every option needs the same square target, regardless of label length. `optionSize` defines option-box dimensions; `optionDistribution` still controls how those boxes spread when the surface is wider than the boxes. ### `anchor` @@ -238,21 +238,21 @@ Use this for icon grids, compact tool pickers or any control where every option - `width` and `height`: non-square anchor geometry. - If `width`/`height` are provided, they override `size` per axis. -Anchors are invisible measurement targets by default. They are useful for overlay controls where the handle should move between compact targets inside wider option labels and they can be styled through `.rsc-option-anchor`. +Anchors are invisible measurement targets by default. They are useful when an overlay handle should move between compact targets inside wider option labels. You can also style them through `.rsc-option-anchor`. ### `track.layout` - `"container"`: the track fills the control container. - `"center-span"`: the track starts at the center of the first option and ends at the center of the last option, using anchors when present. -Use `"container"` for regular pill backgrounds. Use `"center-span"` for rail-like controls where the track should run through the option centers instead of filling the whole wrapper. +Use `"container"` for regular pill backgrounds. Use `"center-span"` for rail-like controls where the track should run through option centers instead of filling the whole wrapper. ### `track.style` - `"surface"`: applies the default track paint. - `"none"`: keeps track geometry but removes default paint so CSS can draw the rail. -`track.style = "none"` does not remove the track element. It keeps the `.rsc-track` slot available so custom CSS can draw a line, gradient, timeline rail or no visible track at all. +`track.style = "none"` does not remove the track element. It keeps the `.rsc-track` slot available so your CSS can draw a line, gradient, timeline rail or no visible track at all. ### `indicator` @@ -269,7 +269,7 @@ Axis precedence: - width resolution: `indicator.width ?? indicator.size` - height resolution: `indicator.height ?? indicator.size` -Explicit indicator sizing is useful when the indicator should be a fixed handle instead of matching the selected option content. The measured dimensions are applied through internal runtime layout variables. +Explicit indicator sizing is useful when the indicator should be a fixed handle instead of matching selected option content. The measured dimensions are applied through internal runtime layout variables. ### `indicator.transition` @@ -278,7 +278,7 @@ Explicit indicator sizing is useful when the indicator should be a fixed handle - `"smooth"` is the default and animates position and size changes. - `"instant"` updates position and size immediately. -This controls selection indicator motion only. It does not delay value changes, change selection semantics or change drag preview timing. +This controls the selection indicator only. It does not delay value changes, change selection semantics or change drag preview timing. ### `indicator.content = "clone-active"` @@ -286,9 +286,9 @@ This controls selection indicator motion only. It does not delay value changes, It does not reorder options. The option model stays fixed; this is selection preview, not drag-and-drop list behavior. -Use it when the active value should travel with the handle, such as camera modes, compact icon + label pickers or controls that intentionally feel like a mini-slider. Skip it for heavy option content, dense controls where cloned content hurts readability or classic segmented controls where a plain selected highlight is clearer. +Use it when the active value should travel with the handle: camera modes, compact icon + label pickers or controls that intentionally feel close to a small slider. Skip it for heavy option content, dense controls where cloned content hurts readability or classic segmented controls where a plain selected highlight is clearer. -For most mode pickers, start with `indicator.content = "none"`. Reach for `clone-active` only when the moving value capsule is part of the intended interaction. +For most mode pickers, start with `indicator.content = "none"`. Reach for `clone-active` only when the moving value capsule is part of the interaction you want. Recipe (`overlay + clone-active`): @@ -332,22 +332,22 @@ type SegmentedChoiceSlotProps = { }; ``` -What `slotProps` is for: +Use `slotProps` to: - add `className` - add `data-*` attributes - add non-conflicting `aria-*` attributes - attach event handlers -What it is not for: +Do not use it for: -- inline styling (`style`) is not part of public API and is ignored. +- inline styling. `style` is not public API and is ignored. - owning root radiogroup semantics such as `role`, `ariaLabel`, `ariaLabelledby`, `ariaDescribedby` or `aria-orientation`. Handler ordering: - internal list pointer/focus handlers run before `slotProps.list` handlers -- user handlers should observe or augment list interactions, not depend on canceling internal drag/commit logic with `preventDefault()` +- user handlers should observe or add to list interactions, not depend on canceling internal drag or commit logic with `preventDefault()` Slot map: @@ -364,8 +364,8 @@ Slot map: | `optionLabel` | `.rsc-option-label` primary label wrapper | | `optionDescription` | `.rsc-option-description` secondary text wrapper | -Use `slotProps` when you need to attach integration metadata, event observers or extra class names to one of these slots. -Use CSS selectors against the stable class hooks below when you only need visual styling. +Use `slotProps` when you need integration metadata, event observers or extra class names on one of these slots. +Use CSS selectors against the stable class hooks below when the change is only visual. Example: @@ -388,7 +388,7 @@ Example: ## Stable Class Hooks -These are the stable selectors for the slots listed in the `slotProps` map: +These selectors match the slots listed in the `slotProps` map: ```css .rsc-root @@ -408,9 +408,9 @@ Use `.rsc-root` or your own root `className` to scope a theme. Use `.rsc-option`, `.rsc-option-content`, `.rsc-option-label` and `.rsc-option-description` for option styling. Use `.rsc-track`, `.rsc-indicator`, `.rsc-indicator-content` and `.rsc-option-anchor` for geometry-driven visuals such as rails, handles, cloned content, anchors, rings or release feedback. -The `.rsc-option-input` hook exists because the native radio input is part of the public DOM structure. In normal styling, leave the input visually hidden and style the visible option slots instead. +The `.rsc-option-input` hook exists because the native radio input is part of the public DOM structure. In normal styling, leave that input visually hidden and style the visible option slots instead. -Class hooks describe structure. Data attributes describe state and resolved configuration. In most custom themes, use both together: +Class hooks describe structure. Data attributes describe state. In most custom themes, use both together: ```css .billing-range .rsc-option[data-selected='true'] .rsc-option-content { @@ -437,13 +437,13 @@ Option attrs: - `data-has-description`: `"true"` when `option.description` is present. - `data-previewed`: `"true"` on the option currently targeted during an active drag. It can differ from `data-selected` until pointer release commits the value. -Internal runtime selectors use `data-rsc-*`. They are owned by the bundled stylesheet and scoped runtime layout system and are not supported as public customization API. +Internal runtime selectors use `data-rsc-*`. They are owned by the bundled stylesheet and scoped runtime layout system. Do not treat them as public customization API. ## Data Attribute Scoping Best Practices -Rule: +Keep these selectors scoped: -- Always scope option-state selectors by component hooks such as `.rsc-root` and `.rsc-option`. +- Scope option-state selectors by component hooks such as `.rsc-root` and `.rsc-option`. - Prefer a user-owned class on the root when styling one control instance. - Avoid global bare `[data-*]` selectors. These attrs use simple names and may exist elsewhere in your app. @@ -477,11 +477,9 @@ Don't: ## Stable CSS Variables -These are public `--rsc-*` tokens intended for external CSS overrides. +These public `--rsc-*` tokens are the normal CSS override surface. -Import `react-segmented-choice/styles.css` before your app or component CSS so -your overrides can naturally customize these variables and stable `.rsc-*` -hooks. +Import `react-segmented-choice/styles.css` before your app or component CSS so your overrides win in normal cascade order. ### Surface and color @@ -560,11 +558,11 @@ Internal: - owned by component runtime layout logic - not covered by semver or public docs -Do not build app-level styling contracts on top of `--_rsc-*` or `data-rsc-*`. +Do not build app-level styling contracts on `--_rsc-*` or `data-rsc-*`. ## Practical Examples -This section keeps compact code examples close to the API reference. For broader runnable examples, use Storybook locally with `pnpm storybook` or browse the hosted build at [sb.segmentedchoice.visiofutura.com](https://sb.segmentedchoice.visiofutura.com/). +These examples stay close to the API reference. For more visual recipes, run Storybook with `pnpm storybook` or browse the hosted build at [sb.segmentedchoice.visiofutura.com](https://sb.segmentedchoice.visiofutura.com/). ### 1) Controlled value @@ -583,7 +581,7 @@ const [value, setValue] = useState('week'); />; ``` -Use controlled mode when selection is owned by app state, URL state, a form library or another external store. The component calls `onValueChange` when a new value commits and the parent passes that value back through `value`. +Use controlled mode when selection belongs to app state, URL state, a form library or another external store. The component calls `onValueChange` when a new value commits; the parent passes that value back through `value`. ### 2) Uncontrolled value @@ -602,7 +600,7 @@ Use controlled mode when selection is owned by app state, URL state, a form libr /> ``` -Use uncontrolled mode when the control can manage its own selected value after the initial render. `defaultValue` is the initial selection; if it is missing or invalid, the component falls back to the first enabled option. +Use uncontrolled mode when the control can own its selected value after the first render. `defaultValue` is the initial selection; if it is missing or invalid, the component falls back to the first enabled option. ### 3) Icon or non-text labels @@ -662,9 +660,9 @@ When `label` is not readable text, provide `ariaLabel` on the option. The group } ``` -This pattern uses `center-span` to position the track from the first option center to the last option center. The styled `.rsc-track` becomes the visible rail, explicit anchors provide compact targets and a fixed indicator moves along that rail. +This pattern uses `center-span` to run the track from the first option center to the last option center. The styled `.rsc-track` becomes the visible rail, explicit anchors provide compact targets and a fixed indicator moves along that rail. -With `track.style = "none"`, the component does not draw the default surface; the visible rail and label placement are yours to style with CSS. You can keep labels centered and size the anchors around them, move labels above or below the rail or use another layout that fits your design. This example moves the labels below the rail only to keep the rail shape easy to read. +With `track.style = "none"`, the component does not draw the default surface. The visible rail and label placement are yours to style with CSS. This example moves labels below the rail only to keep the rail shape easy to read. ### 5) Overlay with `clone-active` @@ -688,7 +686,7 @@ With `track.style = "none"`, the component does not draw the default surface; th /> ``` -Use `clone-active` when the moving overlay should carry the active option content. It previews the target selection during drag and does not reorder options. +Use `clone-active` when the moving overlay should carry the active option content. It previews the target selection during drag. It does not reorder options. ### 6) Slot-level analytics attrs @@ -742,7 +740,7 @@ Use `clone-active` when the moving overlay should carry the active option conten } ``` -`data-selected` is the committed value. `data-previewed` is the drag target and only appears while dragging. +`data-selected` is the committed value. `data-previewed` is the drag target and appears only while dragging. ### 8) CSS-first theming @@ -806,7 +804,7 @@ Use `clone-active` when the moving overlay should carry the active option conten } ``` -`unstyled` removes the bundled visual skin, not the behavior or DOM structure. The stable slots, radio semantics, keyboard behavior, drag behavior and geometry logic still apply. +`unstyled` removes the bundled visual skin, not the behavior or DOM structure. Stable slots, radio semantics, keyboard behavior, drag behavior and geometry logic still apply. ### 10) Complex domain objects through string IDs @@ -834,22 +832,21 @@ const byId = new Map(plans.map(plan => [plan.id, plan.value])); />; ``` -Keep rich values in your app model and pass stable string IDs to the component. This keeps DOM values, form behavior and TypeScript generics aligned with the public `SegmentedChoiceValue = string` contract. +Keep rich values in your app model and pass stable string IDs to the component. That keeps DOM values, form behavior and TypeScript generics aligned with the public `SegmentedChoiceValue = string` contract. ## Guidance: `geometry` vs CSS vs `slotProps` -Use this split to avoid API confusion: +Use this split when you are deciding where a customization belongs: - `geometry`: mechanics and layout math. - internal runtime stylesheet: instance-scoped layout values. - CSS variables and selectors: look and theme. - `slotProps`: attrs/events/class hooks for integration. -If a customization can be expressed in CSS, prefer CSS over adding new JS props. +If a customization can be expressed in CSS, prefer CSS over adding a new JS prop. For drag feedback, `data-dragging` marks the active pointer drag phase. -`data-drag-released` is briefly `true` after a pointer drag ends and is intended for optional CSS-only release feedback. -The library exposes this state hook but does not ship a default release animation. +`data-drag-released` is briefly `true` after a pointer drag ends. It is there for optional CSS-only release feedback; the library does not ship a default release animation. ## Browser Support diff --git a/README.md b/README.md index eca1669..b4187a6 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,17 @@ [![codecov](https://codecov.io/gh/innrvoice/react-segmented-choice/branch/main/graph/badge.svg)](https://codecov.io/gh/innrvoice/react-segmented-choice) [![bundle size](https://codecov.io/github/innrvoice/react-segmented-choice/branch/main/graph/bundle/react-segmented-choice-esm/badge.svg)](https://app.codecov.io/github/innrvoice/react-segmented-choice/bundles/main/react-segmented-choice-esm) -`react-segmented-choice` is an accessible segmented control for immediate single-choice selection in React. +`react-segmented-choice` is a React segmented control for choices that should feel like real UI, not styled tabs. -It is built for controls that should feel like real form inputs, not just clickable tabs: native radio semantics, keyboard behavior, drag-to-select, CSS-first theming and geometry hooks for custom indicator layouts. +It keeps the boring parts in place: native radio semantics, keyboard behavior, form-friendly state and drag-to-select. From there, you can shape the same component into a switch, toolbar, option picker, rail or compact mode control with CSS and measured geometry. Typical use cases: - report ranges, billing periods, density switches and mode pickers -- icon controls where every option should keep a stable target -- custom segmented controls that still need form and accessibility behavior +- icon rails and compact toolbars where every option needs a stable target +- custom switches or tabs alternatives that should still behave like form controls -Browse practical customization examples and architecture stories in the hosted [Storybook](https://sb.segmentedchoice.visiofutura.com/). +The hosted [Storybook](https://sb.segmentedchoice.visiofutura.com/) shows the range: plain controls, rails, thumbnails, filters, toggles and the geometry stories behind them. ## Install @@ -31,9 +31,7 @@ Import bundled styles once: import 'react-segmented-choice/styles.css'; ``` -Import the bundled stylesheet before your app or component CSS so your -component-level overrides can naturally customize public `--rsc-*` variables -and stable `.rsc-*` hooks. +Import the bundled stylesheet before your app or component CSS. That keeps the default skin available while letting your own classes override public `--rsc-*` variables and stable `.rsc-*` hooks in normal CSS order. ## Quick Start @@ -58,7 +56,7 @@ export function ReportRange() { ## Value Type (`string` only) -`SegmentedChoiceValue` is `string`. Option values are the public selection contract, so use stable IDs even when the selected domain object is richer than a string. +`SegmentedChoiceValue` is `string`. Treat option values as stable public IDs. If the selected thing is a richer object, keep that object in your app state and pass the ID to the control. For complex domain values, keep external mapping by ID: @@ -83,7 +81,7 @@ const byId = Object.fromEntries(items.map(x => [x.id, x.plan])); ## Public API -The component has one main entry point and a small set of supporting types. The README keeps the surface map short; `API.md` has the prop-by-prop reference and longer customization examples. +The package has one component entry point and a small type surface. This README keeps the map short; `API.md` is where the prop-by-prop reference and longer examples live. Exports: @@ -125,7 +123,7 @@ Option fields: ### `geometry` -Use `geometry` for mechanics and measured layout: underlay vs overlay, track span, explicit option/anchor/indicator sizing, drag scale, indicator paint mode, cloned indicator content and indicator transition behavior. +Use `geometry` when the layout mechanics need to change, not just the colors. It controls things like underlay vs overlay behavior, track span, fixed option or indicator sizes, drag scale, cloned indicator content and indicator motion. ```ts geometry?: { @@ -163,16 +161,16 @@ Defaults: - `geometry.indicator.content = "none"` - `geometry.indicator.transition = "smooth"` -`geometry.indicator.transition` controls selection indicator geometry motion: +`geometry.indicator.transition` is only about the moving selection indicator: - `"smooth"` animates selection indicator position and size changes. -- `"instant"` updates selection indicator geometry without movement or resize animation. +- `"instant"` updates indicator position and size without movement or resize animation. -This affects the selection indicator only. It does not delay value changes or alter drag preview behavior. +It does not delay value changes or change drag preview behavior. ### `slotProps` -`slotProps` is for external attrs/events/class hooks only: +`slotProps` is for integration hooks, not visual styling: - allowed: `className`, `data-*`, non-conflicting `aria-*`, handlers (`onPointerEnter`, etc.) - not supported: `style` @@ -180,7 +178,7 @@ This affects the selection indicator only. It does not delay value changes or al `style` is intentionally ignored at runtime even if forced through a cast. For `slotProps.list`, internal pointer/focus handlers run before user-provided handlers. -Use list-level handlers to observe or augment interactions, not to override the built-in drag/commit flow. +Use list-level handlers to observe or add to interactions. Do not rely on them to override the built-in drag and commit flow. Root radiogroup semantics stay controlled by top-level props such as `ariaLabel`, `ariaLabelledby` and `ariaDescribedby`. @@ -203,16 +201,16 @@ Example: ## CSS-First Customization Contract -`SegmentedChoice` does not write `style={...}` onto its rendered slots. +`SegmentedChoice` does not put `style={...}` on its rendered slots. -The public styling surface is intentionally CSS-first: +The styling contract is intentionally CSS-first: - stable `.rsc-*` classes for slot targeting - core state `data-*` attrs for state-specific styling - public `--rsc-*` variables for theme tokens -- no rendered slot inline styles, so external CSS can still override variables such as `--rsc-surface` +- no rendered slot inline styles, so your CSS can still override variables such as `--rsc-surface` -Runtime geometry is different from theme styling. Measured layout values are written through an internal scoped stylesheet, not through element inline styles. +Runtime geometry is separate from theme styling. Measured positions and sizes are written through an internal scoped stylesheet, not through slot inline styles. ### CSP-safe runtime styles @@ -267,7 +265,7 @@ Optional public override: Public variables are the documented `--rsc-*` tokens above. They are safe to theme from external CSS. -Internal runtime selectors and variables use `data-rsc-*` and `--_rsc-*`. They are implementation details used by the bundled stylesheet and scoped runtime layout rules, not supported customization API. +Internal runtime selectors and variables use `data-rsc-*` and `--_rsc-*`. They belong to the bundled stylesheet and scoped runtime layout rules. Do not build app-level styling contracts on them. ### SSR and Hydration @@ -275,7 +273,7 @@ Server rendering does not emit the internal runtime stylesheet. - SSR markup is safe to render without touching `window`, `document` or `navigator` - the scoped runtime CSS is installed on the client after hydration during the first layout effect -- plan for the indicator and measured geometry to settle on the client after hydration +- expect indicator geometry to settle on the client after hydration ## API Precedence @@ -285,26 +283,26 @@ Server rendering does not emit the internal runtime stylesheet. 4. Class/data selectors define contextual styling. 5. `slotProps` adds attrs/events/class names to slots. -Use `geometry` for behavior/geometry, CSS for appearance. +Use `geometry` when the component should measure or move differently. Use CSS when the same mechanics should look different. -`unstyled` means "remove the default visual skin", not "headless DOM primitive". Structure, semantics, slots and layout logic still come from the component. +`unstyled` means "remove the default visual skin". It is not a headless primitive. Structure, semantics, slots and layout logic still come from the component. For deeper guidance, examples and sentence-level descriptions of each stable class/data hook, read `API.md`. ## Storybook -The hosted Storybook contains practical customization patterns, architecture stories, interaction demos and real-world segmented control examples. +Storybook is the easiest place to see the component pushed past the default pill control. It covers: - baseline variants and state examples - geometry and indicator architecture - CSS-first customization patterns -- rails, thumbnails, filters and toggle-like controls +- rails, thumbnails, filters, custom switches and toggle-like controls - keyboard, pointer, drag and accessibility interaction behavior - styling examples built only on the documented public API -Run Storybook locally with `pnpm storybook` or browse and examine the hosted examples at [sb.segmentedchoice.visiofutura.com](https://sb.segmentedchoice.visiofutura.com/). +Run it locally with `pnpm storybook`, or browse the hosted examples at [sb.segmentedchoice.visiofutura.com](https://sb.segmentedchoice.visiofutura.com/). ## Browser Support @@ -322,7 +320,7 @@ pnpm test:visual pnpm test:visual:update ``` -`test:visual` builds Storybook and runs screenshot diffs against the curated `tags: ["visual"]` stories. +`test:visual` builds Storybook and compares screenshots for the curated `tags: ["visual"]` stories. ## Accessibility @@ -336,7 +334,7 @@ pnpm test:visual:update Tagged releases publish a CycloneDX SBOM generated from the pnpm lockfile. -The SBOM can be used by security and compliance tooling to inspect dependency metadata. +Security and compliance tooling can use the SBOM to inspect dependency metadata. ## Quality Gates @@ -351,7 +349,7 @@ The SBOM can be used by security and compliance tooling to inspect dependency me - Visual regression: `pnpm test:visual` - Coverage: `pnpm test:coverage` -CI runs formatting, lint, unit coverage, package build, public contract checks, package-content checks, Storybook browser tests and Chromium/WebKit visual regression. +CI runs format checks, lint, unit coverage, package build, public contract checks, package-content checks, Storybook browser tests and Chromium/WebKit visual regression. `prepack` runs: build -> contract check -> pack check. diff --git a/package.json b/package.json index f96a856..39b301a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-segmented-choice", - "version": "1.0.6", + "version": "1.0.7", "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.tsx b/src/SegmentedChoice/SegmentedChoice.tsx index 264baf8..6a66612 100644 --- a/src/SegmentedChoice/SegmentedChoice.tsx +++ b/src/SegmentedChoice/SegmentedChoice.tsx @@ -13,7 +13,11 @@ import { SegmentedChoiceOptionText } from './components/SegmentedChoiceOptionTex import { useControllableValue } from './hooks/useControllableValue'; import { useDragSelection } from './hooks/useDragSelection'; import { useEqualDistributionLayout } from './hooks/useEqualDistributionLayout'; -import { useIndicatorLayout } from './hooks/useIndicatorLayout'; +import { + measureIndicatorLayout, + useIndicatorLayout, + type IndicatorLayout, +} from './hooks/useIndicatorLayout'; import { useSegmentedChoiceInteractions } from './hooks/useSegmentedChoiceInteractions'; import { useTrackLayout } from './hooks/useTrackLayout'; import { buildSegmentedChoiceRuntimeRule } from './internal/buildSegmentedChoiceRuntimeRule'; @@ -94,6 +98,12 @@ function isNearLayoutSize(measured: number, expected: number) { return Math.abs(measured - expected) < 0.5; } +function getIndicatorLayoutSignature( + layout: Pick +) { + return [layout.x, layout.y, layout.width, layout.height].join('|'); +} + function InnerSegmentedChoice( { ariaDescribedby, @@ -327,7 +337,14 @@ function InnerSegmentedChoice( sizeAdjustment: indicatorSizeAdjustment, useRenderedIndicatorSize: hasExplicitIndicatorSize, }); - const [indicatorMotionState, setIndicatorMotionState] = useState<'initial' | 'ready'>('initial'); + const [indicatorMotionState, setIndicatorMotionState] = useState<'initial' | 'settled' | 'ready'>( + 'initial' + ); + const latestInitialIndicatorLayoutSignatureRef = useRef(''); + const stableInitialIndicatorLayoutRef = useRef<{ + frameCount: number; + signature: string; + } | null>(null); const trackLayout = useTrackLayout({ listRef, measureRefs: anchorRefs, @@ -380,6 +397,9 @@ function InnerSegmentedChoice( ? 'grab' : 'pointer'; const listTouchAction = !disabled && draggable ? 'none' : undefined; + const indicatorLayoutSignature = getIndicatorLayoutSignature(indicatorLayout); + + latestInitialIndicatorLayoutSignatureRef.current = indicatorLayoutSignature; useLayoutEffect(() => { optionRefs.current.length = options.length; @@ -396,40 +416,106 @@ function InnerSegmentedChoice( (isNearLayoutSize(indicatorLayout.width, expectedFixedIndicatorSize) && isNearLayoutSize(indicatorLayout.height, expectedFixedIndicatorSize)); + if (indicatorMotionState === 'settled') { + let frame = 0; + frame = window.requestAnimationFrame(() => { + setIndicatorMotionState('ready'); + }); + + return () => { + window.cancelAnimationFrame(frame); + }; + } + if ( indicatorMotionState !== 'initial' || !hasMeasuredIndicatorLayout || !hasSettledFixedIndicatorSize || typeof window === 'undefined' ) { + if (indicatorMotionState !== 'ready') { + stableInitialIndicatorLayoutRef.current = null; + } return; } let frame = 0; - const releaseAfterPaint = (remainingFrames: number) => { + const releaseWhenLayoutIsStable = () => { frame = window.requestAnimationFrame(() => { - if (remainingFrames <= 1) { - setIndicatorMotionState('ready'); + const measuredIndicatorLayout = measureIndicatorLayout({ + activeIndex, + centerToOption: indicatorCentersOnOption, + indicatorRef, + inset: indicatorCentersOnOption ? 0 : indicatorInsetPx, + listRef, + measureRefs: anchorRefs, + optionRefs, + sizeAdjustment: indicatorSizeAdjustment, + useRenderedIndicatorSize: hasExplicitIndicatorSize, + }); + + if (!measuredIndicatorLayout) { + stableInitialIndicatorLayoutRef.current = null; + releaseWhenLayoutIsStable(); return; } - releaseAfterPaint(remainingFrames - 1); + const measuredIndicatorLayoutSignature = + getIndicatorLayoutSignature(measuredIndicatorLayout); + + if ( + measuredIndicatorLayoutSignature !== indicatorLayoutSignature || + latestInitialIndicatorLayoutSignatureRef.current !== indicatorLayoutSignature + ) { + stableInitialIndicatorLayoutRef.current = null; + releaseWhenLayoutIsStable(); + return; + } + + const currentStableLayout = stableInitialIndicatorLayoutRef.current; + const nextStableLayout = + currentStableLayout?.signature === indicatorLayoutSignature + ? { + frameCount: currentStableLayout.frameCount + 1, + signature: indicatorLayoutSignature, + } + : { + frameCount: 1, + signature: indicatorLayoutSignature, + }; + + stableInitialIndicatorLayoutRef.current = nextStableLayout; + + if (nextStableLayout.frameCount >= 2) { + setIndicatorMotionState('settled'); + return; + } + + releaseWhenLayoutIsStable(); }); }; - releaseAfterPaint(2); + releaseWhenLayoutIsStable(); return () => { window.cancelAnimationFrame(frame); }; }, [ + activeIndex, + anchorRefs, expectedFixedIndicatorSize, + hasExplicitIndicatorSize, + indicatorCentersOnOption, + indicatorInsetPx, indicatorLayout.height, indicatorLayout.isVisible, indicatorLayout.width, - indicatorLayout.x, - indicatorLayout.y, + indicatorLayoutSignature, indicatorMotionState, + indicatorRef, + indicatorSizeAdjustment, + listRef, + optionRefs, ]); const optionRowRefs = { @@ -454,6 +540,10 @@ function InnerSegmentedChoice( resolvedName, shouldRenderAnchor, }; + const renderedIndicatorLayout = { + ...indicatorLayout, + isVisible: indicatorMotionState !== 'initial' && indicatorLayout.isVisible, + }; const instanceStyleText = buildSegmentedChoiceRuntimeRule({ anchorHeight: anchorConfig.height, @@ -463,7 +553,7 @@ function InnerSegmentedChoice( indicatorColor: indicatorOption?.accentColor, indicatorCursor: interactiveCursor, indicatorHeight: indicatorConfig.height !== undefined ? indicatorConfig.height : undefined, - indicatorLayout, + indicatorLayout: renderedIndicatorLayout, indicatorScale, indicatorWidth: indicatorConfig.width !== undefined ? indicatorConfig.width : undefined, instanceId, @@ -508,7 +598,7 @@ function InnerSegmentedChoice( } data-rsc-drag-previewing={dragPreviewing ? 'true' : 'false'} data-rsc-indicator-content-mode={indicatorConfig.contentMode} - data-rsc-indicator-motion={indicatorMotionState === 'initial' ? 'initial' : undefined} + data-rsc-indicator-motion={indicatorMotionState !== 'ready' ? 'initial' : undefined} data-rsc-indicator-style={indicatorConfig.style} data-rsc-indicator-transition={indicatorConfig.transition} data-rsc-instance={instanceId} diff --git a/src/SegmentedChoice/tests/SegmentedChoice.behavior.suite.tsx b/src/SegmentedChoice/tests/SegmentedChoice.behavior.suite.tsx index bc985da..de01286 100644 --- a/src/SegmentedChoice/tests/SegmentedChoice.behavior.suite.tsx +++ b/src/SegmentedChoice/tests/SegmentedChoice.behavior.suite.tsx @@ -2952,6 +2952,57 @@ export function registerSegmentedChoiceBehaviorSuite() { }); }); + it('keeps initial motion suppressed until vertical indicator position is stable', async () => { + const options = [ + { value: 'cursor', label: 'Cursor' }, + { value: 'pen', label: 'Pen' }, + { value: 'text', label: 'Text' }, + { value: 'frame', label: 'Frame' }, + ] as const; + 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: 64, height: 256 }); + setRectsForAxis(labels, 'vertical', { size: 64, gap: 0, crossSize: 64 }); + setElementRect(labels[3] as Element, { left: 0, top: 64, width: 64, height: 64 }); + triggerResizeObservers(); + + await waitFor(() => { + expect(getCssVar(root, '--_rsc-indicator-width')).toBe('70px'); + expect(getCssVar(root, '--_rsc-indicator-height')).toBe('70px'); + expect(getCssVar(root, '--_rsc-indicator-transform')).toContain('-3px,61px'); + expect(root.dataset.rscIndicatorMotion).toBe('initial'); + }); + + setElementRect(labels[3] as Element, { left: 0, top: 192, width: 64, height: 64 }); + triggerResizeObservers(); + + await waitFor(() => { + expect(getCssVar(root, '--_rsc-indicator-transform')).toContain('-3px,189px'); + 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/stories/architecture/GeometryArchitecture.stories.tsx b/src/stories/architecture/GeometryArchitecture.stories.tsx index 1828f51..0ce6568 100644 --- a/src/stories/architecture/GeometryArchitecture.stories.tsx +++ b/src/stories/architecture/GeometryArchitecture.stories.tsx @@ -11,7 +11,7 @@ const meta = { docs: { description: { component: - 'Curated visual stories for the geometry, runtime data attributes and CSS-first customization model.', + 'Visual notes for the parts that matter when the default pill control is not enough: geometry, runtime state attrs and CSS-first styling.', }, }, controls: { @@ -1326,7 +1326,7 @@ export const MentalModelPipeline: ArchitectureStory = {
@@ -1361,12 +1361,12 @@ export const MentalModelPipeline: ArchitectureStory = {
Runtime measures layout - The component positions the track and indicator from rendered option geometry. + The component positions the track and indicator from the rendered options.
CSS paints the result - Consumers theme public slots, state attrs and CSS variables. + App CSS themes public slots, state attrs and CSS variables.
@@ -1418,8 +1418,8 @@ export const MentalModelPipeline: ArchitectureStory = {

Measured positions, transforms and track spans are emitted through the scoped - runtime stylesheet. Treat those values as implementation mechanics. App CSS should - use documented classes, attrs and public + runtime stylesheet. Treat those values as mechanics. App CSS should use documented + classes, attrs and public --rsc-* variables instead of internal runtime values.

@@ -1440,14 +1440,14 @@ export const TrackAsAxis: ArchitectureStory = {

Default surface

- The common case uses surface paint and lets the track fill the control container. + The common case paints a surface and lets the track fill the control container.

@@ -1474,8 +1474,8 @@ export const TrackAsAxis: ArchitectureStory = {

Center-span surface

- The paint is still the default surface. Only the measured span changes: first anchor - center to last anchor center. + The paint stays ordinary. Only the measured span changes: first anchor center to + last anchor center.

@@ -1507,8 +1507,8 @@ export const TrackAsAxis: ArchitectureStory = {

Surface paint

- `track.style: "surface"` is the default painted track, including with center-span - measurement. + `track.style: "surface"` is the default painted track, including when the measured + span comes from anchors.

No track paint

- `track.style: "none"` leaves the same measured geometry, but the library does not - paint the track surface. + `track.style: "none"` keeps the same measured geometry, but leaves track paint to + your CSS.

@@ -1586,7 +1586,7 @@ export const SurfaceDistributionModel: ArchitectureStory = {

default compact surface

With no explicit surface width, the control wraps its option boxes. Distribution is - still part of the contract, but there is no spare space to see. + still part of the contract, but there is almost no spare space to see.

@@ -1647,8 +1647,8 @@ export const SurfaceDistributionModel: ArchitectureStory = {

explicit wide surface + space-between

- When consumer CSS gives the surface width, the same option boxes distribute across - that space. Option sizing still decides the box dimensions. + When app CSS gives the surface width, the same option boxes distribute across that + space. Option sizing still decides the box dimensions.

@@ -1743,7 +1743,7 @@ export const SurfaceDistributionModel: ArchitectureStory = {

The contract

Default controls are compact because no width is assigned. If CSS makes the surface - wider, optionDistribution decides how already-sized option boxes use that extra space. + wider, optionDistribution decides how already-sized option boxes use the extra space.

{`
@@ -1808,7 +1808,9 @@ export const IndicatorModesMap: ArchitectureStory = {
overlay + fill - A handle above options. Useful for slider-like controls. + + A handle above options. Useful when a control should feel closer to a slider. +
overlay + ring - The same geometry with transparent fill and a visible border. + The same geometry, painted as a border instead of a filled capsule.
none - The library indicator is not painted. This card colors the selected option with CSS. + The library indicator is not painted. This card lets CSS color the selected option.
clone-active - Overlay indicator carries the selected option content as a value capsule. + The overlay indicator carries selected option content as a value capsule.
transition - Same selected geometry, different movement policy. + The same selected geometry with a different movement policy.
@@ -1948,7 +1950,7 @@ export const GeometrySizing: ArchitectureStory = {
@@ -2044,7 +2046,7 @@ export const GeometrySizing: ArchitectureStory = {
geometry.optionSize - Sets fixed square option boxes while measurement details stay internal. + Sets fixed square option boxes while measurement details stay inside the component.
@@ -2084,8 +2086,8 @@ export const DataAttrStylingContract: ArchitectureStory = {
@@ -2183,8 +2185,8 @@ export const DataAttrStylingContract: ArchitectureStory = {
Disabled state

- Disabled is not only a prop. Each disabled option also exposes a stable option attr - that CSS can target. + Disabled is not only a prop. Each disabled option also exposes a stable attr for + state styling.

{`.contract-choice .rsc-option[data-disabled="true"] @@ -2204,7 +2206,7 @@ export const DataAttrStylingContract: ArchitectureStory = {
Interaction state

- During pointer drag, root and option attrs describe the live preview before release + During pointer drag, root and option attrs describe the preview before release commits the next value.

{`.contract-choice.rsc-root[data-dragging="true"] diff --git a/src/stories/basics/States.stories.tsx b/src/stories/basics/States.stories.tsx index e0ea608..8d106f7 100644 --- a/src/stories/basics/States.stories.tsx +++ b/src/stories/basics/States.stories.tsx @@ -25,7 +25,7 @@ const meta = { docs: { description: { component: - 'Core state references covering uncontrolled usage, controlled state and disabled variants.', + 'State references for the cases most apps hit first: controlled value, default value and disabled choices.', }, }, }, @@ -111,7 +111,7 @@ export const Controlled: Story = { docs: { description: { story: - 'A controlled example with a live readout, useful for integrating SegmentedChoice into stateful application logic.', + 'A controlled example with a live readout, matching the shape used when selection belongs to app state.', }, }, }, @@ -133,7 +133,8 @@ export const Uncontrolled: Story = { parameters: { docs: { description: { - story: 'An uncontrolled baseline that relies on `defaultValue` and internal state only.', + story: + 'An uncontrolled baseline: pass `defaultValue`, then let the control own the selected value.', }, }, }, @@ -157,7 +158,7 @@ export const Disabled: Story = { parameters: { docs: { description: { - story: 'Shows the fully disabled state where the entire control is non-interactive.', + story: 'The whole control disabled, including pointer, keyboard and form interaction.', }, }, }, @@ -180,7 +181,8 @@ export const PartiallyDisabled: Story = { parameters: { docs: { description: { - story: 'Demonstrates mixed availability where disabled options coexist with enabled ones.', + story: + 'Enabled and disabled options in the same group, for choices that are temporarily unavailable.', }, }, }, @@ -198,7 +200,7 @@ export const KeyboardFocusVisible: Story = { docs: { description: { story: - 'Shows the keyboard-visible focus treatment on a selected option so focus-ring regressions are caught visually.', + 'Shows the keyboard-visible focus treatment on a selected option, where regressions are easiest to miss.', }, }, }, diff --git a/src/stories/basics/Variants.stories.tsx b/src/stories/basics/Variants.stories.tsx index 38a643c..ab0c888 100644 --- a/src/stories/basics/Variants.stories.tsx +++ b/src/stories/basics/Variants.stories.tsx @@ -89,7 +89,7 @@ const meta = { docs: { description: { component: - 'Baseline SegmentedChoice variants for orientation and content-shape reference without extra product styling.', + 'Plain SegmentedChoice variants for checking orientation and content shape before adding a custom skin.', }, }, }, @@ -156,7 +156,8 @@ export const TextOnlyHorizontal: Story = { parameters: { docs: { description: { - story: 'The baseline horizontal variant with text-only labels and no extra custom styling.', + story: + 'The plain horizontal control: text labels, default skin and no product-specific styling.', }, }, }, @@ -175,8 +176,7 @@ export const IconOnlyHorizontal: Story = { parameters: { docs: { description: { - story: - 'An icon-only horizontal control that demonstrates required option aria labels and compact visual chips.', + story: 'An icon-only control with readable option aria labels and small visual chips.', }, }, }, @@ -201,7 +201,7 @@ export const TextOnlyVertical: Story = { docs: { description: { story: - 'The default vertical layout with text-only labels, useful as the simplest orientation reference.', + 'The same text-only control arranged vertically, useful when a sidebar or tool rail needs real radio semantics.', }, }, }, @@ -222,7 +222,7 @@ export const IconOnlyVertical: Story = { docs: { description: { story: - 'The vertical icon-only variant keeps the same accessible labeling while changing only orientation.', + 'The icon-only variant turned vertical, with the same accessible labels and a different axis.', }, }, }, diff --git a/src/stories/customization/Examples.stories.tsx b/src/stories/customization/Examples.stories.tsx index da5ca75..c73f116 100644 --- a/src/stories/customization/Examples.stories.tsx +++ b/src/stories/customization/Examples.stories.tsx @@ -36,7 +36,7 @@ const meta = { docs: { description: { component: - 'A curated set of distinct SegmentedChoice customization examples with controls and actions enabled.', + 'A curated set of SegmentedChoice examples that push the same component into rails, filters, thumbnails, toolbars and switches.', }, }, },