@tangle-network/ui 11.34.0 → 11.35.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.35.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 19e3cd6: SegmentedControl: a strip whose options outgrow it now shows a chevron at each edge with options past it, instead of an edge fade that a host's inline padding or a cut between two options made invisible. The chevrons page the strip on click or tap and take no room in the row. The selected option is kept fully in view clear of the chevrons, and this now also works for a row control a host made scroll with `flex-nowrap overflow-x-auto`.
8
+
3
9
  ## 11.34.0
4
10
 
5
11
  ### Minor Changes
@@ -24,9 +24,12 @@ import * as React from "react";
24
24
  * - Every label keeps one weight. Selection is colour plus surface or
25
25
  * underline, so changing the value never reflows the row.
26
26
  *
27
- * The tabs variant scrolls sideways when its options outgrow the row: the
28
- * selected tab is kept in view, and a faded edge shows that more tabs sit
29
- * past it.
27
+ * When the options outgrow the row they scroll sideways (the tabs variant
28
+ * always can; the row variant can when a host gives it `flex-nowrap
29
+ * overflow-x-auto`). A chevron button pinned to each edge with options past it
30
+ * shows that the strip scrolls and pages it on click or tap; touch and
31
+ * trackpad scroll it natively, and arrow keys move the selection, which is
32
+ * kept fully in view clear of the chevrons.
30
33
  */
31
34
  export interface SegmentedControlOption<T extends string = string> {
32
35
  value: T;
@@ -5,14 +5,39 @@ import { cn } from "../lib/utils.js";
5
5
  import "../lib/control-presentation.js";
6
6
  import * as React from "react";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { ChevronLeft, ChevronRight } from "lucide-react";
8
9
  //#region src/primitives/segmented-control.tsx
9
- /** Width of the faded edge that marks more tabs past the strip, in px. */
10
- const TAB_EDGE_FADE = 24;
11
- const tabEdgeFade = [
12
- "data-[overflow-start]:[mask-image:linear-gradient(to_right,transparent,#000_24px)]",
13
- "data-[overflow-end]:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)]",
14
- "data-[overflow-start]:data-[overflow-end]:[mask-image:linear-gradient(to_right,transparent,#000_24px,#000_calc(100%-24px),transparent)]"
15
- ];
10
+ /** Size of the edge chevron, in px. The selected option is kept this far
11
+ * (plus the strip's own padding) from an edge so the chevron never covers it. */
12
+ const SCROLL_CUE_SIZE = 32;
13
+ /**
14
+ * A chevron pinned to one edge of the scrolling strip. It is a sticky flex
15
+ * item with a negative margin equal to its width plus the gap, so it takes no room in the
16
+ * row (showing it never reflows the options) and stays at the visible edge
17
+ * while the strip scrolls. A solid surface keeps it legible on any plane,
18
+ * which an edge fade is not: a fade over the gap between two options, or over
19
+ * a host's inline padding, shows nothing.
20
+ */
21
+ function ScrollCue({ side, onPage }) {
22
+ const Icon = side === "start" ? ChevronLeft : ChevronRight;
23
+ return /* @__PURE__ */ jsx("button", {
24
+ type: "button",
25
+ tabIndex: -1,
26
+ "aria-hidden": "true",
27
+ "data-scroll-cue": side,
28
+ onClick: onPage,
29
+ className: cn("sticky z-[1] flex shrink-0 items-center justify-center self-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground", side === "start" ? "left-0" : "right-0"),
30
+ style: {
31
+ width: SCROLL_CUE_SIZE,
32
+ height: SCROLL_CUE_SIZE,
33
+ [side === "start" ? "marginRight" : "marginLeft"]: -36
34
+ },
35
+ children: /* @__PURE__ */ jsx(Icon, {
36
+ className: "h-4 w-4",
37
+ "aria-hidden": "true"
38
+ })
39
+ });
40
+ }
16
41
  function SegmentedControl({ value, onValueChange, options, variant = "row", className, ...rest }) {
17
42
  if (typeof process !== "undefined" && process?.env?.NODE_ENV !== "production" && !rest["aria-label"] && !rest["aria-labelledby"]) console.warn("[SegmentedControl] role=\"radiogroup\" requires either aria-label or aria-labelledby for accessibility.");
18
43
  const optionRefs = React.useRef(/* @__PURE__ */ new Map());
@@ -21,7 +46,7 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
21
46
  start: false,
22
47
  end: false
23
48
  });
