@godxjp/ui 28.5.0 → 28.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/components/data-display/data-table.d.ts +10 -2
  2. package/dist/components/data-display/data-table.js +12 -5
  3. package/dist/components/data-display/scroll-area.js +16 -2
  4. package/dist/components/data-display/service-launcher-card.d.ts +25 -0
  5. package/dist/components/data-display/table.d.ts +38 -1
  6. package/dist/components/data-display/table.js +57 -39
  7. package/dist/components/data-entry/field.d.ts +1 -1
  8. package/dist/components/data-entry/field.js +35 -4
  9. package/dist/components/data-entry/form-field.js +29 -12
  10. package/dist/components/data-entry/input.d.ts +1 -1
  11. package/dist/components/layout/index.d.ts +1 -1
  12. package/dist/components/layout/nav-list.d.ts +9 -1
  13. package/dist/components/layout/nav-list.js +22 -11
  14. package/dist/components/layout/sidebar.d.ts +16 -2
  15. package/dist/components/layout/sidebar.js +1 -0
  16. package/dist/components/navigation/app-setting-picker.js +20 -3
  17. package/dist/components/ui/password-input.d.ts +1 -1
  18. package/dist/components/ui/table.js +1 -0
  19. package/dist/contracts/measurement.json +1 -1
  20. package/dist/i18n/messages/en.json +5 -1
  21. package/dist/i18n/messages/ja.json +5 -1
  22. package/dist/i18n/messages/vi.json +5 -1
  23. package/dist/lib/control-styles.d.ts +18 -3
  24. package/dist/lib/control-styles.js +1 -1
  25. package/dist/lib/hooks.d.ts +23 -0
  26. package/dist/lib/hooks.js +27 -0
  27. package/dist/props/components/data-display.prop.d.ts +14 -0
  28. package/dist/props/components/data-entry.prop.d.ts +36 -2
  29. package/dist/props/components/layout.prop.d.ts +16 -5
  30. package/dist/props/registry.d.ts +2 -2
  31. package/dist/props/registry.js +10 -2
  32. package/dist/styles/control.css +12 -0
  33. package/dist/styles/data-display-layout.css +25 -2
  34. package/dist/styles/dialog-layout.css +12 -0
  35. package/dist/styles/form-layout.css +22 -0
  36. package/dist/styles/layout.css +1 -2
  37. package/dist/styles/navigation-layout.css +18 -19
  38. package/dist/tokens/components/data-display.css +2 -1
  39. package/dist/tokens/components/feedback.css +2 -0
  40. package/dist/tokens/components/form.css +2 -0
  41. package/dist/tokens/components/navigation.css +2 -0
  42. package/docs/COMPOSITION-VS-COMPONENT.md +3 -0
  43. package/docs/DESIGN-AUTHORITY.md +25 -5
  44. package/docs/FRAME-COVERAGE-REPORT.md +1 -1
  45. package/docs/data-display/collapsible.tsx +5 -5
  46. package/docs/data-display/descriptions.tsx +188 -109
  47. package/docs/data-display/legend.tsx +17 -4
  48. package/docs/data-display/scroll-area.tsx +106 -5
  49. package/docs/data-display/service-launcher-card.tsx +275 -115
  50. package/docs/data-display/stat-card.tsx +319 -64
  51. package/docs/data-entry/form.tsx +700 -6
  52. package/docs/data-entry/input.tsx +663 -53
  53. package/docs/data-entry/radio-group.tsx +216 -2
  54. package/docs/data-entry/switch.tsx +210 -4
  55. package/docs/data-entry/upload-crop-dialog.tsx +24 -25
  56. package/docs/layout/nav-list.tsx +556 -35
  57. package/package.json +9 -7
  58. package/scripts/visual-audit.mjs +29 -1
@@ -186,10 +186,27 @@ const AppSettingPicker = React.forwardRef(
186
186
  )
187
187
  ) : (
188
188
  // Labeled: sized to a per-kind width from `sm` up; below `sm` it hugs its content and
189
- // A form field that wants a full-width control passes
190
- // `className="w-full"`, which wins over `w-auto`.
191
189
  // `compact` drops the per-kind width entirely so the trigger hugs its value.
192
- cn("w-auto max-w-full", !isCompact && "ui-app-setting-picker-trigger")
190
+ //
191
+ // The width is a UTILITY READING A TOKEN, for the third time in this file's
192
+ // history and for the same reason (gh#366 icon, gh#375 Select, gh#819 here):
193
+ // @layer utilities is ordered after @layer components, so the `w-auto` that
194
+ // stood here outranked `.ui-app-setting-picker-trigger[data-kind]`'s
195
+ // `inline-size` and every one of the eight per-kind tokens was inert — a
196
+ // consumer who retuned --app-setting-picker-density-width saw nothing move.
197
+ // `--app-setting-picker-trigger-width` is what `data-kind` resolves in
198
+ // navigation-layout.css (a custom property, which no utility competes with);
199
+ // it defaults to `auto`, so below the breakpoint and under `compact` this is
200
+ // byte-for-byte the old `w-auto` behaviour.
201
+ //
202
+ // It stays a SINGLE unprefixed `w-*` (not `sm:w-[…]` per kind): `cn` is
203
+ // tailwind-merge, so a form field that wants a full-width control still passes
204
+ // `className="w-full"` and REPLACES this one. A `sm:`-variant utility would
205
+ // survive that merge and quietly ignore the consumer above 40rem.
206
+ cn(
207
+ "w-[length:var(--app-setting-picker-trigger-width)] max-w-full",
208
+ !isCompact && "ui-app-setting-picker-trigger"
209
+ )
193
210
  ),
