@tea-ui/core 0.1.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -0
  3. package/dist/card.d.ts +60 -0
  4. package/dist/card.js +116 -0
  5. package/dist/card.js.map +1 -0
  6. package/dist/feedback/alert.d.ts +57 -0
  7. package/dist/feedback/alert.js +104 -0
  8. package/dist/feedback/alert.js.map +1 -0
  9. package/dist/feedback/badge.d.ts +34 -0
  10. package/dist/feedback/badge.js +62 -0
  11. package/dist/feedback/badge.js.map +1 -0
  12. package/dist/feedback/index.d.ts +13 -0
  13. package/dist/feedback/index.js +62 -0
  14. package/dist/feedback/index.js.map +1 -0
  15. package/dist/feedback/progress.d.ts +25 -0
  16. package/dist/feedback/progress.js +49 -0
  17. package/dist/feedback/progress.js.map +1 -0
  18. package/dist/feedback/skeleton.d.ts +38 -0
  19. package/dist/feedback/skeleton.js +49 -0
  20. package/dist/feedback/skeleton.js.map +1 -0
  21. package/dist/feedback/spinner.d.ts +31 -0
  22. package/dist/feedback/spinner.js +55 -0
  23. package/dist/feedback/spinner.js.map +1 -0
  24. package/dist/feedback/status.d.ts +63 -0
  25. package/dist/feedback/status.js +156 -0
  26. package/dist/feedback/status.js.map +1 -0
  27. package/dist/feedback/toast.d.ts +72 -0
  28. package/dist/feedback/toast.js +211 -0
  29. package/dist/feedback/toast.js.map +1 -0
  30. package/dist/format/index.d.ts +63 -0
  31. package/dist/format/index.js +84 -0
  32. package/dist/format/index.js.map +1 -0
  33. package/dist/index.d.ts +43 -0
  34. package/dist/index.js +163 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/inputs/button-group.d.ts +70 -0
  37. package/dist/inputs/button-group.js +59 -0
  38. package/dist/inputs/button-group.js.map +1 -0
  39. package/dist/inputs/button.d.ts +52 -0
  40. package/dist/inputs/button.js +84 -0
  41. package/dist/inputs/button.js.map +1 -0
  42. package/dist/inputs/checkbox.d.ts +48 -0
  43. package/dist/inputs/checkbox.js +74 -0
  44. package/dist/inputs/checkbox.js.map +1 -0
  45. package/dist/inputs/combobox.d.ts +77 -0
  46. package/dist/inputs/combobox.js +273 -0
  47. package/dist/inputs/combobox.js.map +1 -0
  48. package/dist/inputs/field.d.ts +220 -0
  49. package/dist/inputs/field.js +183 -0
  50. package/dist/inputs/field.js.map +1 -0
  51. package/dist/inputs/icon-button.d.ts +44 -0
  52. package/dist/inputs/icon-button.js +30 -0
  53. package/dist/inputs/icon-button.js.map +1 -0
  54. package/dist/inputs/index.d.ts +22 -0
  55. package/dist/inputs/index.js +92 -0
  56. package/dist/inputs/index.js.map +1 -0
  57. package/dist/inputs/input-group.d.ts +77 -0
  58. package/dist/inputs/input-group.js +76 -0
  59. package/dist/inputs/input-group.js.map +1 -0
  60. package/dist/inputs/input.d.ts +58 -0
  61. package/dist/inputs/input.js +68 -0
  62. package/dist/inputs/input.js.map +1 -0
  63. package/dist/inputs/label.d.ts +47 -0
  64. package/dist/inputs/label.js +29 -0
  65. package/dist/inputs/label.js.map +1 -0
  66. package/dist/inputs/number-input.d.ts +62 -0
  67. package/dist/inputs/number-input.js +86 -0
  68. package/dist/inputs/number-input.js.map +1 -0
  69. package/dist/inputs/password-input.d.ts +66 -0
  70. package/dist/inputs/password-input.js +73 -0
  71. package/dist/inputs/password-input.js.map +1 -0
  72. package/dist/inputs/radio.d.ts +66 -0
  73. package/dist/inputs/radio.js +96 -0
  74. package/dist/inputs/radio.js.map +1 -0
  75. package/dist/inputs/search-input.d.ts +66 -0
  76. package/dist/inputs/search-input.js +76 -0
  77. package/dist/inputs/search-input.js.map +1 -0
  78. package/dist/inputs/select.d.ts +65 -0
  79. package/dist/inputs/select.js +139 -0
  80. package/dist/inputs/select.js.map +1 -0
  81. package/dist/inputs/slider.d.ts +67 -0
  82. package/dist/inputs/slider.js +107 -0
  83. package/dist/inputs/slider.js.map +1 -0
  84. package/dist/inputs/switch.d.ts +42 -0
  85. package/dist/inputs/switch.js +69 -0
  86. package/dist/inputs/switch.js.map +1 -0
  87. package/dist/inputs/textarea.d.ts +51 -0
  88. package/dist/inputs/textarea.js +82 -0
  89. package/dist/inputs/textarea.js.map +1 -0
  90. package/dist/inputs/toggle.d.ts +57 -0
  91. package/dist/inputs/toggle.js +91 -0
  92. package/dist/inputs/toggle.js.map +1 -0
  93. package/dist/internal/index.d.ts +5 -0
  94. package/dist/internal/index.js +19 -0
  95. package/dist/internal/index.js.map +1 -0
  96. package/dist/internal/refs.d.ts +31 -0
  97. package/dist/internal/refs.js +34 -0
  98. package/dist/internal/refs.js.map +1 -0
  99. package/dist/internal/slot.d.ts +76 -0
  100. package/dist/internal/slot.js +40 -0
  101. package/dist/internal/slot.js.map +1 -0
  102. package/dist/internal/use-controllable-state.d.ts +25 -0
  103. package/dist/internal/use-controllable-state.js +38 -0
  104. package/dist/internal/use-controllable-state.js.map +1 -0
  105. package/dist/layout/index.d.ts +131 -0
  106. package/dist/layout/index.js +212 -0
  107. package/dist/layout/index.js.map +1 -0
  108. package/dist/navigation/index.d.ts +114 -0
  109. package/dist/navigation/index.js +523 -0
  110. package/dist/navigation/index.js.map +1 -0
  111. package/dist/overlays/alert-dialog.d.ts +75 -0
  112. package/dist/overlays/alert-dialog.js +163 -0
  113. package/dist/overlays/alert-dialog.js.map +1 -0
  114. package/dist/overlays/dialog.d.ts +44 -0
  115. package/dist/overlays/dialog.js +121 -0
  116. package/dist/overlays/dialog.js.map +1 -0
  117. package/dist/overlays/drawer.d.ts +49 -0
  118. package/dist/overlays/drawer.js +125 -0
  119. package/dist/overlays/drawer.js.map +1 -0
  120. package/dist/overlays/dropdown-menu.d.ts +53 -0
  121. package/dist/overlays/dropdown-menu.js +192 -0
  122. package/dist/overlays/dropdown-menu.js.map +1 -0
  123. package/dist/overlays/index.d.ts +10 -0
  124. package/dist/overlays/index.js +169 -0
  125. package/dist/overlays/index.js.map +1 -0
  126. package/dist/overlays/popover.d.ts +58 -0
  127. package/dist/overlays/popover.js +105 -0
  128. package/dist/overlays/popover.js.map +1 -0
  129. package/dist/styles.css +2 -0
  130. package/dist/typography/index.d.ts +79 -0
  131. package/dist/typography/index.js +280 -0
  132. package/dist/typography/index.js.map +1 -0
  133. package/package.json +60 -0
