@seekalfred/primitives 0.7.1 → 0.7.3

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 (101) hide show
  1. package/README.md +195 -4
  2. package/dist/alert/alert.d.ts +49 -4
  3. package/dist/alert/alert.js +66 -38
  4. package/dist/alert/alert.js.map +1 -1
  5. package/dist/alert/index.d.ts +2 -2
  6. package/dist/alert/index.js +2 -2
  7. package/dist/alert-dialog/alert-dialog.d.ts +17 -4
  8. package/dist/alert-dialog/alert-dialog.js +26 -2
  9. package/dist/alert-dialog/alert-dialog.js.map +1 -1
  10. package/dist/badge/badge.d.ts +1 -1
  11. package/dist/button/button.d.ts +2 -2
  12. package/dist/calendar/calendar.d.ts +1 -1
  13. package/dist/card/card.d.ts +22 -1
  14. package/dist/card/card.js +60 -7
  15. package/dist/card/card.js.map +1 -1
  16. package/dist/carousel/carousel.d.ts +21 -0
  17. package/dist/carousel/carousel.js +34 -8
  18. package/dist/carousel/carousel.js.map +1 -1
  19. package/dist/copy-button/copy-button.d.ts +18 -2
  20. package/dist/copy-button/copy-button.js +29 -8
  21. package/dist/copy-button/copy-button.js.map +1 -1
  22. package/dist/donut-chart/donut-chart.d.ts +22 -0
  23. package/dist/donut-chart/donut-chart.js +35 -18
  24. package/dist/donut-chart/donut-chart.js.map +1 -1
  25. package/dist/donut-chart/index.d.ts +2 -2
  26. package/dist/empty-state/empty-state.d.ts +21 -0
  27. package/dist/empty-state/empty-state.js +16 -4
  28. package/dist/empty-state/empty-state.js.map +1 -1
  29. package/dist/empty-state/index.d.ts +2 -2
  30. package/dist/empty-state/index.js +2 -2
  31. package/dist/filter-bar/filter-bar.d.ts +4 -1
  32. package/dist/filter-bar/filter-bar.js +53 -9
  33. package/dist/filter-bar/filter-bar.js.map +1 -1
  34. package/dist/index.d.ts +10 -10
  35. package/dist/index.js +7 -7
  36. package/dist/input/input.js +4 -4
  37. package/dist/input/input.js.map +1 -1
  38. package/dist/lib/field-shell.js +10 -3
  39. package/dist/lib/field-shell.js.map +1 -1
  40. package/dist/lib/field-surface.js +22 -1
  41. package/dist/lib/field-surface.js.map +1 -1
  42. package/dist/lib/use-copy-feedback.js +17 -3
  43. package/dist/lib/use-copy-feedback.js.map +1 -1
  44. package/dist/line-chart/index.d.ts +2 -2
  45. package/dist/line-chart/line-chart.d.ts +42 -2
  46. package/dist/line-chart/line-chart.js +143 -14
  47. package/dist/line-chart/line-chart.js.map +1 -1
  48. package/dist/money-input/money-input.d.ts +22 -1
  49. package/dist/money-input/money-input.js +13 -7
  50. package/dist/money-input/money-input.js.map +1 -1
  51. package/dist/morph-select/morph-select.d.ts +19 -1
  52. package/dist/morph-select/morph-select.js +21 -2
  53. package/dist/morph-select/morph-select.js.map +1 -1
  54. package/dist/multi-select/index.d.ts +2 -2
  55. package/dist/multi-select/index.js +2 -2
  56. package/dist/multi-select/multi-select.d.ts +43 -2
  57. package/dist/multi-select/multi-select.js +93 -36
  58. package/dist/multi-select/multi-select.js.map +1 -1
  59. package/dist/password-field/password-field.d.ts +16 -0
  60. package/dist/password-field/password-field.js +37 -8
  61. package/dist/password-field/password-field.js.map +1 -1
  62. package/dist/popover/index.d.ts +2 -2
  63. package/dist/popover/index.js +2 -2
  64. package/dist/popover/popover.d.ts +39 -2
  65. package/dist/popover/popover.js +29 -2
  66. package/dist/popover/popover.js.map +1 -1
  67. package/dist/progress/progress.d.ts +16 -2
  68. package/dist/progress/progress.js +15 -8
  69. package/dist/progress/progress.js.map +1 -1
  70. package/dist/radio-group/index.d.ts +2 -2
  71. package/dist/radio-group/index.js +2 -2
  72. package/dist/radio-group/radio-group.d.ts +33 -3
  73. package/dist/radio-group/radio-group.js +109 -9
  74. package/dist/radio-group/radio-group.js.map +1 -1
  75. package/dist/scroll-area/scroll-area.js +4 -3
  76. package/dist/scroll-area/scroll-area.js.map +1 -1
  77. package/dist/search-field/search-field.d.ts +22 -1
  78. package/dist/search-field/search-field.js +22 -7
  79. package/dist/search-field/search-field.js.map +1 -1
  80. package/dist/select/select.d.ts +1 -1
  81. package/dist/select/select.js +8 -6
  82. package/dist/select/select.js.map +1 -1
  83. package/dist/separator/separator.d.ts +21 -9
  84. package/dist/separator/separator.js +33 -11
  85. package/dist/separator/separator.js.map +1 -1
  86. package/dist/stat-card/index.d.ts +2 -2
  87. package/dist/stat-card/index.js +2 -2
  88. package/dist/stat-card/stat-card.d.ts +34 -1
  89. package/dist/stat-card/stat-card.js +51 -21
  90. package/dist/stat-card/stat-card.js.map +1 -1
  91. package/dist/styles.css +1 -1
  92. package/dist/table/table.d.ts +1 -1
  93. package/dist/time-picker/time-picker.d.ts +36 -1
  94. package/dist/time-picker/time-picker.js +33 -5
  95. package/dist/time-picker/time-picker.js.map +1 -1
  96. package/dist/toast/toast.d.ts +2 -2
  97. package/dist/usage-meter/index.d.ts +2 -2
  98. package/dist/usage-meter/usage-meter.d.ts +30 -0
  99. package/dist/usage-meter/usage-meter.js +56 -38
  100. package/dist/usage-meter/usage-meter.js.map +1 -1
  101. package/package.json +3 -3
@@ -19,8 +19,22 @@ import { RadioGroupProps, RadioProps } from "react-aria-components";
19
19
  * Checkbox's exact pair -- `bg-primary` with a `primary-strong` boundary --
20
20
  * so a radio and a checkbox in the same form agree.
21
21
  */