194
211
  // Compact re-tiers the box through tokens (--app-setting-picker-compact-*); the height
195
212
  // still comes from the official --control-height-sm tier, never a literal. The gap is a
@@ -18,7 +18,7 @@ export type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<typeof Inpu
18
18
  };
19
19
  export declare const PasswordInput: React.ForwardRefExoticComponent<Omit<Omit<Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "size"> & {
20
20
  onValueChange?: (value: string) => void;
21
- size?: "sm" | "md" | "lg";
21
+ size?: "xs" | "sm" | "md" | "lg";
22
22
  status?: import("../../props/index.js").ControlStatusProp;
23
23
  variant?: import("../../props/index.js").ControlVariantProp;
24
24
  allowClear?: import("../../props/index.js").AllowClearProp;
@@ -1 +1,2 @@
1
+ "use client";
1
2
  export * from "../data-display/table.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "28.5.0",
3
+ "version": "28.7.0",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -325,6 +325,9 @@
325
325
  "ariaLabel": "Progress",
326
326
  "breakdownSeparator": ", "
327
327
  },
328
+ "scrollArea": {
329
+ "region": "Scrollable region"
330
+ },
328
331
  "tree": {
329
332
  "expand": "Expand",
330
333
  "collapse": "Collapse",
@@ -537,7 +540,8 @@
537
540
  "sortAscending": "Sort ascending",
538
541
  "sortDescending": "Sort descending",
539
542
  "sortCancel": "Cancel sort",
540
- "selectColumn": "Select"
543
+ "selectColumn": "Select",
544
+ "scrollRegion": "Scrollable table"
541
545
  },
542
546
  "dataGrid": {
543
547
  "search": "Search",
@@ -318,6 +318,9 @@
318
318
  "ariaLabel": "進捗",
319
319
  "breakdownSeparator": "、"
320
320
  },
321
+ "scrollArea": {
322
+ "region": "スクロール可能な領域"
323
+ },
321
324
  "tree": {
322
325
  "expand": "展開",
323
326
  "collapse": "折りたたむ",
@@ -524,7 +527,8 @@
524
527
  "sortAscending": "昇順に並べ替え",
525
528
  "sortDescending": "降順に並べ替え",
526
529
  "sortCancel": "並べ替えを解除",
527
- "selectColumn": "選択"
530
+ "selectColumn": "選択",
531
+ "scrollRegion": "スクロール可能な表"
528
532
  },
529
533
  "dataGrid": {
530
534
  "search": "検索",
@@ -319,6 +319,9 @@
319
319
  "ariaLabel": "Tiến độ",
320
320
  "breakdownSeparator": ", "
321
321
  },
322
+ "scrollArea": {
323
+ "region": "Vùng có thể cuộn"
324
+ },
322
325
  "tree": {
323
326
  "expand": "Mở rộng",
324
327
  "collapse": "Thu gọn",
@@ -525,7 +528,8 @@
525
528
  "sortAscending": "Sắp xếp tăng dần",
526
529
  "sortDescending": "Sắp xếp giảm dần",
527
530
  "sortCancel": "Bỏ sắp xếp",
528
- "selectColumn": "Chọn"
531
+ "selectColumn": "Chọn",
532
+ "scrollRegion": "Bảng có thể cuộn"
529
533
  },
