eidos-ui 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +19 -9
  2. package/dist/{SegmentedControl.types-D_gHYL9X.d.cts → SegmentedControl.types-50fprBiO.d.cts} +9 -0
  3. package/dist/{SegmentedControl.types-CHtjABQM.d.ts → SegmentedControl.types-DKlamnBZ.d.ts} +9 -0
  4. package/dist/{ThemeProvider.types-DgXN0gEH.d.cts → ThemeProvider.types-CI9P1anY.d.cts} +53 -2
  5. package/dist/{ThemeProvider.types-DgXN0gEH.d.ts → ThemeProvider.types-CI9P1anY.d.ts} +53 -2
  6. package/dist/{chunk-363IKX3C.js → chunk-BHZ76J2K.js} +43 -31
  7. package/dist/chunk-BHZ76J2K.js.map +1 -0
  8. package/dist/{chunk-U5KD4BWL.js → chunk-DHOBGYRD.js} +159 -29
  9. package/dist/chunk-DHOBGYRD.js.map +1 -0
  10. package/dist/{chunk-YZEALKC4.cjs → chunk-E7BFBU6K.cjs} +43 -31
  11. package/dist/chunk-E7BFBU6K.cjs.map +1 -0
  12. package/dist/{chunk-XOUNVK45.js → chunk-EJWEG66P.js} +225 -41
  13. package/dist/chunk-EJWEG66P.js.map +1 -0
  14. package/dist/{chunk-FAXDWZQH.js → chunk-FFYPSLQV.js} +2 -2
  15. package/dist/{chunk-PVNBH7XN.cjs → chunk-JO4NDJRA.cjs} +177 -47
  16. package/dist/chunk-JO4NDJRA.cjs.map +1 -0
  17. package/dist/{chunk-QDZVZY53.cjs → chunk-O45W57Y4.cjs} +229 -45
  18. package/dist/chunk-O45W57Y4.cjs.map +1 -0
  19. package/dist/{chunk-NYGRVB2J.cjs → chunk-VGMRIXW4.cjs} +3 -3
  20. package/dist/{chunk-NYGRVB2J.cjs.map → chunk-VGMRIXW4.cjs.map} +1 -1
  21. package/dist/data-grid/index.cjs +3 -3
  22. package/dist/data-grid/index.d.cts +1 -1
  23. package/dist/data-grid/index.d.ts +1 -1
  24. package/dist/data-grid/index.js +2 -2
  25. package/dist/index.cjs +8 -8
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.css +345 -205
  28. package/dist/index.d.cts +2 -2
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.js +7 -7
  31. package/dist/segmented-control/index.cjs +2 -2
  32. package/dist/segmented-control/index.d.cts +2 -2
  33. package/dist/segmented-control/index.d.ts +2 -2
  34. package/dist/segmented-control/index.js +1 -1
  35. package/dist/theme-editor/index.cjs +5 -3
  36. package/dist/theme-editor/index.cjs.map +1 -1
  37. package/dist/theme-editor/index.d.cts +1 -1
  38. package/dist/theme-editor/index.d.ts +1 -1
  39. package/dist/theme-editor/index.js +4 -2
  40. package/dist/theme-provider/index.cjs +2 -2
  41. package/dist/theme-provider/index.d.cts +2 -2
  42. package/dist/theme-provider/index.d.ts +2 -2
  43. package/dist/theme-provider/index.js +1 -1
  44. package/package.json +1 -1
  45. package/dist/chunk-363IKX3C.js.map +0 -1
  46. package/dist/chunk-PVNBH7XN.cjs.map +0 -1
  47. package/dist/chunk-QDZVZY53.cjs.map +0 -1
  48. package/dist/chunk-U5KD4BWL.js.map +0 -1
  49. package/dist/chunk-XOUNVK45.js.map +0 -1
  50. package/dist/chunk-YZEALKC4.cjs.map +0 -1
  51. /package/dist/{chunk-FAXDWZQH.js.map → chunk-FFYPSLQV.js.map} +0 -0
package/README.md CHANGED
@@ -19,14 +19,14 @@
19
19
 
20
20
  ## Features
21
21
 