24
- const tabs = variant === "tabs";
49
+ const optionKey = options.map((o) => o.value).join("\0");
25
50
  const measureOverflow = React.useCallback(() => {
26
51
  const strip = stripRef.current;
27
52
  if (!strip) return;
@@ -32,31 +57,45 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
32
57
  end
33
58
  });
34
59
  }, []);
35
- React.useLayoutEffect(() => {
36
- if (!tabs) return;
60
+ const valueRef = React.useRef(value);
61
+ valueRef.current = value;
62
+ const revealSelected = React.useCallback(() => {
37
63
  const strip = stripRef.current;
38
- const option = optionRefs.current.get(value);
39
- if (strip && option) {
64
+ const option = optionRefs.current.get(valueRef.current);
65
+ if (strip && option && strip.scrollWidth > strip.clientWidth) {
66
+ const style = getComputedStyle(strip);
67
+ const reserveStart = 36 + (Number.parseFloat(style.paddingLeft) || 0);
68
+ const reserveEnd = 36 + (Number.parseFloat(style.paddingRight) || 0);
40
69
  const start = option.offsetLeft;
41
70
  const end = start + option.offsetWidth;
42
- if (start < strip.scrollLeft + TAB_EDGE_FADE) strip.scrollLeft = Math.max(0, start - TAB_EDGE_FADE);
43
- else if (end > strip.scrollLeft + strip.clientWidth - TAB_EDGE_FADE) strip.scrollLeft = end - strip.clientWidth + TAB_EDGE_FADE;
71
+ if (start - reserveStart < strip.scrollLeft) strip.scrollLeft = Math.max(0, start - reserveStart);
72
+ else if (end + reserveEnd > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = end + reserveEnd - strip.clientWidth;
44
73
  }
45
74
  measureOverflow();
46
- }, [
47
- tabs,
75
+ }, [measureOverflow]);
76
+ React.useLayoutEffect(revealSelected, [
48
77
  value,
49
78
  options.length,
50
- measureOverflow
79
+ revealSelected
51
80
  ]);
52
81
  React.useEffect(() => {
53
- if (!tabs || typeof ResizeObserver === "undefined") return;
82
+ if (typeof ResizeObserver === "undefined") return;
54
83
  const strip = stripRef.current;
55
84
  if (!strip) return;
56
- const observer = new ResizeObserver(measureOverflow);
85
+ const observer = new ResizeObserver(revealSelected);
57
86
  observer.observe(strip);
87
+ for (const option of optionRefs.current.values()) observer.observe(option);
58
88
  return () => observer.disconnect();
59
- }, [tabs, measureOverflow]);
89
+ }, [optionKey, revealSelected]);
90
+ const page = (direction) => {
91
+ const strip = stripRef.current;
92
+ if (!strip) return;
93
+ const reduce = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
94
+ strip.scrollBy({
95
+ left: direction * strip.clientWidth * .75,
96
+ behavior: reduce ? "auto" : "smooth"
97
+ });
98
+ };
60
99
  const hasMatch = options.some((o) => o.value === value);
61
100
  const handleKeyDown = (e) => {
62
101
  if (options.length === 0) return;
@@ -73,7 +112,7 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
73
112
  optionRefs.current.get(options[next].value)?.focus();
74
113
  }
75
114
  };
76
- return /* @__PURE__ */ jsx("div", {
115
+ return /* @__PURE__ */ jsxs("div", {
77
116
  role: "radiogroup",
78
117
  id: rest.id,
79
118
  "aria-label": rest["aria-label"],
@@ -83,41 +122,51 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
83
122
  "data-overflow-end": overflow.end || void 0,
84
123
  ref: stripRef,
85
124
  onKeyDown: handleKeyDown,
86
- onScroll: tabs ? measureOverflow : void 0,
87
- className: cn("flex gap-1", variant === "row" && "flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1", variant === "tabs" && ["relative flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", tabEdgeFade], className),
88
- children: options.map((option, i) => {
89
- const active = option.value === value;
90
- const focusable = active || !hasMatch && i === 0;
91
- return /* @__PURE__ */ jsxs("button", {
92
- ref: (el) => {
93
- if (el) optionRefs.current.set(option.value, el);
94
- else optionRefs.current.delete(option.value);
95
- },
96
- type: "button",
97
- role: "radio",
98
- "aria-checked": active,
99
- "data-testid": option.testId,
100
- tabIndex: focusable ? 0 : -1,
101
- onClick: () => {
102
- if (!active) onValueChange(option.value);
103
- },
104
- className: cn("relative inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-[color,background-color,border-color,box-shadow]", variant === "row" && [
105
- "rounded-md border border-transparent px-3 py-1.5",
106
- focusRing,
107
- "hover:bg-[var(--ix-hover-bg)] active:bg-[var(--ix-sel-bg)]",
108
- "aria-pressed:bg-[var(--ix-sel-bg)] aria-pressed:text-[var(--accent-text)] aria-pressed:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] aria-checked:bg-[var(--ix-sel-bg)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] aria-[current=page]:bg-[var(--ix-sel-bg)] aria-[current=page]:text-[var(--accent-text)] aria-[current=page]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] data-[state=on]:bg-[var(--ix-sel-bg)] data-[state=on]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] data-[state=active]:bg-[var(--ix-sel-bg)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)]"
109
- ], variant === "tabs" && [
110
- "shrink-0 rounded-none border-b-2 border-transparent -mb-px px-4 py-2",
111
- "pointer-coarse:min-h-11",
112
- focusRingInset,
113
- "hover:shadow-[inset_0_-2px_0_hsl(var(--hsl-ring)/0.4)] data-[state=active]:bg-[hsl(var(--hsl-ring)/0.07)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_-2px_0_var(--ix-sel-ring)] aria-checked:bg-[hsl(var(--hsl-ring)/0.07)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_-2px_0_var(--ix-sel-ring)]"
114
- ], active ? "text-[var(--accent-text)]" : "text-muted-foreground hover:text-foreground"),
115
- children: [/* @__PURE__ */ jsx("span", { children: option.label }), option.adornment && /* @__PURE__ */ jsx("span", {
116
- className: cn("text-xs", active ? "text-[var(--accent-text)]/80" : "text-muted-foreground"),
117
- children: option.adornment
118
- })]
119
- }, option.value);
120
- })
125
+ onScroll: measureOverflow,
126
+ className: cn("relative flex gap-1", variant === "row" && "flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1", variant === "tabs" && "flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", className),
127
+ children: [
128
+ overflow.start && /* @__PURE__ */ jsx(ScrollCue, {
129
+ side: "start",
130
+ onPage: () => page(-1)
131
+ }),
132
+ options.map((option, i) => {
133
+ const active = option.value === value;
134
+ const focusable = active || !hasMatch && i === 0;
135
+ return /* @__PURE__ */ jsxs("button", {
136
+ ref: (el) => {
137
+ if (el) optionRefs.current.set(option.value, el);
138
+ else optionRefs.current.delete(option.value);
139
+ },
140
+ type: "button",
141
+ role: "radio",
142
+ "aria-checked": active,
143
+ "data-testid": option.testId,
144
+ tabIndex: focusable ? 0 : -1,
145
+ onClick: () => {
146
+ if (!active) onValueChange(option.value);
147
+ },
148
+ className: cn("relative inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-[color,background-color,border-color,box-shadow]", variant === "row" && [
149
+ "rounded-md border border-transparent px-3 py-1.5",
150
+ focusRing,
151
+ "hover:bg-[var(--ix-hover-bg)] active:bg-[var(--ix-sel-bg)]",
152
+ "aria-pressed:bg-[var(--ix-sel-bg)] aria-pressed:text-[var(--accent-text)] aria-pressed:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] aria-checked:bg-[var(--ix-sel-bg)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] aria-[current=page]:bg-[var(--ix-sel-bg)] aria-[current=page]:text-[var(--accent-text)] aria-[current=page]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] data-[state=on]:bg-[var(--ix-sel-bg)] data-[state=on]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] data-[state=active]:bg-[var(--ix-sel-bg)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)]"
153
+ ], variant === "tabs" && [
154
+ "shrink-0 rounded-none border-b-2 border-transparent -mb-px px-4 py-2",
155
+ "pointer-coarse:min-h-11",
156
+ focusRingInset,
157
+ "hover:shadow-[inset_0_-2px_0_hsl(var(--hsl-ring)/0.4)] data-[state=active]:bg-[hsl(var(--hsl-ring)/0.07)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_-2px_0_var(--ix-sel-ring)] aria-checked:bg-[hsl(var(--hsl-ring)/0.07)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_-2px_0_var(--ix-sel-ring)]"
158
+ ], active ? "text-[var(--accent-text)]" : "text-muted-foreground hover:text-foreground"),
159
+ children: [/* @__PURE__ */ jsx("span", { children: option.label }), option.adornment && /* @__PURE__ */ jsx("span", {
160
+ className: cn("text-xs", active ? "text-[var(--accent-text)]/80" : "text-muted-foreground"),
161
+ children: option.adornment
162
+ })]
163
+ }, option.value);
164
+ }),
165
+ overflow.end && /* @__PURE__ */ jsx(ScrollCue, {
166
+ side: "end",
167
+ onPage: () => page(1)
168
+ })
169
+ ]
121
170
  });
122
171
  }
123
172
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.34.0",
3
+ "version": "11.35.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -150,7 +150,7 @@
150
150
  "peerDependencies": {
151
151
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
152
152
  "@nanostores/react": ">=0.8.4 <3.0.0",
153
- "@tangle-network/brand": "^1.24.1",
153
+ "@tangle-network/brand": "^1.24.2",
154
154
  "@tiptap/core": ">=3.20.4 <4.0.0",
155
155
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
156
156
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
@@ -0,0 +1,89 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import * as React from "react"
3
+ import { SegmentedControl, type SegmentedControlOption } from "./segmented-control"
4
+
5
+ const meta: Meta = {
6
+ title: "Primitives/SegmentedControl",
7
+ parameters: { layout: "fullscreen" },
8
+ }
9
+
10
+ export default meta
11
+ type Story = StoryObj
12
+
13
+ const workTabs: SegmentedControlOption[] = [
14
+ { value: "today", label: "Today" },
15
+ { value: "messages", label: "Messages" },
16
+ { value: "tasks", label: "Tasks" },
17
+ { value: "operations", label: "Operations" },
18
+ { value: "payments", label: "Payments" },
19
+ { value: "approvals", label: "Approvals" },
20
+ { value: "activity", label: "Activity" },
21
+ { value: "people", label: "People" },
22
+ ]
23
+
24
+ const fileSources: SegmentedControlOption[] = [
25
+ { value: "knowledge", label: "Knowledge" },
26
+ { value: "attachments", label: "Attachments" },
27
+ { value: "apps", label: "Apps & chats" },
28
+ ]
29
+
30
+ function Controlled({
31
+ initial,
32
+ render,
33
+ }: {
34
+ initial: string
35
+ render: (value: string, setValue: (value: string) => void) => React.ReactNode
36
+ }) {
37
+ const [value, setValue] = React.useState(initial)
38
+ return <>{render(value, setValue)}</>
39
+ }
40
+
41
+ /**
42
+ * Eight section tabs on a 390 px phone, as a workspace header renders them:
43
+ * full width with the page's 16 px inline padding on the strip itself. Most
44
+ * tabs sit past the right edge, the selected one starts off screen, and the
45
+ * cut lands in the gap between two tabs, so only a chevron shows that the strip
46
+ * scrolls.
47
+ */
48
+ export const OverflowingTabs: Story = {
49
+ render: () => (
50
+ <div className="w-full max-w-[390px] py-4">
51
+ <Controlled
52
+ initial="approvals"
53
+ render={(value, setValue) => (
54
+ <SegmentedControl
55
+ data-testid="overflow-tabs"
56
+ aria-label="Work"
57
+ variant="tabs"
58
+ value={value}
59
+ onValueChange={setValue}
60
+ options={workTabs}
61
+ className="w-full px-4"
62
+ />
63
+ )}
64
+ />
65
+ </div>
66
+ ),
67
+ }
68
+
69
+ /** A row control a host made scroll instead of wrap, beside a page title. */
70
+ export const OverflowingRow: Story = {
71
+ render: () => (
72
+ <div className="flex w-full max-w-[390px] items-center gap-3 px-4 py-4">
73
+ <h2 className="shrink-0 text-lg font-semibold">Files</h2>
74
+ <Controlled
75
+ initial="knowledge"
76
+ render={(value, setValue) => (
77
+ <SegmentedControl
78
+ data-testid="overflow-row"
79
+ aria-label="File source"
80
+ value={value}
81
+ onValueChange={setValue}
82
+ options={fileSources}
83
+ className="min-w-0 flex-nowrap overflow-x-auto"
84
+ />
85
+ )}
86
+ />
87
+ </div>
88
+ ),
89
+ }
@@ -361,13 +361,14 @@ describe("SegmentedControl tabs on a narrow strip", () => {
361
361
  Object.defineProperty(proto, key, { configurable: true, ...descriptor })
362
362
  }
363
363
  const isStrip = (el: Element) => el.getAttribute("role") === "radiogroup"
364
- const index = (el: Element) => Array.from(el.parentElement?.children ?? []).indexOf(el)
364
+ // Options sit side by side; the edge chevrons take no room in the row.
365
+ const index = (el: Element) => Array.from(el.parentElement?.querySelectorAll(':scope > [role="radio"]') ?? []).indexOf(el)
365
366
 
366
367
  beforeEach(() => {
367
368
  define(HTMLElement.prototype, "offsetLeft", { get(this: HTMLElement) { return isStrip(this) ? 0 : index(this) * TAB } })
368
369
  define(HTMLElement.prototype, "offsetWidth", { get(this: HTMLElement) { return isStrip(this) ? STRIP : TAB } })
369
370
  define(Element.prototype, "clientWidth", { get(this: Element) { return isStrip(this) ? STRIP : TAB } })
370
- define(Element.prototype, "scrollWidth", { get(this: Element) { return isStrip(this) ? this.children.length * TAB : TAB } })
371
+ define(Element.prototype, "scrollWidth", { get(this: Element) { return isStrip(this) ? this.querySelectorAll(':scope > [role="radio"]').length * TAB : TAB } })
371
372
  define(Element.prototype, "scrollLeft", {
372
373
  get(this: Element) { return scroll.get(this) ?? 0 },
373
374
  set(this: Element, v: number) { scroll.set(this, v) },
@@ -390,32 +391,44 @@ describe("SegmentedControl tabs on a narrow strip", () => {
390
391
  <SegmentedControl aria-label="Work" variant="tabs" value="payments" onValueChange={vi.fn()} options={work} />,
391
392
  )
392
393
  const strip = screen.getByRole("radiogroup")
393
- // Payments spans 400-500px: its right edge plus the 24px fade fits the 250px view.
394
- expect(strip.scrollLeft).toBe(500 - STRIP + 24)
394
+ // Payments spans 400-500px: its right edge clears the 32px chevron, plus a 4px gap, in the 250px view.
395
+ expect(strip.scrollLeft).toBe(500 - STRIP + 32 + 4)
395
396
 
396
397
  rerender(<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />)
397
398
  expect(strip.scrollLeft).toBe(0)
398
399
  })
399
400
 
400
- it("fades the edge that has more tabs past it", () => {
401
+ it("shows a chevron at the edge that has more tabs past it", () => {
401
402
  const { rerender } = render(
402
403
  <SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />,
403
404
  )
404
405
  const strip = screen.getByRole("radiogroup")
405
406
  expect(strip).toHaveAttribute("data-overflow-end", "true")
406
407
  expect(strip).not.toHaveAttribute("data-overflow-start")
407
- expect(strip.className).toContain("data-[overflow-end]:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)]")
408
+ expect(strip.querySelector('[data-scroll-cue="end"]')).not.toBeNull()
409
+ expect(strip.querySelector('[data-scroll-cue="start"]')).toBeNull()
408
410
 
409
411
  rerender(<SegmentedControl aria-label="Work" variant="tabs" value="people" onValueChange={vi.fn()} options={work} />)
410
412
  expect(strip).toHaveAttribute("data-overflow-start", "true")
411
413
  expect(strip).not.toHaveAttribute("data-overflow-end")
414
+ expect(strip.querySelector('[data-scroll-cue="start"]')).not.toBeNull()
415
+ expect(strip.querySelector('[data-scroll-cue="end"]')).toBeNull()
412
416
  })
413
417
 
414
- it("leaves a strip that fits unmasked", () => {
418
+ it("keeps the chevrons out of the radio group and the tab order", () => {
419
+ render(<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />)
420
+ expect(screen.getAllByRole("radio")).toHaveLength(work.length)
421
+ const cue = screen.getByRole("radiogroup").querySelector('[data-scroll-cue="end"]')
422
+ expect(cue).toHaveAttribute("aria-hidden", "true")
423
+ expect(cue).toHaveAttribute("tabindex", "-1")
424
+ })
425
+
426
+ it("leaves a strip that fits without chevrons", () => {
415
427
  render(<SegmentedControl aria-label="Work" variant="tabs" value="all" onValueChange={vi.fn()} options={options.slice(0, 2)} />)
416
428
  const strip = screen.getByRole("radiogroup")
417
429
  expect(strip).not.toHaveAttribute("data-overflow-start")
418
430
  expect(strip).not.toHaveAttribute("data-overflow-end")
431
+ expect(strip.querySelector("[data-scroll-cue]")).toBeNull()
419
432
  })
420
433
  })
421
434
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  declare const process: { env: { NODE_ENV?: string } } | undefined
4
4
 
5
+ import { ChevronLeft, ChevronRight } from "lucide-react"
5
6
  import * as React from "react"
6
7
  import { touchTarget } from "../lib/control-presentation"
7
8
  import { focusRing, focusRingInset } from "../lib/focus"
@@ -32,9 +33,12 @@ import { cn } from "../lib/utils"
32
33
  * - Every label keeps one weight. Selection is colour plus surface or
33
34
  * underline, so changing the value never reflows the row.
34
35
  *
35
- * The tabs variant scrolls sideways when its options outgrow the row: the
36
- * selected tab is kept in view, and a faded edge shows that more tabs sit
37
- * past it.
36
+ * When the options outgrow the row they scroll sideways (the tabs variant
37
+ * always can; the row variant can when a host gives it `flex-nowrap
38
+ * overflow-x-auto`). A chevron button pinned to each edge with options past it
39
+ * shows that the strip scrolls and pages it on click or tap; touch and
40
+ * trackpad scroll it natively, and arrow keys move the selection, which is
41
+ * kept fully in view clear of the chevrons.
38
42
  */
39
43
  export interface SegmentedControlOption<T extends string = string> {
40
44
  value: T
@@ -61,14 +65,47 @@ export interface SegmentedControlProps<T extends string = string>
61
65
  "data-testid"?: string
62
66
  }
63
67
 
64
- /** Width of the faded edge that marks more tabs past the strip, in px. */
65
- const TAB_EDGE_FADE = 24
66
- // Fade whichever edge has tabs past it; the two-attribute rule wins on specificity.
67
- const tabEdgeFade = [
68
- "data-[overflow-start]:[mask-image:linear-gradient(to_right,transparent,#000_24px)]",
69
- "data-[overflow-end]:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)]",
70
- "data-[overflow-start]:data-[overflow-end]:[mask-image:linear-gradient(to_right,transparent,#000_24px,#000_calc(100%-24px),transparent)]",
71
- ]
68
+ /** Size of the edge chevron, in px. The selected option is kept this far
69
+ * (plus the strip's own padding) from an edge so the chevron never covers it. */
70
+ const SCROLL_CUE_SIZE = 32
71
+ /** The strip's flex gap (`gap-1`). A cue's negative margin cancels its width
72
+ * and this gap, so showing it never shifts the options: a shift is not a
73
+ * resize, so nothing would re-reveal the selected option afterwards. */
74
+ const STRIP_GAP = 4
75
+
76
+ /**
77
+ * A chevron pinned to one edge of the scrolling strip. It is a sticky flex
78
+ * item with a negative margin equal to its width plus the gap, so it takes no room in the
79
+ * row (showing it never reflows the options) and stays at the visible edge
80
+ * while the strip scrolls. A solid surface keeps it legible on any plane,
81
+ * which an edge fade is not: a fade over the gap between two options, or over
82
+ * a host's inline padding, shows nothing.
83
+ */
84
+ function ScrollCue({ side, onPage }: { side: "start" | "end"; onPage: () => void }) {
85
+ const Icon = side === "start" ? ChevronLeft : ChevronRight
86
+ return (
87
+ <button
88
+ type="button"
89
+ // Pointer and touch affordance only: keyboard users move the selection
90
+ // with the arrow keys, and the strip keeps it in view.
91
+ tabIndex={-1}
92
+ aria-hidden="true"
93
+ data-scroll-cue={side}
94
+ onClick={onPage}
95
+ className={cn(
96
+ "sticky z-[1] flex shrink-0 items-center justify-center self-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground",
97
+ side === "start" ? "left-0" : "right-0",
98
+ )}
99
+ style={{
100
+ width: SCROLL_CUE_SIZE,
101
+ height: SCROLL_CUE_SIZE,
102
+ [side === "start" ? "marginRight" : "marginLeft"]: -(SCROLL_CUE_SIZE + STRIP_GAP),
103
+ }}
104
+ >
105
+ <Icon className="h-4 w-4" aria-hidden="true" />
106
+ </button>
107
+ )
108
+ }
72
109
 
73
110
  export function SegmentedControl<T extends string = string>({
74
111
  value,
@@ -92,7 +129,9 @@ export function SegmentedControl<T extends string = string>({
92
129
  const optionRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map())
93
130
  const stripRef = React.useRef<HTMLDivElement>(null)
94
131
  const [overflow, setOverflow] = React.useState({ start: false, end: false })
95
- const tabs = variant === "tabs"
132
+ // Re-observe when the option set changes, not on every render of a host
133
+ // that builds its options array inline.
134
+ const optionKey = options.map((o) => o.value).join("\u0000")
96
135
 
97
136
  const measureOverflow = React.useCallback(() => {
98
137
  const strip = stripRef.current
@@ -102,32 +141,52 @@ export function SegmentedControl<T extends string = string>({
102
141
  setOverflow((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
103
142
  }, [])
104
143
 
105
- // Keep the selected tab inside the strip. Only the strip scrolls: the page
106
- // never moves, which scrollIntoView cannot promise.
107
- React.useLayoutEffect(() => {
108
- if (!tabs) return
144
+ // Keep the selected option inside the strip and clear of the chevrons.
145
+ // Only the strip scrolls: the page never moves, which scrollIntoView cannot
146
+ // promise. A strip that does not overflow ignores the assignment.
147
+ const valueRef = React.useRef(value)
148
+ valueRef.current = value
149
+ const revealSelected = React.useCallback(() => {
109
150
  const strip = stripRef.current
110
- const option = optionRefs.current.get(value)
111
- if (strip && option) {
151
+ const option = optionRefs.current.get(valueRef.current)
152
+ if (strip && option && strip.scrollWidth > strip.clientWidth) {
153
+ const style = getComputedStyle(strip)
154
+ // offsetLeft/offsetWidth round to whole pixels while the tab is fractional (a 101.42 px tab overlapped the
155
+ // chevron by 0.66 px at 390 px), so keep a small gap beside each cue rather than touching its edge.
156
+ const gap = 4
157
+ const reserveStart = SCROLL_CUE_SIZE + gap + (Number.parseFloat(style.paddingLeft) || 0)
158
+ const reserveEnd = SCROLL_CUE_SIZE + gap + (Number.parseFloat(style.paddingRight) || 0)
112
159
  const start = option.offsetLeft
113
160
  const end = start + option.offsetWidth
114
- if (start < strip.scrollLeft + TAB_EDGE_FADE) {
115
- strip.scrollLeft = Math.max(0, start - TAB_EDGE_FADE)
116
- } else if (end > strip.scrollLeft + strip.clientWidth - TAB_EDGE_FADE) {
117
- strip.scrollLeft = end - strip.clientWidth + TAB_EDGE_FADE
161
+ if (start - reserveStart < strip.scrollLeft) {
162
+ strip.scrollLeft = Math.max(0, start - reserveStart)
163
+ } else if (end + reserveEnd > strip.scrollLeft + strip.clientWidth) {
164
+ strip.scrollLeft = end + reserveEnd - strip.clientWidth
118
165
  }
119
166
  }
120
167
  measureOverflow()
121
- }, [tabs, value, options.length, measureOverflow])
168
+ }, [measureOverflow])
122
169
 
170
+ React.useLayoutEffect(revealSelected, [value, options.length, revealSelected])
171
+
172
+ // The strip's own size and its options' sizes (late web fonts, a changed
173
+ // label or adornment) move both the selected option and the overflow edges.
123
174
  React.useEffect(() => {
124
- if (!tabs || typeof ResizeObserver === "undefined") return
175
+ if (typeof ResizeObserver === "undefined") return
125
176
  const strip = stripRef.current
126
177
  if (!strip) return
127
- const observer = new ResizeObserver(measureOverflow)
178
+ const observer = new ResizeObserver(revealSelected)
128
179
  observer.observe(strip)
180
+ for (const option of optionRefs.current.values()) observer.observe(option)
129
181
  return () => observer.disconnect()
130
- }, [tabs, measureOverflow])
182
+ }, [optionKey, revealSelected])
183
+
184
+ const page = (direction: 1 | -1) => {
185
+ const strip = stripRef.current
186
+ if (!strip) return
187
+ const reduce = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
188
+ strip.scrollBy({ left: direction * strip.clientWidth * 0.75, behavior: reduce ? "auto" : "smooth" })
189
+ }
131
190
 
132
191
  const hasMatch = options.some((o) => o.value === value)
133
192
 
@@ -161,16 +220,17 @@ export function SegmentedControl<T extends string = string>({
161
220
  data-overflow-end={overflow.end || undefined}
162
221
  ref={stripRef}
163
222
  onKeyDown={handleKeyDown}
164
- onScroll={tabs ? measureOverflow : undefined}
223
+ onScroll={measureOverflow}
165
224
  className={cn(
166
- "flex gap-1",
225
+ "relative flex gap-1",
167
226
  variant === "row" &&
168
227
  "flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1",
169
228
  variant === "tabs" &&
170
- ["relative flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", tabEdgeFade],
229
+ "flex-nowrap items-end border-b border-border pb-0 overflow-x-auto",
171
230
  className,
172
231
  )}
173
232
  >
233
+ {overflow.start && <ScrollCue side="start" onPage={() => page(-1)} />}
174
234
  {options.map((option, i) => {
175
235
  const active = option.value === value
176
236
  const focusable = active || (!hasMatch && i === 0)
@@ -214,6 +274,7 @@ export function SegmentedControl<T extends string = string>({
214
274
  </button>
215
275
  )
216
276
  })}
277
+ {overflow.end && <ScrollCue side="end" onPage={() => page(1)} />}
217
278
  </div>
218
279
  )
219
280
  }