@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.
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/card.d.ts +60 -0
- package/dist/card.js +116 -0
- package/dist/card.js.map +1 -0
- package/dist/feedback/alert.d.ts +57 -0
- package/dist/feedback/alert.js +104 -0
- package/dist/feedback/alert.js.map +1 -0
- package/dist/feedback/badge.d.ts +34 -0
- package/dist/feedback/badge.js +62 -0
- package/dist/feedback/badge.js.map +1 -0
- package/dist/feedback/index.d.ts +13 -0
- package/dist/feedback/index.js +62 -0
- package/dist/feedback/index.js.map +1 -0
- package/dist/feedback/progress.d.ts +25 -0
- package/dist/feedback/progress.js +49 -0
- package/dist/feedback/progress.js.map +1 -0
- package/dist/feedback/skeleton.d.ts +38 -0
- package/dist/feedback/skeleton.js +49 -0
- package/dist/feedback/skeleton.js.map +1 -0
- package/dist/feedback/spinner.d.ts +31 -0
- package/dist/feedback/spinner.js +55 -0
- package/dist/feedback/spinner.js.map +1 -0
- package/dist/feedback/status.d.ts +63 -0
- package/dist/feedback/status.js +156 -0
- package/dist/feedback/status.js.map +1 -0
- package/dist/feedback/toast.d.ts +72 -0
- package/dist/feedback/toast.js +211 -0
- package/dist/feedback/toast.js.map +1 -0
- package/dist/format/index.d.ts +63 -0
- package/dist/format/index.js +84 -0
- package/dist/format/index.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +163 -0
- package/dist/index.js.map +1 -0
- package/dist/inputs/button-group.d.ts +70 -0
- package/dist/inputs/button-group.js +59 -0
- package/dist/inputs/button-group.js.map +1 -0
- package/dist/inputs/button.d.ts +52 -0
- package/dist/inputs/button.js +84 -0
- package/dist/inputs/button.js.map +1 -0
- package/dist/inputs/checkbox.d.ts +48 -0
- package/dist/inputs/checkbox.js +74 -0
- package/dist/inputs/checkbox.js.map +1 -0
- package/dist/inputs/combobox.d.ts +77 -0
- package/dist/inputs/combobox.js +273 -0
- package/dist/inputs/combobox.js.map +1 -0
- package/dist/inputs/field.d.ts +220 -0
- package/dist/inputs/field.js +183 -0
- package/dist/inputs/field.js.map +1 -0
- package/dist/inputs/icon-button.d.ts +44 -0
- package/dist/inputs/icon-button.js +30 -0
- package/dist/inputs/icon-button.js.map +1 -0
- package/dist/inputs/index.d.ts +22 -0
- package/dist/inputs/index.js +92 -0
- package/dist/inputs/index.js.map +1 -0
- package/dist/inputs/input-group.d.ts +77 -0
- package/dist/inputs/input-group.js +76 -0
- package/dist/inputs/input-group.js.map +1 -0
- package/dist/inputs/input.d.ts +58 -0
- package/dist/inputs/input.js +68 -0
- package/dist/inputs/input.js.map +1 -0
- package/dist/inputs/label.d.ts +47 -0
- package/dist/inputs/label.js +29 -0
- package/dist/inputs/label.js.map +1 -0
- package/dist/inputs/number-input.d.ts +62 -0
- package/dist/inputs/number-input.js +86 -0
- package/dist/inputs/number-input.js.map +1 -0
- package/dist/inputs/password-input.d.ts +66 -0
- package/dist/inputs/password-input.js +73 -0
- package/dist/inputs/password-input.js.map +1 -0
- package/dist/inputs/radio.d.ts +66 -0
- package/dist/inputs/radio.js +96 -0
- package/dist/inputs/radio.js.map +1 -0
- package/dist/inputs/search-input.d.ts +66 -0
- package/dist/inputs/search-input.js +76 -0
- package/dist/inputs/search-input.js.map +1 -0
- package/dist/inputs/select.d.ts +65 -0
- package/dist/inputs/select.js +139 -0
- package/dist/inputs/select.js.map +1 -0
- package/dist/inputs/slider.d.ts +67 -0
- package/dist/inputs/slider.js +107 -0
- package/dist/inputs/slider.js.map +1 -0
- package/dist/inputs/switch.d.ts +42 -0
- package/dist/inputs/switch.js +69 -0
- package/dist/inputs/switch.js.map +1 -0
- package/dist/inputs/textarea.d.ts +51 -0
- package/dist/inputs/textarea.js +82 -0
- package/dist/inputs/textarea.js.map +1 -0
- package/dist/inputs/toggle.d.ts +57 -0
- package/dist/inputs/toggle.js +91 -0
- package/dist/inputs/toggle.js.map +1 -0
- package/dist/internal/index.d.ts +5 -0
- package/dist/internal/index.js +19 -0
- package/dist/internal/index.js.map +1 -0
- package/dist/internal/refs.d.ts +31 -0
- package/dist/internal/refs.js +34 -0
- package/dist/internal/refs.js.map +1 -0
- package/dist/internal/slot.d.ts +76 -0
- package/dist/internal/slot.js +40 -0
- package/dist/internal/slot.js.map +1 -0
- package/dist/internal/use-controllable-state.d.ts +25 -0
- package/dist/internal/use-controllable-state.js +38 -0
- package/dist/internal/use-controllable-state.js.map +1 -0
- package/dist/layout/index.d.ts +131 -0
- package/dist/layout/index.js +212 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/navigation/index.d.ts +114 -0
- package/dist/navigation/index.js +523 -0
- package/dist/navigation/index.js.map +1 -0
- package/dist/overlays/alert-dialog.d.ts +75 -0
- package/dist/overlays/alert-dialog.js +163 -0
- package/dist/overlays/alert-dialog.js.map +1 -0
- package/dist/overlays/dialog.d.ts +44 -0
- package/dist/overlays/dialog.js +121 -0
- package/dist/overlays/dialog.js.map +1 -0
- package/dist/overlays/drawer.d.ts +49 -0
- package/dist/overlays/drawer.js +125 -0
- package/dist/overlays/drawer.js.map +1 -0
- package/dist/overlays/dropdown-menu.d.ts +53 -0
- package/dist/overlays/dropdown-menu.js +192 -0
- package/dist/overlays/dropdown-menu.js.map +1 -0
- package/dist/overlays/index.d.ts +10 -0
- package/dist/overlays/index.js +169 -0
- package/dist/overlays/index.js.map +1 -0
- package/dist/overlays/popover.d.ts +58 -0
- package/dist/overlays/popover.js +105 -0
- package/dist/overlays/popover.js.map +1 -0
- package/dist/styles.css +2 -0
- package/dist/typography/index.d.ts +79 -0
- package/dist/typography/index.js +280 -0
- package/dist/typography/index.js.map +1 -0
- 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 };
|