22
- | | |
23
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
24
- | **Themeable** | Every design token is a CSS custom property. Override them statically, or let users change the palette and typography at runtime with `ThemeProvider`. |
25
- | **Type-safe** | Written in TypeScript with full prop typings exported for every component. |
26
- | **Tree-shakeable** | Per-component entry points (`eidos-ui/button`, `eidos-ui/table`, ...) keep bundles lean. |
27
- | **Icon-agnostic** | Works with any icon library - Lucide, MUI Icons, Font Awesome, Remix Icons, or your own. |
28
- | **Accessible** | Built with keyboard navigation and ARIA semantics in mind. |
29
- | **Optimized build** | Bundled with tsup, shipping both ESM and CJS with source maps and `.d.ts` files. |
22
+ | | |
23
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | **Themeable** | Every design token is a CSS custom property. Override them statically, or let users change the palette and typography at runtime with `ThemeProvider`. Light, dark and system colour schemes, AA in both. |
25
+ | **Type-safe** | Written in TypeScript with full prop typings exported for every component. |
26
+ | **Tree-shakeable** | Per-component entry points (`eidos-ui/button`, `eidos-ui/table`, ...) keep bundles lean. |
27
+ | **Icon-agnostic** | Works with any icon library - Lucide, MUI Icons, Font Awesome, Remix Icons, or your own. |
28
+ | **Accessible** | Built with keyboard navigation and ARIA semantics in mind. |
29
+ | **Optimized build** | Bundled with tsup, shipping both ESM and CJS with source maps and `.d.ts` files. |
30
30
 
31
31
  ## Installation
32
32
 
@@ -244,6 +244,16 @@ Individual components can also be targeted directly, following each component's
244
244
  }