22
- export declare const radioGroupVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
22
+ export declare const radioGroupVariants: (props?: ({
23
+ variant?: "default" | "card" | null | undefined;
24
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
23
25
  export declare const radioVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
26
+ /**
27
+ * One choice drawn as a selectable card: a bordered surface the whole of
28
+ * which is the hit target, with the title, an optional description and icon.
29
+ *
30
+ * Selection is shown twice, by the border AND by the dot, so it never rests
31
+ * on colour alone (WCAG 1.4.1). The border reuses StatCard's selected filter
32
+ * pair (`border-primary-strong`, hover darkens an unselected edge), so a
33
+ * selected card and a selected stat card read the same.
34
+ */
35
+ export declare const radioCardVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
36
+ /** Which look the group's radios take. */
37
+ export type RadioGroupVariant = "default" | "card";
24
38
  /**
25
39
  * A set of mutually exclusive choices.
26
40
  *
@@ -31,6 +45,14 @@ export declare const radioVariants: (props?: import("class-variance-authority/ty
31
45
  * </RadioGroup>
32
46
  */
33
47
  interface RadioGroupProps$1 extends Omit<RadioGroupProps, "className" | "style"> {
48
+ /**
49
+ * `card` draws each choice as a selectable card, with a title, an optional
50
+ * `description` and `icon` on each `Radio`. The keyboard model is the same:
51
+ * one tab stop, arrow keys move and select.
52
+ *
53
+ * @default "default"
54
+ */
55
+ variant?: RadioGroupVariant;
34
56
  /** Extra classes. Merged last, so they win conflicts with ours. */
35
57
  className?: string;
36
58
  }
@@ -42,9 +64,17 @@ declare const RadioGroup$1: import("react").ForwardRefExoticComponent<RadioGroup
42
64
  * <Radio value="pro">Pro</Radio>
43
65
  */
44
66
  interface RadioProps$1 extends Omit<RadioProps, "className" | "style" | "children"> {
45
- /** The label, and this option's accessible name. */
67
+ /** The label, and this option's accessible name. In a card, the title. */
46
68
  children?: ReactNode;
47
- /** Extra classes for the label. Merged last, so they win conflicts. */
69
+ /**
70
+ * A line under the title, read as the option's description
71
+ * (`aria-describedby`), not as part of its name. Card variant only; the
72
+ * default variant ignores it.
73
+ */
74
+ description?: ReactNode;
75
+ /** A decorative icon before the title. Card variant only; hidden from assistive tech. */
76
+ icon?: ReactNode;
77
+ /** Extra classes for the label (the whole card in the card variant). Merged last, so they win conflicts. */
48
78
  className?: string;
49
79
  }
50
80
  declare const Radio$1: import("react").ForwardRefExoticComponent<RadioProps$1 & import("react").RefAttributes<HTMLInputElement>>;
@@ -2,7 +2,7 @@
2
2
  import { cn, cnAll } from "../lib/cn.js";
3
3
  import { selectionLabelVariants, selectionRingVariants } from "../lib/selection-control.js";
4
4
  import { cva } from "class-variance-authority";
5
- import { forwardRef } from "react";
5
+ import { createContext, forwardRef, useContext, useId } from "react";
6
6
  import { Radio, RadioGroup } from "react-aria-components";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/radio-group/radio-group.tsx
@@ -24,25 +24,79 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
24
24
  * Checkbox's exact pair -- `bg-primary` with a `primary-strong` boundary --
25
25
  * so a radio and a checkbox in the same form agree.
26
26
  */
27
- const radioGroupVariants = cva(["flex flex-col gap-2", "box-border font-sans"]);
28
- const radioVariants = cva([
27
+ const radioGroupVariants = cva(["box-border font-sans"], {
28
+ variants: {
29
+ /** `card` lays the choices out as a grid; add `sm:grid-cols-2` and the like. */
30
+ variant: {
31
+ default: "flex flex-col gap-2",
32
+ card: "grid gap-3"
33
+ } },
34
+ defaultVariants: { variant: "default" }
35
+ });
36
+ /**
37
+ * The box and dot, shared by both variants. Only the default variant rings
38
+ * the dot itself: a card rings the whole card, and a second ring on the dot
39
+ * inside it would just be noise.
40
+ */
41
+ const radioMark = [
29
42
  "inline-flex size-4 shrink-0 items-center justify-center",
30
43
  "rounded-full border-(length:--border-width) box-border",
31
44
  "bg-surface border-control-edge",
32
45
  "radio-mark",
33
46
  "group-data-[selected]:bg-primary group-data-[selected]:border-primary-strong",
34
- "group-data-[selected]:text-primary-foreground",
35
- selectionRingVariants()
47
+ "group-data-[selected]:text-primary-foreground"
48
+ ];
49
+ const radioVariants = cva([...radioMark, selectionRingVariants()]);
50
+ /**
51
+ * One choice drawn as a selectable card: a bordered surface the whole of
52
+ * which is the hit target, with the title, an optional description and icon.
53
+ *
54
+ * Selection is shown twice, by the border AND by the dot, so it never rests
55
+ * on colour alone (WCAG 1.4.1). The border reuses StatCard's selected filter
56
+ * pair (`border-primary-strong`, hover darkens an unselected edge), so a
57
+ * selected card and a selected stat card read the same.
58
+ */
59
+ const radioCardVariants = cva([
60
+ "group flex items-start gap-3 p-4",
61
+ "rounded-panel border-(length:--border-width) border-border bg-surface",
62
+ "box-border font-sans text-sm leading-(--leading-normal) text-foreground text-start",
63
+ "cursor-pointer select-none",
64
+ "motion-safe:transition-[border-color] motion-safe:duration-(--duration-quick) motion-safe:ease-settle",
65
+ "data-[hovered]:not-data-[selected]:border-muted-foreground",
66
+ "data-[selected]:border-primary-strong",
67
+ "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-(--opacity-disabled)",
68
+ "outline-none",
69
+ "data-[focus-visible]:outline-solid",
70
+ "data-[focus-visible]:outline-(length:--focus-ring-width)",
71
+ "data-[focus-visible]:outline-offset-(--focus-ring-offset)",
72
+ "data-[focus-visible]:outline-(--color-ring)"
36
73
  ]);
37
- const RadioGroup$1 = forwardRef(function RadioGroup$2({ className, ...props }, ref) {
74
+ const radioCardDotVariants = cva([...radioMark, "mt-0.5"]);
75
+ /** The group hands its variant down, so every Radio inside matches it. */
76
+ const RadioVariantContext = createContext("default");
77
+ const RadioGroup$1 = forwardRef(function RadioGroup$2({ className, variant = "default", children, ...props }, ref) {
38
78
  return /* @__PURE__ */ jsx(RadioGroup, {
39
79
  ref,
40
80
  ...props,
41
81
  "data-slot": "radio-group",
42
- className: cn(radioGroupVariants(), className)
82
+ "data-variant": variant === "default" ? void 0 : variant,
83
+ className: cn(radioGroupVariants({ variant }), className),
84
+ children: (renderProps) => /* @__PURE__ */ jsx(RadioVariantContext.Provider, {
85
+ value: variant,
86
+ children: typeof children === "function" ? children(renderProps) : children
87
+ })
43
88
  });
44
89
  });
45
- const Radio$1 = forwardRef(function Radio$2({ className, children, ...props }, ref) {
90
+ const Radio$1 = forwardRef(function Radio(props, ref) {
91
+ return useContext(RadioVariantContext) === "card" ? /* @__PURE__ */ jsx(RadioCard, {
92
+ ...props,
93
+ ref
94
+ }) : /* @__PURE__ */ jsx(RadioDefault, {
95
+ ...props,
96
+ ref
97
+ });
98
+ });
99
+ const RadioDefault = forwardRef(function RadioDefault({ className, children, description: _description, icon: _icon, ...props }, ref) {
46
100
  return /* @__PURE__ */ jsx(Radio, {
47
101
  ...props,
48
102
  inputRef: ref,
@@ -63,7 +117,53 @@ const Radio$1 = forwardRef(function Radio$2({ className, children, ...props }, r
63
117
  })] })
64
118
  });
65
119
  });
120
+ const RadioCard = forwardRef(function RadioCard({ className, children, description, icon, ...props }, ref) {
121
+ const id = useId();
122
+ const titleId = `${id}-title`;
123
+ const descriptionId = `${id}-description`;
124
+ return /* @__PURE__ */ jsx(Radio, {
125
+ "aria-labelledby": children == null || props["aria-label"] != null ? void 0 : titleId,
126
+ "aria-describedby": description == null ? void 0 : descriptionId,
127
+ ...props,
128
+ inputRef: ref,
129
+ "data-slot": "radio",
130
+ "data-variant": "card",
131
+ className: cn(radioCardVariants(), className),
132
+ children: ({ isSelected }) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
133
+ icon == null ? null : /* @__PURE__ */ jsx("span", {
134
+ "aria-hidden": "true",
135
+ "data-slot": "radio-icon",
136
+ className: "mt-0.5 inline-flex shrink-0 text-text-secondary [&_svg]:size-4",
137
+ children: icon
138
+ }),
139
+ /* @__PURE__ */ jsxs("span", {
140
+ className: "flex min-w-0 flex-1 flex-col gap-1",
141
+ children: [children == null ? null : /* @__PURE__ */ jsx("span", {
142
+ id: titleId,
143
+ "data-slot": "radio-label",
144
+ className: "font-medium",
145
+ children
146
+ }), description == null ? null : /* @__PURE__ */ jsx("span", {
147
+ id: descriptionId,
148
+ "data-slot": "radio-description",
149
+ className: "text-text-secondary",
150
+ children: description
151
+ })]
152
+ }),
153
+ /* @__PURE__ */ jsx("span", {
154
+ "aria-hidden": "true",
155
+ "data-slot": "radio-dot",
156
+ className: radioCardDotVariants(),
157
+ children: /* @__PURE__ */ jsx("span", {
158
+ "data-slot": "radio-indicator",
159
+ "data-selected": isSelected || void 0,
160
+ className: "radio-dot size-1.5 rounded-full bg-current"
161
+ })
162
+ })
163
+ ] })
164
+ });
165
+ });
66
166
  //#endregion
67
- export { Radio$1 as Radio, RadioGroup$1 as RadioGroup, radioGroupVariants, radioVariants };
167
+ export { Radio$1 as Radio, RadioGroup$1 as RadioGroup, radioCardVariants, radioGroupVariants, radioVariants };
68
168
 
69
169
  //# sourceMappingURL=radio-group.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"radio-group.js","names":["RadioGroup","AriaRadioGroup","Radio","AriaRadio"],"sources":["../../src/radio-group/radio-group.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, type ReactNode } from \"react\";\nimport {\n Radio as AriaRadio,\n RadioGroup as AriaRadioGroup,\n type RadioGroupProps as AriaRadioGroupProps,\n type RadioProps as AriaRadioProps,\n} from \"react-aria-components\";\n\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { selectionLabelVariants, selectionRingVariants } from \"../lib/selection-control.js\";\n\n/**\n * RadioGroup, ported from the legacy shadcn/Radix component onto React Aria.\n *\n * Census: 1 file, 3 uses -- `RadioGroup` 1, `RadioGroupItem` 2. The\n * smallest real surface in the migration, so the port is deliberately plain.\n *\n * THE LABEL MOVES INSIDE THE ITEM. Radix's `RadioGroupItem` is a bare\n * button and the caller pairs it with a separate `<Label htmlFor>`; React\n * Aria's `Radio` takes its label as `children` and wires the association\n * itself. That is the one rename a call site has to make, and it removes\n * the id bookkeeping.\n *\n * `border-primary` becomes `border-border` for the same reason it did on\n * Checkbox: brand orange is 2.44:1 on white and cannot legally be the only\n * thing describing a control's edge (WCAG 1.4.11). Selected reuses\n * Checkbox's exact pair -- `bg-primary` with a `primary-strong` boundary --\n * so a radio and a checkbox in the same form agree.\n */\nexport const radioGroupVariants = cva([\n \"flex flex-col gap-2\",\n \"box-border font-sans\",\n]);\n\nexport const radioVariants = cva([\n \"inline-flex size-4 shrink-0 items-center justify-center\",\n \"rounded-full border-(length:--border-width) box-border\",\n \"bg-surface border-control-edge\",\n // Colours ease, the mark dips under a press and springs back (see radio-mark).\n \"radio-mark\",\n \"group-data-[selected]:bg-primary group-data-[selected]:border-primary-strong\",\n \"group-data-[selected]:text-primary-foreground\",\n selectionRingVariants(),\n]);\n\n/**\n * A set of mutually exclusive choices.\n *\n * @example\n * <RadioGroup value={plan} onChange={setPlan} aria-label=\"Plan\">\n * <Radio value=\"free\">Free</Radio>\n * <Radio value=\"pro\">Pro</Radio>\n * </RadioGroup>\n */\nexport interface RadioGroupProps extends Omit<AriaRadioGroupProps, \"className\" | \"style\"> {\n /** Extra classes. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\nexport const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(function RadioGroup(\n { className, ...props },\n ref,\n) {\n return (\n <AriaRadioGroup\n ref={ref}\n {...props}\n data-slot=\"radio-group\"\n className={cn(radioGroupVariants(), className)}\n />\n );\n});\n\n/**\n * One choice.\n *\n * @example\n * <Radio value=\"pro\">Pro</Radio>\n */\nexport interface RadioProps extends Omit<AriaRadioProps, \"className\" | \"style\" | \"children\"> {\n /** The label, and this option's accessible name. */\n children?: ReactNode;\n /** Extra classes for the label. Merged last, so they win conflicts. */\n className?: string;\n}\n\nexport const Radio = forwardRef<HTMLInputElement, RadioProps>(function Radio(\n { className, children, ...props },\n ref,\n) {\n return (\n <AriaRadio\n {...props}\n // inputRef, not ref: AriaRadio's own ref targets the label it renders\n // around a visually-hidden native input, and a label cannot be\n // focused -- ref.current.focus() would silently no-op. inputRef is\n // React Aria's own escape hatch to the real control.\n inputRef={ref}\n data-slot=\"radio\"\n // justify-start, overriding the shared label's centring: a radio sits\n // in a vertical list where every label must start on the same line,\n // not centre itself in a 24px box.\n className={cn(cnAll(selectionLabelVariants(), \"justify-start\"), className)}\n >\n {({ isSelected }) => (\n <>\n <span aria-hidden=\"true\" data-slot=\"radio-dot\" className={radioVariants()}>\n {/*\n Always there: the state decides whether it shows, so the dot can\n spring in and shrink away. data-selected carries the state to a\n test, which can no longer read it from the dot's presence.\n */}\n <span\n data-slot=\"radio-indicator\"\n data-selected={isSelected || undefined}\n className=\"radio-dot size-1.5 rounded-full bg-current\"\n />\n </span>\n {children == null ? null : <span data-slot=\"radio-label\">{children}</span>}\n </>\n )}\n </AriaRadio>\n );\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAa,qBAAqB,IAAI,CACpC,uBACA,sBACF,CAAC;AAED,MAAa,gBAAgB,IAAI;CAC/B;CACA;CACA;CAEA;CACA;CACA;CACA,sBAAsB;AACxB,CAAC;AAgBD,MAAaA,eAAa,WAA4C,SAASA,aAC7E,EAAE,WAAW,GAAG,SAChB,KACA;CACA,OACE,oBAACC,YAAD;EACO;EACL,GAAI;EACJ,aAAU;EACV,WAAW,GAAG,mBAAmB,GAAG,SAAS;CAC9C,CAAA;AAEL,CAAC;AAeD,MAAaC,UAAQ,WAAyC,SAASA,QACrE,EAAE,WAAW,UAAU,GAAG,SAC1B,KACA;CACA,OACE,oBAACC,OAAD;EACE,GAAI;EAKJ,UAAU;EACV,aAAU;EAIV,WAAW,GAAG,MAAM,uBAAuB,GAAG,eAAe,GAAG,SAAS;EAEvE,WAAA,EAAE,iBACF,qBAAA,YAAA,EAAA,UAAA,CACE,oBAAC,QAAD;GAAM,eAAY;GAAO,aAAU;GAAY,WAAW,cAAc;GAMtE,UAAA,oBAAC,QAAD;IACE,aAAU;IACV,iBAAe,cAAc,KAAA;IAC7B,WAAU;GACX,CAAA;EACG,CAAA,GACL,YAAY,OAAO,OAAO,oBAAC,QAAD;GAAM,aAAU;GAAe;EAAe,CAAA,CACzE,EAAA,CAAA;CAEK,CAAA;AAEf,CAAC"}
1
+ {"version":3,"file":"radio-group.js","names":["RadioGroup","AriaRadioGroup","Radio","AriaRadio"],"sources":["../../src/radio-group/radio-group.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { createContext, forwardRef, useContext, useId, type ReactNode } from \"react\";\nimport {\n Radio as AriaRadio,\n RadioGroup as AriaRadioGroup,\n type RadioGroupProps as AriaRadioGroupProps,\n type RadioProps as AriaRadioProps,\n} from \"react-aria-components\";\n\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { selectionLabelVariants, selectionRingVariants } from \"../lib/selection-control.js\";\n\n/**\n * RadioGroup, ported from the legacy shadcn/Radix component onto React Aria.\n *\n * Census: 1 file, 3 uses -- `RadioGroup` 1, `RadioGroupItem` 2. The\n * smallest real surface in the migration, so the port is deliberately plain.\n *\n * THE LABEL MOVES INSIDE THE ITEM. Radix's `RadioGroupItem` is a bare\n * button and the caller pairs it with a separate `<Label htmlFor>`; React\n * Aria's `Radio` takes its label as `children` and wires the association\n * itself. That is the one rename a call site has to make, and it removes\n * the id bookkeeping.\n *\n * `border-primary` becomes `border-border` for the same reason it did on\n * Checkbox: brand orange is 2.44:1 on white and cannot legally be the only\n * thing describing a control's edge (WCAG 1.4.11). Selected reuses\n * Checkbox's exact pair -- `bg-primary` with a `primary-strong` boundary --\n * so a radio and a checkbox in the same form agree.\n */\nexport const radioGroupVariants = cva([\"box-border font-sans\"], {\n variants: {\n /** `card` lays the choices out as a grid; add `sm:grid-cols-2` and the like. */\n variant: {\n default: \"flex flex-col gap-2\",\n card: \"grid gap-3\",\n },\n },\n defaultVariants: { variant: \"default\" },\n});\n\n/**\n * The box and dot, shared by both variants. Only the default variant rings\n * the dot itself: a card rings the whole card, and a second ring on the dot\n * inside it would just be noise.\n */\nconst radioMark = [\n \"inline-flex size-4 shrink-0 items-center justify-center\",\n \"rounded-full border-(length:--border-width) box-border\",\n \"bg-surface border-control-edge\",\n // Colours ease, the mark dips under a press and springs back (see radio-mark).\n \"radio-mark\",\n \"group-data-[selected]:bg-primary group-data-[selected]:border-primary-strong\",\n \"group-data-[selected]:text-primary-foreground\",\n];\n\nexport const radioVariants = cva([...radioMark, selectionRingVariants()]);\n\n/**\n * One choice drawn as a selectable card: a bordered surface the whole of\n * which is the hit target, with the title, an optional description and icon.\n *\n * Selection is shown twice, by the border AND by the dot, so it never rests\n * on colour alone (WCAG 1.4.1). The border reuses StatCard's selected filter\n * pair (`border-primary-strong`, hover darkens an unselected edge), so a\n * selected card and a selected stat card read the same.\n */\nexport const radioCardVariants = cva([\n \"group flex items-start gap-3 p-4\",\n \"rounded-panel border-(length:--border-width) border-border bg-surface\",\n \"box-border font-sans text-sm leading-(--leading-normal) text-foreground text-start\",\n \"cursor-pointer select-none\",\n \"motion-safe:transition-[border-color] motion-safe:duration-(--duration-quick) motion-safe:ease-settle\",\n \"data-[hovered]:not-data-[selected]:border-muted-foreground\",\n \"data-[selected]:border-primary-strong\",\n \"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-(--opacity-disabled)\",\n // The same ring as selectionRingVariants, on purpose not reused: that helper\n // reads the state from a `group-data-[focus-visible]:` parent, and here the\n // card IS the group, so its own `data-[focus-visible]:` is the state. Keep\n // the two in step. outline-solid is load-bearing: outline-none sets the\n // style the width utility resolves from (see selectionRingVariants).\n \"outline-none\",\n \"data-[focus-visible]:outline-solid\",\n \"data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset)\",\n \"data-[focus-visible]:outline-(--color-ring)\",\n]);\n\nconst radioCardDotVariants = cva([...radioMark, \"mt-0.5\"]);\n\n/** Which look the group's radios take. */\nexport type RadioGroupVariant = \"default\" | \"card\";\n\n/** The group hands its variant down, so every Radio inside matches it. */\nconst RadioVariantContext = createContext<RadioGroupVariant>(\"default\");\n\n/**\n * A set of mutually exclusive choices.\n *\n * @example\n * <RadioGroup value={plan} onChange={setPlan} aria-label=\"Plan\">\n * <Radio value=\"free\">Free</Radio>\n * <Radio value=\"pro\">Pro</Radio>\n * </RadioGroup>\n */\nexport interface RadioGroupProps extends Omit<AriaRadioGroupProps, \"className\" | \"style\"> {\n /**\n * `card` draws each choice as a selectable card, with a title, an optional\n * `description` and `icon` on each `Radio`. The keyboard model is the same:\n * one tab stop, arrow keys move and select.\n *\n * @default \"default\"\n */\n variant?: RadioGroupVariant;\n /** Extra classes. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\nexport const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(function RadioGroup(\n { className, variant = \"default\", children, ...props },\n ref,\n) {\n return (\n <AriaRadioGroup\n ref={ref}\n {...props}\n data-slot=\"radio-group\"\n data-variant={variant === \"default\" ? undefined : variant}\n className={cn(radioGroupVariants({ variant }), className)}\n >\n {(renderProps) => (\n <RadioVariantContext.Provider value={variant}>\n {typeof children === \"function\" ? children(renderProps) : children}\n </RadioVariantContext.Provider>\n )}\n </AriaRadioGroup>\n );\n});\n\n/**\n * One choice.\n *\n * @example\n * <Radio value=\"pro\">Pro</Radio>\n */\nexport interface RadioProps extends Omit<AriaRadioProps, \"className\" | \"style\" | \"children\"> {\n /** The label, and this option's accessible name. In a card, the title. */\n children?: ReactNode;\n /**\n * A line under the title, read as the option's description\n * (`aria-describedby`), not as part of its name. Card variant only; the\n * default variant ignores it.\n */\n description?: ReactNode;\n /** A decorative icon before the title. Card variant only; hidden from assistive tech. */\n icon?: ReactNode;\n /** Extra classes for the label (the whole card in the card variant). Merged last, so they win conflicts. */\n className?: string;\n}\n\nexport const Radio = forwardRef<HTMLInputElement, RadioProps>(function Radio(props, ref) {\n const variant = useContext(RadioVariantContext);\n return variant === \"card\" ? <RadioCard {...props} ref={ref} /> : <RadioDefault {...props} ref={ref} />;\n});\n\nconst RadioDefault = forwardRef<HTMLInputElement, RadioProps>(function RadioDefault(\n // description and icon belong to the card; pulled out so they never reach the DOM.\n { className, children, description: _description, icon: _icon, ...props },\n ref,\n) {\n return (\n <AriaRadio\n {...props}\n // inputRef, not ref: AriaRadio's own ref targets the label it renders\n // around a visually-hidden native input, and a label cannot be\n // focused -- ref.current.focus() would silently no-op. inputRef is\n // React Aria's own escape hatch to the real control.\n inputRef={ref}\n data-slot=\"radio\"\n // justify-start, overriding the shared label's centring: a radio sits\n // in a vertical list where every label must start on the same line,\n // not centre itself in a 24px box.\n className={cn(cnAll(selectionLabelVariants(), \"justify-start\"), className)}\n >\n {({ isSelected }) => (\n <>\n <span aria-hidden=\"true\" data-slot=\"radio-dot\" className={radioVariants()}>\n {/*\n Always there: the state decides whether it shows, so the dot can\n spring in and shrink away. data-selected carries the state to a\n test, which can no longer read it from the dot's presence.\n */}\n <span\n data-slot=\"radio-indicator\"\n data-selected={isSelected || undefined}\n className=\"radio-dot size-1.5 rounded-full bg-current\"\n />\n </span>\n {children == null ? null : <span data-slot=\"radio-label\">{children}</span>}\n </>\n )}\n </AriaRadio>\n );\n});\n\nconst RadioCard = forwardRef<HTMLInputElement, RadioProps>(function RadioCard(\n { className, children, description, icon, ...props },\n ref,\n) {\n const id = useId();\n const titleId = `${id}-title`;\n const descriptionId = `${id}-description`;\n return (\n <AriaRadio\n // The title alone names the option; the description describes it. Left\n // to the wrapping <label>, the name would be title and description run\n // together. A caller's own labelling wins: their aria-labelledby or\n // aria-describedby by the spread below, and their aria-label because the\n // title's aria-labelledby is then left off (it would outrank aria-label\n // in the name computation).\n aria-labelledby={children == null || props[\"aria-label\"] != null ? undefined : titleId}\n aria-describedby={description == null ? undefined : descriptionId}\n {...props}\n inputRef={ref}\n data-slot=\"radio\"\n data-variant=\"card\"\n className={cn(radioCardVariants(), className)}\n >\n {({ isSelected }) => (\n <>\n {icon == null ? null : (\n <span\n aria-hidden=\"true\"\n data-slot=\"radio-icon\"\n className=\"mt-0.5 inline-flex shrink-0 text-text-secondary [&_svg]:size-4\"\n >\n {icon}\n </span>\n )}\n <span className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {children == null ? null : (\n <span id={titleId} data-slot=\"radio-label\" className=\"font-medium\">\n {children}\n </span>\n )}\n {description == null ? null : (\n <span id={descriptionId} data-slot=\"radio-description\" className=\"text-text-secondary\">\n {description}\n </span>\n )}\n </span>\n <span aria-hidden=\"true\" data-slot=\"radio-dot\" className={radioCardDotVariants()}>\n <span\n data-slot=\"radio-indicator\"\n data-selected={isSelected || undefined}\n className=\"radio-dot size-1.5 rounded-full bg-current\"\n />\n </span>\n </>\n )}\n </AriaRadio>\n );\n});\n"],"mappings":"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"}
@@ -46,10 +46,11 @@ framed: {
46
46
  const scrollAreaVariants = cva([
47
47
  "scroll-area-viewport min-h-0 min-w-0 flex-1 overscroll-contain box-border",
48
48
  "outline-none",
49
- "focus-visible:outline-solid",
49
+ "focus-visible:not-data-[fade]:outline-solid",
50
50
  "focus-visible:outline-(length:--focus-ring-width)",
51
- "focus-visible:outline-offset-(--focus-ring-offset)",
52
- "focus-visible:outline-(--color-ring)"
51
+ "focus-visible:-outline-offset-(--focus-ring-width)",
52
+ "focus-visible:outline-(--color-ring)",
53
+ "rounded-[inherit]"
53
54
  ], {
54
55
  variants: { orientation: {
55
56
  vertical: "overflow-x-hidden overflow-y-auto",
@@ -1 +1 @@
1
- {"version":3,"file":"scroll-area.js","names":[],"sources":["../../src/scroll-area/scroll-area.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n type ComponentPropsWithoutRef,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from \"react\";\n\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { prefersReducedMotion, useIsomorphicLayoutEffect } from \"../lib/motion.js\";\n\n/**\n * ScrollArea: a native scroll container with thin overlay scrollbars and edge\n * fades. React Aria has no equivalent, so the scrolling stays the browser's --\n * keyboard, touch momentum and scroll snap all behave as the platform expects --\n * and only the scrollbar is drawn here.\n *\n * Census: fourteen paths match, thirteen are counted, ELEVEN render anything,\n * and across them 17 uses. Every one passes `className` and nothing else, and 13\n * of the 17 are a height or flex constraint plus padding. The legacy Radix one\n * cannot be scrolled by keyboard at all.\n *\n * The native scrollbar is hidden and replaced by an overlay pill that appears\n * while scrolling or hovering and fades away at rest (or stays, with\n * `scrollbars=\"always\"`). It is written straight to the DOM on each scroll\n * frame, so scrolling never re-renders React. The pill can be dragged, and\n * pressing its track pages toward the pointer.\n *\n * WHERE THINGS LAND. `className`, `style` and the rest go on the ROOT, the box\n * that carries the height and the optional card. `ref` and `viewportClassName`\n * go on the VIEWPORT, the element that really scrolls -- so a caller can call\n * `ref.current.scrollTo(...)` or `scrollBy(...)` for a \"back to top\" or an\n * arrow button, and put padding on the content without moving the scrollbar\n * inside it.\n *\n * The font is declared here: the reset in @seekalfred/styles is OPT-IN, so a\n * consumer may load none, and a scroll area's content (a list of plain\n * paragraphs) would render in the browser's serif.\n */\nexport const scrollAreaRootVariants = cva(\n [\"scroll-area relative flex min-h-0 min-w-0 flex-col overflow-hidden box-border font-sans\"],\n {\n variants: {\n /** The area is a white card: the panel radius and a hairline border. */\n framed: {\n true: \"rounded-panel border-(length:--border-width) border-border bg-surface\",\n false: \"\",\n },\n },\n defaultVariants: { framed: false },\n },\n);\n\nexport const scrollAreaVariants = cva(\n [\n \"scroll-area-viewport min-h-0 min-w-0 flex-1 overscroll-contain box-border\",\n // A scrollable region must be reachable by keyboard, or its content is\n // unreachable without a pointer -- WCAG 2.1.1, and axe's\n // scrollable-region-focusable rule. The legacy has no tabIndex at all.\n \"outline-none\",\n \"focus-visible:outline-solid\",\n \"focus-visible:outline-(length:--focus-ring-width)\",\n \"focus-visible:outline-offset-(--focus-ring-offset)\",\n \"focus-visible:outline-(--color-ring)\",\n ],\n {\n variants: {\n orientation: {\n vertical: \"overflow-x-hidden overflow-y-auto\",\n horizontal: \"overflow-x-auto overflow-y-hidden\",\n both: \"overflow-auto\",\n },\n },\n defaultVariants: { orientation: \"vertical\" },\n },\n);\n\ntype Axis = \"y\" | \"x\";\n\ninterface Drag {\n axis: Axis;\n start: number;\n scroll: number;\n ratio: number;\n sign: number;\n}\n\n/** The shortest a scrollbar thumb gets, so a very long list still leaves something to grab. */\nconst MIN_THUMB = 28;\n\n/**\n * A scrolling region.\n *\n * @example\n * <ScrollArea label=\"Activity\" className=\"h-64\" framed fade={28}>...</ScrollArea>\n */\nexport interface ScrollAreaProps extends ComponentPropsWithoutRef<\"div\"> {\n children?: ReactNode;\n /**\n * Names the scrolling region.\n *\n * **Required**, and it took a failing test to get here. A focusable\n * element with no role fails axe `focus-order-semantics`, so the\n * keyboard fix cannot ship without a role -- and a role with no\n * accessible name is a violation of its own. Naming it is the only\n * combination that is actually correct.\n *\n * `role=\"region\"` specifically. `group` was the first attempt -- it is\n * lighter, since region is a landmark and seventeen landmarks in one app\n * is noise -- but axe does not accept `group` as an appropriate role for\n * a focusable element, and it is right: a named region is the role the\n * ARIA practices name for a scrollable content area, and being able to\n * jump to it is the point.\n */\n label: string;\n /**\n * Which axes scroll.\n *\n * @default \"vertical\"\n */\n orientation?: \"vertical\" | \"horizontal\" | \"both\";\n /**\n * Fade the content out at an edge by exactly as much as there is content hidden\n * past it, up to this many pixels: the fade grows in with the first pixels of\n * scroll and is gone at the ends, so an edge fades only while content\n * overflows past it. `0` turns it off, and the default leaves the area as it\n * was. Written on each scroll frame straight to the element, so scrolling\n * never re-renders.\n *\n * @default 0\n */\n fade?: number;\n /**\n * `auto` shows the scrollbar while scrolling or hovering and fades it away at\n * rest; `always` keeps it visible whenever the content overflows.\n *\n * @default \"auto\"\n */\n scrollbars?: \"auto\" | \"always\";\n /**\n * How long the scrollbar lingers after scrolling stops, in ms.\n *\n * @default 900\n */\n hideDelay?: number;\n /**\n * Draws the area as a white card: the panel radius and a light border. The\n * content is clipped to it, so an edge fade and the scrollbar sit inside.\n *\n * @default false\n */\n framed?: boolean;\n /**\n * Classes for the viewport, the element that scrolls. Padding for the\n * content goes here.\n */\n viewportClassName?: string;\n /**\n * Change this when the contents change, and the region scrolls back to the\n * top.\n *\n * This stays a declarative prop even though a `ref` exists: reaching\n * for it would mean the caller writes `ref.current.scrollTop = 0` in their\n * own effect on every content change, which is worse ergonomics than a\n * prop that says \"reset when this value changes.\"\n *\n * Sheet forwards its own `scrollResetKey` straight through: going a level\n * deeper in a panel and landing halfway down the new view is disorienting,\n * and it is exactly the thing every call site forgets.\n */\n scrollResetKey?: string | number;\n}\n\n/** The `ref` reaches the VIEWPORT, the element that scrolls. `onScroll` runs on its scroll events. */\nexport const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(function ScrollArea(\n {\n className,\n children,\n label,\n scrollResetKey,\n orientation = \"vertical\",\n fade = 0,\n scrollbars = \"auto\",\n hideDelay = 900,\n framed = false,\n viewportClassName,\n onScroll,\n ...props\n },\n ref,\n) {\n const root = useRef<HTMLDivElement>(null);\n const viewport = useRef<HTMLDivElement>(null);\n const tracks = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n const hideTimer = useRef<number | undefined>(undefined);\n const drag = useRef<Drag | null>(null);\n\n const vertical = orientation !== \"horizontal\";\n const horizontal = orientation !== \"vertical\";\n\n useEffect(() => {\n // Unconditional rather than guarded on scrollResetKey being set: with no\n // key this runs once on mount, where scrollTop is already 0.\n if (viewport.current) viewport.current.scrollTop = 0;\n }, [scrollResetKey]);\n\n // Memoized, not called inline: mergeRefs returns a new callback every call, so\n // an inline call would detach and reattach the DOM ref on every re-render.\n const viewportRef = useMemo(() => mergeRefs(ref, viewport), [ref]);\n\n /*\n * Everything visual is written straight to the DOM: the fade at each edge is\n * how much content is hidden past it (up to `fade`), and a thumb is sized and\n * placed from the scroll position. A track with nothing to scroll is hidden.\n */\n const sync = useCallback(() => {\n const node = viewport.current;\n if (!node) return;\n const { scrollTop, scrollHeight, clientHeight, scrollWidth, clientWidth } = node;\n const maxY = Math.max(0, scrollHeight - clientHeight);\n const maxX = Math.max(0, scrollWidth - clientWidth);\n // scrollLeft runs negative in a right-to-left layout; the distance travelled is what counts.\n const left = Math.abs(node.scrollLeft);\n\n if (fade > 0) {\n node.style.setProperty(\"--fade-top\", `${vertical ? Math.min(fade, scrollTop) : 0}px`);\n node.style.setProperty(\n \"--fade-bottom\",\n `${vertical ? Math.min(fade, Math.max(0, maxY - scrollTop)) : 0}px`,\n );\n node.style.setProperty(\"--fade-left\", `${horizontal ? Math.min(fade, left) : 0}px`);\n node.style.setProperty(\n \"--fade-right\",\n `${horizontal ? Math.min(fade, Math.max(0, maxX - left)) : 0}px`,\n );\n }\n\n const place = (axis: Axis, scroll: number, max: number, client: number, total: number) => {\n const track = tracks.current[axis];\n const thumb = thumbs.current[axis];\n if (!track || !thumb) return;\n const overflowing = max > 0.5;\n track.toggleAttribute(\"data-hidden\", !overflowing);\n if (!overflowing) return;\n const length = axis === \"y\" ? track.clientHeight : track.clientWidth;\n const size = Math.max(MIN_THUMB, (length * client) / total);\n const offset = (length - size) * (scroll / max);\n const sign = axis === \"x\" && getComputedStyle(node).direction === \"rtl\" ? -1 : 1;\n thumb.style[axis === \"y\" ? \"height\" : \"width\"] = `${size}px`;\n thumb.style.transform =\n axis === \"y\" ? `translate3d(0, ${offset}px, 0)` : `translate3d(${offset * sign}px, 0, 0)`;\n };\n if (vertical) place(\"y\", scrollTop, maxY, clientHeight, scrollHeight);\n if (horizontal) place(\"x\", left, maxX, clientWidth, scrollWidth);\n root.current?.toggleAttribute(\"data-overflow\", maxY > 0.5 || maxX > 0.5);\n }, [fade, horizontal, vertical]);\n\n useIsomorphicLayoutEffect(() => {\n const node = viewport.current;\n if (!node) return;\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => sync());\n observer.observe(node);\n for (const child of Array.from(node.children)) observer.observe(child);\n return () => observer.disconnect();\n // `children` re-observes when the content's own boxes are replaced.\n }, [sync, children]);\n\n useEffect(() => () => window.clearTimeout(hideTimer.current), []);\n\n const wake = () => {\n const el = root.current;\n if (!el) return;\n el.setAttribute(\"data-scrolling\", \"\");\n window.clearTimeout(hideTimer.current);\n hideTimer.current = window.setTimeout(() => el.removeAttribute(\"data-scrolling\"), hideDelay);\n };\n\n /* Dragging a thumb maps pointer travel to scroll distance. */\n const onThumbDown = (event: ReactPointerEvent<HTMLDivElement>, axis: Axis) => {\n const node = viewport.current;\n const track = tracks.current[axis];\n const thumb = thumbs.current[axis];\n if (!node || !track || !thumb || event.button !== 0) return;\n event.preventDefault();\n event.stopPropagation();\n event.currentTarget.setPointerCapture(event.pointerId);\n const length = axis === \"y\" ? track.clientHeight : track.clientWidth;\n const size = axis === \"y\" ? thumb.offsetHeight : thumb.offsetWidth;\n const max =\n axis === \"y\" ? node.scrollHeight - node.clientHeight : node.scrollWidth - node.clientWidth;\n drag.current = {\n axis,\n start: axis === \"y\" ? event.clientY : event.clientX,\n scroll: axis === \"y\" ? node.scrollTop : node.scrollLeft,\n ratio: max / Math.max(1, length - size),\n sign: axis === \"x\" && getComputedStyle(node).direction === \"rtl\" ? -1 : 1,\n };\n root.current?.setAttribute(\"data-dragging\", axis);\n };\n const onThumbMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n const state = drag.current;\n const node = viewport.current;\n if (!state || !node) return;\n const travel = (state.axis === \"y\" ? event.clientY : event.clientX) - state.start;\n const to = state.scroll + travel * state.ratio * state.sign;\n if (state.axis === \"y\") node.scrollTop = to;\n else node.scrollLeft = to;\n };\n const onThumbUp = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (!drag.current) return;\n drag.current = null;\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId);\n }\n root.current?.removeAttribute(\"data-dragging\");\n wake();\n };\n /* Pressing the track pages toward the pointer. */\n const onTrackDown = (event: ReactPointerEvent<HTMLDivElement>, axis: Axis) => {\n const node = viewport.current;\n const thumb = thumbs.current[axis];\n if (!node || !thumb || event.button !== 0 || event.target !== event.currentTarget) return;\n const box = thumb.getBoundingClientRect();\n const before = axis === \"y\" ? event.clientY < box.top : event.clientX < box.left;\n const page = (axis === \"y\" ? node.clientHeight : node.clientWidth) * 0.9 * (before ? -1 : 1);\n node.scrollBy({\n [axis === \"y\" ? \"top\" : \"left\"]: page,\n behavior: prefersReducedMotion() ? \"auto\" : \"smooth\",\n });\n };\n\n const bar = (axis: Axis) => (\n <div\n ref={(node) => {\n tracks.current[axis] = node;\n }}\n data-slot=\"scroll-area-track\"\n data-axis={axis}\n data-hidden=\"\"\n aria-hidden=\"true\"\n onPointerDown={(event) => onTrackDown(event, axis)}\n >\n <div\n ref={(node) => {\n thumbs.current[axis] = node;\n }}\n data-slot=\"scroll-area-thumb\"\n onPointerDown={(event) => onThumbDown(event, axis)}\n onPointerMove={onThumbMove}\n onPointerUp={onThumbUp}\n onPointerCancel={onThumbUp}\n />\n </div>\n );\n\n const fadeClass =\n orientation === \"vertical\"\n ? \"scroll-fade-y\"\n : orientation === \"horizontal\"\n ? \"scroll-fade-x\"\n : \"scroll-fade-both\";\n\n return (\n <div\n {...props}\n ref={root}\n data-slot=\"scroll-area-root\"\n data-orientation={orientation}\n data-scrollbars={scrollbars}\n className={cn(scrollAreaRootVariants({ framed }), className)}\n >\n <div\n ref={viewportRef}\n // tabIndex 0 unconditionally: whether the content overflows is a\n // runtime fact that changes as the content does, and a region that\n // becomes scrollable after a fetch would otherwise never gain focus.\n tabIndex={0}\n role=\"region\"\n aria-label={label}\n data-slot=\"scroll-area\"\n data-fade={fade > 0 ? \"\" : undefined}\n onScroll={(event) => {\n sync();\n wake();\n onScroll?.(event);\n }}\n className={cn(\n cnAll(scrollAreaVariants({ orientation }), fade > 0 && fadeClass),\n viewportClassName,\n )}\n >\n {children}\n </div>\n {vertical ? bar(\"y\") : null}\n {horizontal ? bar(\"x\") : null}\n </div>\n );\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"scroll-area.js","names":[],"sources":["../../src/scroll-area/scroll-area.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n type ComponentPropsWithoutRef,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from \"react\";\n\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { prefersReducedMotion, useIsomorphicLayoutEffect } from \"../lib/motion.js\";\n\n/**\n * ScrollArea: a native scroll container with thin overlay scrollbars and edge\n * fades. React Aria has no equivalent, so the scrolling stays the browser's --\n * keyboard, touch momentum and scroll snap all behave as the platform expects --\n * and only the scrollbar is drawn here.\n *\n * Census: fourteen paths match, thirteen are counted, ELEVEN render anything,\n * and across them 17 uses. Every one passes `className` and nothing else, and 13\n * of the 17 are a height or flex constraint plus padding. The legacy Radix one\n * cannot be scrolled by keyboard at all.\n *\n * The native scrollbar is hidden and replaced by an overlay pill that appears\n * while scrolling or hovering and fades away at rest (or stays, with\n * `scrollbars=\"always\"`). It is written straight to the DOM on each scroll\n * frame, so scrolling never re-renders React. The pill can be dragged, and\n * pressing its track pages toward the pointer.\n *\n * WHERE THINGS LAND. `className`, `style` and the rest go on the ROOT, the box\n * that carries the height and the optional card. `ref` and `viewportClassName`\n * go on the VIEWPORT, the element that really scrolls -- so a caller can call\n * `ref.current.scrollTo(...)` or `scrollBy(...)` for a \"back to top\" or an\n * arrow button, and put padding on the content without moving the scrollbar\n * inside it.\n *\n * The font is declared here: the reset in @seekalfred/styles is OPT-IN, so a\n * consumer may load none, and a scroll area's content (a list of plain\n * paragraphs) would render in the browser's serif.\n */\nexport const scrollAreaRootVariants = cva(\n [\"scroll-area relative flex min-h-0 min-w-0 flex-col overflow-hidden box-border font-sans\"],\n {\n variants: {\n /** The area is a white card: the panel radius and a hairline border. */\n framed: {\n true: \"rounded-panel border-(length:--border-width) border-border bg-surface\",\n false: \"\",\n },\n },\n defaultVariants: { framed: false },\n },\n);\n\nexport const scrollAreaVariants = cva(\n [\n \"scroll-area-viewport min-h-0 min-w-0 flex-1 overscroll-contain box-border\",\n // A scrollable region must be reachable by keyboard, or its content is\n // unreachable without a pointer -- WCAG 2.1.1, and axe's\n // scrollable-region-focusable rule. The legacy has no tabIndex at all.\n \"outline-none\",\n // Inset, not offset outward: the root is overflow-hidden (it clips the\n // overlay scrollbars, the fades and the framed radius), so a ring drawn\n // outside the viewport was cut off on every side and keyboard focus was\n // invisible -- WCAG 2.4.7. Drawn just inside the edge, nothing around the\n // area can clip it, and a caller can still recolour it through\n // `viewportClassName`. The radius is inherited so the ring follows the\n // framed card's corners.\n //\n // With `fade` on (data-fade) the viewport carries the edge-fade mask, which\n // would fade the ring's top and bottom out with the content mid-scroll. The\n // ring's style stays `none` there and the ROOT paints it instead, on an\n // unmasked overlay (`scroll-area` in styles.css).\n \"focus-visible:not-data-[fade]:outline-solid\",\n \"focus-visible:outline-(length:--focus-ring-width)\",\n \"focus-visible:-outline-offset-(--focus-ring-width)\",\n \"focus-visible:outline-(--color-ring)\",\n \"rounded-[inherit]\",\n ],\n {\n variants: {\n orientation: {\n vertical: \"overflow-x-hidden overflow-y-auto\",\n horizontal: \"overflow-x-auto overflow-y-hidden\",\n both: \"overflow-auto\",\n },\n },\n defaultVariants: { orientation: \"vertical\" },\n },\n);\n\ntype Axis = \"y\" | \"x\";\n\ninterface Drag {\n axis: Axis;\n start: number;\n scroll: number;\n ratio: number;\n sign: number;\n}\n\n/** The shortest a scrollbar thumb gets, so a very long list still leaves something to grab. */\nconst MIN_THUMB = 28;\n\n/**\n * A scrolling region.\n *\n * @example\n * <ScrollArea label=\"Activity\" className=\"h-64\" framed fade={28}>...</ScrollArea>\n */\nexport interface ScrollAreaProps extends ComponentPropsWithoutRef<\"div\"> {\n children?: ReactNode;\n /**\n * Names the scrolling region.\n *\n * **Required**, and it took a failing test to get here. A focusable\n * element with no role fails axe `focus-order-semantics`, so the\n * keyboard fix cannot ship without a role -- and a role with no\n * accessible name is a violation of its own. Naming it is the only\n * combination that is actually correct.\n *\n * `role=\"region\"` specifically. `group` was the first attempt -- it is\n * lighter, since region is a landmark and seventeen landmarks in one app\n * is noise -- but axe does not accept `group` as an appropriate role for\n * a focusable element, and it is right: a named region is the role the\n * ARIA practices name for a scrollable content area, and being able to\n * jump to it is the point.\n */\n label: string;\n /**\n * Which axes scroll.\n *\n * @default \"vertical\"\n */\n orientation?: \"vertical\" | \"horizontal\" | \"both\";\n /**\n * Fade the content out at an edge by exactly as much as there is content hidden\n * past it, up to this many pixels: the fade grows in with the first pixels of\n * scroll and is gone at the ends, so an edge fades only while content\n * overflows past it. `0` turns it off, and the default leaves the area as it\n * was. Written on each scroll frame straight to the element, so scrolling\n * never re-renders.\n *\n * @default 0\n */\n fade?: number;\n /**\n * `auto` shows the scrollbar while scrolling or hovering and fades it away at\n * rest; `always` keeps it visible whenever the content overflows.\n *\n * @default \"auto\"\n */\n scrollbars?: \"auto\" | \"always\";\n /**\n * How long the scrollbar lingers after scrolling stops, in ms.\n *\n * @default 900\n */\n hideDelay?: number;\n /**\n * Draws the area as a white card: the panel radius and a light border. The\n * content is clipped to it, so an edge fade and the scrollbar sit inside.\n *\n * @default false\n */\n framed?: boolean;\n /**\n * Classes for the viewport, the element that scrolls. Padding for the\n * content goes here.\n */\n viewportClassName?: string;\n /**\n * Change this when the contents change, and the region scrolls back to the\n * top.\n *\n * This stays a declarative prop even though a `ref` exists: reaching\n * for it would mean the caller writes `ref.current.scrollTop = 0` in their\n * own effect on every content change, which is worse ergonomics than a\n * prop that says \"reset when this value changes.\"\n *\n * Sheet forwards its own `scrollResetKey` straight through: going a level\n * deeper in a panel and landing halfway down the new view is disorienting,\n * and it is exactly the thing every call site forgets.\n */\n scrollResetKey?: string | number;\n}\n\n/** The `ref` reaches the VIEWPORT, the element that scrolls. `onScroll` runs on its scroll events. */\nexport const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(function ScrollArea(\n {\n className,\n children,\n label,\n scrollResetKey,\n orientation = \"vertical\",\n fade = 0,\n scrollbars = \"auto\",\n hideDelay = 900,\n framed = false,\n viewportClassName,\n onScroll,\n ...props\n },\n ref,\n) {\n const root = useRef<HTMLDivElement>(null);\n const viewport = useRef<HTMLDivElement>(null);\n const tracks = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n const hideTimer = useRef<number | undefined>(undefined);\n const drag = useRef<Drag | null>(null);\n\n const vertical = orientation !== \"horizontal\";\n const horizontal = orientation !== \"vertical\";\n\n useEffect(() => {\n // Unconditional rather than guarded on scrollResetKey being set: with no\n // key this runs once on mount, where scrollTop is already 0.\n if (viewport.current) viewport.current.scrollTop = 0;\n }, [scrollResetKey]);\n\n // Memoized, not called inline: mergeRefs returns a new callback every call, so\n // an inline call would detach and reattach the DOM ref on every re-render.\n const viewportRef = useMemo(() => mergeRefs(ref, viewport), [ref]);\n\n /*\n * Everything visual is written straight to the DOM: the fade at each edge is\n * how much content is hidden past it (up to `fade`), and a thumb is sized and\n * placed from the scroll position. A track with nothing to scroll is hidden.\n */\n const sync = useCallback(() => {\n const node = viewport.current;\n if (!node) return;\n const { scrollTop, scrollHeight, clientHeight, scrollWidth, clientWidth } = node;\n const maxY = Math.max(0, scrollHeight - clientHeight);\n const maxX = Math.max(0, scrollWidth - clientWidth);\n // scrollLeft runs negative in a right-to-left layout; the distance travelled is what counts.\n const left = Math.abs(node.scrollLeft);\n\n if (fade > 0) {\n node.style.setProperty(\"--fade-top\", `${vertical ? Math.min(fade, scrollTop) : 0}px`);\n node.style.setProperty(\n \"--fade-bottom\",\n `${vertical ? Math.min(fade, Math.max(0, maxY - scrollTop)) : 0}px`,\n );\n node.style.setProperty(\"--fade-left\", `${horizontal ? Math.min(fade, left) : 0}px`);\n node.style.setProperty(\n \"--fade-right\",\n `${horizontal ? Math.min(fade, Math.max(0, maxX - left)) : 0}px`,\n );\n }\n\n const place = (axis: Axis, scroll: number, max: number, client: number, total: number) => {\n const track = tracks.current[axis];\n const thumb = thumbs.current[axis];\n if (!track || !thumb) return;\n const overflowing = max > 0.5;\n track.toggleAttribute(\"data-hidden\", !overflowing);\n if (!overflowing) return;\n const length = axis === \"y\" ? track.clientHeight : track.clientWidth;\n const size = Math.max(MIN_THUMB, (length * client) / total);\n const offset = (length - size) * (scroll / max);\n const sign = axis === \"x\" && getComputedStyle(node).direction === \"rtl\" ? -1 : 1;\n thumb.style[axis === \"y\" ? \"height\" : \"width\"] = `${size}px`;\n thumb.style.transform =\n axis === \"y\" ? `translate3d(0, ${offset}px, 0)` : `translate3d(${offset * sign}px, 0, 0)`;\n };\n if (vertical) place(\"y\", scrollTop, maxY, clientHeight, scrollHeight);\n if (horizontal) place(\"x\", left, maxX, clientWidth, scrollWidth);\n root.current?.toggleAttribute(\"data-overflow\", maxY > 0.5 || maxX > 0.5);\n }, [fade, horizontal, vertical]);\n\n useIsomorphicLayoutEffect(() => {\n const node = viewport.current;\n if (!node) return;\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => sync());\n observer.observe(node);\n for (const child of Array.from(node.children)) observer.observe(child);\n return () => observer.disconnect();\n // `children` re-observes when the content's own boxes are replaced.\n }, [sync, children]);\n\n useEffect(() => () => window.clearTimeout(hideTimer.current), []);\n\n const wake = () => {\n const el = root.current;\n if (!el) return;\n el.setAttribute(\"data-scrolling\", \"\");\n window.clearTimeout(hideTimer.current);\n hideTimer.current = window.setTimeout(() => el.removeAttribute(\"data-scrolling\"), hideDelay);\n };\n\n /* Dragging a thumb maps pointer travel to scroll distance. */\n const onThumbDown = (event: ReactPointerEvent<HTMLDivElement>, axis: Axis) => {\n const node = viewport.current;\n const track = tracks.current[axis];\n const thumb = thumbs.current[axis];\n if (!node || !track || !thumb || event.button !== 0) return;\n event.preventDefault();\n event.stopPropagation();\n event.currentTarget.setPointerCapture(event.pointerId);\n const length = axis === \"y\" ? track.clientHeight : track.clientWidth;\n const size = axis === \"y\" ? thumb.offsetHeight : thumb.offsetWidth;\n const max =\n axis === \"y\" ? node.scrollHeight - node.clientHeight : node.scrollWidth - node.clientWidth;\n drag.current = {\n axis,\n start: axis === \"y\" ? event.clientY : event.clientX,\n scroll: axis === \"y\" ? node.scrollTop : node.scrollLeft,\n ratio: max / Math.max(1, length - size),\n sign: axis === \"x\" && getComputedStyle(node).direction === \"rtl\" ? -1 : 1,\n };\n root.current?.setAttribute(\"data-dragging\", axis);\n };\n const onThumbMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n const state = drag.current;\n const node = viewport.current;\n if (!state || !node) return;\n const travel = (state.axis === \"y\" ? event.clientY : event.clientX) - state.start;\n const to = state.scroll + travel * state.ratio * state.sign;\n if (state.axis === \"y\") node.scrollTop = to;\n else node.scrollLeft = to;\n };\n const onThumbUp = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (!drag.current) return;\n drag.current = null;\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId);\n }\n root.current?.removeAttribute(\"data-dragging\");\n wake();\n };\n /* Pressing the track pages toward the pointer. */\n const onTrackDown = (event: ReactPointerEvent<HTMLDivElement>, axis: Axis) => {\n const node = viewport.current;\n const thumb = thumbs.current[axis];\n if (!node || !thumb || event.button !== 0 || event.target !== event.currentTarget) return;\n const box = thumb.getBoundingClientRect();\n const before = axis === \"y\" ? event.clientY < box.top : event.clientX < box.left;\n const page = (axis === \"y\" ? node.clientHeight : node.clientWidth) * 0.9 * (before ? -1 : 1);\n node.scrollBy({\n [axis === \"y\" ? \"top\" : \"left\"]: page,\n behavior: prefersReducedMotion() ? \"auto\" : \"smooth\",\n });\n };\n\n const bar = (axis: Axis) => (\n <div\n ref={(node) => {\n tracks.current[axis] = node;\n }}\n data-slot=\"scroll-area-track\"\n data-axis={axis}\n data-hidden=\"\"\n aria-hidden=\"true\"\n onPointerDown={(event) => onTrackDown(event, axis)}\n >\n <div\n ref={(node) => {\n thumbs.current[axis] = node;\n }}\n data-slot=\"scroll-area-thumb\"\n onPointerDown={(event) => onThumbDown(event, axis)}\n onPointerMove={onThumbMove}\n onPointerUp={onThumbUp}\n onPointerCancel={onThumbUp}\n />\n </div>\n );\n\n const fadeClass =\n orientation === \"vertical\"\n ? \"scroll-fade-y\"\n : orientation === \"horizontal\"\n ? \"scroll-fade-x\"\n : \"scroll-fade-both\";\n\n return (\n <div\n {...props}\n ref={root}\n data-slot=\"scroll-area-root\"\n data-orientation={orientation}\n data-scrollbars={scrollbars}\n className={cn(scrollAreaRootVariants({ framed }), className)}\n >\n <div\n ref={viewportRef}\n // tabIndex 0 unconditionally: whether the content overflows is a\n // runtime fact that changes as the content does, and a region that\n // becomes scrollable after a fetch would otherwise never gain focus.\n tabIndex={0}\n role=\"region\"\n aria-label={label}\n data-slot=\"scroll-area\"\n data-fade={fade > 0 ? \"\" : undefined}\n onScroll={(event) => {\n sync();\n wake();\n onScroll?.(event);\n }}\n className={cn(\n cnAll(scrollAreaVariants({ orientation }), fade > 0 && fadeClass),\n viewportClassName,\n )}\n >\n {children}\n </div>\n {vertical ? bar(\"y\") : null}\n {horizontal ? bar(\"x\") : null}\n </div>\n );\n});\n"],"mappings":"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"}
@@ -3,8 +3,28 @@ import { InputProps } from "react-aria-components";
3
3
  export interface SearchFieldProps extends Omit<InputProps, "className" | "style" | "size" | "type" | "value" | "defaultValue" | "onChange"> {
4
4
  /** Extra classes for the input. Merged last, so they win conflicts with ours. */
5
5
  className?: string;
6
- /** The visible label, tied to the input with `htmlFor`. */
6
+ /**
7
+ * What the field searches, and its accessible name. Visible above the box,
8
+ * tied to the input with `htmlFor`, unless `labelHidden` is set.
9
+ */
7
10
  label: string;
11
+ /**
12
+ * Hide the label and keep it as the input's accessible name (`aria-label`),
13
+ * for a toolbar search whose purpose the surrounding UI already shows.
14
+ * @default false
15
+ */
16
+ labelHidden?: boolean;
17
+ /**
18
+ * Control scale of the box. Height and inline padding come from the same
19
+ * `--density-control-*` tokens as Input, Select and Button; `sm` also
20
+ * takes Input's 14px text (16px on a touch screen).
21
+ *
22
+ * This REPLACES the native `size` attribute, which sets a width in
23
+ * characters that the flex layout ignores.
24
+ *
25
+ * @default "md"
26
+ */
27
+ size?: "sm" | "md" | "lg";
8
28
  /** The current text. Controlled: the field has no state of its own. */
9
29
  value: string;
10
30
  /** Called with the new text on every edit, and with `""` when the clear button is pressed. */
@@ -20,6 +40,7 @@ export interface SearchFieldProps extends Omit<InputProps, "className" | "style"
20
40
  *
21
41
  * @example
22
42
  * <SearchField label="Search contacts" value={query} onValueChange={setQuery} />
43
+ * <SearchField label="Search tickets" labelHidden size="sm" value={query} onValueChange={setQuery} />
23
44
  */
24
45
  declare const SearchField$1: import("react").ForwardRefExoticComponent<SearchFieldProps & import("react").RefAttributes<HTMLInputElement>>;
25
46
  //#endregion
@@ -2,8 +2,9 @@
2
2
  import { cn } from "../lib/cn.js";
3
3
  import { mergeRefs } from "../lib/merge-refs.js";
4
4
  import { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect } from "../lib/motion.js";
5
- import { fieldSurfaceVariants } from "../lib/field-surface.js";
5
+ import { fieldBoxSize, fieldSurfaceVariants, fieldTextSize } from "../lib/field-surface.js";
6
6
  import { FieldShell, useFieldWiring } from "../lib/field-shell.js";
7
+ import { cva } from "class-variance-authority";
7
8
  import { forwardRef, useCallback, useMemo, useRef, useState } from "react";
8
9
  import { Button, Group, Input } from "react-aria-components";
9
10
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -13,9 +14,21 @@ import { jsx, jsxs } from "react/jsx-runtime";
13
14
  * PasswordField for why a Group, and why `data-focus-within` darkens the border
14
15
  * as well). `group/shell` lets the glass read the box's focus and filled state.
15
16
  */
16
- const shellClass = cn(fieldSurfaceVariants(), "group/shell flex items-center gap-(--density-control-gap) h-(--density-control-h-md) px-(--density-control-pad-md) [&:where([data-focus-within])]:border-border-strong");
17
+ const shellVariants = cva("group/shell flex items-center gap-(--density-control-gap) [&:where([data-focus-within])]:border-border-strong", {
18
+ variants: {
19
+ /**
20
+ * Height and inline padding from the control scale, the same three rungs
21
+ * and tokens as Input, Select and Button, so a toolbar search sits level
22
+ * with the buttons beside it.
23
+ */
24
+ size: fieldBoxSize },
25
+ defaultVariants: { size: "md" }
26
+ });
17
27
  /** Borderless, fill-less input; the browser's own clear button and search decoration are hidden because the field has its own. */
18
- const inputClass = "min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 font-sans text-base text-foreground outline-none placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground data-[disabled]:cursor-not-allowed [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden";
28
+ const inputVariants = cva("min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 font-sans text-foreground outline-none placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground data-[disabled]:cursor-not-allowed [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden", {
29
+ variants: { size: fieldTextSize },
30
+ defaultVariants: { size: "md" }
31
+ });
19
32
  const clearClass = "motion-safe:button-motion grid size-6 cursor-pointer place-items-center rounded-control border-0 bg-transparent p-0 text-muted-foreground outline-none data-[hovered]:bg-muted data-[hovered]:text-foreground motion-safe:data-[pressed]:scale-96 data-[focus-visible]:outline-solid data-[focus-visible]:outline-(length:--focus-ring-width) data-[focus-visible]:outline-offset-(--focus-ring-offset) data-[focus-visible]:outline-(--color-ring) data-[disabled]:cursor-not-allowed";
20
33
  /** The glass wakes up with the field: muted at rest, full contrast while the field has focus or a value. */
21
34
  function SearchGlyph() {
@@ -93,8 +106,9 @@ function ClearButton({ show, label, disabled, onClear }) {
93
106
  *
94
107
  * @example
95
108
  * <SearchField label="Search contacts" value={query} onValueChange={setQuery} />
109
+ * <SearchField label="Search tickets" labelHidden size="sm" value={query} onValueChange={setQuery} />
96
110
  */
97
- const SearchField$1 = forwardRef(function SearchField({ label, value, onValueChange, clearLabel = "Clear search", className, ...props }, ref) {
111
+ const SearchField$1 = forwardRef(function SearchField({ label, labelHidden = false, size = "md", value, onValueChange, clearLabel = "Clear search", className, ...props }, ref) {
98
112
  const inputRef = useRef(null);
99
113
  const refs = useMemo(() => mergeRefs(ref, inputRef), [ref]);
100
114
  const field = useFieldWiring({
@@ -111,18 +125,19 @@ const SearchField$1 = forwardRef(function SearchField({ label, value, onValueCha
111
125
  isDisabled: disabled,
112
126
  "data-filled": value ? "true" : void 0,
113
127
  "data-slot": "search-field-shell",
114
- className: shellClass,
128
+ className: cn(fieldSurfaceVariants(), shellVariants({ size })),
115
129
  children: [
116
130
  /* @__PURE__ */ jsx(SearchGlyph, {}),
117
131
  /* @__PURE__ */ jsx(Input, {
118
132
  ref: refs,
133
+ "aria-label": labelHidden ? label : void 0,
119
134
  ...props,
120
135
  ...field.controlProps,
121
136
  type: "search",
122
137
  value,
123
138
  onChange: (event) => onValueChange(event.target.value),
124
139
  "data-slot": "search-field",
125
- className: cn(inputClass, className)
140
+ className: cn(inputVariants({ size }), className)
126
141
  }),
127
142
  /* @__PURE__ */ jsx("span", {
128
143
  className: "grid size-6 flex-none place-items-center",
@@ -140,7 +155,7 @@ const SearchField$1 = forwardRef(function SearchField({ label, value, onValueCha
140
155
  });
141
156
  if (!field.active) return control;
142
157
  return /* @__PURE__ */ jsx(FieldShell, {
143
- label,
158
+ label: labelHidden ? null : label,
144
159
  controlId: field.controlId,
145
160
  description: void 0,
146
161
  hintId: void 0,
@@ -1 +1 @@
1
- {"version":3,"file":"search-field.js","names":["AriaButton","SearchField","AriaGroup","AriaInput"],"sources":["../../src/search-field/search-field.tsx"],"sourcesContent":["\"use client\";\n\nimport { forwardRef, useCallback, useMemo, useRef, useState } from \"react\";\nimport {\n Button as AriaButton,\n Group as AriaGroup,\n Input as AriaInput,\n type InputProps as AriaInputProps,\n} from \"react-aria-components\";\n\nimport { cn } from \"../lib/cn.js\";\nimport { FieldShell, useFieldWiring } from \"../lib/field-shell.js\";\nimport { fieldSurfaceVariants } from \"../lib/field-surface.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect } from \"../lib/motion.js\";\n\n/**\n * The field's box: a React Aria `Group` carrying the shared field surface (see\n * PasswordField for why a Group, and why `data-focus-within` darkens the border\n * as well). `group/shell` lets the glass read the box's focus and filled state.\n */\nconst shellClass = cn(\n fieldSurfaceVariants(),\n \"group/shell flex items-center gap-(--density-control-gap) h-(--density-control-h-md) px-(--density-control-pad-md) [&:where([data-focus-within])]:border-border-strong\",\n);\n\n/** Borderless, fill-less input; the browser's own clear button and search decoration are hidden because the field has its own. */\nconst inputClass =\n \"min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 font-sans text-base text-foreground outline-none placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground data-[disabled]:cursor-not-allowed [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden\";\n\nconst clearClass =\n \"motion-safe:button-motion grid size-6 cursor-pointer place-items-center rounded-control border-0 bg-transparent p-0 text-muted-foreground outline-none data-[hovered]:bg-muted data-[hovered]:text-foreground motion-safe:data-[pressed]:scale-96 data-[focus-visible]:outline-solid data-[focus-visible]:outline-(length:--focus-ring-width) data-[focus-visible]:outline-offset-(--focus-ring-offset) data-[focus-visible]:outline-(--color-ring) data-[disabled]:cursor-not-allowed\";\n\n/** The glass wakes up with the field: muted at rest, full contrast while the field has focus or a value. */\nfunction SearchGlyph() {\n return (\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n className=\"size-4.5 flex-none text-muted-foreground motion-safe:transition-colors motion-safe:duration-(--duration-quick) motion-safe:ease-settle group-data-[filled]/shell:text-foreground group-data-[focus-within]/shell:text-foreground\"\n >\n <circle cx=\"11\" cy=\"11\" r=\"8\" />\n <path d=\"m21 21-4.34-4.34\" />\n </svg>\n );\n}\n\n/**\n * The clear button, in a box that is always there so the field never changes\n * width. It scales in on the snappy spring while it fades and unblurs, and\n * leaves a little smaller. Nothing animates if it is there on first mount, and\n * while it leaves it is inert so it cannot be pressed or focused.\n */\nfunction ClearButton({\n show,\n label,\n disabled,\n onClear,\n}: {\n show: boolean;\n label: string;\n disabled: boolean;\n onClear: () => void;\n}) {\n const [initial] = useState(show);\n const [rendered, setRendered] = useState(show);\n const [animated, setAnimated] = useState(false);\n const leaving = rendered && !show;\n if (show && !rendered) setRendered(true);\n if (!animated && show !== initial) setAnimated(true);\n const unmount = useCallback(() => setRendered(false), []);\n useExitBackstop(leaving, unmount);\n // Reduced motion removes it at once: an animation that is switched off never ends.\n useIsomorphicLayoutEffect(() => {\n if (leaving && prefersReducedMotion()) unmount();\n }, [leaving, unmount]);\n if (!rendered) return null;\n return (\n <span\n ref={(node) => {\n if (!node) return;\n if (leaving) node.setAttribute(\"inert\", \"\");\n else node.removeAttribute(\"inert\");\n }}\n aria-hidden={leaving || undefined}\n onAnimationEnd={(event) => {\n if (\n leaving &&\n event.target === event.currentTarget &&\n event.animationName === \"field-clear-out\"\n )\n unmount();\n }}\n className={cn(\n \"grid size-6 place-items-center\",\n leaving\n ? \"pointer-events-none motion-safe:animate-field-clear-out\"\n : animated && \"motion-safe:animate-field-clear-in motion-reduce:animate-fade-in\",\n )}\n >\n <AriaButton\n type=\"button\"\n isDisabled={disabled}\n aria-label={label}\n onPress={onClear}\n className={clearClass}\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n className=\"size-4\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </AriaButton>\n </span>\n );\n}\n\nexport interface SearchFieldProps extends Omit<\n AriaInputProps,\n \"className\" | \"style\" | \"size\" | \"type\" | \"value\" | \"defaultValue\" | \"onChange\"\n> {\n /** Extra classes for the input. Merged last, so they win conflicts with ours. */\n className?: string;\n /** The visible label, tied to the input with `htmlFor`. */\n label: string;\n /** The current text. Controlled: the field has no state of its own. */\n value: string;\n /** Called with the new text on every edit, and with `\"\"` when the clear button is pressed. */\n onValueChange: (value: string) => void;\n /** The clear button's accessible name. @default \"Clear search\" */\n clearLabel?: string;\n}\n\n/**\n * A search input with a clear button. The button has a reserved box, so the\n * field never changes width as it comes and goes; pressing it clears the text\n * and returns focus to the input. `onChange` is not accepted: use `onValueChange`.\n * Native input props apply to the input, and `disabled` also disables the button.\n *\n * @example\n * <SearchField label=\"Search contacts\" value={query} onValueChange={setQuery} />\n */\nexport const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(\n { label, value, onValueChange, clearLabel = \"Clear search\", className, ...props },\n ref,\n) {\n const inputRef = useRef<HTMLInputElement | null>(null);\n const refs = useMemo(() => mergeRefs(ref, inputRef), [ref]);\n const field = useFieldWiring({\n enabled: true,\n id: props.id,\n describedBy: props[\"aria-describedby\"],\n invalid: props[\"aria-invalid\"],\n description: undefined,\n error: undefined,\n });\n const disabled = Boolean(props.disabled);\n\n const control = (\n <AriaGroup\n role=\"presentation\"\n isDisabled={disabled}\n data-filled={value ? \"true\" : undefined}\n data-slot=\"search-field-shell\"\n className={shellClass}\n >\n <SearchGlyph />\n <AriaInput\n ref={refs}\n {...props}\n {...field.controlProps}\n type=\"search\"\n value={value}\n onChange={(event) => onValueChange(event.target.value)}\n data-slot=\"search-field\"\n className={cn(inputClass, className)}\n />\n {/* A clear button's box is reserved, so the field never changes width when it appears or leaves. */}\n <span className=\"grid size-6 flex-none place-items-center\">\n <ClearButton\n show={value !== \"\"}\n label={clearLabel}\n disabled={disabled}\n onClear={() => {\n onValueChange(\"\");\n inputRef.current?.focus();\n }}\n />\n </span>\n </AriaGroup>\n );\n\n if (!field.active) return control;\n return (\n <FieldShell\n label={label}\n controlId={field.controlId}\n description={undefined}\n hintId={undefined}\n error={undefined}\n errorId={undefined}\n >\n {control}\n </FieldShell>\n );\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"search-field.js","names":["AriaButton","SearchField","AriaGroup","AriaInput"],"sources":["../../src/search-field/search-field.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useCallback, useMemo, useRef, useState } from \"react\";\nimport {\n Button as AriaButton,\n Group as AriaGroup,\n Input as AriaInput,\n type InputProps as AriaInputProps,\n} from \"react-aria-components\";\n\nimport { cn } from \"../lib/cn.js\";\nimport { FieldShell, useFieldWiring } from \"../lib/field-shell.js\";\nimport { fieldBoxSize, fieldSurfaceVariants, fieldTextSize } from \"../lib/field-surface.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect } from \"../lib/motion.js\";\n\n/**\n * The field's box: a React Aria `Group` carrying the shared field surface (see\n * PasswordField for why a Group, and why `data-focus-within` darkens the border\n * as well). `group/shell` lets the glass read the box's focus and filled state.\n */\nconst shellVariants = cva(\n \"group/shell flex items-center gap-(--density-control-gap) [&:where([data-focus-within])]:border-border-strong\",\n {\n variants: {\n /**\n * Height and inline padding from the control scale, the same three rungs\n * and tokens as Input, Select and Button, so a toolbar search sits level\n * with the buttons beside it.\n */\n size: fieldBoxSize,\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n/** Borderless, fill-less input; the browser's own clear button and search decoration are hidden because the field has its own. */\nconst inputVariants = cva(\n \"min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 font-sans text-foreground outline-none placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground data-[disabled]:cursor-not-allowed [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden\",\n {\n variants: {\n // Input's type rule: `sm` is 14px, except on a coarse pointer, where iOS would zoom a focused field under 16px.\n size: fieldTextSize,\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\nconst clearClass =\n \"motion-safe:button-motion grid size-6 cursor-pointer place-items-center rounded-control border-0 bg-transparent p-0 text-muted-foreground outline-none data-[hovered]:bg-muted data-[hovered]:text-foreground motion-safe:data-[pressed]:scale-96 data-[focus-visible]:outline-solid data-[focus-visible]:outline-(length:--focus-ring-width) data-[focus-visible]:outline-offset-(--focus-ring-offset) data-[focus-visible]:outline-(--color-ring) data-[disabled]:cursor-not-allowed\";\n\n/** The glass wakes up with the field: muted at rest, full contrast while the field has focus or a value. */\nfunction SearchGlyph() {\n return (\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n className=\"size-4.5 flex-none text-muted-foreground motion-safe:transition-colors motion-safe:duration-(--duration-quick) motion-safe:ease-settle group-data-[filled]/shell:text-foreground group-data-[focus-within]/shell:text-foreground\"\n >\n <circle cx=\"11\" cy=\"11\" r=\"8\" />\n <path d=\"m21 21-4.34-4.34\" />\n </svg>\n );\n}\n\n/**\n * The clear button, in a box that is always there so the field never changes\n * width. It scales in on the snappy spring while it fades and unblurs, and\n * leaves a little smaller. Nothing animates if it is there on first mount, and\n * while it leaves it is inert so it cannot be pressed or focused.\n */\nfunction ClearButton({\n show,\n label,\n disabled,\n onClear,\n}: {\n show: boolean;\n label: string;\n disabled: boolean;\n onClear: () => void;\n}) {\n const [initial] = useState(show);\n const [rendered, setRendered] = useState(show);\n const [animated, setAnimated] = useState(false);\n const leaving = rendered && !show;\n if (show && !rendered) setRendered(true);\n if (!animated && show !== initial) setAnimated(true);\n const unmount = useCallback(() => setRendered(false), []);\n useExitBackstop(leaving, unmount);\n // Reduced motion removes it at once: an animation that is switched off never ends.\n useIsomorphicLayoutEffect(() => {\n if (leaving && prefersReducedMotion()) unmount();\n }, [leaving, unmount]);\n if (!rendered) return null;\n return (\n <span\n ref={(node) => {\n if (!node) return;\n if (leaving) node.setAttribute(\"inert\", \"\");\n else node.removeAttribute(\"inert\");\n }}\n aria-hidden={leaving || undefined}\n onAnimationEnd={(event) => {\n if (\n leaving &&\n event.target === event.currentTarget &&\n event.animationName === \"field-clear-out\"\n )\n unmount();\n }}\n className={cn(\n \"grid size-6 place-items-center\",\n leaving\n ? \"pointer-events-none motion-safe:animate-field-clear-out\"\n : animated && \"motion-safe:animate-field-clear-in motion-reduce:animate-fade-in\",\n )}\n >\n <AriaButton\n type=\"button\"\n isDisabled={disabled}\n aria-label={label}\n onPress={onClear}\n className={clearClass}\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n className=\"size-4\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </AriaButton>\n </span>\n );\n}\n\nexport interface SearchFieldProps extends Omit<\n AriaInputProps,\n \"className\" | \"style\" | \"size\" | \"type\" | \"value\" | \"defaultValue\" | \"onChange\"\n> {\n /** Extra classes for the input. Merged last, so they win conflicts with ours. */\n className?: string;\n /**\n * What the field searches, and its accessible name. Visible above the box,\n * tied to the input with `htmlFor`, unless `labelHidden` is set.\n */\n label: string;\n /**\n * Hide the label and keep it as the input's accessible name (`aria-label`),\n * for a toolbar search whose purpose the surrounding UI already shows.\n * @default false\n */\n labelHidden?: boolean;\n /**\n * Control scale of the box. Height and inline padding come from the same\n * `--density-control-*` tokens as Input, Select and Button; `sm` also\n * takes Input's 14px text (16px on a touch screen).\n *\n * This REPLACES the native `size` attribute, which sets a width in\n * characters that the flex layout ignores.\n *\n * @default \"md\"\n */\n size?: \"sm\" | \"md\" | \"lg\";\n /** The current text. Controlled: the field has no state of its own. */\n value: string;\n /** Called with the new text on every edit, and with `\"\"` when the clear button is pressed. */\n onValueChange: (value: string) => void;\n /** The clear button's accessible name. @default \"Clear search\" */\n clearLabel?: string;\n}\n\n/**\n * A search input with a clear button. The button has a reserved box, so the\n * field never changes width as it comes and goes; pressing it clears the text\n * and returns focus to the input. `onChange` is not accepted: use `onValueChange`.\n * Native input props apply to the input, and `disabled` also disables the button.\n *\n * @example\n * <SearchField label=\"Search contacts\" value={query} onValueChange={setQuery} />\n * <SearchField label=\"Search tickets\" labelHidden size=\"sm\" value={query} onValueChange={setQuery} />\n */\nexport const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(\n {\n label,\n labelHidden = false,\n size = \"md\",\n value,\n onValueChange,\n clearLabel = \"Clear search\",\n className,\n ...props\n },\n ref,\n) {\n const inputRef = useRef<HTMLInputElement | null>(null);\n const refs = useMemo(() => mergeRefs(ref, inputRef), [ref]);\n const field = useFieldWiring({\n enabled: true,\n id: props.id,\n describedBy: props[\"aria-describedby\"],\n invalid: props[\"aria-invalid\"],\n description: undefined,\n error: undefined,\n });\n const disabled = Boolean(props.disabled);\n\n const control = (\n <AriaGroup\n role=\"presentation\"\n isDisabled={disabled}\n data-filled={value ? \"true\" : undefined}\n data-slot=\"search-field-shell\"\n className={cn(fieldSurfaceVariants(), shellVariants({ size }))}\n >\n <SearchGlyph />\n <AriaInput\n ref={refs}\n // A hidden label is still the name, inside a TextField too (one with no Label child has no other name).\n // Before the spread, so an explicit aria-label wins as it does over a visible one.\n aria-label={labelHidden ? label : undefined}\n {...props}\n {...field.controlProps}\n type=\"search\"\n value={value}\n onChange={(event) => onValueChange(event.target.value)}\n data-slot=\"search-field\"\n className={cn(inputVariants({ size }), className)}\n />\n {/* A clear button's box is reserved, so the field never changes width when it appears or leaves. */}\n <span className=\"grid size-6 flex-none place-items-center\">\n <ClearButton\n show={value !== \"\"}\n label={clearLabel}\n disabled={disabled}\n onClear={() => {\n onValueChange(\"\");\n inputRef.current?.focus();\n }}\n />\n </span>\n </AriaGroup>\n );\n\n if (!field.active) return control;\n return (\n <FieldShell\n label={labelHidden ? null : label}\n controlId={field.controlId}\n description={undefined}\n hintId={undefined}\n error={undefined}\n errorId={undefined}\n >\n {control}\n </FieldShell>\n );\n});\n"],"mappings":"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"}
@@ -30,7 +30,7 @@ export declare const selectTriggerVariants: (props?: ({
30
30
  size?: "sm" | "md" | "lg" | null | undefined;
31
31
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
32
32
  export declare const selectPopoverVariants: (props?: ({
33
- reveal?: "grow" | "fade" | null | undefined;
33
+ reveal?: "fade" | "grow" | null | undefined;
34
34
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
35
35
  export declare const selectItemVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
36
36
  /**
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { cn, cnAll } from "../lib/cn.js";
3
3
  import { useIsomorphicLayoutEffect } from "../lib/motion.js";
4
- import { fieldSurfaceVariants } from "../lib/field-surface.js";
4
+ import { fieldBoxSize, fieldSurfaceVariants, fieldTextSize } from "../lib/field-surface.js";
5
5
  import { labelVariants } from "../label/label.js";
6
6
  import { RollingValue } from "../lib/rolling-value.js";
7
7
  import { cva } from "class-variance-authority";
@@ -34,6 +34,8 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
34
34
  * not `accent`, for the same reason.
35
35
  */
36
36
  const selectVariants = cva(["group flex w-full flex-col gap-1.5 box-border font-sans"]);
37
+ /** The error line under a Select-based control (Select, TimePicker): one class, so they cannot drift. */
38
+ const selectErrorClass = "text-sm leading-(--leading-normal) text-destructive-strong";
37
39
  const selectTriggerVariants = cva([
38
40
  "group-data-[invalid]:border-destructive-strong",
39
41
  fieldSurfaceVariants(),
@@ -61,9 +63,9 @@ const selectTriggerVariants = cva([
61
63
  * equal to the field beside it.
62
64
  */
63
65
  size: {
64
- sm: "h-(--density-control-h-sm) px-(--density-control-pad-sm) text-sm pointer-coarse:text-base",
65
- md: "h-(--density-control-h-md) px-(--density-control-pad-md) text-base",
66
- lg: "h-(--density-control-h-lg) px-(--density-control-pad-lg) text-base"
66
+ sm: [fieldBoxSize.sm, fieldTextSize.sm],
67
+ md: [fieldBoxSize.md, fieldTextSize.md],
68
+ lg: [fieldBoxSize.lg, fieldTextSize.lg]
67
69
  } },
68
70
  defaultVariants: { size: "md" }
69
71
  });
@@ -180,7 +182,7 @@ const Select$1 = forwardRef(function Select$2({ children, label, labelHidden, pl
180
182
  }),
181
183
  errorMessage == null ? null : /* @__PURE__ */ jsx(FieldError, {
182
184
  "data-slot": "select-error",
183
- className: "text-sm leading-(--leading-normal) text-destructive-strong",
185
+ className: selectErrorClass,
184
186
  children: errorMessage
185
187
  }),
186
188
  /* @__PURE__ */ jsx(Popover, {
@@ -217,6 +219,6 @@ const SelectItem = forwardRef(function SelectItem({ className, children, textVal
217
219
  });
218
220
  });
219
221
  //#endregion
220
- export { Select$1 as Select, SelectItem, selectItemVariants, selectPopoverVariants, selectTriggerVariants, selectVariants };
222
+ export { Select$1 as Select, SelectItem, selectErrorClass, selectItemVariants, selectPopoverVariants, selectTriggerVariants, selectVariants };
221
223
 
222
224
  //# sourceMappingURL=select.js.map