Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 56 additions & 59 deletions API.md

Large diffs are not rendered by default.

58 changes: 28 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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:

Expand All @@ -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:

Expand Down Expand Up @@ -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?: {
Expand Down Expand Up @@ -163,24 +161,24 @@ 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`

`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`.

Expand All @@ -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

Expand Down Expand Up @@ -267,15 +265,15 @@ 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

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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
112 changes: 101 additions & 11 deletions src/SegmentedChoice/SegmentedChoice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -94,6 +98,12 @@ function isNearLayoutSize(measured: number, expected: number) {
return Math.abs(measured - expected) < 0.5;
}

function getIndicatorLayoutSignature(
layout: Pick<IndicatorLayout, 'height' | 'width' | 'x' | 'y'>
) {
return [layout.x, layout.y, layout.width, layout.height].join('|');
}

function InnerSegmentedChoice<T extends SegmentedChoiceValue>(
{
ariaDescribedby,
Expand Down Expand Up @@ -327,7 +337,14 @@ function InnerSegmentedChoice<T extends SegmentedChoiceValue>(
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,
Expand Down Expand Up @@ -380,6 +397,9 @@ function InnerSegmentedChoice<T extends SegmentedChoiceValue>(
? 'grab'
: 'pointer';
const listTouchAction = !disabled && draggable ? 'none' : undefined;
const indicatorLayoutSignature = getIndicatorLayoutSignature(indicatorLayout);

latestInitialIndicatorLayoutSignatureRef.current = indicatorLayoutSignature;

useLayoutEffect(() => {
optionRefs.current.length = options.length;
Expand All @@ -396,40 +416,106 @@ function InnerSegmentedChoice<T extends SegmentedChoiceValue>(
(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 = {
Expand All @@ -454,6 +540,10 @@ function InnerSegmentedChoice<T extends SegmentedChoiceValue>(
resolvedName,
shouldRenderAnchor,
};
const renderedIndicatorLayout = {
...indicatorLayout,
isVisible: indicatorMotionState !== 'initial' && indicatorLayout.isVisible,
};

const instanceStyleText = buildSegmentedChoiceRuntimeRule({
anchorHeight: anchorConfig.height,
Expand All @@ -463,7 +553,7 @@ function InnerSegmentedChoice<T extends SegmentedChoiceValue>(
indicatorColor: indicatorOption?.accentColor,
indicatorCursor: interactiveCursor,
indicatorHeight: indicatorConfig.height !== undefined ? indicatorConfig.height : undefined,
indicatorLayout,
indicatorLayout: renderedIndicatorLayout,
indicatorScale,
indicatorWidth: indicatorConfig.width !== undefined ? indicatorConfig.width : undefined,
instanceId,
Expand Down Expand Up @@ -508,7 +598,7 @@ function InnerSegmentedChoice<T extends SegmentedChoiceValue>(
}
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}
Expand Down
Loading
Loading