@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 +6 -0
- package/dist/primitives/segmented-control.d.ts +6 -3
- package/dist/primitives/segmented-control.js +105 -56
- package/package.json +2 -2
- package/src/primitives/segmented-control.stories.tsx +89 -0
- package/src/primitives/segmented-control.test.tsx +20 -7
- package/src/primitives/segmented-control.tsx +90 -29
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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.
|
|
36
|
-
|
|
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(
|
|
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
|
|
43
|
-
else if (end > strip.scrollLeft + strip.clientWidth
|
|
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
|
-
|
|
75
|
+
}, [measureOverflow]);
|
|
76
|
+
React.useLayoutEffect(revealSelected, [
|
|
48
77
|
value,
|
|
49
78
|
options.length,
|
|
50
|
-
|
|
79
|
+
revealSelected
|
|
51
80
|
]);
|
|
52
81
|
React.useEffect(() => {
|
|
53
|
-
if (
|
|
82
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
54
83
|
const strip = stripRef.current;
|
|
55
84
|
if (!strip) return;
|
|
56
|
-
const observer = new ResizeObserver(
|
|
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
|
-
}, [
|
|
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__ */
|
|
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:
|
|
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" &&
|
|
88
|
-
children:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
394
|
-
expect(strip.scrollLeft).toBe(500 - STRIP +
|
|
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("
|
|
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.
|
|
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("
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
// never moves, which scrollIntoView cannot
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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
|
|
115
|
-
strip.scrollLeft = Math.max(0, start -
|
|
116
|
-
} else if (end > strip.scrollLeft + strip.clientWidth
|
|
117
|
-
strip.scrollLeft = end - strip.clientWidth
|
|
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
|
-
}, [
|
|
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 (
|
|
175
|
+
if (typeof ResizeObserver === "undefined") return
|
|
125
176
|
const strip = stripRef.current
|
|
126
177
|
if (!strip) return
|
|
127
|
-
const observer = new ResizeObserver(
|
|
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
|
-
}, [
|
|
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={
|
|
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
|
-
|
|
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
|
}
|