@tangle-network/ui 11.32.0 → 11.32.1

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,15 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.32.1
4
+
5
+ ### Patch Changes
6
+
7
+ - c94bde7: Fix shared defects from the Hospitality design audit.
8
+
9
+ Brand: dark `--destructive` is now a light red carrying dark ink (`0 90% 77%` on `0 60% 10%`), so `text-destructive` clears AA (at least 4.5:1) on the canvas, card, popover and muted planes of every dark theme; it measured 2.5:1 on Hospitality's dark card. Destructive fills keep their foreground at 8:1. The `--font-sans` and `--font-display` stacks name `"Inter Variable"` ahead of `"Inter"`, and the README now recommends `@fontsource-variable/inter` so apps render every weight the scale uses. Brand still loads no fonts itself.
10
+
11
+ UI: the `tabs` SegmentedControl scrolls the selected tab into view inside the strip (never the page) and fades the edge that has more tabs past it; tab items take a 44px hit height on coarse pointers; labels keep one weight in both variants, so changing the selection no longer shifts the row. SelectTrigger values end in an ellipsis with a gap before the chevron instead of clipping mid-glyph. Plain-text code fences (`text`, `txt`, `plain`, `plaintext`) show no language label.
12
+
3
13
  ## 11.32.0
4
14
 
5
15
  ### Minor Changes
