@cueplusplus/ui 0.5.0 → 0.6.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 (68) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/chrome/_drag.js +59 -0
  3. package/dist/chrome/_status-bar-item.js +85 -0
  4. package/dist/chrome/_status-bar.d.ts +15 -0
  5. package/dist/chrome/_status-bar.js +171 -0
  6. package/dist/chrome/app-shell.d.ts +69 -1
  7. package/dist/chrome/app-shell.js +38 -11
  8. package/dist/chrome/index.d.ts +3 -2
  9. package/dist/chrome/status-bar.d.ts +87 -4
  10. package/dist/chrome/status-bar.js +80 -16
  11. package/dist/chrome/title-bar.d.ts +11 -1
  12. package/dist/chrome/title-bar.js +1 -3
  13. package/dist/date/_segments.js +15 -4
  14. package/dist/forms/_chassis.d.ts +104 -4
  15. package/dist/forms/_chassis.js +104 -4
  16. package/dist/forms/input-group.d.ts +8 -3
  17. package/dist/forms/input-group.js +8 -3
  18. package/dist/forms/number-field.d.ts +3 -2
  19. package/dist/forms/number-field.js +3 -2
  20. package/dist/index.d.ts +7 -3
  21. package/dist/index.js +4 -1
  22. package/dist/instruments/_data-row.js +118 -0
  23. package/dist/instruments/data-row.d.ts +94 -0
  24. package/dist/instruments/data-row.js +151 -0
  25. package/dist/instruments/data-tree.d.ts +126 -0
  26. package/dist/instruments/data-tree.js +303 -0
  27. package/dist/instruments/index.d.ts +5 -2
  28. package/dist/instruments/index.js +4 -1
  29. package/dist/instruments/ledger.d.ts +105 -0
  30. package/dist/instruments/ledger.js +114 -0
  31. package/dist/instruments/table.d.ts +70 -2
  32. package/dist/instruments/table.js +133 -39
  33. package/dist/theming/_presets.js +3 -3
  34. package/manifest/components/app-shell.json +45 -4
  35. package/manifest/components/autocomplete.json +2 -0
  36. package/manifest/components/color-field.json +2 -0
  37. package/manifest/components/color-picker.json +2 -0
  38. package/manifest/components/combobox.json +2 -0
  39. package/manifest/components/composer.json +2 -0
  40. package/manifest/components/data-row.json +203 -0
  41. package/manifest/components/data-tree.json +151 -0
  42. package/manifest/components/date-field.json +2 -0
  43. package/manifest/components/date-picker.json +2 -0
  44. package/manifest/components/date-range-picker.json +2 -0
  45. package/manifest/components/env-var-input.json +2 -0
  46. package/manifest/components/input-group.json +4 -2
  47. package/manifest/components/input.json +3 -1
  48. package/manifest/components/ledger.json +187 -0
  49. package/manifest/components/multi-select.json +2 -0
  50. package/manifest/components/musical-time-input.json +2 -0
  51. package/manifest/components/number-field.json +3 -1
  52. package/manifest/components/otp-field.json +2 -0
  53. package/manifest/components/password-input.json +2 -0
  54. package/manifest/components/scrub-input.json +2 -0
  55. package/manifest/components/search-input.json +2 -0
  56. package/manifest/components/select.json +1 -0
  57. package/manifest/components/status-bar.json +146 -12
  58. package/manifest/components/table-scroll-region.json +6 -0
  59. package/manifest/components/table.json +88 -4
  60. package/manifest/components/tags-input.json +2 -0
  61. package/manifest/components/textarea.json +3 -1
  62. package/manifest/components/time-field.json +2 -0
  63. package/manifest/components/title-bar.json +1 -1
  64. package/manifest/components/toggle-group.json +2 -0
  65. package/manifest/components/toggle.json +2 -0
  66. package/manifest/manifest.json +72 -33
  67. package/manifest/tokens.json +1 -1
  68. package/package.json +4 -4
@@ -1,4 +1,6 @@
1
+ import { StatusBarProminence } from "./_status-bar.js";
1
2
  import * as React from "react";
3
+ import { useRender } from "@base-ui/react/use-render";
2
4
  //#region src/chrome/status-bar.d.ts
3
5
  /**
4
6
  * Ink for a status-bar item. `neutral` inherits the bar's own subtle grey; the
@@ -19,28 +21,109 @@ interface StatusBarItemProps extends React.ComponentPropsWithoutRef<"div"> {
19
21
  push?: boolean;
20
22
  /** Optional leading icon (e.g. a lucide icon), drawn `aria-hidden`. */
21
23
  icon?: React.ElementType;