530
534
  "dataGrid": {
531
535
  "search": "Tìm kiếm",
@@ -30,11 +30,26 @@ export declare const controlOpenRingClass = "ui-control-trigger";
30
30
  * `--control-bounded-width` in gh#375). A control that wants the antd `variant` × `status` matrix
31
31
  * therefore composes THIS class plus `ui-control-surface`, which supplies the identical resting
32
32
  * border and fill from `--control-surface-*`.
33
+ *
34
+ * TRUNCATION HAS TO READ AS TRUNCATION (gh#813). `line-clamp-1` clips the value and — inside the
35
+ * trigger's `whitespace-nowrap` — never reaches a second line, so its own ellipsis never engages:
36
+ * `YYYY-MM-DD(年-月-日)` in a 101px value box showed `YYYY-MM-DD(全`, a format string cut through
37
+ * the middle of a glyph, with nothing to say it had been cut. Two declarations because the two
38
+ * engines ellipsize by different mechanisms, both measured on
39
+ * `/isolate/navigation-app-setting-picker` (dateFormat, ja, 1280×1000, value box 101px / text
40
+ * 160px):
41
+ *
42
+ * text-ellipsis Blink honours `text-overflow` on the clamped box → `YYYY-MM-DD…`.
43
+ * Gecko ignores it there (still `YYYY-MM-DD(全`).
44
+ * whitespace-normal lets `-webkit-line-clamp` clamp a real line, which is the ellipsis Gecko
45
+ * DOES paint → `YYYY-MM-D…` in Firefox, `YYYY-MM-DD…` in Chromium.
46
+ *
47
+ * The clamp keeps it to one line, so a wrappable value cannot grow the control.
33
48
  */
34
- export declare const controlTriggerBaseClass = "ui-control ui-control-trigger flex items-center justify-between gap-2 whitespace-nowrap rounded-[var(--control-radius)] transition-[color,box-shadow] [&>[data-slot=select-value]]:line-clamp-1";
35
- export declare const controlTriggerClass = "ui-control ui-control-trigger flex items-center justify-between gap-2 whitespace-nowrap rounded-[var(--control-radius)] transition-[color,box-shadow] [&>[data-slot=select-value]]:line-clamp-1 border-input bg-background";
49
+ export declare const controlTriggerBaseClass = "ui-control ui-control-trigger flex items-center justify-between gap-2 whitespace-nowrap rounded-[var(--control-radius)] transition-[color,box-shadow] [&>[data-slot=select-value]]:line-clamp-1 [&>[data-slot=select-value]]:whitespace-normal [&>[data-slot=select-value]]:text-ellipsis";
50
+ export declare const controlTriggerClass = "ui-control ui-control-trigger flex items-center justify-between gap-2 whitespace-nowrap rounded-[var(--control-radius)] transition-[color,box-shadow] [&>[data-slot=select-value]]:line-clamp-1 [&>[data-slot=select-value]]:whitespace-normal [&>[data-slot=select-value]]:text-ellipsis border-input bg-background";
36
51
  /** `controlTriggerBaseClass` + the token-driven surface — the select-family trigger. */
37
- export declare const controlSurfaceTriggerClass = "ui-control ui-control-trigger flex items-center justify-between gap-2 whitespace-nowrap rounded-[var(--control-radius)] transition-[color,box-shadow] [&>[data-slot=select-value]]:line-clamp-1 ui-control-surface";
52
+ export declare const controlSurfaceTriggerClass = "ui-control ui-control-trigger flex items-center justify-between gap-2 whitespace-nowrap rounded-[var(--control-radius)] transition-[color,box-shadow] [&>[data-slot=select-value]]:line-clamp-1 [&>[data-slot=select-value]]:whitespace-normal [&>[data-slot=select-value]]:text-ellipsis ui-control-surface";
38
53
  export declare const controlIconClass = "size-[length:var(--control-height)] shrink-0";
39
54
  export declare const controlIconSmClass = "size-[calc(var(--control-height)-0.5rem)] shrink-0";
40
55
  /** Leading/affix icon inside an input row (search, command) — sized to `--control-icon-size`. */
@@ -2,7 +2,7 @@ const controlMultilineClass = "ui-control-multiline aria-invalid:border-destruct
2
2
  const controlMultilineGhostClass = "ui-control-multiline data-[status=error]:border-destructive data-[status=warning]:border-warning w-full min-h-0 border-0 bg-transparent shadow-none placeholder:text-muted-foreground focus-visible:ring-0";
3
3
  const controlMultilineFilledClass = "ui-control-multiline ui-control--filled aria-invalid:border-destructive data-[status=error]:border-destructive data-[status=warning]:border-warning w-full rounded-[var(--control-radius)] ring-offset-background placeholder:text-muted-foreground";
4
4
  const controlOpenRingClass = "ui-control-trigger";
5
- const controlTriggerBaseClass = "ui-control ui-control-trigger flex items-center justify-between gap-2 whitespace-nowrap rounded-[var(--control-radius)] transition-[color,box-shadow] [&>[data-slot=select-value]]:line-clamp-1";
5
+ const controlTriggerBaseClass = "ui-control ui-control-trigger flex items-center justify-between gap-2 whitespace-nowrap rounded-[var(--control-radius)] transition-[color,box-shadow] [&>[data-slot=select-value]]:line-clamp-1 [&>[data-slot=select-value]]:whitespace-normal [&>[data-slot=select-value]]:text-ellipsis";
6
6
  const controlTriggerClass = `${controlTriggerBaseClass} border-input bg-background`;
7
7
  const controlSurfaceTriggerClass = `${controlTriggerBaseClass} ui-control-surface`;
8
8
  const controlIconClass = "size-[length:var(--control-height)] shrink-0";
@@ -1,3 +1,4 @@
1
+ import { type RefObject } from "react";
1
2
  /**
2
3
  * Returns a debounced view of `value`, updated only after `delay` ms of no
3
4
  * change. Use for search inputs to avoid querying on every keystroke.
@@ -37,3 +38,25 @@ export declare function useIsMobile(): boolean;
37
38
  * @param element the scroll container itself (state, not a ref, so the effect re-runs when it mounts)
38
39
  */
39
40
  export declare function useScrollableRegionTabIndex(element: HTMLElement | null): void;
41
+ /**
42
+ * Does this scroll box actually have somewhere to scroll, on the axes it is allowed to scroll on?
43
+ *
44
+ * Drives BOTH halves of a scroll region: the `tabindex="0"` a keyboard user needs to reach the
45
+ * overflow (WCAG 2.1.1 / axe `scrollable-region-focusable`) AND the `role`/name that stop needs in
46
+ * order not to be an anonymous one (gh#817 for the table, gh#821 for `ScrollArea`). A stop that
47
+ * scrolls nothing is pure noise, and a name on it is noise too, so both are withheld until there is
48
+ * overflow to reach.
49
+ *
50
+ * The measurement may only ever REMOVE the stop, never withhold it on a guess: a box that has not
51
+ * been laid out reports 0 for `clientWidth`/`clientHeight` (the server render, jsdom, a
52
+ * `display:none` ancestor, the frame before first layout), and that is not evidence that nothing
53
+ * overflows. Reading it as "no overflow" would strand the overflow from every keyboard user — a
54
+ * worse failure than an extra tab stop — so an unmeasured box counts as scrolling.
55
+ *
56
+ * `axis` is the box's OWN `overflow`, not a preference: an axis it does not scroll on is `hidden`
57
+ * there, so overflow on that axis is CLIPPED rather than reachable, and measuring it would keep a
58
+ * tab stop that scrolls nothing. `ScrollArea`'s `orientation` is exactly this union.
59
+ */
60
+ export declare function useScrollsOnAxis(ref: RefObject<HTMLElement | null>, enabled: boolean, axis: "horizontal" | "vertical" | "both"): boolean;
61
+ /** A table's wrapper scrolls on one axis only, so it asks the one question it has (gh#817). */
62
+ export declare function useScrollsHorizontally(ref: RefObject<HTMLElement | null>, enabled: boolean): boolean;
package/dist/lib/hooks.js CHANGED
@@ -104,11 +104,38 @@ function useScrollableRegionTabIndex(element) {
104
104
  };
105
105
  }, [element]);