@@ -28,6 +28,11 @@ const controlSquare = {
28
28
  * page on focus for anything smaller.
29
29
  */
30
30
  const fieldTouchText = "pointer-coarse:text-base";
31
+ /**
32
+ * A control that is shorter than 44px on a fine pointer still gets a 44px hit
33
+ * height on a coarse one (WCAG 2.5.5, Apple HIG). Same floor as `touch`.
34
+ */
35
+ const touchTarget = "pointer-coarse:min-h-11";
31
36
  /** Field sizes: the control scale with field padding. */
32
37
  const fieldSizes = {
33
38
  sm: cn(controlHeight.sm, controlText.sm, controlRadius.sm, "px-2.5"),
@@ -40,4 +45,4 @@ const controlSizes = {
40
45
  };
41
46
  const fieldPresentation = cn(focusField, controlMotion, "min-w-0 bg-[var(--field-surface,var(--bg-input))] text-foreground", "aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)]", "autofill:shadow-[inset_0_0_0_1000px_var(--bg-input)] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)]");
42
47
  //#endregion
43
- export { controlHeight, controlMotion, controlRadius, controlSizes, controlSquare, controlText, fieldPresentation, fieldSizes, fieldTouchText };
48
+ export { controlHeight, controlMotion, controlRadius, controlSizes, controlSquare, controlText, fieldPresentation, fieldSizes, fieldTouchText, touchTarget };
@@ -1,6 +1,5 @@
1
1
  import { HTMLAttributes, ReactNode } from "react";
2
2
  //#region src/markdown/code-block.d.ts
3
- /** The Shiki grammar for a fence language, or undefined for plain text. */
4
3
  export declare function codeBlockGrammar(language: string | undefined): string | undefined;
5
4
  export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
6
5
  code: string;
@@ -123,6 +123,12 @@ const LANGUAGE_NAMES = {
123
123
  dockerfile: "Dockerfile"
124
124
  };
125
125
  /** The Shiki grammar for a fence language, or undefined for plain text. */
126
+ const PLAIN_TEXT = /* @__PURE__ */ new Set([
127
+ "text",
128
+ "txt",
129
+ "plain",
130
+ "plaintext"
131
+ ]);
126
132
  function codeBlockGrammar(language) {
127
133
  if (!language) return void 0;
128
134
  const id = language.toLowerCase();
@@ -191,7 +197,7 @@ const CodeBlock = memo(({ code, language, label, filename, showLineNumbers = fal
191
197
  active = false;
192
198
  };
193
199
  }, [code, grammar]);
194
- const languageName = grammar ? LANGUAGE_NAMES[grammar] : language;
200
+ const languageName = grammar ? LANGUAGE_NAMES[grammar] : PLAIN_TEXT.has(language?.toLowerCase() ?? "") ? void 0 : language;
195
201
  const headerLabel = label ?? (filename ? void 0 : languageName);
196
202
  const hasHeader = Boolean(filename || headerLabel);
197
203
  const mode = light === void 0 ? {} : { "data-theme": light ? "light" : "dark" };
@@ -17,6 +17,12 @@ import * as React from "react";
17
17
  * that darken on hover. This keeps the selected segment as the
18
18
  * single visual anchor instead of the whole group competing with
19
19
  * itself.
20
+ * - Every label keeps one weight. Selection is colour plus surface or
21
+ * underline, so changing the value never reflows the row.
22
+ *
23
+ * The tabs variant scrolls sideways when its options outgrow the row: the
24
+ * selected tab is kept in view, and a faded edge shows that more tabs sit
25
+ * past it.
20
26
  */
21
27
  export interface SegmentedControlOption<T extends string = string> {
22
28
  value: T;
@@ -1,12 +1,61 @@
1
1
  "use client";
2
2
  import { focusRing, focusRingInset } from "../lib/focus.js";
3
3
  import { cn } from "../lib/utils.js";
4
+ import "../lib/control-presentation.js";
4
5
  import * as React from "react";
5
6
  import { jsx, jsxs } from "react/jsx-runtime";
6
7
  //#region src/primitives/segmented-control.tsx
8
+ /** Width of the faded edge that marks more tabs past the strip, in px. */
9
+ const TAB_EDGE_FADE = 24;
10
+ const tabEdgeFade = [
11
+ "data-[overflow-start]:[mask-image:linear-gradient(to_right,transparent,#000_24px)]",
12
+ "data-[overflow-end]:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)]",
13
+ "data-[overflow-start]:data-[overflow-end]:[mask-image:linear-gradient(to_right,transparent,#000_24px,#000_calc(100%-24px),transparent)]"
14
+ ];
7
15
  function SegmentedControl({ value, onValueChange, options, variant = "row", className, ...rest }) {
8
16
  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.");
9
17
  const optionRefs = React.useRef(/* @__PURE__ */ new Map());
18
+ const stripRef = React.useRef(null);
19
+ const [overflow, setOverflow] = React.useState({
20
+ start: false,
21
+ end: false
22
+ });
23
+ const tabs = variant === "tabs";
24
+ const measureOverflow = React.useCallback(() => {
25
+ const strip = stripRef.current;
26
+ if (!strip) return;
27
+ const start = strip.scrollLeft > 1;
28
+ const end = strip.scrollLeft + strip.clientWidth < strip.scrollWidth - 1;
29
+ setOverflow((prev) => prev.start === start && prev.end === end ? prev : {
30
+ start,
31
+ end
32
+ });
33
+ }, []);
34
+ React.useLayoutEffect(() => {
35
+ if (!tabs) return;
36
+ const strip = stripRef.current;
37
+ const option = optionRefs.current.get(value);
38
+ if (strip && option) {
39
+ const start = option.offsetLeft;
40
+ const end = start + option.offsetWidth;
41
+ if (start < strip.scrollLeft + TAB_EDGE_FADE) strip.scrollLeft = Math.max(0, start - TAB_EDGE_FADE);
42
+ else if (end > strip.scrollLeft + strip.clientWidth - TAB_EDGE_FADE) strip.scrollLeft = end - strip.clientWidth + TAB_EDGE_FADE;
43
+ }
44
+ measureOverflow();
45
+ }, [
46
+ tabs,
47
+ value,
48
+ options.length,
49
+ measureOverflow
50
+ ]);
51
+ React.useEffect(() => {
52
+ if (!tabs || typeof ResizeObserver === "undefined") return;
53
+ const strip = stripRef.current;
54
+ if (!strip) return;
55
+ const observer = new ResizeObserver(measureOverflow);
56
+ observer.observe(strip);
57
+ return () => observer.disconnect();
58
+ }, [tabs, measureOverflow]);
10
59
  const hasMatch = options.some((o) => o.value === value);
11
60
  const handleKeyDown = (e) => {
12
61
  if (options.length === 0) return;
@@ -29,8 +78,12 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
29
78
  "aria-label": rest["aria-label"],
30
79
  "aria-labelledby": rest["aria-labelledby"],
31
80
  "data-testid": rest["data-testid"],
81
+ "data-overflow-start": overflow.start || void 0,
82
+ "data-overflow-end": overflow.end || void 0,
83
+ ref: stripRef,
32
84
  onKeyDown: handleKeyDown,
33
- className: cn("flex gap-1", variant === "row" && "flex-wrap items-center rounded-lg border border-border bg-card p-1", variant === "tabs" && "flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", className),
85
+ onScroll: tabs ? measureOverflow : void 0,
86
+ className: cn("flex gap-1", variant === "row" && "flex-wrap items-center rounded-lg border border-border bg-card p-1", variant === "tabs" && ["relative flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", tabEdgeFade], className),
34
87
  children: options.map((option, i) => {
35
88
  const active = option.value === value;
36
89
  const focusable = active || !hasMatch && i === 0;
@@ -47,7 +100,11 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
47
100
  onClick: () => {
48
101
  if (!active) onValueChange(option.value);
49
102
  },
50
- className: cn("relative inline-flex items-center gap-2 whitespace-nowrap text-sm transition-[color,background-color,border-color,box-shadow]", variant === "row" && ["rounded-md px-3 py-1.5", focusRing], variant === "tabs" && ["rounded-none border-b-2 -mb-px px-4 py-2", focusRingInset], active ? variant === "row" ? "border border-transparent bg-[var(--accent-surface)] text-[var(--accent-text)] font-semibold" : "border-[var(--accent-text)] text-[var(--accent-text)] font-semibold" : variant === "row" ? "border border-transparent text-muted-foreground hover:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"),
103
+ 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" && ["rounded-md px-3 py-1.5", focusRing], variant === "tabs" && [
104
+ "shrink-0 rounded-none border-b-2 -mb-px px-4 py-2",
105
+ "pointer-coarse:min-h-11",
106
+ focusRingInset
107
+ ], active ? variant === "row" ? "border border-transparent bg-[var(--accent-surface)] text-[var(--accent-text)]" : "border-[var(--accent-text)] text-[var(--accent-text)]" : variant === "row" ? "border border-transparent text-muted-foreground hover:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"),
51
108
  children: [/* @__PURE__ */ jsx("span", { children: option.label }), option.adornment && /* @__PURE__ */ jsx("span", {
52
109
  className: cn("text-xs", active ? "text-[var(--accent-text)]/80" : "text-muted-foreground"),
53
110
  children: option.adornment
@@ -11,7 +11,7 @@ const SelectGroup = SelectPrimitive.Group;
11
11
  const SelectValue = SelectPrimitive.Value;
12
12
  const SelectTrigger = React.forwardRef(({ className, children, size = "default", ...props }, ref) => /* @__PURE__ */ jsxs(SelectPrimitive.Trigger, {
13
13
  ref,
14
- className: cn("flex w-full items-center justify-between whitespace-nowrap border py-1 shadow-sm placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", fieldPresentation, fieldTouchText, "data-[placeholder]:text-[var(--text-dim)] [&>span]:min-w-0 [&>span]:text-left", "data-[state=open]:border-primary data-[state=open]:bg-[var(--accent-surface-strong)] [&[data-state=open]>svg]:rotate-180 [&>svg]:transition-transform", size === "compact" || size === "touch" ? cn(fieldSizes.md, controlSizes[size]) : fieldSizes[size === "default" ? "md" : size], className),
14
+ className: cn("flex w-full items-center justify-between gap-2 whitespace-nowrap border py-1 shadow-sm placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50", fieldPresentation, fieldTouchText, "data-[placeholder]:text-[var(--text-dim)] [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left", "data-[state=open]:border-primary data-[state=open]:bg-[var(--accent-surface-strong)] [&[data-state=open]>svg]:rotate-180 [&>svg]:transition-transform", size === "compact" || size === "touch" ? cn(fieldSizes.md, controlSizes[size]) : fieldSizes[size === "default" ? "md" : size], className),
15
15
  ...props,
16
16
  children: [children, /* @__PURE__ */ jsx(SelectPrimitive.Icon, {
17
17
  asChild: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.32.0",
3
+ "version": "11.32.1",
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.21.0",
153
+ "@tangle-network/brand": "^1.22.0",
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",
@@ -46,6 +46,12 @@ export const controlSquare = {
46
46
  */
47
47
  export const fieldTouchText = "pointer-coarse:text-base";
48
48
 
49
+ /**
50
+ * A control that is shorter than 44px on a fine pointer still gets a 44px hit
51
+ * height on a coarse one (WCAG 2.5.5, Apple HIG). Same floor as `touch`.
52
+ */
53
+ export const touchTarget = "pointer-coarse:min-h-11";
54
+
49
55
  /** Field sizes: the control scale with field padding. */
50
56
  export const fieldSizes = {
51
57
  sm: cn(controlHeight.sm, controlText.sm, controlRadius.sm, "px-2.5"),
@@ -14,6 +14,13 @@ afterEach(() => {
14
14
 
15
15
  describe("CodeBlock scoped semantic colors", () => {
16
16
  it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
17
+ // Shiki stops tokenizing a line after 500ms and paints the rest in the last
18
+ // token's color. The first pass over a cold grammar also compiles its
19
+ // patterns, which on a loaded host can take longer than that. Warm the
20
+ // grammar first: this test checks the color mapping, not cold-start time.
21
+ const warm = render(<CodeBlock code={'const message = "hello";'} language="javascript" />);
22
+ await waitFor(() => expect(warm.container.querySelector("[data-highlighted]")).not.toBeNull(), { timeout: 10_000 });
23
+ warm.unmount();
17
24
  const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
18
25
  // Plain code already carries its line numbers, so highlighting shifts no layout.
19
26
  expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
@@ -116,6 +123,17 @@ describe("CodeBlock", () => {
116
123
  expect(getByText("Python")).toBeInTheDocument();
117
124
  });
118
125
 
126
+ it.each(["text", "TXT", "plain", "plaintext"])("shows no %s label on a plain-text fence, only its copy control", (language) => {
127
+ const { container, queryByText } = render(
128
+ <CodeBlock code={"x"} language={language}>
129
+ <CopyButton text="x" />
130
+ </CodeBlock>,
131
+ );
132
+ expect(container.querySelector(".border-b")).toBeNull();
133
+ expect(queryByText(language)).toBeNull();
134
+ expect(container.querySelector('button[title="Copy to clipboard"]')).not.toBeNull();
135
+ });
136
+
119
137
  it("renders no header when neither label nor language is set", () => {
120
138
  const { container } = render(<CodeBlock code={"x"} />);
121
139
  // The header is the only element carrying a bottom border.
@@ -94,6 +94,8 @@ const LANGUAGE_NAMES: Record<string, string> = {
94
94
  };
95
95
 
96
96
  /** The Shiki grammar for a fence language, or undefined for plain text. */
97
+ const PLAIN_TEXT = new Set(["text", "txt", "plain", "plaintext"]);
98
+
97
99
  export function codeBlockGrammar(language: string | undefined): string | undefined {
98
100
  if (!language) return undefined;
99
101
  const id = language.toLowerCase();
@@ -177,7 +179,8 @@ export const CodeBlock = memo(
177
179
  return () => { active = false; };
178
180
  }, [code, grammar]);
179
181
 
180
- const languageName = grammar ? LANGUAGE_NAMES[grammar] : language;
182
+ // A plain-text fence names no language worth showing; it keeps its copy control.
183
+ const languageName = grammar ? LANGUAGE_NAMES[grammar] : PLAIN_TEXT.has(language?.toLowerCase() ?? "") ? undefined : language;
181
184
  const headerLabel = label ?? (filename ? undefined : languageName);
182
185
  const hasHeader = Boolean(filename || headerLabel);
183
186
  const mode = light === undefined ? {} : { "data-theme": light ? "light" : "dark" };
@@ -117,3 +117,19 @@ describe("shared control presentation", () => {
117
117
  expect(buttonVariants()).toContain("motion-reduce:active:scale-100");
118
118
  });
119
119
  });
120
+
121
+ describe("SelectTrigger with a value longer than the trigger", () => {
122
+ it("ends the value in an ellipsis with a gap before the chevron", () => {
123
+ const value = "Luna — ReCenter Guest Concierge & Practitioner Desk";
124
+ render(<Select value="concierge"><SelectTrigger aria-label="Agent" className="w-40"><SelectValue>{value}</SelectValue></SelectTrigger></Select>);
125
+ const trigger = screen.getByLabelText("Agent");
126
+ const classes = trigger.className.split(" ");
127
+ // line-clamp cannot ellipsize a nowrap line: it clipped mid-glyph under the chevron.
128
+ expect(trigger.className).not.toContain("line-clamp");
129
+ expect(classes).toContain("[&>span]:truncate");
130
+ expect(classes).toContain("[&>span]:min-w-0");
131
+ expect(classes).toContain("gap-2");
132
+ // The rule targets the value span, which Radix renders as the trigger's direct child.
133
+ expect(trigger.querySelector(":scope > span")?.textContent).toBe(value);
134
+ });
135
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from "vitest"
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
2
  import { render, screen } from "@testing-library/react"
3
3
  import userEvent from "@testing-library/user-event"
4
4
  import { SegmentedControl, type SegmentedControlOption } from "./segmented-control"
@@ -346,3 +346,90 @@ describe("SegmentedControl test ids", () => {
346
346
  expect(screen.getByTestId("workbench-tab-code")).toHaveAttribute("aria-checked", "true")
347
347
  })
348
348
  })
349
+
350
+ describe("SegmentedControl tabs on a narrow strip", () => {
351
+ // jsdom has no layout. Model a 250px strip holding six 100px tabs, so the
352
+ // strip scrolls and some tabs sit past its edges.
353
+ const STRIP = 250
354
+ const TAB = 100
355
+ const scroll = new WeakMap<Element, number>()
356
+ const overrides: Array<[object, string, PropertyDescriptor | undefined]> = []
357
+ const define = (proto: object, key: string, descriptor: PropertyDescriptor) => {
358
+ overrides.push([proto, key, Object.getOwnPropertyDescriptor(proto, key)])
359
+ Object.defineProperty(proto, key, { configurable: true, ...descriptor })
360
+ }
361
+ const isStrip = (el: Element) => el.getAttribute("role") === "radiogroup"
362
+ const index = (el: Element) => Array.from(el.parentElement?.children ?? []).indexOf(el)
363
+
364
+ beforeEach(() => {
365
+ define(HTMLElement.prototype, "offsetLeft", { get(this: HTMLElement) { return isStrip(this) ? 0 : index(this) * TAB } })
366
+ define(HTMLElement.prototype, "offsetWidth", { get(this: HTMLElement) { return isStrip(this) ? STRIP : TAB } })
367
+ define(Element.prototype, "clientWidth", { get(this: Element) { return isStrip(this) ? STRIP : TAB } })
368
+ define(Element.prototype, "scrollWidth", { get(this: Element) { return isStrip(this) ? this.children.length * TAB : TAB } })
369
+ define(Element.prototype, "scrollLeft", {
370
+ get(this: Element) { return scroll.get(this) ?? 0 },
371
+ set(this: Element, v: number) { scroll.set(this, v) },
372
+ })
373
+ })
374
+ afterEach(() => {
375
+ for (const [proto, key, original] of overrides.reverse()) {
376
+ if (original) Object.defineProperty(proto, key, original)
377
+ else delete (proto as Record<string, unknown>)[key]
378
+ }
379
+ overrides.length = 0
380
+ })
381
+
382
+ const work: SegmentedControlOption[] = ["Today", "Messages", "Tasks", "Operations", "Payments", "People"].map(
383
+ (label) => ({ value: label.toLowerCase(), label }),
384
+ )
385
+
386
+ it("scrolls the strip, not the page, so the selected tab is in view", () => {
387
+ const { rerender } = render(
388
+ <SegmentedControl aria-label="Work" variant="tabs" value="payments" onValueChange={vi.fn()} options={work} />,
389
+ )
390
+ const strip = screen.getByRole("radiogroup")
391
+ // Payments spans 400-500px: its right edge plus the 24px fade fits the 250px view.
392
+ expect(strip.scrollLeft).toBe(500 - STRIP + 24)
393
+
394
+ rerender(<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />)
395
+ expect(strip.scrollLeft).toBe(0)
396
+ })
397
+
398
+ it("fades the edge that has more tabs past it", () => {
399
+ const { rerender } = render(
400
+ <SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />,
401
+ )
402
+ const strip = screen.getByRole("radiogroup")
403
+ expect(strip).toHaveAttribute("data-overflow-end", "true")
404
+ expect(strip).not.toHaveAttribute("data-overflow-start")
405
+ expect(strip.className).toContain("data-[overflow-end]:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)]")
406
+
407
+ rerender(<SegmentedControl aria-label="Work" variant="tabs" value="people" onValueChange={vi.fn()} options={work} />)
408
+ expect(strip).toHaveAttribute("data-overflow-start", "true")
409
+ expect(strip).not.toHaveAttribute("data-overflow-end")
410
+ })
411
+
412
+ it("leaves a strip that fits unmasked", () => {
413
+ render(<SegmentedControl aria-label="Work" variant="tabs" value="all" onValueChange={vi.fn()} options={options.slice(0, 2)} />)
414
+ const strip = screen.getByRole("radiogroup")
415
+ expect(strip).not.toHaveAttribute("data-overflow-start")
416
+ expect(strip).not.toHaveAttribute("data-overflow-end")
417
+ })
418
+ })
419
+
420
+ describe("SegmentedControl stable labels and touch targets", () => {
421
+ it.each(["row", "tabs"] as const)("keeps one label weight whichever %s option is selected", (variant) => {
422
+ render(<SegmentedControl aria-label="Scope" variant={variant} value="personal" onValueChange={vi.fn()} options={options} />)
423
+ for (const radio of screen.getAllByRole("radio")) {
424
+ expect(radio.className).toContain("font-medium")
425
+ expect(radio.className).not.toMatch(/font-(semibold|bold)/)
426
+ }
427
+ })
428
+
429
+ it("gives each tab a 44px hit height on a coarse pointer", () => {
430
+ render(<SegmentedControl aria-label="Scope" variant="tabs" value="all" onValueChange={vi.fn()} options={options} />)
431
+ for (const radio of screen.getAllByRole("radio")) {
432
+ expect(radio.className).toContain("pointer-coarse:min-h-11")
433
+ }
434
+ })
435
+ })
@@ -3,6 +3,7 @@
3
3
  declare const process: { env: { NODE_ENV?: string } } | undefined
4
4
 
5
5
  import * as React from "react"
6
+ import { touchTarget } from "../lib/control-presentation"
6
7
  import { focusRing, focusRingInset } from "../lib/focus"
7
8
  import { cn } from "../lib/utils"
8
9
 
@@ -23,6 +24,12 @@ import { cn } from "../lib/utils"
23
24
  * that darken on hover. This keeps the selected segment as the
24
25
  * single visual anchor instead of the whole group competing with
25
26
  * itself.
27
+ * - Every label keeps one weight. Selection is colour plus surface or
28
+ * underline, so changing the value never reflows the row.
29
+ *
30
+ * The tabs variant scrolls sideways when its options outgrow the row: the
31
+ * selected tab is kept in view, and a faded edge shows that more tabs sit
32
+ * past it.
26
33
  */
27
34
  export interface SegmentedControlOption<T extends string = string> {
28
35
  value: T
@@ -49,6 +56,15 @@ export interface SegmentedControlProps<T extends string = string>
49
56
  "data-testid"?: string
50
57
  }
51
58
 
59
+ /** Width of the faded edge that marks more tabs past the strip, in px. */
60
+ const TAB_EDGE_FADE = 24
61
+ // Fade whichever edge has tabs past it; the two-attribute rule wins on specificity.
62
+ const tabEdgeFade = [
63
+ "data-[overflow-start]:[mask-image:linear-gradient(to_right,transparent,#000_24px)]",
64
+ "data-[overflow-end]:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)]",
65
+ "data-[overflow-start]:data-[overflow-end]:[mask-image:linear-gradient(to_right,transparent,#000_24px,#000_calc(100%-24px),transparent)]",
66
+ ]
67
+
52
68
  export function SegmentedControl<T extends string = string>({
53
69
  value,
54
70
  onValueChange,
@@ -69,6 +85,44 @@ export function SegmentedControl<T extends string = string>({
69
85
  }
70
86
 
71
87
  const optionRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map())
88
+ const stripRef = React.useRef<HTMLDivElement>(null)
89
+ const [overflow, setOverflow] = React.useState({ start: false, end: false })
90
+ const tabs = variant === "tabs"
91
+
92
+ const measureOverflow = React.useCallback(() => {
93
+ const strip = stripRef.current
94
+ if (!strip) return
95
+ const start = strip.scrollLeft > 1
96
+ const end = strip.scrollLeft + strip.clientWidth < strip.scrollWidth - 1
97
+ setOverflow((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
98
+ }, [])
99
+
100
+ // Keep the selected tab inside the strip. Only the strip scrolls: the page
101
+ // never moves, which scrollIntoView cannot promise.
102
+ React.useLayoutEffect(() => {
103
+ if (!tabs) return
104
+ const strip = stripRef.current
105
+ const option = optionRefs.current.get(value)
106
+ if (strip && option) {
107
+ const start = option.offsetLeft
108
+ const end = start + option.offsetWidth
109
+ if (start < strip.scrollLeft + TAB_EDGE_FADE) {
110
+ strip.scrollLeft = Math.max(0, start - TAB_EDGE_FADE)
111
+ } else if (end > strip.scrollLeft + strip.clientWidth - TAB_EDGE_FADE) {
112
+ strip.scrollLeft = end - strip.clientWidth + TAB_EDGE_FADE
113
+ }
114
+ }
115
+ measureOverflow()
116
+ }, [tabs, value, options.length, measureOverflow])
117
+
118
+ React.useEffect(() => {
119
+ if (!tabs || typeof ResizeObserver === "undefined") return
120
+ const strip = stripRef.current
121
+ if (!strip) return
122
+ const observer = new ResizeObserver(measureOverflow)
123
+ observer.observe(strip)
124
+ return () => observer.disconnect()
125
+ }, [tabs, measureOverflow])
72
126
 
73
127
  const hasMatch = options.some((o) => o.value === value)
74
128
 
@@ -98,13 +152,17 @@ export function SegmentedControl<T extends string = string>({
98
152
  aria-label={rest["aria-label"]}
99
153
  aria-labelledby={rest["aria-labelledby"]}
100
154
  data-testid={rest["data-testid"]}
155
+ data-overflow-start={overflow.start || undefined}
156
+ data-overflow-end={overflow.end || undefined}
157
+ ref={stripRef}
101
158
  onKeyDown={handleKeyDown}
159
+ onScroll={tabs ? measureOverflow : undefined}
102
160
  className={cn(
103
161
  "flex gap-1",
104
162
  variant === "row" &&
105
163
  "flex-wrap items-center rounded-lg border border-border bg-card p-1",
106
164
  variant === "tabs" &&
107
- "flex-nowrap items-end border-b border-border pb-0 overflow-x-auto",
165
+ ["relative flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", tabEdgeFade],
108
166
  className,
109
167
  )}
110
168
  >
@@ -127,17 +185,17 @@ export function SegmentedControl<T extends string = string>({
127
185
  if (!active) onValueChange(option.value)
128
186
  }}
129
187
  className={cn(
130
- "relative inline-flex items-center gap-2 whitespace-nowrap text-sm transition-[color,background-color,border-color,box-shadow]",
188
+ "relative inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-[color,background-color,border-color,box-shadow]",
131
189
  variant === "row" && ["rounded-md px-3 py-1.5", focusRing],
132
190
  // The strip scrolls horizontally, which would clip an outer ring.
133
- variant === "tabs" && ["rounded-none border-b-2 -mb-px px-4 py-2", focusRingInset],
191
+ variant === "tabs" && ["shrink-0 rounded-none border-b-2 -mb-px px-4 py-2", touchTarget, focusRingInset],
134
192
  // Active styling is the ONLY styled state — unselected
135
193
  // segments stay transparent on purpose so they don't
136
194
  // compete visually with the selection.
137
195
  active
138
196
  ? variant === "row"
139
- ? "border border-transparent bg-[var(--accent-surface)] text-[var(--accent-text)] font-semibold"
140
- : "border-[var(--accent-text)] text-[var(--accent-text)] font-semibold"
197
+ ? "border border-transparent bg-[var(--accent-surface)] text-[var(--accent-text)]"
198
+ : "border-[var(--accent-text)] text-[var(--accent-text)]"
141
199
  : variant === "row"
142
200
  ? "border border-transparent text-muted-foreground hover:text-foreground"
143
201
  : "border-transparent text-muted-foreground hover:text-foreground",
@@ -162,3 +162,18 @@ export const Overview: Story = {
162
162
  </div>
163
163
  ),
164
164
  }
165
+
166
+ /** A value longer than its trigger ends in an ellipsis before the chevron. */
167
+ export const LongValue: Story = {
168
+ render: () => (
169
+ <Select defaultValue="concierge">
170
+ <SelectTrigger className="w-48">
171
+ <SelectValue />
172
+ </SelectTrigger>
173
+ <SelectContent>
174
+ <SelectItem value="concierge">Luna — ReCenter Guest Concierge</SelectItem>
175
+ <SelectItem value="practitioner">Luna — ReCenter Wellness &amp; Practitioner Desk</SelectItem>
176
+ </SelectContent>
177
+ </Select>
178
+ ),
179
+ }
@@ -22,10 +22,12 @@ const SelectTrigger = React.forwardRef<
22
22
  <SelectPrimitive.Trigger
23
23
  ref={ref}
24
24
  className={cn(
25
- "flex w-full items-center justify-between whitespace-nowrap border py-1 shadow-sm placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
25
+ "flex w-full items-center justify-between gap-2 whitespace-nowrap border py-1 shadow-sm placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50",
26
26
  fieldPresentation,
27
27
  fieldTouchText,
28
- "data-[placeholder]:text-[var(--text-dim)] [&>span]:min-w-0 [&>span]:text-left",
28
+ // A long value ends in an ellipsis before the chevron: line-clamp cannot
29
+ // ellipsize a nowrap line, so it clipped mid-glyph under the icon.
30
+ "data-[placeholder]:text-[var(--text-dim)] [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left",
29
31
  // The designed open state: accent border and surface while the menu is up.
30
32
  "data-[state=open]:border-primary data-[state=open]:bg-[var(--accent-surface-strong)] [&[data-state=open]>svg]:rotate-180 [&>svg]:transition-transform",
31
33
  size === "compact" || size === "touch" ? cn(fieldSizes.md, controlSizes[size]) : fieldSizes[size === "default" ? "md" : size],