24
+ /**
25
+ * Make the item a real control: a `<button type="button">` with hover, press
26
+ * and keyboard-focus feedback. Defaults to `false`.
27
+ *
28
+ * A native button, not a `<div>` with an `onClick` — keyboard order is DOM
29
+ * order, `Enter` and `Space` work because the platform makes them work, and
30
+ * assistive technology is told what the thing is by the element rather than
31
+ * by a role attribute that has to be kept honest by hand.
32
+ *
33
+ * **An icon-only interactive item needs an `aria-label`.** There is no text
34
+ * for a screen reader to read and the icon is drawn `aria-hidden`, so without
35
+ * one the control announces as "button" and nothing else.
36
+ */
37
+ interactive?: boolean;
38
+ /**
39
+ * How loudly the item states itself. Defaults to `"quiet"`.
40
+ *
41
+ * `quiet` is a reading: tone ink on the bar's own ground. `filled` is a chip
42
+ * — a full-height tone wash behind tone ink — for the small set of states
43
+ * that earn a colour of their own: a warning, an error, the remote you are
44
+ * attached to. It is loud deliberately, and a bar of five filled items is a
45
+ * bar nobody reads.
46
+ */
47
+ prominence?: StatusBarProminence;
48
+ /**
49
+ * Replace the rendered element (Base UI render prop) — `render={<a href="…" />}`
50
+ * for a status item that navigates.
51
+ *
52
+ * Implies {@link StatusBarItemProps.interactive}: whatever is rendered gets
53
+ * the control chassis, because an element a reader can click has to look like
54
+ * one whether it is a button or a link.
55
+ */
56
+ render?: useRender.RenderProp;
57
+ /**
58
+ * Grey the control out, take it out of the pointer's reach and announce it as
59
+ * disabled. Defaults to `false`. Ignored by a static item, which is a reading
60
+ * and not a control.
61
+ *
62
+ * Honoured whatever {@link StatusBarItemProps.render} renders, but by two
63
+ * routes: the `disabled` attribute where it means something — the default
64
+ * `<button>`, a form control, a component that takes a `disabled` prop of its
65
+ * own — and `aria-disabled` plus `data-disabled` everywhere, which is all an
66
+ * anchor gets. There is no such thing as a disabled link; a link that should
67
+ * not be followed should not be a link, and this states the intent rather
68
+ * than pretending to enforce it.
69
+ */
70
+ disabled?: boolean;
71
+ }
72
+ interface StatusBarGroupProps extends React.ComponentPropsWithoutRef<"div"> {
73
+ /**
74
+ * Start the trailing cluster at this group: pushes it and everything after it
75
+ * to the far edge, exactly as `push` does on a single item.
76
+ */
77
+ push?: boolean;
78
+ /**
79
+ * What the cluster is called. Optional, and worth giving whenever the members
80
+ * only make sense together — "Repository", "Connection" — because a `group`
81
+ * with no name is a landmark a screen reader reads straight past.
82
+ */
83
+ "aria-label"?: string;
22
84
  }
23
85
  /**
24
86
  * The quiet strip along the bottom of a window.
25
87
  *
26
88
  * The smallest type in the system in the subtlest ink: a status bar is read
27
89
  * when you go looking for it and ignored otherwise, so it must never compete
28
- * with the work above it. Height comes from `--cue-chrome-statusbar`.
90
+ * with the work above it. Height comes from `--cue-chrome-statusbar`, and the
91
+ * bar clips rather than wrapping — one rung of the frame, always, whatever it
92
+ * is asked to hold.
93
+ *
94
+ * Items come in two kinds and the difference is deliberate. A plain
95
+ * `StatusBar.Item` is a **reading**: a `<div>`, inert by construction, with no
96
+ * hover, no focus and nothing to click — which is what most of a status bar is.
97
+ * `interactive` (or a `render`) makes one a **control**, and only then does it
98
+ * grow the button chassis, the full-height hover fill and the inset focus ring.
99
+ * `StatusBar.Group` fuses adjacent items into one segmented control.
29
100
  *
30
101
  * Not a live region — a status bar changes constantly (frame counters, DMX
31
102
  * output), and announcing every change would make the app unusable with a
32
103
  * screen reader. Put anything that genuinely must be announced in a `Toast`.
33
104
  *
34
- * Static markup — no `"use client"`.
105
+ * Static markup — no `"use client"`. A bar of readings renders on the server
106
+ * and ships no JavaScript at all; the interactive item is the one part that
107
+ * crosses the boundary, and it crosses it alone (`./_status-bar-item.tsx`).
35
108
  *
36
109
  * @example
37
110
  * <StatusBar>
38
111
  * <StatusBar.Item icon={Plug} tone="ok">Art-Net connected</StatusBar.Item>
39
112
  * <StatusBar.Item push>512 channels</StatusBar.Item>
40
113
  * </StatusBar>
114
+ * @example
115
+ * // The VS Code shape: a clickable cluster left, a loud state right.
116
+ * <StatusBar>
117
+ * <StatusBar.Group aria-label="Repository">
118
+ * <StatusBar.Item interactive icon={GitBranch}>main</StatusBar.Item>
119
+ * <StatusBar.Item interactive icon={ArrowUp}>2</StatusBar.Item>
120
+ * </StatusBar.Group>
121
+ * <StatusBar.Item push interactive prominence="filled" tone="danger">3 faults</StatusBar.Item>
122
+ * </StatusBar>
41
123
  */
42
124
  declare const StatusBar: React.ForwardRefExoticComponent<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>> & {
43
- Item: React.ForwardRefExoticComponent<StatusBarItemProps & React.RefAttributes<HTMLDivElement>>;
125
+ Item: React.ForwardRefExoticComponent<StatusBarItemProps & React.RefAttributes<HTMLElement>>;
126
+ Group: React.ForwardRefExoticComponent<StatusBarGroupProps & React.RefAttributes<HTMLDivElement>>;
44
127
  };
45
128
  //#endregion
46
- export { StatusBar, StatusBarItemProps, StatusBarProps, StatusBarTone };
129
+ export { StatusBar, StatusBarGroupProps, StatusBarItemProps, type StatusBarProminence, StatusBarProps, StatusBarTone };
@@ -1,30 +1,41 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { STATUS_BAR_GROUP_CLASSES, statusBarItemClasses } from "./_status-bar.js";
3
+ import { StatusBarInteractiveItem } from "./_status-bar-item.js";
2
4
  import * as React from "react";