106
106
  }
107
+ function useScrollsOnAxis(ref, enabled, axis) {
108
+ const [scrolls, setScrolls] = useState(true);
109
+ useEffect(() => {
110
+ const el = ref.current;
111
+ if (!enabled || !el) return void 0;
112
+ const overflows = (client, scroll) => client === 0 || scroll - client > 1;
113
+ const update = () => {
114
+ setScrolls(
115
+ axis !== "vertical" && overflows(el.clientWidth, el.scrollWidth) || axis !== "horizontal" && overflows(el.clientHeight, el.scrollHeight)
116
+ );
117
+ };
118
+ update();
119
+ if (typeof ResizeObserver === "undefined") return void 0;
120
+ const observer = new ResizeObserver(update);
121
+ observer.observe(el);
122
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
123
+ return () => {
124
+ observer.disconnect();
125
+ };
126
+ }, [ref, enabled, axis]);
127
+ return enabled && scrolls;
128
+ }
129
+ function useScrollsHorizontally(ref, enabled) {
130
+ return useScrollsOnAxis(ref, enabled, "horizontal");
131
+ }
107
132
  export {
108
133
  useControlledLatch,
109
134
  useDebouncedValue,
110
135
  useIsMobile,
111
136
  useMediaQuery,
112
137
  useScrollableRegionTabIndex,
138
+ useScrollsHorizontally,
139
+ useScrollsOnAxis,
113
140
  useTimeoutFlag
114
141
  };
@@ -605,6 +605,20 @@ export type ScrollAreaProp = {
605
605
  * from it — anchoring must never be the only route back to new content.
606
606
  */
607
607
  onAnchoredChange?: (anchored: boolean) => void;
608
+ /**
609
+ * Accessible name for the scroll REGION — the `tabindex="0"` element a keyboard user lands on to
610
+ * scroll content taller or wider than the box.
611
+ *
612
+ * Optional on purpose. A consumer is never forced to invent a name for every scrolling box: left
613
+ * out, the region takes the localized `dataDisplay.scrollArea.region` default ("Scrollable
614
+ * region"), which is what a screen-reader user needs to hear anyway — that the arrow keys now
615
+ * scroll something. Pass a plain string when the screen can say WHICH region ("Activity log"); a
616
+ * non-string node cannot be an `aria-label`, so it falls back to the default.
617
+ *
618
+ * The region is only announced while it HAS overflow to reach, on the axes `orientation` opens:
619
+ * no overflow, no tab stop, no role and no name. (gh#821)
620
+ */
621
+ label?: LabelProp;
608
622
  };