@@ -0,0 +1,273 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Check, ChevronDown, Search, X } from "@tea-ui/icons";
4
+ import { cn, cva } from "@tea-ui/utils";
5
+ import { dataSlot, stateAttributes } from "../internal/index.js";
6
+ import { useFieldControlProps } from "./field.js";
7
+ const comboboxTriggerVariants = cva(
8
+ [
9
+ "control-h inline-flex w-full items-center gap-2 border bg-surface px-[length:var(--tea-control-px)] text-ui text-fg",
10
+ "transition-colors duration-fast ease-standard",
11
+ "disabled:cursor-not-allowed disabled:opacity-50",
12
+ "aria-invalid:border-critical"
13
+ ],
14
+ {
15
+ variants: {
16
+ variant: {
17
+ outline: "border-line data-[hover]:border-line-strong",
18
+ ghost: "border-transparent bg-transparent"
19
+ }
20
+ },
21
+ defaultVariants: { variant: "outline" }
22
+ }
23
+ );
24
+ const DEFAULT_FILTER = (option, query) => {
25
+ const needle = query.trim().toLocaleLowerCase("de");
26
+ if (!needle) return true;
27
+ return option.label.toLocaleLowerCase("de").includes(needle);
28
+ };
29
+ const Combobox = React.forwardRef(function Combobox2({
30
+ label,
31
+ options,
32
+ value,
33
+ defaultValue,
34
+ onValueChange,
35
+ onQueryChange,
36
+ filter = DEFAULT_FILTER,
37
+ emptyMessage = "Keine Treffer",
38
+ loading = false,
39
+ disabled = false,
40
+ multiple = false,
41
+ variant,
42
+ className,
43
+ ...props
44
+ }, ref) {
45
+ const field = useFieldControlProps();
46
+ const stem = `tea-combobox-${React.useId().replace(/:/g, "")}`;
47
+ const listboxId = `${stem}-listbox`;
48
+ const [query, setQuery] = React.useState("");
49
+ const [open, setOpen] = React.useState(false);
50
+ const [activeIndex, setActiveIndex] = React.useState(-1);
51
+ const [selected, setSelected] = React.useState(
52
+ () => value !== void 0 ? [value] : defaultValue !== void 0 ? [defaultValue] : []
53
+ );
54
+ const controlledSelection = value === void 0 ? void 0 : value === "" ? [] : [value];
55
+ if (controlledSelection && !sameSelection(controlledSelection, selected)) {
56
+ setSelected(controlledSelection);
57
+ }
58
+ const isDisabled = field.disabled ?? disabled;
59
+ const isInvalid = field["aria-invalid"] ?? false;
60
+ const filtered = React.useMemo(
61
+ () => query.trim() ? options.filter((option) => filter(option, query)) : options,
62
+ [options, query, filter]
63
+ );
64
+ const listRef = React.useRef(null);
65
+ const rootRef = React.useRef(null);
66
+ const commit = React.useCallback(
67
+ (option) => {
68
+ if (option.disabled) return;
69
+ if (multiple) {
70
+ const next = selected.includes(option.value) ? selected.filter((entry) => entry !== option.value) : [...selected, option.value];
71
+ setSelected(next);
72
+ onValueChange?.(next[next.length - 1] ?? "");
73
+ } else {
74
+ setSelected([option.value]);
75
+ onValueChange?.(option.value);
76
+ setQuery("");
77
+ setOpen(false);
78
+ }
79
+ setActiveIndex(-1);
80
+ },
81
+ [multiple, onValueChange, selected]
82
+ );
83
+ const moveActive = React.useCallback(
84
+ (delta) => {
85
+ if (filtered.length === 0) return;
86
+ setActiveIndex((previous) => {
87
+ const next = previous + delta;
88
+ if (next < 0) return filtered.length - 1;
89
+ if (next >= filtered.length) return 0;
90
+ return next;
91
+ });
92
+ },
93
+ [filtered.length]
94
+ );
95
+ React.useEffect(() => {
96
+ if (activeIndex < 0 || !listRef.current) return;
97
+ const node = listRef.current.children[activeIndex];
98
+ node?.scrollIntoView({ block: "nearest" });
99
+ }, [activeIndex]);
100
+ React.useEffect(() => {
101
+ if (!open) return;
102
+ const onPointerDown = (event) => {
103
+ if (!rootRef.current?.contains(event.target)) setOpen(false);
104
+ };
105
+ document.addEventListener("pointerdown", onPointerDown);
106
+ return () => document.removeEventListener("pointerdown", onPointerDown);
107
+ }, [open]);
108
+ const activeId = activeIndex >= 0 ? `${stem}-option-${activeIndex}` : void 0;
109
+ const displayValue = multiple ? selected.map((v) => labelFor(options, v)).join(", ") : labelFor(options, selected[0] ?? "");
110
+ return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: "relative", ...dataSlot("combobox", "root"), children: [
111
+ /* @__PURE__ */ jsxs(
112
+ "div",
113
+ {
114
+ role: "combobox",
115
+ "aria-expanded": open,
116
+ "aria-controls": listboxId,
117
+ "aria-haspopup": "listbox",
118
+ "aria-owns": listboxId,
119
+ className: cn(comboboxTriggerVariants({ variant }), "cursor-text", className),
120
+ "data-open": open || void 0,
121
+ ...stateAttributes({ disabled: isDisabled, loading, invalid: isInvalid }),
122
+ onClick: () => !isDisabled && setOpen(true),
123
+ children: [
124
+ /* @__PURE__ */ jsx(Search, { size: 16, "aria-hidden": "true", className: "shrink-0 text-fg-muted" }),
125
+ /* @__PURE__ */ jsx(
126
+ "input",
127
+ {
128
+ ref,
129
+ id: field.id,
130
+ role: "searchbox",
131
+ type: "text",
132
+ autoComplete: "off",
133
+ className: "min-w-0 flex-1 bg-transparent text-ui text-fg outline-none placeholder:text-fg-subtle",
134
+ placeholder: displayValue || label,
135
+ value: query,
136
+ disabled: isDisabled,
137
+ "aria-label": label,
138
+ "aria-autocomplete": "list",
139
+ "aria-activedescendant": activeId,
140
+ "aria-describedby": field["aria-describedby"],
141
+ "aria-invalid": isInvalid || void 0,
142
+ "aria-busy": loading || void 0,
143
+ onChange: (event) => {
144
+ setQuery(event.target.value);
145
+ onQueryChange?.(event.target.value);
146
+ setOpen(true);
147
+ setActiveIndex(event.target.value ? 0 : -1);
148
+ },
149
+ onFocus: () => setOpen(true),
150
+ onKeyDown: (event) => {
151
+ switch (event.key) {
152
+ case "ArrowDown":
153
+ event.preventDefault();
154
+ if (!open) setOpen(true);
155
+ else moveActive(1);
156
+ break;
157
+ case "ArrowUp":
158
+ event.preventDefault();
159
+ if (!open) setOpen(true);
160
+ else moveActive(-1);
161
+ break;
162
+ case "Home":
163
+ if (open && filtered.length) {
164
+ event.preventDefault();
165
+ setActiveIndex(0);
166
+ }
167
+ break;
168
+ case "End":
169
+ if (open && filtered.length) {
170
+ event.preventDefault();
171
+ setActiveIndex(filtered.length - 1);
172
+ }
173
+ break;
174
+ case "Enter":
175
+ if (open && activeIndex >= 0 && filtered[activeIndex]) {
176
+ event.preventDefault();
177
+ commit(filtered[activeIndex]);
178
+ }
179
+ break;
180
+ case "Escape":
181
+ if (query) {
182
+ event.preventDefault();
183
+ setQuery("");
184
+ setActiveIndex(-1);
185
+ } else {
186
+ setOpen(false);
187
+ }
188
+ break;
189
+ default:
190
+ break;
191
+ }
192
+ },
193
+ ...props
194
+ }
195
+ ),
196
+ loading ? /* @__PURE__ */ jsx("span", { className: "text-fg-muted", "aria-hidden": "true", children: "\u2026" }) : null,
197
+ query ? /* @__PURE__ */ jsx(
198
+ "button",
199
+ {
200
+ type: "button",
201
+ "aria-label": "Suche leeren",
202
+ className: "shrink-0 text-fg-muted hover:text-fg",
203
+ onClick: (event) => {
204
+ event.stopPropagation();
205
+ setQuery("");
206
+ onQueryChange?.("");
207
+ },
208
+ children: /* @__PURE__ */ jsx(X, { size: 14, "aria-hidden": "true" })
209
+ }
210
+ ) : null,
211
+ /* @__PURE__ */ jsx(ChevronDown, { size: 16, "aria-hidden": "true", className: "shrink-0 text-fg-muted" })
212
+ ]
213
+ }
214
+ ),
215
+ open ? /* @__PURE__ */ jsxs(
216
+ "ul",
217
+ {
218
+ ref: listRef,
219
+ id: listboxId,
220
+ role: "listbox",
221
+ "aria-label": label,
222
+ "aria-multiselectable": multiple || void 0,
223
+ className: "absolute z-popover mt-1 max-h-72 w-full overflow-auto border border-line bg-surface-2 p-1 shadow-overlay",
224
+ ...dataSlot("combobox", "listbox"),
225
+ children: [
226
+ filtered.length === 0 ? /* @__PURE__ */ jsx("li", { role: "presentation", className: "px-3 py-2 text-micro text-fg-muted", children: emptyMessage }) : null,
227
+ filtered.map((option, index) => {
228
+ const isSelected = selected.includes(option.value);
229
+ return /* @__PURE__ */ jsxs(
230
+ "li",
231
+ {
232
+ id: `${stem}-option-${index}`,
233
+ role: "option",
234
+ "aria-selected": isSelected,
235
+ "aria-disabled": option.disabled || void 0,
236
+ "data-active": index === activeIndex || void 0,
237
+ className: cn(
238
+ "flex cursor-default select-none flex-col gap-0.5 px-3 py-2 text-ui",
239
+ "data-[active]:bg-accent-subtle",
240
+ "data-[active=true]:bg-accent-subtle",
241
+ option.disabled && "pointer-events-none opacity-50",
242
+ isSelected && "text-primary"
243
+ ),
244
+ onPointerMove: () => setActiveIndex(index),
245
+ onClick: () => commit(option),
246
+ children: [
247
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
248
+ option.label,
249
+ isSelected ? /* @__PURE__ */ jsx(Check, { size: 14, "aria-hidden": "true", className: "shrink-0" }) : null
250
+ ] }),
251
+ option.description ? /* @__PURE__ */ jsx("span", { className: "text-micro text-fg-muted", children: option.description }) : null
252
+ ]
253
+ },
254
+ option.value
255
+ );
256
+ })
257
+ ]
258
+ }
259
+ ) : null
260
+ ] });
261
+ });
262
+ function sameSelection(a, b) {
263
+ return a.length === b.length && a.every((entry, index) => entry === b[index]);
264
+ }
265
+ function labelFor(options, value) {
266
+ if (!value) return "";
267
+ return options.find((option) => option.value === value)?.label ?? value;
268
+ }
269
+ export {
270
+ Combobox,
271
+ comboboxTriggerVariants
272
+ };
273
+ //# sourceMappingURL=combobox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/inputs/combobox.tsx"],"sourcesContent":["import * as React from \"react\";\nimport { Check, ChevronDown, Search, X } from \"@tea-ui/icons\";\nimport { cn, cva, type VariantProps } from \"@tea-ui/utils\";\n\nimport { dataSlot, stateAttributes } from \"../internal\";\nimport { useFieldControlProps } from \"./field\";\n\n/**\n * TEA UI — Combobox.\n *\n * The ARIA 1.2 combobox/listbox pattern, implemented properly. This component\n * exists because the audit's global search had *no* combobox semantics at all:\n * no `role=\"combobox\"`, no `aria-expanded`, no `aria-activedescendant`, no arrow\n * keys, and results that were plain buttons. A screen-reader user and a\n * keyboard-only user both had no way to know a result list existed, let alone\n * move through it.\n *\n * The pattern, in full:\n * - the input is the combobox: `role=\"combobox\"`, `aria-expanded`,\n * `aria-controls` pointing at the list, `aria-activedescendant` pointing at\n * the active option;\n * - the list is `role=\"listbox\"`, the options are `role=\"option\"` with\n * `aria-selected`;\n * - ArrowDown/ArrowUp move the active option and scroll it into view,\n * Home/End jump, Enter selects, Escape reverts the query first and closes\n * second;\n * - the active option is tracked with `aria-activedescendant`, never by moving\n * DOM focus, so the query text is not destroyed on every keystroke.\n *\n * Options are supplied, not fetched. The component takes `options` and an\n * optional `filter`; async resolution belongs to the product, which is why\n * `loading` and `emptyMessage` are props rather than a data source.\n */\n\nexport interface ComboboxOption {\n /** The value emitted on selection. */\n readonly value: string;\n /** The visible label. */\n readonly label: string;\n /** Optional secondary text. Never the only carrier of meaning. */\n readonly description?: string | undefined;\n /** Group heading. Rendered as a `role=\"presentation\"` separator row. */\n readonly group?: string | undefined;\n /** Disabled options stay visible and stay focusable, but cannot be chosen. */\n readonly disabled?: boolean | undefined;\n}\n\nexport interface ComboboxProps\n extends Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\" | \"onSelect\" | \"size\">,\n Omit<VariantProps<typeof comboboxTriggerVariants>, \"size\"> {\n /** The accessible name. Required — the search icon is not a label. */\n label: string;\n options: readonly ComboboxOption[];\n /** The controlled value. */\n value?: string | undefined;\n /** The uncontrolled initial value. */\n defaultValue?: string | undefined;\n /** Called with the emitted `value` of the chosen option. */\n onValueChange?: ((value: string) => undefined | void) | undefined;\n /** Called on every keystroke, with the raw query. */\n onQueryChange?: ((query: string) => void) | undefined;\n /**\n * Filtering. Defaults to a case- and diacritic-insensitive substring match\n * over the label, so a product that needs fuzzy search opts in rather than\n * getting it by accident.\n */\n filter?: ((option: ComboboxOption, query: string) => boolean) | undefined;\n /** Shown when the query matches nothing. */\n emptyMessage?: string | undefined;\n /** A search is running. Shows a spinner and sets `aria-busy`. */\n loading?: boolean | undefined;\n /** Disable the whole control. */\n disabled?: boolean | undefined;\n /** Allow choosing more than one option. */\n multiple?: boolean | undefined;\n className?: string | undefined;\n}\n\nexport const comboboxTriggerVariants = cva(\n [\n \"control-h inline-flex w-full items-center gap-2 border bg-surface px-[length:var(--tea-control-px)] text-ui text-fg\",\n \"transition-colors duration-fast ease-standard\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"aria-invalid:border-critical\",\n ],\n {\n variants: {\n variant: {\n outline: \"border-line data-[hover]:border-line-strong\",\n ghost: \"border-transparent bg-transparent\",\n },\n },\n defaultVariants: { variant: \"outline\" },\n },\n);\n\nconst DEFAULT_FILTER = (option: ComboboxOption, query: string): boolean => {\n const needle = query.trim().toLocaleLowerCase(\"de\");\n if (!needle) return true;\n return option.label.toLocaleLowerCase(\"de\").includes(needle);\n};\n\n/** Stable, id-safe DOM ids for one listbox. */\nexport const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(function Combobox(\n {\n label,\n options,\n value,\n defaultValue,\n onValueChange,\n onQueryChange,\n filter = DEFAULT_FILTER,\n emptyMessage = \"Keine Treffer\",\n loading = false,\n disabled = false,\n multiple = false,\n variant,\n className,\n ...props\n },\n ref,\n) {\n const field = useFieldControlProps();\n\n // `useId` is the only correct source of a DOM id here. A module-level counter\n // breaks under concurrent rendering and across multiple roots, and reading a\n // ref during render is something React 19 explicitly disallows. `useId` gives a\n // stable, SSR-safe id that React itself deduplicates.\n const stem = `tea-combobox-${React.useId().replace(/:/g, \"\")}`;\n const listboxId = `${stem}-listbox`;\n\n const [query, setQuery] = React.useState<string>(\"\");\n const [open, setOpen] = React.useState(false);\n const [activeIndex, setActiveIndex] = React.useState(-1);\n const [selected, setSelected] = React.useState<string[]>(() =>\n value !== undefined ? [value] : defaultValue !== undefined ? [defaultValue] : [],\n );\n\n // A controlled value is synced during render rather than in an effect. An\n // effect would paint one frame with the stale selection — a visible flash\n // after a programmatic change — and comparing before writing is React's\n // documented pattern for exactly this.\n const controlledSelection = value === undefined ? undefined : value === \"\" ? [] : [value];\n if (controlledSelection && !sameSelection(controlledSelection, selected)) {\n setSelected(controlledSelection);\n }\n\n const isDisabled = field.disabled ?? disabled;\n const isInvalid = field[\"aria-invalid\"] ?? false;\n\n const filtered = React.useMemo(\n () => (query.trim() ? options.filter((option) => filter(option, query)) : options),\n [options, query, filter],\n );\n\n const listRef = React.useRef<HTMLUListElement | null>(null);\n const rootRef = React.useRef<HTMLDivElement | null>(null);\n\n const commit = React.useCallback(\n (option: ComboboxOption) => {\n if (option.disabled) return;\n if (multiple) {\n const next = selected.includes(option.value)\n ? selected.filter((entry) => entry !== option.value)\n : [...selected, option.value];\n setSelected(next);\n onValueChange?.(next[next.length - 1] ?? \"\");\n } else {\n setSelected([option.value]);\n onValueChange?.(option.value);\n setQuery(\"\");\n setOpen(false);\n }\n setActiveIndex(-1);\n },\n [multiple, onValueChange, selected],\n );\n\n const moveActive = React.useCallback(\n (delta: number) => {\n if (filtered.length === 0) return;\n setActiveIndex((previous) => {\n const next = previous + delta;\n if (next < 0) return filtered.length - 1;\n if (next >= filtered.length) return 0;\n return next;\n });\n },\n [filtered.length],\n );\n\n // The active option must be scrolled into view, and it must be the element\n // `aria-activedescendant` points at — one index, two consumers, no drift.\n React.useEffect(() => {\n if (activeIndex < 0 || !listRef.current) return;\n const node = listRef.current.children[activeIndex] as HTMLElement | undefined;\n node?.scrollIntoView({ block: \"nearest\" });\n }, [activeIndex]);\n\n React.useEffect(() => {\n if (!open) return;\n const onPointerDown = (event: PointerEvent) => {\n if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n }, [open]);\n\n const activeId = activeIndex >= 0 ? `${stem}-option-${activeIndex}` : undefined;\n const displayValue = multiple ? selected.map((v) => labelFor(options, v)).join(\", \") : labelFor(options, selected[0] ?? \"\");\n\n return (\n <div ref={rootRef} className=\"relative\" {...dataSlot(\"combobox\", \"root\")}>\n <div\n role=\"combobox\"\n aria-expanded={open}\n aria-controls={listboxId}\n aria-haspopup=\"listbox\"\n aria-owns={listboxId}\n className={cn(comboboxTriggerVariants({ variant }), \"cursor-text\", className)}\n data-open={open || undefined}\n {...stateAttributes({ disabled: isDisabled, loading, invalid: isInvalid })}\n onClick={() => !isDisabled && setOpen(true)}\n >\n <Search size={16} aria-hidden=\"true\" className=\"shrink-0 text-fg-muted\" />\n <input\n ref={ref}\n id={field.id}\n role=\"searchbox\"\n type=\"text\"\n autoComplete=\"off\"\n className=\"min-w-0 flex-1 bg-transparent text-ui text-fg outline-none placeholder:text-fg-subtle\"\n placeholder={displayValue || label}\n value={query}\n disabled={isDisabled}\n aria-label={label}\n aria-autocomplete=\"list\"\n aria-activedescendant={activeId}\n aria-describedby={field[\"aria-describedby\"]}\n aria-invalid={isInvalid || undefined}\n aria-busy={loading || undefined}\n onChange={(event) => {\n setQuery(event.target.value);\n onQueryChange?.(event.target.value);\n setOpen(true);\n setActiveIndex(event.target.value ? 0 : -1);\n }}\n onFocus={() => setOpen(true)}\n onKeyDown={(event) => {\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n if (!open) setOpen(true);\n else moveActive(1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n if (!open) setOpen(true);\n else moveActive(-1);\n break;\n case \"Home\":\n if (open && filtered.length) {\n event.preventDefault();\n setActiveIndex(0);\n }\n break;\n case \"End\":\n if (open && filtered.length) {\n event.preventDefault();\n setActiveIndex(filtered.length - 1);\n }\n break;\n case \"Enter\":\n if (open && activeIndex >= 0 && filtered[activeIndex]) {\n event.preventDefault();\n commit(filtered[activeIndex]!);\n }\n break;\n case \"Escape\":\n // Escape reverts the query before it closes the list. Closing\n // first would leave a visibly empty field with a live query,\n // which reads as data loss.\n if (query) {\n event.preventDefault();\n setQuery(\"\");\n setActiveIndex(-1);\n } else {\n setOpen(false);\n }\n break;\n default:\n break;\n }\n }}\n {...props}\n />\n {loading ? <span className=\"text-fg-muted\" aria-hidden=\"true\">…</span> : null}\n {query ? (\n <button\n type=\"button\"\n aria-label=\"Suche leeren\"\n className=\"shrink-0 text-fg-muted hover:text-fg\"\n onClick={(event) => {\n event.stopPropagation();\n setQuery(\"\");\n onQueryChange?.(\"\");\n }}\n >\n <X size={14} aria-hidden=\"true\" />\n </button>\n ) : null}\n <ChevronDown size={16} aria-hidden=\"true\" className=\"shrink-0 text-fg-muted\" />\n </div>\n\n {open ? (\n <ul\n ref={listRef}\n id={listboxId}\n role=\"listbox\"\n aria-label={label}\n aria-multiselectable={multiple || undefined}\n className=\"absolute z-popover mt-1 max-h-72 w-full overflow-auto border border-line bg-surface-2 p-1 shadow-overlay\"\n {...dataSlot(\"combobox\", \"listbox\")}\n >\n {filtered.length === 0 ? (\n <li role=\"presentation\" className=\"px-3 py-2 text-micro text-fg-muted\">\n {emptyMessage}\n </li>\n ) : null}\n {filtered.map((option, index) => {\n const isSelected = selected.includes(option.value);\n return (\n <li\n key={option.value}\n id={`${stem}-option-${index}`}\n role=\"option\"\n aria-selected={isSelected}\n aria-disabled={option.disabled || undefined}\n data-active={index === activeIndex || undefined}\n className={cn(\n \"flex cursor-default select-none flex-col gap-0.5 px-3 py-2 text-ui\",\n \"data-[active]:bg-accent-subtle\",\n \"data-[active=true]:bg-accent-subtle\",\n option.disabled && \"pointer-events-none opacity-50\",\n isSelected && \"text-primary\",\n )}\n onPointerMove={() => setActiveIndex(index)}\n onClick={() => commit(option)}\n >\n <span className=\"flex items-center gap-2\">\n {option.label}\n {isSelected ? <Check size={14} aria-hidden=\"true\" className=\"shrink-0\" /> : null}\n </span>\n {option.description ? (\n <span className=\"text-micro text-fg-muted\">{option.description}</span>\n ) : null}\n </li>\n );\n })}\n </ul>\n ) : null}\n </div>\n );\n});\n\nfunction sameSelection(a: readonly string[], b: readonly string[]): boolean {\n return a.length === b.length && a.every((entry, index) => entry === b[index]);\n}\n\nfunction labelFor(options: readonly ComboboxOption[], value: string): string {\n if (!value) return \"\";\n return options.find((option) => option.value === value)?.label ?? value;\n}\n"],"mappings":"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","names":["Combobox"]}
@@ -0,0 +1,220 @@
1
+ import * as React from 'react';
2
+ import { Label } from 'radix-ui';
3
+
4
+ /**
5
+ * TEA UI — the Field system.
6
+ *
7
+ * This file exists because of a defect the audit found in **four of five**
8
+ * field implementations across the two source projects: a `<label>` that was
9
+ * never associated with its control. The consequence is not subtle — a screen
10
+ * reader user reaches a text field and is told nothing about what belongs in
11
+ * it, and a voice-control user simply cannot fill the form, because there is no
12
+ * name to say. It is also invisible in review, because `<label>` next to
13
+ * `<input>` *looks* right.
14
+ *
15
+ * The fix is structural rather than a review rule. `Field` owns the id space;
16
+ * `FieldLabel`, `FieldDescription` and `FieldError` write into it; and every
17
+ * control calls {@link useFieldControlProps}. A control cannot be wired wrong,
18
+ * because it never writes the wiring by hand:
19
+ *
20
+ * ```tsx
21
+ * <Field invalid={!!error} required>
22
+ * <FieldLabel>E-Mail</FieldLabel>
23
+ * <Input type="email" />
24
+ * <FieldDescription>Wir senden keine Bestätigung.</FieldDescription>
25
+ * <FieldError>{error}</FieldError>
26
+ * </Field>
27
+ * ```
28
+ *
29
+ * The four parts degrade safely. `FieldError` with no message renders nothing
30
+ * at all, and the error id disappears from `aria-describedby` with it, so there
31
+ * is never a description pointing at an element that does not exist.
32
+ */
33
+ /** The state a `Field` passes down to its parts. */
34
+ interface FieldState {
35
+ /** The control holds a value that failed validation. */
36
+ invalid?: boolean | undefined;
37
+ /** The control may not be left empty. */
38
+ required?: boolean | undefined;
39
+ /** The control and everything around it is unavailable. */
40
+ disabled?: boolean | undefined;
41
+ /** The value is visible but not editable. */
42
+ readOnly?: boolean | undefined;
43
+ }
44
+ interface FieldContextValue {
45
+ /** Base name of the field, used to build the three ids. */
46
+ name: string;
47
+ /** The control's id. This is what `FieldLabel` points `htmlFor` at. */
48
+ id: string;
49
+ /** Id of the description element, whether or not one is rendered. */
50
+ descriptionId: string;
51
+ /** Id of the error element, whether or not one is rendered. */
52
+ errorId: string;
53
+ isInvalid: boolean;
54
+ isRequired: boolean;
55
+ isDisabled: boolean;
56
+ isReadOnly: boolean;
57
+ }
58
+ /**
59
+ * Read the enclosing `Field`, or `null` when a control is used standalone.
60
+ *
61
+ * A control outside a `Field` is legal — a search field in a toolbar has
62
+ * nowhere sensible to put a `FieldDescription` — so every control must work
63
+ * without one. That is why this returns `null` rather than throwing.
64
+ */
65
+ declare function useField(): FieldContextValue | null;
66
+ /** The subset of a field's context a control needs to wire itself. */
67
+ interface FieldControlProps {
68
+ id: string | undefined;
69
+ "aria-describedby": string | undefined;
70
+ "aria-invalid": true | undefined;
71
+ "aria-required": true | undefined;
72
+ disabled: true | undefined;
73
+ readOnly: true | undefined;
74
+ }
75
+ /**
76
+ * Wire a control to the enclosing `Field`. Call this in **every** control.
77
+ *
78
+ * The returned object is exactly the six attributes a control needs, so a
79
+ * control spreads it and is done:
80
+ *
81
+ * ```tsx
82
+ * const field = useFieldControlProps();
83
+ * return <input {...field} className={…} />;
84
+ * ```
85
+ *
86
+ * `aria-describedby` lists the description *and* the error, in that order. The
87
+ * ids are added only once the corresponding element is really on screen, so the
88
+ * attribute never points at nothing — an `aria-describedby` reference to a
89
+ * missing id is a validation error in axe and is silently dropped by some
90
+ * screen readers, which leaves the error text unannounced.
91
+ *
92
+ * Outside a `Field` every key is `undefined`, so `{...field}` is a no-op and
93
+ * the consumer's own `id` and handlers survive.
94
+ */
95
+ declare function useFieldControlProps(): FieldControlProps;
96
+ interface FieldProps extends Omit<React.ComponentProps<"div">, "children">, FieldState {
97
+ children?: React.ReactNode;
98
+ /** Render the consumer's single child element instead of a `<div>`. */
99
+ asChild?: boolean | undefined;
100
+ /** The element to render instead of `<div>`. */
101
+ as?: React.ElementType | undefined;
102
+ /**
103
+ * Override the generated id stem. Only needed when the ids must be stable
104
+ * across server and client renders; `React.useId` already is.
105
+ */
106
+ idBase?: string | undefined;
107
+ }
108
+ /**
109
+ * The container that owns a field's id space and state.
110
+ *
111
+ * Renders a `<div>` by default and nothing else — no role, no landmark. The
112
+ * field is a layout relationship, and inventing a role for it would put a
113
+ * meaningless entry in the screen reader's structure list.
114
+ *
115
+ * @example
116
+ * ```tsx
117
+ * <Field required>
118
+ * <FieldLabel>Name</FieldLabel>
119
+ * <Input />
120
+ * </Field>
121
+ * ```
122
+ */
123
+ declare const Field: React.ForwardRefExoticComponent<Omit<FieldProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
124
+ interface FieldLabelProps extends React.ComponentProps<typeof Label.Root> {
125
+ /**
126
+ * Render the required marker. Defaults to the enclosing `Field`'s `required`;
127
+ * set it explicitly to label a field as required outside one.
128
+ */
129
+ required?: boolean | undefined;
130
+ }
131
+ /**
132
+ * The field's label. A real `<label>` with `htmlFor` pointing at the control.
133
+ *
134
+ * Uses `Label.Root` from `radix-ui` rather than a bare `<label>` so that
135
+ * clicking the label activates the control even when the control is a Radix
136
+ * primitive rendering a `<button>` rather than an `<input>`.
137
+ *
138
+ * The required marker is a visible, `aria-hidden` `*` with a `title`, and the
139
+ * requirement itself is carried by the control's `required` / `aria-required`.
140
+ * It is deliberately **not** a visually hidden "Pflichtfeld" word inside the
141
+ * label: that would append to the control's accessible name, so the control
142
+ * would be announced as "E-Mail Pflichtfeld" instead of "E-Mail", and a
143
+ * consumer matching on the visible label would find nothing. The attribute
144
+ * already says it, and it says it correctly.
145
+ *
146
+ * @example
147
+ * ```tsx
148
+ * <FieldLabel required>E-Mail</FieldLabel>
149
+ * ```
150
+ */
151
+ declare const FieldLabel: React.ForwardRefExoticComponent<Omit<FieldLabelProps, "ref"> & React.RefAttributes<HTMLLabelElement>>;
152
+ type FieldDescriptionProps = React.ComponentProps<"p">;
153
+ /**
154
+ * Help text for the field, associated through `aria-describedby`.
155
+ *
156
+ * @example
157
+ * ```tsx
158
+ * <FieldDescription>Nur Großbuchstaben, Zahlen und Bindestriche.</FieldDescription>
159
+ * ```
160
+ */
161
+ declare const FieldDescription: React.ForwardRefExoticComponent<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>, "ref"> & React.RefAttributes<HTMLParagraphElement>>;
162
+ interface FieldErrorProps extends Omit<React.ComponentProps<"p">, "children"> {
163
+ /**
164
+ * The error text. `null`, `undefined` and `false` render nothing, which is
165
+ * what lets a caller write `<FieldError>{error}</FieldError>` and leave it in
166
+ * the tree permanently.
167
+ */
168
+ children?: React.ReactNode;
169
+ }
170
+ /**
171
+ * The field's error message: `role="alert"`, so it is announced when it appears.
172
+ *
173
+ * A colour change is not an announcement. Without `role="alert"` a validation
174
+ * error is invisible to a screen reader user, who is left with a red box and no
175
+ * idea what is wrong with what they just typed.
176
+ *
177
+ * Renders nothing when there is no message — including when it is inside a
178
+ * `Field` and therefore participates in `aria-describedby`.
179
+ *
180
+ * @example
181
+ * ```tsx
182
+ * <FieldError>{errors.email}</FieldError>
183
+ * ```
184
+ */
185
+ declare const FieldError: React.ForwardRefExoticComponent<Omit<FieldErrorProps, "ref"> & React.RefAttributes<HTMLParagraphElement>>;
186
+ interface FieldGroupProps extends Omit<React.ComponentProps<"fieldset">, "children"> {
187
+ children?: React.ReactNode;
188
+ /**
189
+ * The group's label. Rendered as the `<legend>`, which is what associates the
190
+ * set of controls with a name — `aria-label` on a `<fieldset>` does not
191
+ * become the group's accessible name in every browser.
192
+ */
193
+ legend?: React.ReactNode;
194
+ /** Help text for the whole set, associated with the `<fieldset>`. */
195
+ description?: React.ReactNode;
196
+ }
197
+ /**
198
+ * A named set of related controls: `<fieldset>` + `<legend>`.
199
+ *
200
+ * A native `<fieldset disabled>` also disables every form control inside it,
201
+ * which is the cheapest possible "these five settings are not editable" — a
202
+ * `<fieldset>` in a `<form>` participates in form semantics that a `div` cannot.
203
+ *
204
+ * One component rather than `FieldSet` + `FieldLegend`: a legend is required
205
+ * for the fieldset to be named, so separating them produces an unnamed group
206
+ * whenever the legend is forgotten. The audit found plenty of those.
207
+ *
208
+ * @example
209
+ * ```tsx
210
+ * <FieldGroup legend="Benachrichtigungen" disabled={saving}>
211
+ * <Field>
212
+ * <FieldLabel>Fehler per E-Mail</FieldLabel>
213
+ * <Switch />
214
+ * </Field>
215
+ * </FieldGroup>
216
+ * ```
217
+ */
218
+ declare const FieldGroup: React.ForwardRefExoticComponent<Omit<FieldGroupProps, "ref"> & React.RefAttributes<HTMLFieldSetElement>>;
219
+
220
+ export { Field, type FieldContextValue, type FieldControlProps, FieldDescription, type FieldDescriptionProps, FieldError, type FieldErrorProps, FieldGroup, type FieldGroupProps, FieldLabel, type FieldLabelProps, type FieldProps, type FieldState, useField, useFieldControlProps };