3
5
  import { jsx, jsxs } from "react/jsx-runtime";
4
6
  //#region src/chrome/status-bar.tsx
5
- const ITEM_TONES = {
6
- neutral: "",
7
- accent: "text-accent",
8
- ok: "text-ok",
9
- busy: "text-busy",
10
- warn: "text-warn",
11
- danger: "text-danger",
12
- info: "text-info"
13
- };
14
7
  const StatusBarRoot = React.forwardRef(function StatusBar({ className, ...elementProps }, ref) {
15
8
  return /* @__PURE__ */ jsx("div", {
16
9
  ref,
17
10
  "data-slot": "status-bar",
18
- className: cn("flex h-(--cue-chrome-statusbar) w-full shrink-0 items-center gap-(--cue-space-4) border-t border-border bg-surface-1 px-(--cue-pad-row-x) font-mono text-(length:--cue-text-label) text-fg-subtle", className),
11
+ className: cn("flex h-(--cue-chrome-statusbar) w-full shrink-0 items-center gap-(--cue-space-4) overflow-x-clip border-t border-border bg-surface-1 px-(--cue-pad-row-x) font-mono text-(length:--cue-text-label) text-fg-subtle", className),
19
12
  ...elementProps
20
13
  });
21
14
  });
22
- /** One reading in the bar: a mode, a count, a connection state. */
23
- const StatusBarItem = React.forwardRef(function StatusBarItem({ className, tone = "neutral", push = false, icon: Icon, children, ...elementProps }, ref) {
15
+ /**
16
+ * One reading in the bar: a mode, a count, a connection state — or, given
17
+ * `interactive`, one control in it.
18
+ */
19
+ const StatusBarItem = React.forwardRef(function StatusBarItem({ className, tone = "neutral", push = false, icon: Icon, interactive = false, prominence = "quiet", render, disabled, children, ...elementProps }, ref) {
20
+ if (interactive || render !== void 0) return /* @__PURE__ */ jsx(StatusBarInteractiveItem, {
21
+ ref,
22
+ tone,
23
+ prominence,
24
+ className,
25
+ push,
26
+ icon: Icon,
27
+ render,
28
+ disabled,
29
+ elementProps,
30
+ children
31
+ });
24
32
  return /* @__PURE__ */ jsxs("div", {
25
33
  ref,
26
34
  "data-slot": "status-bar-item",
27
- className: cn("inline-flex min-w-0 shrink-0 items-center gap-(--cue-space-2) whitespace-nowrap", ITEM_TONES[tone], push ? "ml-auto" : void 0, className),
35
+ className: cn(statusBarItemClasses({
36
+ tone,
37
+ prominence
38
+ }), push ? "ml-auto" : void 0, className),
28
39
  ...elementProps,
29
40
  children: [Icon === void 0 ? null : /* @__PURE__ */ jsx(Icon, {
30
41
  "aria-hidden": "true",
@@ -33,24 +44,77 @@ const StatusBarItem = React.forwardRef(function StatusBarItem({ className, tone
33
44
  });
34
45
  });
35
46
  /**
47
+ * Items that belong to each other, one pixel apart.
48
+ *
49
+ * The bar spaces its readings at `--cue-space-4` because they are unrelated
50
+ * facts. A branch name and its ahead/behind count are not unrelated facts; they
51
+ * are one control that happens to have two halves, and the gap between them is
52
+ * what says so. This is VS Code's `compact` mechanic — the seam collapses, and
53
+ * hovering either half grounds both while the half under the cursor goes one
54
+ * rung further — expressed as a wrapper rather than as an id-reference between
55
+ * two items, because a design system's items do not have ids to point at.
56
+ *
57
+ * A `role="group"` rather than nothing: the members are announced as a set, and
58
+ * the DOM order they are announced in is the order they are read in. Give it an
59
+ * `aria-label` when the members only make sense together.
60
+ *
61
+ * @example
62
+ * <StatusBar.Group aria-label="Repository">
63
+ * <StatusBar.Item interactive icon={GitBranch}>main</StatusBar.Item>
64
+ * <StatusBar.Item interactive icon={ArrowUp}>2</StatusBar.Item>
65
+ * </StatusBar.Group>
66
+ */
67
+ const StatusBarGroup = React.forwardRef(function StatusBarGroup({ className, push = false, role = "group", ...elementProps }, ref) {
68
+ return /* @__PURE__ */ jsx("div", {
69
+ ref,
70
+ "data-slot": "status-bar-group",
71
+ role,
72
+ className: cn(STATUS_BAR_GROUP_CLASSES, push ? "ml-auto" : void 0, className),
73
+ ...elementProps
74
+ });
75
+ });
76
+ /**
36
77
  * The quiet strip along the bottom of a window.
37
78
  *
38
79
  * The smallest type in the system in the subtlest ink: a status bar is read
39
80
  * when you go looking for it and ignored otherwise, so it must never compete
40
- * with the work above it. Height comes from `--cue-chrome-statusbar`.
81
+ * with the work above it. Height comes from `--cue-chrome-statusbar`, and the
82
+ * bar clips rather than wrapping — one rung of the frame, always, whatever it
83
+ * is asked to hold.
84
+ *
85
+ * Items come in two kinds and the difference is deliberate. A plain
86
+ * `StatusBar.Item` is a **reading**: a `<div>`, inert by construction, with no
87
+ * hover, no focus and nothing to click — which is what most of a status bar is.
88
+ * `interactive` (or a `render`) makes one a **control**, and only then does it
89
+ * grow the button chassis, the full-height hover fill and the inset focus ring.
90
+ * `StatusBar.Group` fuses adjacent items into one segmented control.
41
91
  *
42
92
  * Not a live region — a status bar changes constantly (frame counters, DMX
43
93
  * output), and announcing every change would make the app unusable with a
44
94
  * screen reader. Put anything that genuinely must be announced in a `Toast`.
45
95
  *
46
- * Static markup — no `"use client"`.
96
+ * Static markup — no `"use client"`. A bar of readings renders on the server
97
+ * and ships no JavaScript at all; the interactive item is the one part that
98
+ * crosses the boundary, and it crosses it alone (`./_status-bar-item.tsx`).
47
99
  *
48
100
  * @example
49
101
  * <StatusBar>
50
102
  * <StatusBar.Item icon={Plug} tone="ok">Art-Net connected</StatusBar.Item>
51
103
  * <StatusBar.Item push>512 channels</StatusBar.Item>
52
104
  * </StatusBar>
105
+ * @example
106
+ * // The VS Code shape: a clickable cluster left, a loud state right.
107
+ * <StatusBar>
108
+ * <StatusBar.Group aria-label="Repository">
109
+ * <StatusBar.Item interactive icon={GitBranch}>main</StatusBar.Item>
110
+ * <StatusBar.Item interactive icon={ArrowUp}>2</StatusBar.Item>
111
+ * </StatusBar.Group>
112
+ * <StatusBar.Item push interactive prominence="filled" tone="danger">3 faults</StatusBar.Item>
113
+ * </StatusBar>
53
114
  */
54
- const StatusBar = Object.assign(StatusBarRoot, { Item: StatusBarItem });
115
+ const StatusBar = Object.assign(StatusBarRoot, {
116
+ Item: StatusBarItem,
117
+ Group: StatusBarGroup
118
+ });
55
119
  //#endregion
56
120
  export { StatusBar };
@@ -9,7 +9,17 @@ interface TitleBarProps extends React.ComponentPropsWithoutRef<"div"> {
9
9
  * the same component ships into both shells and neither one recognises the
10
10
  * other's contract. The interactive clusters are carved back out with
11
11
  * `app-region: no-drag`, or the buttons in the bar would only ever move the
12
- * window instead of firing.
12
+ * window instead of firing. Both halves of that contract live in
13
+ * `./_drag.ts`, which `AppShell.Bar` emits from as well — the two bars are
14
+ * draggable the same way or they are not draggable the same way.
15
+ *
16
+ * Double-click-to-maximise is not wired here. Tauri's own injected script
17
+ * already provides it on any drag region, cancel-by-dragging-away on macOS
18
+ * included; Electron provides nothing, and an Electron app has to listen for
19
+ * `dblclick` and toggle the window itself. Neither is a decision a component
20
+ * in a design system can make on the window's behalf.
21
+ *
22
+ * Defaults to `false`.
13
23
  */
14
24
  platformDrag?: boolean;
15
25
  /**
@@ -1,11 +1,9 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.js";
3
+ import { DRAG_CLASSES, NO_DRAG_CLASSES } from "./_drag.js";
3
4
  import * as React from "react";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
6
  //#region src/chrome/title-bar.tsx
6
- /** `app-region` needs the unprefixed and the WebKit form to cover both shells. */
7
- const DRAG_CLASSES = "[app-region:drag] [-webkit-app-region:drag]";
8
- const NO_DRAG_CLASSES = "[app-region:no-drag] [-webkit-app-region:no-drag]";
9
7
  /**
10
8
  * The window's own bar: app mark left, document name centred, controls right.
11
9
  *
@@ -1,5 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
- import { controlVariants } from "../forms/_chassis.js";
2
+ import { controlGroupFocusClasses, controlVariants } from "../forms/_chassis.js";
3
3
  import { ariaClassName } from "../lib/aria-class.js";
4
4
  import "react";
5
5
  import { jsx } from "react/jsx-runtime";
@@ -24,11 +24,22 @@ import { DateInput, DateSegment } from "react-aria-components";
24
24
  * The chassis lights its rim on `:focus`, which this group can never match —
25
25
  * React Aria keeps DOM focus on the individual segment and reports the group's
26
26
  * state as `data-focus-within`. Without the extra variant the inherited
27
- * `focus:border-accent` is a rule that cannot fire, and a segmented field is the
28
- * only control in the system whose rim stays dead while it is being typed into.
27
+ * `focus:border-border-strong` is a rule that cannot fire, and a segmented field
28
+ * would be the only control in the system whose rim stays dead while it is being
29
+ * typed into.
30
+ *
31
+ * A segmented field is a grouped control in every sense that matters, so it
32
+ * takes the grouped contract whole rather than a rim of its own invention:
33
+ * `controlGroupFocusClasses` strengthens the rim for any focus inside and paints
34
+ * the single flush `--cue-focus` ring only for a `:focus-visible` descendant.
35
+ * React Aria makes each value segment `contentEditable`, and a typing surface
36
+ * matches `:focus-visible` on a pointer click as well as on Tab, so the ring
37
+ * lands whenever the field is actually being edited. `data-focus-within` is kept
38
+ * beside the native `:focus-within` rim because it is the state React Aria
39
+ * documents, and the two agree on the same colour either way.
29
40
  */
30
41
  function segmentGroupClasses(size) {
31
- return cn(controlVariants({ size }), "inline-flex w-auto items-center whitespace-nowrap data-[focus-within]:border-accent");
42
+ return cn(controlVariants({ size }), "inline-flex w-auto items-center whitespace-nowrap data-[focus-within]:border-border-strong", controlGroupFocusClasses);
32
43
  }
33
44
  /**
34
45
  * One segment. `data-focused` — not `:focus` — carries the highlight, because
@@ -20,10 +20,84 @@ type FormControlSize = "sm" | "md" | "lg";
20
20
  * raising them off it, which is the portfolio-wide convention for anything the
21
21
  * user types into.
22
22
  *
23
+ * **Focus is one signal, not two.** The rim walks one rung up the border ladder
24
+ * on `:focus` — `border-border` to `border-border-strong` — and `:focus-visible`
25
+ * adds a single 2px outline, pulled back over that rim with
26
+ * `-outline-offset-1` so it sits flush against the control instead of floating
27
+ * a pixel clear of it. The recipe this replaced painted a saturated accent rim
28
+ * *and* an offset accent ring simultaneously, which in a dark, dense console
29
+ * reads as an error or a selection rather than as "the caret is here". No
30
+ * shipping system draws both: Carbon and Radix each draw one flush ring, and
31
+ * shadcn moved off the offset-ring default it used to ship.
32
+ *
33
+ * Flush is also the cheap way to stay correct. WCAG 2.4.13 accepts a 2px
34
+ * perimeter only when it touches the component's own outer edge; an indicator
35
+ * inset further, floating on a gap, has to be 3px thick to cover the same area.
36
+ * And because an outline is painted outside the layout box, none of this moves
37
+ * a control by a pixel or reflows the row it sits in.
38
+ *
39
+ * There is no quieter pointer path to fall back on, which is why the single
40
+ * treatment has to be quiet itself: every engine puts typing surfaces on the
41
+ * `:focus-visible` allowlist, so a text input matches it on a mouse click just
42
+ * as it does on Tab. Only the non-typing controls that ride this chassis — the
43
+ * `Select` trigger, the date triggers — actually distinguish the two.
44
+ *
45
+ * `--cue-focus`, not `--cue-accent`. Every theme ships both roles, and keeping
46
+ * them separable is what WCAG 1.4.11 needs: one role whose job is "read at 3:1
47
+ * against the surface behind the ring", independent of the role whose job is
48
+ * "look like the brand". Seventeen of the twenty theme blocks still set the two
49
+ * to the same value, so there the separation costs nothing on screen — but three
50
+ * light presets can no longer afford it. luma.light, venu.light and hivehub.light
51
+ * each read below 3:1 against surfaces a field actually sits on (luma.light as
52
+ * low as 2.45, and against four of its five grounds), so each now carries its
53
+ * own ring — the accent's hue stepped darker — while the accent itself stays
54
+ * put. Measured across all five grounds a field can land on — the sunken fill,
55
+ * the page background and the three surface rungs — every preset now clears
56
+ * 3:1, the thinnest being signal.light's 3.27. Pointing the ring at the focus
57
+ * role is what made that retune possible without repainting every accent
58
+ * surface in the theme.
59
+ *
60
+ * (Named in prose rather than as `--cue-*` on purpose: `tokensUsed` in the
61
+ * published manifest is scanned out of this file's text, so spelling the other
62
+ * four grounds as tokens would have every control on this chassis claim it
63
+ * paints a surface it never touches.)
64
+ *
65
+ * `--cue-danger` carries the same obligation on an invalid field, where it is
66
+ * the ring rather than only the rim — a 1.4.11 surface, not purely a semantic
67
+ * colour. It needed no retune: the red already clears 3:1 on all five grounds in
68
+ * every preset, thinnest at terminal.dark's 3.13. Both floors are held by
69
+ * `test/theming/contrast.test.ts`, which measures every block on every ground —
70
+ * the guard that was missing when these three shipped a ring nobody could see.
71
+ *
72
+ * The ring is an `outline` and never a `box-shadow`: `forced-colors: active`
73
+ * discards box shadows outright and repaints outlines in the user's own
74
+ * highlight colour, so an outline is the only indicator that survives
75
+ * high-contrast mode.
76
+ *
77
+ * **Invalid and focused: the ring turns red, it does not sit beside the rim.**
78
+ * `data-[invalid]:border-danger` does win the cascade over the focus rim, but
79
+ * winning it changes nothing on screen: an outline's band runs from
80
+ * `border-edge + offset` outward by its own width, so a 2px ring at
81
+ * `-outline-offset-1` spans from one pixel inside the border box to one pixel
82
+ * outside it — the whole 1px rim — and outlines paint after borders. Measured
83
+ * in both engines: an invalid control paints 456 danger pixels at rest and zero
84
+ * while focused. Pushing the ring off the rim would mean either floating it on
85
+ * a gap (WCAG 2.4.13 then wants 3px, and the halo is back) or insetting it
86
+ * further (which buries it deeper). So the ring takes the danger colour instead
87
+ * — `data-[invalid]:focus-visible:outline-danger` — which is the ruling shadcn
88
+ * and Radix both reached: the border stays danger, the ring communicates focus,
89
+ * and red communicates invalid whichever of the two is painting.
90
+ *
91
+ * The compound modifier outranks the plain one on specificity — an attribute
92
+ * selector and a pseudo-class against one pseudo-class — so its position in
93
+ * Tailwind's utility order cannot decide it. `focus-visible:outline-solid` and
94
+ * `focus-visible:outline-2` still supply the style and the width in both cases,
95
+ * which is why only the colour is restated.
96
+ *
23
97
  * `focus-visible:outline-solid` re-declares the outline style the sibling
24
98
  * `outline-none` switched off — see the note on `buttonChassis`.
25
99
  */
26
- declare const controlSurface = "w-full rounded-(--radius-control) border border-border bg-sunken font-mono text-fg placeholder:text-fg-subtle outline-none focus:border-accent focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-accent disabled:opacity-40 data-[disabled]:opacity-40 data-[invalid]:border-danger";
100
+ declare const controlSurface = "w-full rounded-(--radius-control) border border-border bg-sunken font-mono text-fg placeholder:text-fg-subtle outline-none focus:border-border-strong focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-1 focus-visible:outline-(--cue-focus) disabled:opacity-40 data-[disabled]:opacity-40 data-[invalid]:border-danger data-[invalid]:focus-visible:outline-danger";
27
101
  /**
28
102
  * The full recessed-control recipe, one variant per rung of the control ladder.
29
103
  *
@@ -56,9 +130,35 @@ declare const controlClasses: string;
56
130
  declare const bareControlClasses = "min-w-0 flex-1 border-0 bg-transparent p-0 font-mono text-[length:inherit] text-fg placeholder:text-fg-subtle outline-none disabled:opacity-40";
57
131
  /**
58
132
  * Focus feedback for a wrapper that owns the rim on behalf of an inner control.
59
- * A `<div>` never matches `:focus-visible`, so the group has to watch its
60
- * descendants instead.
133
+ *
134
+ * A `<div>` never matches `:focus-visible` itself, so a group has to read the
135
+ * state off its descendants — and the two halves of {@link controlSurface}'s
136
+ * treatment want two different questions asked of them.
137
+ *
138
+ * The rim follows plain `:focus-within`: anything focused inside strengthens the
139
+ * border. That is the quiet half, and it is the right answer however the focus
140
+ * arrived. The ring follows `:has(:focus-visible)`, so it paints only when the
141
+ * descendant that has focus is itself showing a focus ring. Using
142
+ * `:focus-within` for *both* — the recipe this replaced — is what made a mouse
143
+ * click inside a `SearchInput` or a `NumberField` light the whole cluster up
144
+ * with a saturated block, the single loudest thing in a console form.
145
+ *
146
+ * `:has()` is Baseline — Chrome 105, Safari 15.4, Firefox 121 — so no
147
+ * `:focus-within` fallback layer is needed at the floor this library supports.
148
+ *
149
+ * Geometry matches {@link controlSurface} exactly, down to the negative offset:
150
+ * a grouped field and a plain one are the same control, and a rim that differed
151
+ * by a pixel between them would say otherwise. The inner control stays ringless
152
+ * — see {@link bareControlClasses} — because two concentric rings on one field
153
+ * is the halo this treatment exists to remove.
154
+ *
155
+ * The invalid ruling comes with the geometry, for the same reason: at
156
+ * `-outline-offset-1` the ring covers the rim it is drawn over, so an invalid
157
+ * group in focus recolours the ring rather than pretending the red under it is
158
+ * still visible. `data-invalid` is read off the wrapper, which is where Base
159
+ * UI's `Field` stamps it and where the group already takes
160
+ * `data-[invalid]:border-danger` from {@link controlVariants}.
61
161
  */
62
- declare const controlGroupFocusClasses = "focus-within:border-accent focus-within:outline-2 focus-within:outline-solid focus-within:outline-offset-1 focus-within:outline-accent";
162
+ declare const controlGroupFocusClasses = "focus-within:border-border-strong has-focus-visible:outline-2 has-focus-visible:outline-solid has-focus-visible:-outline-offset-1 has-focus-visible:outline-(--cue-focus) data-[invalid]:has-focus-visible:outline-danger";
63
163
  //#endregion
64
164
  export { ControlVariantProps, FormControlSize, bareControlClasses, controlClasses, controlGroupFocusClasses, controlSurface, controlVariants };
@@ -12,10 +12,84 @@ import { cva } from "../lib/cva.js";
12
12
  * raising them off it, which is the portfolio-wide convention for anything the
13
13
  * user types into.
14
14
  *
15
+ * **Focus is one signal, not two.** The rim walks one rung up the border ladder
16
+ * on `:focus` — `border-border` to `border-border-strong` — and `:focus-visible`
17
+ * adds a single 2px outline, pulled back over that rim with
18
+ * `-outline-offset-1` so it sits flush against the control instead of floating
19
+ * a pixel clear of it. The recipe this replaced painted a saturated accent rim
20
+ * *and* an offset accent ring simultaneously, which in a dark, dense console
21
+ * reads as an error or a selection rather than as "the caret is here". No
22
+ * shipping system draws both: Carbon and Radix each draw one flush ring, and
23
+ * shadcn moved off the offset-ring default it used to ship.
24
+ *
25
+ * Flush is also the cheap way to stay correct. WCAG 2.4.13 accepts a 2px
26
+ * perimeter only when it touches the component's own outer edge; an indicator
27
+ * inset further, floating on a gap, has to be 3px thick to cover the same area.
28
+ * And because an outline is painted outside the layout box, none of this moves
29
+ * a control by a pixel or reflows the row it sits in.
30
+ *
31
+ * There is no quieter pointer path to fall back on, which is why the single
32
+ * treatment has to be quiet itself: every engine puts typing surfaces on the
33
+ * `:focus-visible` allowlist, so a text input matches it on a mouse click just
34
+ * as it does on Tab. Only the non-typing controls that ride this chassis — the
35
+ * `Select` trigger, the date triggers — actually distinguish the two.
36
+ *
37
+ * `--cue-focus`, not `--cue-accent`. Every theme ships both roles, and keeping
38
+ * them separable is what WCAG 1.4.11 needs: one role whose job is "read at 3:1
39
+ * against the surface behind the ring", independent of the role whose job is
40
+ * "look like the brand". Seventeen of the twenty theme blocks still set the two
41
+ * to the same value, so there the separation costs nothing on screen — but three
42
+ * light presets can no longer afford it. luma.light, venu.light and hivehub.light
43
+ * each read below 3:1 against surfaces a field actually sits on (luma.light as
44
+ * low as 2.45, and against four of its five grounds), so each now carries its
45
+ * own ring — the accent's hue stepped darker — while the accent itself stays
46
+ * put. Measured across all five grounds a field can land on — the sunken fill,
47
+ * the page background and the three surface rungs — every preset now clears
48
+ * 3:1, the thinnest being signal.light's 3.27. Pointing the ring at the focus
49
+ * role is what made that retune possible without repainting every accent
50
+ * surface in the theme.
51
+ *
52
+ * (Named in prose rather than as `--cue-*` on purpose: `tokensUsed` in the
53
+ * published manifest is scanned out of this file's text, so spelling the other
54
+ * four grounds as tokens would have every control on this chassis claim it
55
+ * paints a surface it never touches.)
56
+ *
57
+ * `--cue-danger` carries the same obligation on an invalid field, where it is
58
+ * the ring rather than only the rim — a 1.4.11 surface, not purely a semantic
59
+ * colour. It needed no retune: the red already clears 3:1 on all five grounds in
60
+ * every preset, thinnest at terminal.dark's 3.13. Both floors are held by
61
+ * `test/theming/contrast.test.ts`, which measures every block on every ground —
62
+ * the guard that was missing when these three shipped a ring nobody could see.
63
+ *
64
+ * The ring is an `outline` and never a `box-shadow`: `forced-colors: active`
65
+ * discards box shadows outright and repaints outlines in the user's own
66
+ * highlight colour, so an outline is the only indicator that survives
67
+ * high-contrast mode.
68
+ *
69
+ * **Invalid and focused: the ring turns red, it does not sit beside the rim.**
70
+ * `data-[invalid]:border-danger` does win the cascade over the focus rim, but
71
+ * winning it changes nothing on screen: an outline's band runs from
72
+ * `border-edge + offset` outward by its own width, so a 2px ring at
73
+ * `-outline-offset-1` spans from one pixel inside the border box to one pixel
74
+ * outside it — the whole 1px rim — and outlines paint after borders. Measured
75
+ * in both engines: an invalid control paints 456 danger pixels at rest and zero
76
+ * while focused. Pushing the ring off the rim would mean either floating it on
77
+ * a gap (WCAG 2.4.13 then wants 3px, and the halo is back) or insetting it
78
+ * further (which buries it deeper). So the ring takes the danger colour instead
79
+ * — `data-[invalid]:focus-visible:outline-danger` — which is the ruling shadcn
80
+ * and Radix both reached: the border stays danger, the ring communicates focus,
81
+ * and red communicates invalid whichever of the two is painting.
82
+ *
83
+ * The compound modifier outranks the plain one on specificity — an attribute
84
+ * selector and a pseudo-class against one pseudo-class — so its position in
85
+ * Tailwind's utility order cannot decide it. `focus-visible:outline-solid` and
86
+ * `focus-visible:outline-2` still supply the style and the width in both cases,
87
+ * which is why only the colour is restated.
88
+ *
15
89
  * `focus-visible:outline-solid` re-declares the outline style the sibling
16
90
  * `outline-none` switched off — see the note on `buttonChassis`.
17
91
  */
18
- const controlSurface = "w-full rounded-(--radius-control) border border-border bg-sunken font-mono text-fg placeholder:text-fg-subtle outline-none focus:border-accent focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-accent disabled:opacity-40 data-[disabled]:opacity-40 data-[invalid]:border-danger";
92
+ const controlSurface = "w-full rounded-(--radius-control) border border-border bg-sunken font-mono text-fg placeholder:text-fg-subtle outline-none focus:border-border-strong focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-1 focus-visible:outline-(--cue-focus) disabled:opacity-40 data-[disabled]:opacity-40 data-[invalid]:border-danger data-[invalid]:focus-visible:outline-danger";
19
93
  /**
20
94
  * The full recessed-control recipe, one variant per rung of the control ladder.
21
95
  *
@@ -51,9 +125,35 @@ const controlClasses = controlVariants();
51
125
  const bareControlClasses = "min-w-0 flex-1 border-0 bg-transparent p-0 font-mono text-[length:inherit] text-fg placeholder:text-fg-subtle outline-none disabled:opacity-40";
52
126
  /**
53
127
  * Focus feedback for a wrapper that owns the rim on behalf of an inner control.
54
- * A `<div>` never matches `:focus-visible`, so the group has to watch its
55
- * descendants instead.
128
+ *
129
+ * A `<div>` never matches `:focus-visible` itself, so a group has to read the
130
+ * state off its descendants — and the two halves of {@link controlSurface}'s
131
+ * treatment want two different questions asked of them.
132
+ *
133
+ * The rim follows plain `:focus-within`: anything focused inside strengthens the
134
+ * border. That is the quiet half, and it is the right answer however the focus
135
+ * arrived. The ring follows `:has(:focus-visible)`, so it paints only when the
136
+ * descendant that has focus is itself showing a focus ring. Using
137
+ * `:focus-within` for *both* — the recipe this replaced — is what made a mouse
138
+ * click inside a `SearchInput` or a `NumberField` light the whole cluster up
139
+ * with a saturated block, the single loudest thing in a console form.
140
+ *
141
+ * `:has()` is Baseline — Chrome 105, Safari 15.4, Firefox 121 — so no
142
+ * `:focus-within` fallback layer is needed at the floor this library supports.
143
+ *
144
+ * Geometry matches {@link controlSurface} exactly, down to the negative offset:
145
+ * a grouped field and a plain one are the same control, and a rim that differed
146
+ * by a pixel between them would say otherwise. The inner control stays ringless
147
+ * — see {@link bareControlClasses} — because two concentric rings on one field
148
+ * is the halo this treatment exists to remove.
149
+ *
150
+ * The invalid ruling comes with the geometry, for the same reason: at
151
+ * `-outline-offset-1` the ring covers the rim it is drawn over, so an invalid
152
+ * group in focus recolours the ring rather than pretending the red under it is
153
+ * still visible. `data-invalid` is read off the wrapper, which is where Base
154
+ * UI's `Field` stamps it and where the group already takes
155
+ * `data-[invalid]:border-danger` from {@link controlVariants}.
56
156
  */
57
- const controlGroupFocusClasses = "focus-within:border-accent focus-within:outline-2 focus-within:outline-solid focus-within:outline-offset-1 focus-within:outline-accent";
157
+ const controlGroupFocusClasses = "focus-within:border-border-strong has-focus-visible:outline-2 has-focus-visible:outline-solid has-focus-visible:-outline-offset-1 has-focus-visible:outline-(--cue-focus) data-[invalid]:has-focus-visible:outline-danger";
58
158
  //#endregion
59
159
  export { bareControlClasses, controlClasses, controlGroupFocusClasses, controlSurface, controlVariants };
@@ -17,9 +17,14 @@ interface InputGroupProps extends React.ComponentPropsWithoutRef<"div"> {
17
17
  * The group — not the input — owns the chassis, so there is exactly one rim and
18
18
  * one fill however many affordances are bolted on. It publishes that fact
19
19
  * through context: an {@link Input} rendered inside strips its own border,
20
- * background, height and padding and inherits the group's font size. Focus
21
- * feedback moves to `focus-within`, since a `<div>` never matches
22
- * `:focus-visible`.
20
+ * background, height and padding and inherits the group's font size.
21
+ *
22
+ * Focus feedback splits in two, because a `<div>` matches neither `:focus` nor
23
+ * `:focus-visible` itself. The rim strengthens on `:focus-within` — anything
24
+ * focused inside, however it got there — while the ring waits for
25
+ * `:has(:focus-visible)`, so a mouse click into the field does not light the
26
+ * whole cluster up. Both halves live on the wrapper; the inner control paints
27
+ * no ring of its own.
23
28
  *
24
29
  * @example
25
30
  * <InputGroup leading={<Search />} trailing={<Kbd>⌘K</Kbd>}>
@@ -17,9 +17,14 @@ const InputGroupContext = React.createContext(false);
17
17
  * The group — not the input — owns the chassis, so there is exactly one rim and
18
18
  * one fill however many affordances are bolted on. It publishes that fact
19
19
  * through context: an {@link Input} rendered inside strips its own border,
20
- * background, height and padding and inherits the group's font size. Focus
21
- * feedback moves to `focus-within`, since a `<div>` never matches
22
- * `:focus-visible`.
20
+ * background, height and padding and inherits the group's font size.
21
+ *
22
+ * Focus feedback splits in two, because a `<div>` matches neither `:focus` nor
23
+ * `:focus-visible` itself. The rim strengthens on `:focus-within` — anything
24
+ * focused inside, however it got there — while the ring waits for
25
+ * `:has(:focus-visible)`, so a mouse click into the field does not light the
26
+ * whole cluster up. Both halves live on the wrapper; the inner control paints
27
+ * no ring of its own.
23
28
  *
24
29
  * @example
25
30
  * <InputGroup leading={<Search />} trailing={<Kbd>⌘K</Kbd>}>
@@ -25,8 +25,9 @@ interface NumberFieldProps extends Omit<NumberField.Root.Props, "className"> {
25
25
  * and values typed out of range are clamped to `min`/`max` on blur.
26
26
  *
27
27
  * The group owns the rim; the input and both buttons render bare inside it, so
28
- * the whole cluster reads as one field and focus feedback moves to
29
- * `focus-within`.
28
+ * the whole cluster reads as one field. Focus feedback follows the group's
29
+ * split: the rim strengthens on `:focus-within`, and the ring waits for
30
+ * `:has(:focus-visible)` so a click on a stepper does not paint one.
30
31
  *
31
32
  * @example
32
33
  * <NumberField aria-label="Fade time" defaultValue={3} min={0} max={600} step={0.5} />
@@ -17,8 +17,9 @@ const stepperClasses = "flex shrink-0 items-center justify-center border-0 bg-tr
17
17
  * and values typed out of range are clamped to `min`/`max` on blur.
18
18
  *
19
19
  * The group owns the rim; the input and both buttons render bare inside it, so
20
- * the whole cluster reads as one field and focus feedback moves to
21
- * `focus-within`.
20
+ * the whole cluster reads as one field. Focus feedback follows the group's
21
+ * split: the rim strengthens on `:focus-within`, and the ring waits for
22
+ * `:has(:focus-visible)` so a click on a stepper does not paint one.
22
23
  *
23
24
  * @example
24
25
  * <NumberField aria-label="Fade time" defaultValue={3} min={0} max={600} step={0.5} />