609
623
  /**
610
624
  * @see TimelineGrid — one COLUMN of the grid (a day, a room, a machine). The label names the
@@ -126,8 +126,18 @@ export type CommandProp = {
126
126
  };
127
127
  export type InputProp = Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "prefix"> & {
128
128
  onValueChange?: (value: string) => void;
129
- /** Control height tier: `md` (default), `sm` or `lg` — the same tiers as SelectTrigger. */
130
- size?: "sm" | "md" | "lg";
129
+ /**
130
+ * Control height tier: `md` (default), plus `xs`, `sm` and `lg` — the same tiers as
131
+ * SelectTrigger and NumberInput.
132
+ *
133
+ * `xs` was missing from this union alone. Everything underneath it already worked: `Input`
134
+ * renders `ui-control` and emits `data-size={size}`, and `.ui-control[data-size="xs"]`
135
+ * (src/styles/control.css:1674) binds `--control-height-xs`. That rule was added for the
136
+ * select family — its comment says the token "existed with nothing reading it" — and this
137
+ * union was never widened to match, so the most-used control in the package was the one
138
+ * control missing the bottom rung, for a reason no user could have guessed from behaviour.
139
+ */
140
+ size?: "xs" | "sm" | "md" | "lg";
131
141
  /** Validation state the field paints — antd `status`. `error` also reports `aria-invalid`. */
132
142
  status?: ControlStatusProp;
133
143
  /** Chrome level — antd `variant`. Default `outlined`. */
@@ -547,7 +557,31 @@ export type SwitchProp = Omit<React.ComponentPropsWithoutRef<"button">, "checked
547
557
  export type FieldProp = {
548
558
  id: IdProp;
549
559
  label: LabelProp;
560
+ /**
561
+ * Optional content rendered BESIDE the label and OUTSIDE the `<label>` element — a help
562
+ * button, a Tooltip trigger, a status chip, a short text action. `FormField.labelAddon` with
563
+ * the same semantics, and it has to live outside the label for a reason this row has that a
564
+ * stacked field does not: `Field`'s label is a real `<label htmlFor>` pointing at a
565
+ * checkbox / radio / switch, so a browser forwards a click anywhere inside it to that
566
+ * control. An interactive addon passed through `label` therefore TOGGLES the control when
567
+ * pressed (gh#812). A non-interactive mark (a `Badge as="span"`) is still fine inside
568
+ * `label`; anything pressable belongs here.
569
+ */
570
+ labelAddon?: React.ReactNode;
571
+ /**
572
+ * Muted hint under the label. Announced with the control (`aria-describedby`) rather than
573
+ * left as decoration, and it COMPOSES with `error` — `.ui-choice-content` is already a
574
+ * vertical stack, so the two lines sit under each other exactly as they do on `FormField`.
575
+ */
550
576
  description?: React.ReactNode;
577
+ /**
578
+ * Validation message under the description (`role="alert"`). Presence of a message IS the
579
+ * invalid state: the control is cloned with `aria-invalid`, and the message id reaches it
580
+ * through `aria-errormessage` AND `aria-describedby` (see field.tsx — react-aria's hidden
581
+ * input drops the former). This is the slot that lets a validated boolean stay in `Field`:
582
+ * `FormField` must not wrap a bare `Switch`, so before this there was nowhere to put it.
583
+ */
584
+ error?: ErrorProp;
551
585
  className?: ClassNameProp;
552
586
  children: React.ReactNode;
553
587
  };
@@ -932,11 +932,14 @@ export type SidebarItemProp = {
932
932
  id: string;
933
933
  label: string;
934
934
  /**
935
- * Leading 16px glyph — REQUIRED: the collapsed rail is icon-only and the expanded rail aligns
936
- * every label to the icon column. Its colour is themeable separately from the label via
935
+ * Leading 16px glyph. STRONGLY RECOMMENDED on the rail — the collapsed rail is icon-only, so a
936
+ * row without one reads as a hole there — but OPTIONAL at the type level (gh#815): the runtime
937
+ * has always rendered an empty `.sb-icon` box for a row whose data carries no glyph, precisely
938
+ * so the label column still aligns, and `NavList` (which has no collapsed rail) routinely mixes
939
+ * rows with and without one. Its colour is themeable separately from the label via
937
940
  * `--sidebar-nav-icon-foreground` (see {@link SidebarProp}).
938
941
  */
939
- icon: ComponentType<SVGProps<SVGSVGElement>>;
942
+ icon?: ComponentType<SVGProps<SVGSVGElement>>;
940
943
  /**
941
944
  * Count/status affix rendered in the row's `.sb-badge` pill. Pass the CONTENT ONLY — a number, a
942
945
  * string, `"9+"`.
@@ -968,7 +971,11 @@ export type SidebarItemProp = {
968
971
  * right-click / open-in-new-tab / middle-click all work.
969
972
  */
970
973
  href?: string;
971
- /** Nested rows — renders a collapsible submenu group (the parent reads active when any child is). */
974
+ /**
975
+ * Nested rows — renders a collapsible submenu group (the parent reads active when any child is).
976
+ * Honoured by BOTH `Sidebar` and `NavList` (gh#815): `NavList` used to map `items` straight to a
977
+ * row, so a nested group type-checked, rendered as one flat row and lost its children silently.
978
+ */
972
979
  children?: SidebarItemProp[];
973
980
  };