245
245
  ```
246
246
 
247
+ ### Light and dark
248
+
249
+ Set `data-color-scheme` on `<html>` - `light` (the default), `dark`, or `system` to follow the operating system:
250
+
251
+ ```html
252
+ <html data-color-scheme="system"></html>
253
+ ```
254
+
255
+ Or let `ThemeProvider` manage it with `colorScheme` / `defaultColorScheme`. `system` is a media query, so it needs no JavaScript and cannot flash the wrong scheme on load. Custom colours apply to both schemes: in dark each one is lightened until it reads on the dark page, so a themed app's dark mode stays AA without a second palette. For your own CSS, use `--surface`, `--surface-raised` and `--text-default` - the literals (`--white`, `--dark-color`) keep their values in both schemes.
256
+
247
257
  ### Runtime theming
248
258
 
249
259
  `ThemeProvider` applies a theme at runtime - for a settings screen, a per-tenant palette, or a colour a user picks. Supply one base colour per family and the shades, tints, ramp steps and accessible foregrounds are derived from it:
@@ -267,7 +277,7 @@ import { ThemeProvider, ThemeEditor } from 'eidos-ui';
267
277
 
268
278
  Ten things are editable - seven colour bases, two font stacks, and a font scale. Tokens are written through the CSSOM to `document.documentElement`, which needs **no Content Security Policy allowance** and covers portaled overlays too. A theme equal to the preset writes nothing at all.
269
279
 
270
- Use `useTheme()` to read or change it from your own UI, and `toCss()` to export the resolved tokens as a `:root` block you can paste into a stylesheet - useful for baking a theme in at build time.
280
+ Use `useTheme()` to read or change it from your own UI (including `setColorScheme`), and `toCss()` to export the resolved tokens - light and dark - as CSS you can paste into a stylesheet, useful for baking a theme in at build time.
271
281
 
272
282
  ### Fonts
273
283
 
@@ -44,6 +44,15 @@ interface SegmentedControlProps {
44
44
  * Scroll affordance for a track too wide for its container. @default 'auto'
45
45
  */
46
46
  scrollButtons?: SegmentedControlScrollButtonsProps;
47
+ /**
48
+ * Accessible name for the group - what the choice is *about* ("Colour
49
+ * scheme"). The segments name the options; without this a screen reader
50
+ * announces an unnamed radio group. Prefer `aria-labelledby` when a
51
+ * visible label already exists.
52
+ */
53
+ ariaLabel?: string;
54
+ /** Id of a visible element naming the group. */
55
+ 'aria-labelledby'?: string;
47
56
  className?: string;
48
57
  }
49
58
 
@@ -44,6 +44,15 @@ interface SegmentedControlProps {
44
44
  * Scroll affordance for a track too wide for its container. @default 'auto'
45
45
  */
46
46
  scrollButtons?: SegmentedControlScrollButtonsProps;
47
+ /**
48
+ * Accessible name for the group - what the choice is *about* ("Colour
49
+ * scheme"). The segments name the options; without this a screen reader
50
+ * announces an unnamed radio group. Prefer `aria-labelledby` when a
51
+ * visible label already exists.
52
+ */
53
+ ariaLabel?: string;
54
+ /** Id of a visible element naming the group. */
55
+ 'aria-labelledby'?: string;
47
56
  className?: string;
48
57
  }
49
58
 
@@ -44,9 +44,31 @@ interface ThemeTypography {
44
44
  * exported by MUI, styled-components and others in a consumer's namespace.
45
45
  */
46
46
  interface ThemeConfig {
47
+ /**
48
+ * The palette, applied to **both** colour schemes. In the dark scheme each
49
+ * colour is tone-shifted automatically - lightened until it clears 5:1
50
+ * against the dark surfaces, with a dark foreground on it - so one brand
51
+ * palette stays accessible in light and dark.
52
+ */
47
53
  colors?: ThemeColors;
48
54
  typography?: ThemeTypography;
55
+ /**
56
+ * Exact dark-scheme colours, for a brand that has its own dark palette.
57
+ * Each one replaces the colour derived from `colors` for that family only.
58
+ * A value that falls below 4.5:1 against the dark surfaces is still used,
59
+ * with a development warning - text in that colour will fail AA there.
60
+ */
61
+ dark?: {
62
+ colors?: ThemeColors;
63
+ };
49
64
  }
65
+ /**
66
+ * Which colour scheme to use. `system` follows the operating system's
67
+ * `prefers-color-scheme`, and keeps following it as it changes.
68
+ */
69
+ type ColorScheme = 'light' | 'dark' | 'system';
70
+ /** The scheme actually in effect once `system` is resolved. */
71
+ type ResolvedColorScheme = 'light' | 'dark';
50
72
  /** A theme with every field populated - what the provider actually applies. */
51
73
  interface ResolvedTheme {
52
74
  colors: Record<ThemeColorKey, {
@@ -54,6 +76,13 @@ interface ResolvedTheme {
54
76
  contrast?: string;
55
77
  }>;
56
78
  typography: Required<ThemeTypography>;
79
+ /** The dark-scheme colours: an explicit `dark.colors` entry, or derived from `colors`. */
80
+ dark: {
81
+ colors: Record<ThemeColorKey, {
82
+ base: string;
83
+ contrast?: string;
84
+ }>;
85
+ };
57
86
  }
58
87
  interface ThemeContextValue {
59
88
  /** The theme as supplied, with omitted fields left absent. */
@@ -68,8 +97,17 @@ interface ThemeContextValue {
68
97
  resetTheme: () => void;
69
98
  /** True when nothing differs from the preset, so no tokens are being written. */
70
99
  isDefault: boolean;
71
- /** The resolved theme as a `:root { … }` CSS block. */
100
+ /**
101
+ * The resolved theme as CSS: a `:root { … }` block for the light scheme and
102
+ * the matching blocks for the dark and `system` schemes.
103
+ */
72
104
  toCss: () => string;
105
+ /** The scheme requested - `light` unless one has been set. */
106
+ colorScheme: ColorScheme;
107
+ /** The scheme in effect, with `system` resolved against the OS setting. */
108
+ resolvedColorScheme: ResolvedColorScheme;
109
+ /** Switches the scheme. In controlled mode this only calls `onColorSchemeChange`. */
110
+ setColorScheme: (scheme: ColorScheme) => void;
73
111
  }
74
112
  interface ThemeProviderProps {
75
113
  children: React__default.ReactNode;
@@ -83,6 +121,19 @@ interface ThemeProviderProps {
83
121
  defaultTheme?: ThemeConfig;
84
122
  /** Fires whenever the theme changes, in both controlled and uncontrolled mode. */
85
123
  onThemeChange?: (theme: ThemeConfig) => void;
124
+ /**
125
+ * Controlled colour scheme, written to `<html data-color-scheme>`. Pair with
126
+ * `onColorSchemeChange` and persist it yourself.
127
+ *
128
+ * Leave both this and `defaultColorScheme` unset to have the provider leave
129
+ * the attribute alone - for an app that server-renders it to avoid a flash
130
+ * of the wrong scheme. The provider then reads the scheme from the page.
131
+ */
132
+ colorScheme?: ColorScheme;
133
+ /** Initial scheme for uncontrolled usage. Ignored when `colorScheme` is supplied. */
134
+ defaultColorScheme?: ColorScheme;
135
+ /** Fires whenever the scheme is changed through `setColorScheme`. */
136
+ onColorSchemeChange?: (scheme: ColorScheme) => void;
86
137
  }
87
138
 
88
- export type { ResolvedTheme as R, ThemeColorKey as T, ThemeColorValue as a, ThemeColors as b, ThemeConfig as c, ThemeContextValue as d, ThemeFontOption as e, ThemeProviderProps as f, ThemeTypography as g };
139
+ export type { ColorScheme as C, ResolvedColorScheme as R, ThemeColorKey as T, ResolvedTheme as a, ThemeColorValue as b, ThemeColors as c, ThemeConfig as d, ThemeContextValue as e, ThemeFontOption as f, ThemeProviderProps as g, ThemeTypography as h };
@@ -44,9 +44,31 @@ interface ThemeTypography {
44
44
  * exported by MUI, styled-components and others in a consumer's namespace.
45
45
  */
46
46
  interface ThemeConfig {
47
+ /**
48
+ * The palette, applied to **both** colour schemes. In the dark scheme each
49
+ * colour is tone-shifted automatically - lightened until it clears 5:1
50
+ * against the dark surfaces, with a dark foreground on it - so one brand
51
+ * palette stays accessible in light and dark.
52
+ */
47
53
  colors?: ThemeColors;
48
54
  typography?: ThemeTypography;
55
+ /**
56
+ * Exact dark-scheme colours, for a brand that has its own dark palette.
57
+ * Each one replaces the colour derived from `colors` for that family only.
58
+ * A value that falls below 4.5:1 against the dark surfaces is still used,
59
+ * with a development warning - text in that colour will fail AA there.
60
+ */
61
+ dark?: {
62
+ colors?: ThemeColors;
63
+ };
49
64
  }
65
+ /**
66
+ * Which colour scheme to use. `system` follows the operating system's
67
+ * `prefers-color-scheme`, and keeps following it as it changes.
68
+ */
69
+ type ColorScheme = 'light' | 'dark' | 'system';
70
+ /** The scheme actually in effect once `system` is resolved. */
71
+ type ResolvedColorScheme = 'light' | 'dark';
50
72
  /** A theme with every field populated - what the provider actually applies. */
51
73
  interface ResolvedTheme {
52
74
  colors: Record<ThemeColorKey, {
@@ -54,6 +76,13 @@ interface ResolvedTheme {
54
76
  contrast?: string;
55
77
  }>;
56
78
  typography: Required<ThemeTypography>;
79
+ /** The dark-scheme colours: an explicit `dark.colors` entry, or derived from `colors`. */
80
+ dark: {
81
+ colors: Record<ThemeColorKey, {
82
+ base: string;
83
+ contrast?: string;
84
+ }>;
85
+ };
57
86
  }
58
87
  interface ThemeContextValue {
59
88
  /** The theme as supplied, with omitted fields left absent. */
@@ -68,8 +97,17 @@ interface ThemeContextValue {
68
97
  resetTheme: () => void;
69
98
  /** True when nothing differs from the preset, so no tokens are being written. */
70
99
  isDefault: boolean;
71
- /** The resolved theme as a `:root { … }` CSS block. */
100
+ /**
101
+ * The resolved theme as CSS: a `:root { … }` block for the light scheme and
102
+ * the matching blocks for the dark and `system` schemes.
103
+ */
72
104
  toCss: () => string;
105
+ /** The scheme requested - `light` unless one has been set. */
106
+ colorScheme: ColorScheme;
107
+ /** The scheme in effect, with `system` resolved against the OS setting. */
108
+ resolvedColorScheme: ResolvedColorScheme;
109
+ /** Switches the scheme. In controlled mode this only calls `onColorSchemeChange`. */
110
+ setColorScheme: (scheme: ColorScheme) => void;
73
111
  }
74
112
  interface ThemeProviderProps {
75
113
  children: React__default.ReactNode;
@@ -83,6 +121,19 @@ interface ThemeProviderProps {
83
121
  defaultTheme?: ThemeConfig;
84
122
  /** Fires whenever the theme changes, in both controlled and uncontrolled mode. */
85
123
  onThemeChange?: (theme: ThemeConfig) => void;
124
+ /**
125
+ * Controlled colour scheme, written to `<html data-color-scheme>`. Pair with
126
+ * `onColorSchemeChange` and persist it yourself.
127
+ *
128
+ * Leave both this and `defaultColorScheme` unset to have the provider leave
129
+ * the attribute alone - for an app that server-renders it to avoid a flash
130
+ * of the wrong scheme. The provider then reads the scheme from the page.
131
+ */
132
+ colorScheme?: ColorScheme;
133
+ /** Initial scheme for uncontrolled usage. Ignored when `colorScheme` is supplied. */
134
+ defaultColorScheme?: ColorScheme;
135
+ /** Fires whenever the scheme is changed through `setColorScheme`. */
136
+ onColorSchemeChange?: (scheme: ColorScheme) => void;
86
137
  }
87
138
 
88
- export type { ResolvedTheme as R, ThemeColorKey as T, ThemeColorValue as a, ThemeColors as b, ThemeConfig as c, ThemeContextValue as d, ThemeFontOption as e, ThemeProviderProps as f, ThemeTypography as g };
139
+ export type { ColorScheme as C, ResolvedColorScheme as R, ThemeColorKey as T, ResolvedTheme as a, ThemeColorValue as b, ThemeColors as c, ThemeConfig as d, ThemeContextValue as e, ThemeFontOption as f, ThemeProviderProps as g, ThemeTypography as h };
@@ -20,6 +20,8 @@ var SegmentedControl = ({
20
20
  disabled = false,
21
21
  fullWidth = false,
22
22
  scrollButtons = "auto",
23
+ ariaLabel,
24
+ "aria-labelledby": ariaLabelledBy,
23
25
  className = ""
24
26
  }) => {
25
27
  const isControlled = value !== void 0;
@@ -128,37 +130,47 @@ var SegmentedControl = ({
128
130
  `eidos-segmented-scroll-button--${direction}`,
129
131
  `eidos-segmented-scroll-button--${size}`
130
132
  ].join(" ");
131
- const track = /* @__PURE__ */ jsx("div", { ref: trackRef, className: containerClasses, role: "radiogroup", children: options.map((opt) => {
132
- const isActive = activeValue === opt.value;
133
- const isDisabled = disabled || !!opt.disabled;
134
- if (!opt.label && !opt.tooltip) {
135
- devWarn(
136
- "segmented-icon-only-name",
137
- `SegmentedControl: option "${opt.value}" has an icon but no \`label\` or \`tooltip\`, so it has no accessible name.`
138
- );
133
+ const track = /* @__PURE__ */ jsx(
134
+ "div",
135
+ {
136
+ ref: trackRef,
137
+ className: containerClasses,
138
+ role: "radiogroup",
139
+ "aria-label": ariaLabel,
140
+ "aria-labelledby": ariaLabelledBy,
141
+ children: options.map((opt) => {
142
+ const isActive = activeValue === opt.value;
143
+ const isDisabled = disabled || !!opt.disabled;
144
+ if (!opt.label && !opt.tooltip) {
145
+ devWarn(
146
+ "segmented-icon-only-name",
147
+ `SegmentedControl: option "${opt.value}" has an icon but no \`label\` or \`tooltip\`, so it has no accessible name.`
148
+ );
149
+ }
150
+ const segment = /* @__PURE__ */ jsxs(
151
+ "button",
152
+ {
153
+ type: "button",
154
+ role: "radio",
155
+ "aria-checked": isActive,
156
+ disabled: isDisabled,
157
+ "data-value": opt.value,
158
+ tabIndex: isTabbable(opt.value) ? 0 : -1,
159
+ className: ["eidos-segmented-item", isActive && "eidos-segmented-item--active"].filter(Boolean).join(" "),
160
+ onClick: () => !isDisabled && handleSelect(opt.value),
161
+ onKeyDown: handleKeyDown,
162
+ "aria-label": !opt.label ? opt.tooltip : void 0,
163
+ children: [
164
+ opt.icon && renderIcon(opt.icon, "eidos-segmented-icon"),
165
+ opt.label && /* @__PURE__ */ jsx("span", { className: "eidos-segmented-label", children: opt.label })
166
+ ]
167
+ },
168
+ opt.value
169
+ );
170
+ return opt.tooltip ? /* @__PURE__ */ jsx(Tooltip, { message: opt.tooltip, children: segment }, opt.value) : React.cloneElement(segment, { key: opt.value });
171
+ })
139
172
  }
140
- const segment = /* @__PURE__ */ jsxs(
141
- "button",
142
- {
143
- type: "button",
144
- role: "radio",
145
- "aria-checked": isActive,
146
- disabled: isDisabled,
147
- "data-value": opt.value,
148
- tabIndex: isTabbable(opt.value) ? 0 : -1,
149
- className: ["eidos-segmented-item", isActive && "eidos-segmented-item--active"].filter(Boolean).join(" "),
150
- onClick: () => !isDisabled && handleSelect(opt.value),
151
- onKeyDown: handleKeyDown,
152
- "aria-label": !opt.label ? opt.tooltip : void 0,
153
- children: [
154
- opt.icon && renderIcon(opt.icon, "eidos-segmented-icon"),
155
- opt.label && /* @__PURE__ */ jsx("span", { className: "eidos-segmented-label", children: opt.label })
156
- ]
157
- },
158
- opt.value
159
- );
160
- return opt.tooltip ? /* @__PURE__ */ jsx(Tooltip, { message: opt.tooltip, children: segment }, opt.value) : React.cloneElement(segment, { key: opt.value });
161
- }) });
173
+ );
162
174
  return /* @__PURE__ */ jsxs("div", { className: barClasses, children: [
163
175
  showPrevButton && /* @__PURE__ */ jsx(
164
176
  "button",
@@ -191,4 +203,4 @@ var SegmentedControl = ({
191
203
  export {
192
204
  SegmentedControl
193
205
  };
194
- //# sourceMappingURL=chunk-363IKX3C.js.map
206
+ //# sourceMappingURL=chunk-BHZ76J2K.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/SegmentedControl/SegmentedControl.component.tsx"],"sourcesContent":["import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { Tooltip } from '../Tooltip';\nimport { renderIcon, devWarn } from '../../utils';\nimport type { SegmentedControlProps } from './SegmentedControl.types';\n\n/**\n * SegmentedControl - a compact, single-select toggle group rendered as a pill.\n *\n * Semantics: `role=\"radiogroup\"` on the container, `role=\"radio\"` on each\n * segment. Supports controlled and uncontrolled usage.\n *\n * @example\n * ```tsx\n * // Controlled\n * const [view, setView] = useState('list');\n * <SegmentedControl\n * options={[\n * { value: 'list', icon: List, tooltip: 'List view' },\n * { value: 'grid', icon: LayoutGrid, tooltip: 'Grid view' },\n * { value: 'table', icon: Table2, tooltip: 'Table view' },\n * ]}\n * value={view}\n * onChange={setView}\n * />\n *\n * // With labels\n * <SegmentedControl\n * options={[\n * { value: 'day', label: 'Day' },\n * { value: 'week', label: 'Week' },\n * { value: 'month', label: 'Month' },\n * ]}\n * defaultValue=\"week\"\n * onChange={console.log}\n * />\n * ```\n */\nexport const SegmentedControl: React.FC<SegmentedControlProps> = ({\n options,\n value,\n defaultValue,\n onChange,\n size = 'md',\n color = 'primary',\n disabled = false,\n fullWidth = false,\n scrollButtons = 'auto',\n ariaLabel,\n 'aria-labelledby': ariaLabelledBy,\n className = '',\n}) => {\n const isControlled = value !== undefined;\n const [internalValue, setInternalValue] = useState<string>(\n defaultValue ?? options[0]?.value ?? '',\n );\n\n const activeValue = isControlled ? value : internalValue;\n\n const handleSelect = (optValue: string) => {\n if (disabled) return;\n if (optValue === activeValue) return; // already selected\n if (!isControlled) setInternalValue(optValue);\n onChange?.(optValue);\n };\n\n const trackRef = useRef<HTMLDivElement>(null);\n\n // Roving tabindex: the group is a single tab stop, and arrow keys move\n // within it - the WAI-ARIA radiogroup pattern, which this had none of.\n // Every segment used to be its own tab stop, so a six-option control cost\n // six presses to skip past.\n //\n // The tabbable segment is the selected one; if nothing is selected (a\n // controlled `value` matching no option), the first enabled segment takes\n // the stop so the group can still be reached at all.\n const selectableValues = options.filter((opt) => !opt.disabled).map((opt) => opt.value);\n const hasSelection = selectableValues.includes(activeValue);\n const fallbackTabbableValue = selectableValues[0];\n\n const isTabbable = (optValue: string) =>\n hasSelection ? optValue === activeValue : optValue === fallbackTabbableValue;\n\n // Selection follows focus, which is what the radio pattern specifies -\n // unlike `Tabs`, where arrow keys only move focus. Disabled segments are\n // skipped rather than selected-and-ignored, and both ends wrap.\n const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n if (disabled || selectableValues.length === 0) return;\n\n const currentIndex = selectableValues.indexOf(activeValue);\n const from = currentIndex === -1 ? 0 : currentIndex;\n let targetIndex = -1;\n\n if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {\n targetIndex = (from + 1) % selectableValues.length;\n } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {\n targetIndex = (from - 1 + selectableValues.length) % selectableValues.length;\n } else if (event.key === 'Home') {\n targetIndex = 0;\n } else if (event.key === 'End') {\n targetIndex = selectableValues.length - 1;\n }\n\n if (targetIndex === -1) return;\n\n event.preventDefault();\n const targetValue = selectableValues[targetIndex];\n handleSelect(targetValue);\n\n // Focus has to follow the selection explicitly: the segment that was\n // focused is about to lose its `tabIndex={0}` to the newly selected one,\n // and focus does not move on its own.\n const target = trackRef.current?.querySelector<HTMLElement>(\n `[data-value=\"${CSS.escape(targetValue)}\"]`,\n );\n target?.focus();\n };\n\n // The selected segment can sit outside the scrollport - after a controlled\n // `value` change, or once focus moves to a segment off screen. `scrollLeft`\n // is set directly rather than through `scrollIntoView`, which walks the\n // whole ancestor chain and would scroll the page vertically when the\n // control is below the fold.\n useLayoutEffect(() => {\n const track = trackRef.current;\n if (!track) return;\n\n const activeItem = track.querySelector('[aria-checked=\"true\"]') as HTMLElement | null;\n if (!activeItem) return;\n\n const left = activeItem.offsetLeft;\n const right = left + activeItem.offsetWidth;\n\n if (left < track.scrollLeft) {\n track.scrollLeft = left;\n } else if (right > track.scrollLeft + track.clientWidth) {\n track.scrollLeft = right - track.clientWidth;\n }\n }, [activeValue]);\n\n // Whether either end has segments hidden past it. Drives the presence of\n // each button: none at all when the track fits, and none at the end already\n // reached, since a button there would point at nothing.\n const [scrollState, setScrollState] = useState({ canScrollPrev: false, canScrollNext: false });\n const hasScrollButtons = scrollButtons !== 'none';\n\n useEffect(() => {\n if (!hasScrollButtons) return;\n const track = trackRef.current;\n if (!track) return;\n\n const update = () => {\n const maxScroll = track.scrollWidth - track.clientWidth;\n // 1px of tolerance: fractional layout widths leave a sub-pixel\n // remainder at either end that would otherwise keep a button enabled\n // for a scroll that can no longer move anything.\n const canScrollPrev = track.scrollLeft > 1;\n const canScrollNext = track.scrollLeft < maxScroll - 1;\n setScrollState((previous) =>\n previous.canScrollPrev === canScrollPrev && previous.canScrollNext === canScrollNext\n ? previous\n : { canScrollPrev, canScrollNext },\n );\n };\n\n update();\n track.addEventListener('scroll', update, { passive: true });\n\n // The track's own box stays the same width when a label reflows or a font\n // finishes loading, so the segments have to be observed as well as the\n // container - only their sizes tell us the track started overflowing.\n const observer = new ResizeObserver(update);\n observer.observe(track);\n Array.from(track.children).forEach((child) => observer.observe(child));\n\n return () => {\n track.removeEventListener('scroll', update);\n observer.disconnect();\n };\n }, [hasScrollButtons, options.length, size, fullWidth]);\n\n const scrollByStep = (direction: -1 | 1) => {\n const track = trackRef.current;\n if (!track) return;\n\n // A step just short of a full page keeps a segment of context on screen,\n // the same way a paged scrollbar click does.\n const step = track.clientWidth * 0.8;\n const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n track.scrollBy({ left: direction * step, behavior: reducedMotion ? 'auto' : 'smooth' });\n };\n\n const barClasses = [\n 'eidos-segmented-bar',\n fullWidth && 'eidos-segmented-bar--full-width',\n disabled && 'eidos-segmented-bar--disabled',\n className,\n ]\n .filter(Boolean)\n .join(' ');\n\n const containerClasses = [\n 'eidos-segmented',\n `eidos-segmented--${size}`,\n `eidos-segmented--${color}`,\n disabled && 'eidos-segmented--disabled',\n fullWidth && 'eidos-segmented--full-width',\n // With buttons, they are the affordance - a native scrollbar under a\n // compact pill control is redundant and would break its shape.\n hasScrollButtons && 'eidos-segmented--hide-scrollbar',\n ]\n .filter(Boolean)\n .join(' ');\n\n const showPrevButton = hasScrollButtons && scrollState.canScrollPrev;\n const showNextButton = hasScrollButtons && scrollState.canScrollNext;\n\n const scrollButtonClasses = (direction: 'prev' | 'next') =>\n [\n 'eidos-segmented-scroll-button',\n `eidos-segmented-scroll-button--${direction}`,\n `eidos-segmented-scroll-button--${size}`,\n ].join(' ');\n\n const track = (\n <div\n ref={trackRef}\n className={containerClasses}\n role=\"radiogroup\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n >\n {options.map((opt) => {\n const isActive = activeValue === opt.value;\n const isDisabled = disabled || !!opt.disabled;\n\n // An icon-only segment has no text to be named by. `tooltip` wraps\n // it in a `Tooltip`, which contributes nothing to the accessible\n // name, so such a segment was announced as just \"radio\" - identical\n // to every other segment, making the group unusable by voice or\n // screen reader. Same fallback as `Button`.\n if (!opt.label && !opt.tooltip) {\n devWarn(\n 'segmented-icon-only-name',\n `SegmentedControl: option \"${opt.value}\" has an icon but no \\`label\\` or \\`tooltip\\`, so it has no accessible name.`,\n );\n }\n\n const segment = (\n <button\n key={opt.value}\n type=\"button\"\n role=\"radio\"\n aria-checked={isActive}\n disabled={isDisabled}\n // Read back by the keyboard handler to move focus onto the newly\n // selected segment, which `value` alone can't identify in the DOM.\n data-value={opt.value}\n tabIndex={isTabbable(opt.value) ? 0 : -1}\n className={['eidos-segmented-item', isActive && 'eidos-segmented-item--active']\n .filter(Boolean)\n .join(' ')}\n onClick={() => !isDisabled && handleSelect(opt.value)}\n onKeyDown={handleKeyDown}\n aria-label={!opt.label ? opt.tooltip : undefined}\n >\n {opt.icon && renderIcon(opt.icon, 'eidos-segmented-icon')}\n {opt.label && <span className=\"eidos-segmented-label\">{opt.label}</span>}\n </button>\n );\n\n return opt.tooltip ? (\n <Tooltip key={opt.value} message={opt.tooltip}>\n {segment}\n </Tooltip>\n ) : (\n React.cloneElement(segment, { key: opt.value })\n );\n })}\n </div>\n );\n\n // The buttons are siblings of the track, never children of it:\n // `role=\"radiogroup\"` only accepts radios. They are also hidden from\n // assistive tech and skipped by Tab, because they are a pointer affordance\n // for something the keyboard already does - tabbing to a segment scrolls it\n // into view. Same arrangement as `Tabs`.\n return (\n <div className={barClasses}>\n {showPrevButton && (\n <button\n type=\"button\"\n className={scrollButtonClasses('prev')}\n onClick={() => scrollByStep(-1)}\n // `tabIndex={-1}` keeps these out of the tab order, but it does not\n // stop a *click* from focusing them - and focus landing inside an\n // `aria-hidden` subtree is an error the browser reports (\"Blocked\n // aria-hidden on an element because its descendant retained\n // focus\"). Suppressing the default mousedown behaviour keeps the\n // button unfocusable by pointer as well, while the click still\n // fires. `inert` would also prevent the click, so it is not an\n // option here.\n onMouseDown={(event) => event.preventDefault()}\n tabIndex={-1}\n aria-hidden=\"true\"\n >\n <ChevronLeft className=\"eidos-segmented-scroll-button-icon\" />\n </button>\n )}\n\n {track}\n\n {showNextButton && (\n <button\n type=\"button\"\n className={scrollButtonClasses('next')}\n onClick={() => scrollByStep(1)}\n onMouseDown={(event) => event.preventDefault()} // see the prev button\n tabIndex={-1}\n aria-hidden=\"true\"\n >\n <ChevronRight className=\"eidos-segmented-scroll-button-icon\" />\n </button>\n )}\n </div>\n );\n};\n"],"mappings":"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","names":["track"]}