974
981
  /**
@@ -979,7 +986,11 @@ export type SidebarItemProp = {
979
986
  * learn a second one, and a fix to the row reaches both.
980
987
  */
981
988
  export type NavListProp = Omit<React.HTMLAttributes<HTMLElement>, "onSelect"> & {
982
- /** Rows, in reading order. `icon` is required by `SidebarItemProp` — the label aligns to it. */
989
+ /**
990
+ * Rows, in reading order. An item carrying `children` renders as a collapsible GROUP — the same
991
+ * `.sb-nav-group` the rail draws, opened by the route whenever a descendant is active — so a
992
+ * grouped settings nav is one `NavList` and one `<nav>` landmark, not one per group (gh#815).
993
+ */
983
994
  items: SidebarItemProp[];
984
995
  /** `SidebarItemProp.id` of the current route; that row gets `aria-current="page"`. */
985
996
  activeId?: string;
@@ -1470,7 +1470,7 @@ export declare const COMPONENT_PROP_REGISTRY: {
1470
1470
  readonly FieldProp: {
1471
1471
  readonly group: "data-entry";
1472
1472
  readonly file: "components/data-entry.prop.ts";
1473
- readonly vocabulary: readonly ["IdProp", "LabelProp", "DescriptionProp", "ClassNameProp", "ChildrenProp"];
1473
+ readonly vocabulary: readonly ["IdProp", "LabelProp", "DescriptionProp", "ErrorProp", "ClassNameProp", "ChildrenProp"];
1474
1474
  };
1475
1475
  readonly SliderProp: {
1476
1476
  readonly group: "data-entry";
@@ -1851,7 +1851,7 @@ export declare const COMPONENT_PROP_REGISTRY: {
1851
1851
  readonly field: "onAnchoredChange";
1852
1852
  readonly local: true;
1853
1853
  readonly reason: 'Pinned-state change handler (boolean), so the consumer can render its own focusable "jump to newest" affordance.';
1854
- }];
1854
+ }, "LabelProp"];
1855
1855
  };
1856
1856
  readonly ChartSeriesProp: {
1857
1857
  readonly group: "data-display";
@@ -1689,7 +1689,14 @@ const COMPONENT_PROP_REGISTRY = {
1689
1689
  FieldProp: {
1690
1690
  group: "data-entry",
1691
1691
  file: "components/data-entry.prop.ts",
1692
- vocabulary: ["IdProp", "LabelProp", "DescriptionProp", "ClassNameProp", "ChildrenProp"]
1692
+ vocabulary: [
1693
+ "IdProp",
1694
+ "LabelProp",
1695
+ "DescriptionProp",
1696
+ "ErrorProp",
1697
+ "ClassNameProp",
1698
+ "ChildrenProp"
1699
+ ]
1693
1700
  },
1694
1701
  SliderProp: {
1695
1702
  group: "data-entry",
@@ -2254,7 +2261,8 @@ const COMPONENT_PROP_REGISTRY = {
2254
2261
  field: "onAnchoredChange",
2255
2262
  local: true,
2256
2263
  reason: 'Pinned-state change handler (boolean), so the consumer can render its own focusable "jump to newest" affordance.'
2257
- }
2264
+ },
2265
+ "LabelProp"
2258
2266
  ]
2259
2267
  },
2260
2268
  ChartSeriesProp: {
@@ -435,11 +435,23 @@
435
435
  font-weight: var(--font-weight-normal);
436
436
  }
437
437
 
438
+ .ui-choice-label-row {
439
+ display: flex;
440
+ flex-wrap: wrap;
441
+ align-items: center;
442
+ gap: var(--control-label-space-gap);
443
+ }
444
+
438
445
  .ui-choice-description {
439
446
  color: hsl(var(--muted-foreground));
440
447
  font-size: var(--choice-description-font-size);
441
448
  }
442
449
 
450
+ .ui-choice-error {
451
+ color: hsl(var(--text-error));
452
+ font-size: var(--choice-description-font-size);
453
+ }
454
+
443
455
  .ui-label {
444
456
  display: flex;
445
457
  align-items: center;
@@ -289,9 +289,32 @@
289
289
  .ui-progress-ring-label {
290
290
  position: relative;
291
291
  color: hsl(var(--foreground));
292
- font-size: var(--progress-ring-label-font-size);
292
+ font-size: var(--progress-ring-label-font-size, calc(var(--progress-ring-size) * 0.28));
293
293
  font-variant-numeric: tabular-nums;
294
- line-height: 1;
294
+
295
+ line-height: var(--line-height-normal);
296
+
297
+ max-inline-size: calc(
298
+ var(--progress-ring-size) - 2 * var(--progress-ring-border-width) - var(--space-1)
299
+ );
300
+ overflow: hidden;
301
+ text-overflow: ellipsis;
302
+ white-space: nowrap;
303
+ }
304
+
305
+ .ui-disclosure-chevron {
306
+ transition: rotate var(--duration-fast) var(--ease-standard);
307
+ }
308
+
309
+ :is([data-state="open"], [aria-expanded="true"]) > .ui-disclosure-chevron,
310
+ :is([data-state="open"], [aria-expanded="true"]) .ui-disclosure-chevron {
311
+ rotate: 180deg;
312
+ }
313
+
314
+ @media (prefers-reduced-motion: reduce) {
315
+ .ui-disclosure-chevron {
316
+ transition: none;
317
+ }
295
318
  }
296
319
 
297
320
  .ui-legend {
@@ -146,6 +146,18 @@
146
146
  opacity: var(--dialog-close-rest-alpha);
147
147
  }
148
148
 
149
+ :is([data-slot="dialog-close"], [data-slot="sheet-close"])::after {
150
+ content: "";
151
+ position: absolute;
152
+ inset-block-start: 50%;
153
+ inset-inline-start: 50%;
154
+ translate: -50% -50%;
155
+ min-inline-size: var(--dialog-close-size);
156
+ min-block-size: var(--dialog-close-size);
157
+ inline-size: 100%;
158
+ block-size: 100%;
159
+ }
160
+
149
161
  [data-slot="dialog-close"]:hover {
150
162
  opacity: 1;
151
163
  }
@@ -259,4 +259,26 @@
259
259
  text-align: end;
260
260
  }
261
261
  }
262
+
263
+ .ui-form-feedback > svg {
264
+ inline-size: var(--form-feedback-icon-size);
265
+ block-size: var(--form-feedback-icon-size);
266
+ flex-shrink: 0;
267
+ }
268
+
269
+ .ui-form-feedback[data-status="success"] {
270
+ color: hsl(var(--text-success));
271
+ }
272
+
273
+ .ui-form-feedback[data-status="warning"] {
274
+ color: hsl(var(--text-warning));
275
+ }
276
+
277
+ .ui-form-feedback[data-status="error"] {
278
+ color: hsl(var(--text-error));
279
+ }
280
+
281
+ .ui-form-feedback[data-status="validating"] {
282
+ color: hsl(var(--muted-foreground));
283
+ }
262
284
  }
@@ -834,7 +834,7 @@
834
834
  .ui-page-header-extra {
835
835
  width: auto;
836
836
  flex-shrink: 1;
837
- min-inline-size: 0;
837
+ min-inline-size: auto;
838
838
  justify-content: flex-end;
839
839
  }
840
840
 
@@ -1053,7 +1053,6 @@
1053
1053
  }
1054
1054
 
1055
1055
  .ui-pagination-size-trigger {
1056
- width: var(--pagination-size-width);
1057
1056
 
1058
1057
  height: var(--control-height);
1059
1058
  }
@@ -220,27 +220,27 @@
220
220
  }
221
221
 
222
222
  [data-slot="tabs"][data-variant="card"][data-placement="start"] [data-slot="tabs-list"],
223
- [data-slot="tabs"][data-variant="editable-card"][data-placement="start"]
224
- [data-slot="tabs-list"] {
223
+ [data-slot="tabs"][data-variant="editable-card"][data-placement="start"] [data-slot="tabs-list"] {
225
224
  box-shadow: inset calc(-1 * var(--tabs-card-rail-border-width)) 0 0 hsl(var(--border));
226
225
  }
227
226
 
228
227
  [data-slot="tabs"][data-variant="card"][data-placement="end"] [data-slot="tabs-list"],
229
- [data-slot="tabs"][data-variant="editable-card"][data-placement="end"]
230
- [data-slot="tabs-list"] {
228
+ [data-slot="tabs"][data-variant="editable-card"][data-placement="end"] [data-slot="tabs-list"] {
231
229
  box-shadow: inset var(--tabs-card-rail-border-width) 0 0 hsl(var(--border));
232
230
  }
233
231
 
234
- [dir="rtl"] [data-slot="tabs"][data-variant="card"][data-placement="start"]
232
+ [dir="rtl"]
233
+ [data-slot="tabs"][data-variant="card"][data-placement="start"]
235
234
  [data-slot="tabs-list"],
236
- [dir="rtl"] [data-slot="tabs"][data-variant="editable-card"][data-placement="start"]
235
+ [dir="rtl"]
236
+ [data-slot="tabs"][data-variant="editable-card"][data-placement="start"]
237
237
  [data-slot="tabs-list"] {
238
238
  box-shadow: inset var(--tabs-card-rail-border-width) 0 0 hsl(var(--border));
239
239
  }
240
240
 
241
- [dir="rtl"] [data-slot="tabs"][data-variant="card"][data-placement="end"]
242
- [data-slot="tabs-list"],
243
- [dir="rtl"] [data-slot="tabs"][data-variant="editable-card"][data-placement="end"]
241
+ [dir="rtl"] [data-slot="tabs"][data-variant="card"][data-placement="end"] [data-slot="tabs-list"],
242
+ [dir="rtl"]
243
+ [data-slot="tabs"][data-variant="editable-card"][data-placement="end"]
244
244
  [data-slot="tabs-list"] {
245
245
  box-shadow: inset calc(-1 * var(--tabs-card-rail-border-width)) 0 0 hsl(var(--border));
246
246
  }
@@ -277,7 +277,7 @@
277
277
  margin-inline-start: calc(var(--tabs-panel-space-inset) + var(--tabs-panel-border-width));
278
278
  }
279
279
 
280
- [data-slot="tabs"][data-bodied="true"] > [data-slot="tabs-panel"],
280
+ [data-slot="tabs"][data-bodied="true"] > [data-slot="tabs-panel"],
281
281
  [data-slot="tabs"][data-bodied="true"] > [data-slot="tabs-content"] {
282
282
  border: var(--tabs-panel-border-width) solid hsl(var(--border));
283
283
  border-radius: var(--tabs-panel-radius);
@@ -1002,7 +1002,6 @@
1002
1002
  }
1003
1003
 
1004
1004
  .ui-app-setting-picker-icon {
1005
- inline-size: var(--control-height);
1006
1005
  justify-content: center;
1007
1006
  border-color: transparent;
1008
1007
  background: transparent;
@@ -1026,35 +1025,35 @@
1026
1025
 
1027
1026
  @media (min-width: 40rem) {
1028
1027
  .ui-app-setting-picker-trigger[data-kind="locale"] {
1029
- inline-size: var(--app-setting-picker-locale-width);
1028
+ --app-setting-picker-trigger-width: var(--app-setting-picker-locale-width);
1030
1029
  }
1031
1030
 
1032
1031
  .ui-app-setting-picker-trigger[data-kind="timezone"] {
1033
- inline-size: var(--app-setting-picker-timezone-width);
1032
+ --app-setting-picker-trigger-width: var(--app-setting-picker-timezone-width);
1034
1033
  }
1035
1034
 
1036
1035
  .ui-app-setting-picker-trigger[data-kind="dateFormat"] {
1037
- inline-size: var(--app-setting-picker-date-format-width);
1036
+ --app-setting-picker-trigger-width: var(--app-setting-picker-date-format-width);
1038
1037
  }
1039
1038
 
1040
1039
  .ui-app-setting-picker-trigger[data-kind="timeFormat"] {
1041
- inline-size: var(--app-setting-picker-time-format-width);
1040
+ --app-setting-picker-trigger-width: var(--app-setting-picker-time-format-width);
1042
1041
  }
1043
1042
 
1044
1043
  .ui-app-setting-picker-trigger[data-kind="theme"] {
1045
- inline-size: var(--app-setting-picker-theme-width);
1044
+ --app-setting-picker-trigger-width: var(--app-setting-picker-theme-width);
1046
1045
  }
1047
1046
 
1048
1047
  .ui-app-setting-picker-trigger[data-kind="brand"] {
1049
- inline-size: var(--app-setting-picker-brand-width);
1048
+ --app-setting-picker-trigger-width: var(--app-setting-picker-brand-width);
1050
1049
  }
1051
1050
 
1052
1051
  .ui-app-setting-picker-trigger[data-kind="density"] {
1053
- inline-size: var(--app-setting-picker-density-width);
1052
+ --app-setting-picker-trigger-width: var(--app-setting-picker-density-width);
1054
1053
  }
1055
1054
 
1056
1055
  .ui-app-setting-picker-trigger[data-kind="fontSize"] {
1057
- inline-size: var(--app-setting-picker-font-size-width);
1056
+ --app-setting-picker-trigger-width: var(--app-setting-picker-font-size-width);
1058
1057
  }
1059
1058
  }
1060
1059
  }
@@ -113,7 +113,8 @@
113
113
  --progress-ring-size: 2.75rem;
114
114
  --progress-ring-size-sm: 2rem;
115
115
  --progress-ring-border-width: var(--stroke-lg);
116
- --progress-ring-label-font-size: var(--font-size-xs);
116
+
117
+ --progress-ring-label-font-size: initial;
117
118
 
118
119
  --legend-gap: 0.875rem;
119
120
  --legend-item-gap: 0.3125rem;
@@ -43,6 +43,8 @@
43
43
 
44
44
  --dialog-close-rest-alpha: 0.7;
45
45
 
46
+ --dialog-close-size: var(--touch-target-min);
47
+
46
48
  --dialog-step-up-error-font-size: var(--font-size-sm);
47
49
 
48
50
  --alert-radius: var(--card-radius);