@kwtv/components 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/README.md +77 -0
- package/package.json +32 -0
- package/src/astro/Accordion.astro +25 -0
- package/src/astro/AccordionItem.astro +77 -0
- package/src/astro/ActiveFilterTags.astro +65 -0
- package/src/astro/Alert.astro +93 -0
- package/src/astro/Badge.astro +138 -0
- package/src/astro/Band.astro +83 -0
- package/src/astro/Breadcrumb.astro +74 -0
- package/src/astro/Button.astro +217 -0
- package/src/astro/Card.astro +115 -0
- package/src/astro/CartLine.astro +112 -0
- package/src/astro/Checkbox.astro +89 -0
- package/src/astro/Chip.astro +130 -0
- package/src/astro/ChipRail.astro +24 -0
- package/src/astro/DotGrid.astro +62 -0
- package/src/astro/EmptyState.astro +53 -0
- package/src/astro/FilterGroup.astro +58 -0
- package/src/astro/Filters.astro +84 -0
- package/src/astro/Footer.astro +237 -0
- package/src/astro/FooterColumn.astro +64 -0
- package/src/astro/Header.astro +116 -0
- package/src/astro/Icon.astro +72 -0
- package/src/astro/IconButton.astro +117 -0
- package/src/astro/Marquee.astro +72 -0
- package/src/astro/NavDropdown.astro +164 -0
- package/src/astro/NavLink.astro +94 -0
- package/src/astro/Newsletter.astro +118 -0
- package/src/astro/Pager.astro +195 -0
- package/src/astro/PickCard.astro +142 -0
- package/src/astro/ProductCard.astro +195 -0
- package/src/astro/Radio.astro +89 -0
- package/src/astro/Skeleton.astro +80 -0
- package/src/astro/Social.astro +55 -0
- package/src/astro/StepIndicator.astro +104 -0
- package/src/astro/Sticker.astro +80 -0
- package/src/astro/Swatch.astro +100 -0
- package/src/astro/SwipeCarousel.astro +14 -0
- package/src/astro/Table.astro +117 -0
- package/src/astro/TableCard.astro +63 -0
- package/src/astro/TextField.astro +231 -0
- package/src/astro/Totals.astro +92 -0
- package/src/react/Drawer.css +67 -0
- package/src/react/Drawer.tsx +115 -0
- package/src/react/Modal.css +116 -0
- package/src/react/Modal.tsx +152 -0
- package/src/react/NewsCarousel.css +86 -0
- package/src/react/NewsCarousel.tsx +97 -0
- package/src/react/QuantityStepper.css +93 -0
- package/src/react/QuantityStepper.tsx +143 -0
- package/src/react/Select.css +183 -0
- package/src/react/Select.tsx +389 -0
- package/src/react/Toast.css +140 -0
- package/src/react/Toast.tsx +157 -0
- package/src/styles/base.css +39 -0
- package/src/styles/controls.css +211 -0
- package/src/styles/motion.css +238 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/* Select. Trigger plus listbox. All values from the design-system tokens. */
|
|
2
|
+
|
|
3
|
+
.ui-field {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: var(--space-1-5);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.ui-field__label {
|
|
10
|
+
font-size: var(--text-sm);
|
|
11
|
+
font-weight: var(--weight-medium);
|
|
12
|
+
color: var(--text);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.ui-field__req {
|
|
16
|
+
color: var(--danger);
|
|
17
|
+
margin-inline-start: var(--space-0-5);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ui-field__hint {
|
|
21
|
+
font-size: var(--text-xs);
|
|
22
|
+
line-height: var(--leading-xs);
|
|
23
|
+
color: var(--text-muted);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.ui-field__error {
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: var(--space-1-5);
|
|
30
|
+
font-size: var(--text-xs);
|
|
31
|
+
line-height: var(--leading-xs);
|
|
32
|
+
color: var(--danger);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.ui-select {
|
|
36
|
+
position: relative;
|
|
37
|
+
display: block;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.ui-select__btn {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: space-between;
|
|
44
|
+
gap: var(--space-2);
|
|
45
|
+
width: 100%;
|
|
46
|
+
min-height: 40px;
|
|
47
|
+
padding: 0 var(--space-3);
|
|
48
|
+
background: var(--surface);
|
|
49
|
+
border: 1px solid var(--border);
|
|
50
|
+
border-radius: var(--radius-md);
|
|
51
|
+
color: var(--text);
|
|
52
|
+
font-family: var(--font-sans);
|
|
53
|
+
font-size: var(--text-sm);
|
|
54
|
+
text-align: start;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
transition: border-color var(--duration-fast) var(--ease-standard);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.ui-select__btn:hover,
|
|
60
|
+
.ui-select__btn.is-hover {
|
|
61
|
+
border-color: var(--border-strong);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.ui-select__btn:focus-visible,
|
|
65
|
+
.ui-select__btn.is-focus {
|
|
66
|
+
outline: 2px solid var(--focus);
|
|
67
|
+
outline-offset: 2px;
|
|
68
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ui-select__btn:disabled {
|
|
72
|
+
opacity: 0.5;
|
|
73
|
+
cursor: not-allowed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.ui-select__value {
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
text-overflow: ellipsis;
|
|
79
|
+
white-space: nowrap;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.ui-select__value--empty {
|
|
83
|
+
color: var(--text-muted);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ui-select__chev {
|
|
87
|
+
display: inline-flex;
|
|
88
|
+
flex: none;
|
|
89
|
+
color: var(--text-muted);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Invalid: 2 px danger border. */
|
|
93
|
+
.ui-select--invalid .ui-select__btn {
|
|
94
|
+
border-width: 2px;
|
|
95
|
+
border-color: var(--danger);
|
|
96
|
+
padding-inline: calc(var(--space-3) - 1px);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* The list. 6 px offset, 272 px max height, 36 px rows. */
|
|
100
|
+
.ui-select__list {
|
|
101
|
+
position: absolute;
|
|
102
|
+
top: calc(100% + var(--space-1-5));
|
|
103
|
+
left: 0;
|
|
104
|
+
right: 0;
|
|
105
|
+
z-index: var(--z-dropdown);
|
|
106
|
+
max-height: 272px;
|
|
107
|
+
overflow-y: auto;
|
|
108
|
+
padding: var(--space-1);
|
|
109
|
+
background: var(--surface);
|
|
110
|
+
border: 1px solid var(--border);
|
|
111
|
+
border-radius: var(--radius-md);
|
|
112
|
+
box-shadow: var(--shadow-lg);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.ui-select__opt {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: var(--space-2);
|
|
119
|
+
width: 100%;
|
|
120
|
+
min-height: 36px;
|
|
121
|
+
padding: 0 var(--space-2);
|
|
122
|
+
background: transparent;
|
|
123
|
+
border: 0;
|
|
124
|
+
border-radius: var(--radius-sm);
|
|
125
|
+
color: var(--text);
|
|
126
|
+
font-family: var(--font-sans);
|
|
127
|
+
font-size: var(--text-sm);
|
|
128
|
+
text-align: start;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.ui-select__opt:hover,
|
|
133
|
+
.ui-select__opt.is-hover,
|
|
134
|
+
.ui-select__opt.is-focus {
|
|
135
|
+
background: var(--surface-hover);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Selection marked by the tick and a weight change, not the fill alone. */
|
|
139
|
+
.ui-select__opt[aria-selected="true"] {
|
|
140
|
+
font-weight: var(--weight-semibold);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.ui-select__tick {
|
|
144
|
+
display: inline-flex;
|
|
145
|
+
flex: none;
|
|
146
|
+
width: 13px;
|
|
147
|
+
color: var(--primary);
|
|
148
|
+
visibility: hidden;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.ui-select__opt[aria-selected="true"] .ui-select__tick {
|
|
152
|
+
visibility: visible;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.ui-select__opt.is-disabled,
|
|
156
|
+
.ui-select__opt[aria-disabled="true"] {
|
|
157
|
+
opacity: 0.5;
|
|
158
|
+
cursor: not-allowed;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Group headings. Not selectable. */
|
|
162
|
+
.ui-select__grouplabel {
|
|
163
|
+
display: block;
|
|
164
|
+
padding: var(--space-2) var(--space-2) var(--space-1);
|
|
165
|
+
font-family: var(--font-mono);
|
|
166
|
+
font-size: var(--text-2xs);
|
|
167
|
+
font-weight: var(--weight-semibold);
|
|
168
|
+
letter-spacing: var(--tracking-wider);
|
|
169
|
+
text-transform: uppercase;
|
|
170
|
+
color: var(--text-muted);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@media (max-width: 700px), (pointer: coarse) {
|
|
174
|
+
.ui-select__opt {
|
|
175
|
+
min-height: 40px;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@media (prefers-reduced-motion: reduce) {
|
|
180
|
+
.ui-select__btn {
|
|
181
|
+
transition: none;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useId,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
} from "react";
|
|
9
|
+
import "./Select.css";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Select. A custom combobox built as a trigger plus a listbox, because the
|
|
13
|
+
* native popup cannot be styled and breaks the brand at the one moment the
|
|
14
|
+
* reader is choosing.
|
|
15
|
+
*
|
|
16
|
+
* Keyboard behaviour stays native: Enter or Space opens, arrows move the active
|
|
17
|
+
* option, Enter picks, Escape closes and returns focus to the trigger.
|
|
18
|
+
*
|
|
19
|
+
* Selection is marked by a tick and a weight change, never by the sage fill
|
|
20
|
+
* alone, so it reads without colour.
|
|
21
|
+
*
|
|
22
|
+
* Grouping: pass each option a `group` string. Options that share a group are
|
|
23
|
+
* rendered under one non-selectable `.ui-select__grouplabel`. Ungrouped options
|
|
24
|
+
* render first, in order. Disabled options stay in the list so the reader
|
|
25
|
+
* learns the group exists.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface SelectOption {
|
|
29
|
+
value: string;
|
|
30
|
+
label: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
/** Optional group heading. Options sharing a group render under one label. */
|
|
33
|
+
group?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SelectProps {
|
|
37
|
+
/** Visible label text. Rendered above the field and wired by aria-labelledby. */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** Accessible name when there is no visible label. */
|
|
40
|
+
ariaLabel?: string;
|
|
41
|
+
options: SelectOption[];
|
|
42
|
+
value?: string;
|
|
43
|
+
onChange?: (value: string) => void;
|
|
44
|
+
placeholder?: string;
|
|
45
|
+
invalid?: boolean;
|
|
46
|
+
/** Error text, shown under the field and wired by aria-describedby. */
|
|
47
|
+
error?: string;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
required?: boolean;
|
|
50
|
+
/** Hint text under the field, when not invalid. */
|
|
51
|
+
hint?: string;
|
|
52
|
+
/** Start with the listbox open. Useful for a persistent first view or specimen. */
|
|
53
|
+
defaultOpen?: boolean;
|
|
54
|
+
className?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface FlatRow {
|
|
58
|
+
kind: "group" | "option";
|
|
59
|
+
label: string;
|
|
60
|
+
option?: SelectOption;
|
|
61
|
+
/** Index into the list of selectable options, for active tracking. */
|
|
62
|
+
optionIndex?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildRows(options: SelectOption[]): {
|
|
66
|
+
rows: FlatRow[];
|
|
67
|
+
selectable: SelectOption[];
|
|
68
|
+
} {
|
|
69
|
+
const ungrouped = options.filter((o) => !o.group);
|
|
70
|
+
const groups: string[] = [];
|
|
71
|
+
for (const o of options) {
|
|
72
|
+
if (o.group && !groups.includes(o.group)) groups.push(o.group);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const rows: FlatRow[] = [];
|
|
76
|
+
const selectable: SelectOption[] = [];
|
|
77
|
+
|
|
78
|
+
const pushOption = (o: SelectOption) => {
|
|
79
|
+
const optionIndex = selectable.length;
|
|
80
|
+
selectable.push(o);
|
|
81
|
+
rows.push({ kind: "option", label: o.label, option: o, optionIndex });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
ungrouped.forEach(pushOption);
|
|
85
|
+
for (const g of groups) {
|
|
86
|
+
rows.push({ kind: "group", label: g });
|
|
87
|
+
options.filter((o) => o.group === g).forEach(pushOption);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { rows, selectable };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default function Select({
|
|
94
|
+
label,
|
|
95
|
+
ariaLabel,
|
|
96
|
+
options,
|
|
97
|
+
value,
|
|
98
|
+
onChange,
|
|
99
|
+
placeholder = "Kies een optie",
|
|
100
|
+
invalid = false,
|
|
101
|
+
error,
|
|
102
|
+
disabled = false,
|
|
103
|
+
required = false,
|
|
104
|
+
hint,
|
|
105
|
+
defaultOpen = false,
|
|
106
|
+
className,
|
|
107
|
+
}: SelectProps) {
|
|
108
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
109
|
+
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
|
110
|
+
|
|
111
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
112
|
+
const btnRef = useRef<HTMLButtonElement>(null);
|
|
113
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
114
|
+
|
|
115
|
+
const reactId = useId();
|
|
116
|
+
const labelId = label ? `${reactId}-label` : undefined;
|
|
117
|
+
const errorId = error ? `${reactId}-error` : undefined;
|
|
118
|
+
const hintId = hint && !error ? `${reactId}-hint` : undefined;
|
|
119
|
+
|
|
120
|
+
const { rows, selectable } = buildRows(options);
|
|
121
|
+
const selected = selectable.find((o) => o.value === value);
|
|
122
|
+
|
|
123
|
+
const close = useCallback((returnFocus = true) => {
|
|
124
|
+
setOpen(false);
|
|
125
|
+
setActiveIndex(-1);
|
|
126
|
+
if (returnFocus) btnRef.current?.focus();
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const openList = useCallback(() => {
|
|
130
|
+
if (disabled) return;
|
|
131
|
+
setOpen(true);
|
|
132
|
+
const current = selectable.findIndex((o) => o.value === value && !o.disabled);
|
|
133
|
+
const firstEnabled = selectable.findIndex((o) => !o.disabled);
|
|
134
|
+
setActiveIndex(current >= 0 ? current : firstEnabled);
|
|
135
|
+
}, [disabled, selectable, value]);
|
|
136
|
+
|
|
137
|
+
const pick = useCallback(
|
|
138
|
+
(option: SelectOption) => {
|
|
139
|
+
if (option.disabled) return;
|
|
140
|
+
onChange?.(option.value);
|
|
141
|
+
close();
|
|
142
|
+
},
|
|
143
|
+
[onChange, close],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Close on outside click.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!open) return;
|
|
149
|
+
const onDown = (e: MouseEvent) => {
|
|
150
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
|
151
|
+
setOpen(false);
|
|
152
|
+
setActiveIndex(-1);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
document.addEventListener("mousedown", onDown);
|
|
156
|
+
return () => document.removeEventListener("mousedown", onDown);
|
|
157
|
+
}, [open]);
|
|
158
|
+
|
|
159
|
+
// Keep the active option scrolled into view.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!open || activeIndex < 0 || !listRef.current) return;
|
|
162
|
+
const el = listRef.current.querySelector<HTMLElement>(
|
|
163
|
+
`[data-opt-index="${activeIndex}"]`,
|
|
164
|
+
);
|
|
165
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
166
|
+
}, [open, activeIndex]);
|
|
167
|
+
|
|
168
|
+
const moveActive = (dir: 1 | -1) => {
|
|
169
|
+
if (selectable.length === 0) return;
|
|
170
|
+
let i = activeIndex;
|
|
171
|
+
for (let step = 0; step < selectable.length; step++) {
|
|
172
|
+
i = (i + dir + selectable.length) % selectable.length;
|
|
173
|
+
if (!selectable[i].disabled) {
|
|
174
|
+
setActiveIndex(i);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
181
|
+
switch (e.key) {
|
|
182
|
+
case "Enter":
|
|
183
|
+
case " ":
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
if (!open) {
|
|
186
|
+
openList();
|
|
187
|
+
} else if (activeIndex >= 0) {
|
|
188
|
+
pick(selectable[activeIndex]);
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
case "ArrowDown":
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
if (!open) openList();
|
|
194
|
+
else moveActive(1);
|
|
195
|
+
break;
|
|
196
|
+
case "ArrowUp":
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
if (!open) openList();
|
|
199
|
+
else moveActive(-1);
|
|
200
|
+
break;
|
|
201
|
+
case "Escape":
|
|
202
|
+
if (open) {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
close();
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
case "Tab":
|
|
208
|
+
if (open) {
|
|
209
|
+
setOpen(false);
|
|
210
|
+
setActiveIndex(-1);
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
case "Home":
|
|
214
|
+
if (open) {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
const first = selectable.findIndex((o) => !o.disabled);
|
|
217
|
+
if (first >= 0) setActiveIndex(first);
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
case "End":
|
|
221
|
+
if (open) {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
for (let i = selectable.length - 1; i >= 0; i--) {
|
|
224
|
+
if (!selectable[i].disabled) {
|
|
225
|
+
setActiveIndex(i);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const activeId =
|
|
235
|
+
open && activeIndex >= 0 ? `${reactId}-opt-${activeIndex}` : undefined;
|
|
236
|
+
|
|
237
|
+
const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;
|
|
238
|
+
|
|
239
|
+
const fieldClasses = ["ui-select", invalid && "ui-select--invalid", open && "is-open"]
|
|
240
|
+
.filter(Boolean)
|
|
241
|
+
.join(" ");
|
|
242
|
+
|
|
243
|
+
const selectControl = (
|
|
244
|
+
<div className={fieldClasses} ref={rootRef}>
|
|
245
|
+
<button
|
|
246
|
+
ref={btnRef}
|
|
247
|
+
type="button"
|
|
248
|
+
className="ui-select__btn"
|
|
249
|
+
role="combobox"
|
|
250
|
+
aria-haspopup="listbox"
|
|
251
|
+
aria-expanded={open}
|
|
252
|
+
aria-controls={`${reactId}-list`}
|
|
253
|
+
aria-activedescendant={activeId}
|
|
254
|
+
aria-labelledby={labelId}
|
|
255
|
+
aria-label={!labelId ? ariaLabel : undefined}
|
|
256
|
+
aria-invalid={invalid || undefined}
|
|
257
|
+
aria-describedby={describedBy}
|
|
258
|
+
aria-required={required || undefined}
|
|
259
|
+
disabled={disabled}
|
|
260
|
+
onClick={() => (open ? close(false) : openList())}
|
|
261
|
+
onKeyDown={onKeyDown}
|
|
262
|
+
>
|
|
263
|
+
<span
|
|
264
|
+
className={`ui-select__value${selected ? "" : " ui-select__value--empty"}`}
|
|
265
|
+
>
|
|
266
|
+
{selected ? selected.label : placeholder}
|
|
267
|
+
</span>
|
|
268
|
+
<span className="ui-select__chev" aria-hidden="true">
|
|
269
|
+
<svg
|
|
270
|
+
width="14"
|
|
271
|
+
height="14"
|
|
272
|
+
viewBox="0 0 24 24"
|
|
273
|
+
fill="none"
|
|
274
|
+
stroke="currentColor"
|
|
275
|
+
strokeWidth="2.2"
|
|
276
|
+
strokeLinecap="round"
|
|
277
|
+
strokeLinejoin="round"
|
|
278
|
+
>
|
|
279
|
+
<path d="M6 9l6 6 6-6" />
|
|
280
|
+
</svg>
|
|
281
|
+
</span>
|
|
282
|
+
</button>
|
|
283
|
+
{open && (
|
|
284
|
+
<div
|
|
285
|
+
ref={listRef}
|
|
286
|
+
id={`${reactId}-list`}
|
|
287
|
+
className="ui-select__list"
|
|
288
|
+
role="listbox"
|
|
289
|
+
aria-labelledby={labelId}
|
|
290
|
+
aria-label={!labelId ? ariaLabel : undefined}
|
|
291
|
+
tabIndex={-1}
|
|
292
|
+
>
|
|
293
|
+
{rows.map((row, i) =>
|
|
294
|
+
row.kind === "group" ? (
|
|
295
|
+
<span
|
|
296
|
+
key={`g-${i}`}
|
|
297
|
+
className="ui-select__grouplabel"
|
|
298
|
+
role="presentation"
|
|
299
|
+
>
|
|
300
|
+
{row.label}
|
|
301
|
+
</span>
|
|
302
|
+
) : (
|
|
303
|
+
<button
|
|
304
|
+
key={`o-${row.option!.value}`}
|
|
305
|
+
id={`${reactId}-opt-${row.optionIndex}`}
|
|
306
|
+
data-opt-index={row.optionIndex}
|
|
307
|
+
type="button"
|
|
308
|
+
className={[
|
|
309
|
+
"ui-select__opt",
|
|
310
|
+
row.optionIndex === activeIndex && "is-focus",
|
|
311
|
+
row.option!.disabled && "is-disabled",
|
|
312
|
+
]
|
|
313
|
+
.filter(Boolean)
|
|
314
|
+
.join(" ")}
|
|
315
|
+
role="option"
|
|
316
|
+
aria-selected={row.option!.value === value}
|
|
317
|
+
aria-disabled={row.option!.disabled || undefined}
|
|
318
|
+
onClick={() => pick(row.option!)}
|
|
319
|
+
onMouseEnter={() =>
|
|
320
|
+
!row.option!.disabled && setActiveIndex(row.optionIndex!)
|
|
321
|
+
}
|
|
322
|
+
tabIndex={-1}
|
|
323
|
+
>
|
|
324
|
+
<span className="ui-select__tick" aria-hidden="true">
|
|
325
|
+
<svg
|
|
326
|
+
width="13"
|
|
327
|
+
height="13"
|
|
328
|
+
viewBox="0 0 24 24"
|
|
329
|
+
fill="none"
|
|
330
|
+
stroke="currentColor"
|
|
331
|
+
strokeWidth="2.6"
|
|
332
|
+
strokeLinecap="round"
|
|
333
|
+
strokeLinejoin="round"
|
|
334
|
+
>
|
|
335
|
+
<path d="M5 13l4 4L19 7" />
|
|
336
|
+
</svg>
|
|
337
|
+
</span>
|
|
338
|
+
{row.label}
|
|
339
|
+
</button>
|
|
340
|
+
),
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
if (!label && !error && !hint) {
|
|
348
|
+
return <div className={className}>{selectControl}</div>;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<label className={["ui-field", className].filter(Boolean).join(" ")}>
|
|
353
|
+
{label && (
|
|
354
|
+
<span className="ui-field__label" id={labelId}>
|
|
355
|
+
{label}
|
|
356
|
+
{required && (
|
|
357
|
+
<span className="ui-field__req" aria-hidden="true">
|
|
358
|
+
*
|
|
359
|
+
</span>
|
|
360
|
+
)}
|
|
361
|
+
</span>
|
|
362
|
+
)}
|
|
363
|
+
{selectControl}
|
|
364
|
+
{error ? (
|
|
365
|
+
<span className="ui-field__error" id={errorId}>
|
|
366
|
+
<span aria-hidden="true">
|
|
367
|
+
<svg
|
|
368
|
+
width="12"
|
|
369
|
+
height="12"
|
|
370
|
+
viewBox="0 0 24 24"
|
|
371
|
+
fill="none"
|
|
372
|
+
stroke="currentColor"
|
|
373
|
+
strokeWidth="2.2"
|
|
374
|
+
strokeLinecap="round"
|
|
375
|
+
>
|
|
376
|
+
<circle cx="12" cy="12" r="9" />
|
|
377
|
+
<path d="M12 8v4.5M12 16v.5" />
|
|
378
|
+
</svg>
|
|
379
|
+
</span>
|
|
380
|
+
{error}
|
|
381
|
+
</span>
|
|
382
|
+
) : hint ? (
|
|
383
|
+
<span className="ui-field__hint" id={hintId}>
|
|
384
|
+
{hint}
|
|
385
|
+
</span>
|
|
386
|
+
) : null}
|
|
387
|
+
</label>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* Toast. All values from the design-system tokens. */
|
|
2
|
+
|
|
3
|
+
.ui-toast-region {
|
|
4
|
+
position: fixed;
|
|
5
|
+
z-index: var(--z-toast);
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
gap: var(--space-2);
|
|
9
|
+
padding: var(--space-4);
|
|
10
|
+
/* Shrink-wrap to the toasts instead of covering a whole corner, so the empty
|
|
11
|
+
area never overlays or blocks clicks on the page behind it. */
|
|
12
|
+
width: max-content;
|
|
13
|
+
max-width: 100vw;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ui-toast-region--bottom-right {
|
|
17
|
+
right: 0;
|
|
18
|
+
bottom: 0;
|
|
19
|
+
align-items: flex-end;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.ui-toast-region--bottom-center {
|
|
23
|
+
left: 50%;
|
|
24
|
+
bottom: 0;
|
|
25
|
+
transform: translateX(-50%);
|
|
26
|
+
align-items: center;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* A light card lifted off the page by its shadow. Dark text keeps every word
|
|
30
|
+
at full contrast; the tone shows in a coloured icon, not the surface. */
|
|
31
|
+
.ui-toast {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: flex-start;
|
|
34
|
+
gap: var(--space-3);
|
|
35
|
+
width: 100%;
|
|
36
|
+
max-width: 380px;
|
|
37
|
+
padding: var(--space-4);
|
|
38
|
+
/* ivory-light, not ivory-base: ivory-base (#C9C2B1) is dark enough that the
|
|
39
|
+
undo link drops to 3.79 on it, the very contrast problem this redesign
|
|
40
|
+
fixes. ivory-light keeps a warm card distinct from the page and clears AA
|
|
41
|
+
for the link (5.65) and everything on it. */
|
|
42
|
+
background: var(--surface-hover);
|
|
43
|
+
border: 1px solid var(--border);
|
|
44
|
+
border-radius: var(--radius-lg);
|
|
45
|
+
box-shadow: var(--shadow-xl);
|
|
46
|
+
font-size: var(--text-sm);
|
|
47
|
+
line-height: var(--leading-sm);
|
|
48
|
+
color: var(--text);
|
|
49
|
+
pointer-events: auto;
|
|
50
|
+
animation: ui-toast-in var(--duration-base) var(--ease-enter);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ui-toast__icon {
|
|
54
|
+
display: inline-flex;
|
|
55
|
+
flex: none;
|
|
56
|
+
margin-top: 1px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* One coloured icon per tone. The status roles already clear 4.5:1 on the
|
|
60
|
+
light surface, so the glyph carries the meaning without tinting the card. */
|
|
61
|
+
.ui-toast--success .ui-toast__icon {
|
|
62
|
+
color: var(--success);
|
|
63
|
+
}
|
|
64
|
+
.ui-toast--danger .ui-toast__icon {
|
|
65
|
+
color: var(--danger);
|
|
66
|
+
}
|
|
67
|
+
.ui-toast--warning .ui-toast__icon {
|
|
68
|
+
color: var(--warning);
|
|
69
|
+
}
|
|
70
|
+
.ui-toast--info .ui-toast__icon {
|
|
71
|
+
color: var(--primary);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.ui-toast__msg {
|
|
75
|
+
flex: 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ui-toast__action {
|
|
79
|
+
display: inline-block;
|
|
80
|
+
margin-top: var(--space-1);
|
|
81
|
+
background: none;
|
|
82
|
+
border: 0;
|
|
83
|
+
padding: 0;
|
|
84
|
+
font: inherit;
|
|
85
|
+
font-weight: var(--weight-semibold);
|
|
86
|
+
color: var(--link);
|
|
87
|
+
text-decoration: underline;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
}
|
|
90
|
+
.ui-toast__action:hover {
|
|
91
|
+
color: var(--link-hover);
|
|
92
|
+
}
|
|
93
|
+
.ui-toast__action:focus-visible {
|
|
94
|
+
outline: 2px solid var(--focus);
|
|
95
|
+
outline-offset: 2px;
|
|
96
|
+
border-radius: var(--radius-sm);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.ui-toast__x {
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
flex: none;
|
|
102
|
+
align-items: center;
|
|
103
|
+
justify-content: center;
|
|
104
|
+
width: 24px;
|
|
105
|
+
height: 24px;
|
|
106
|
+
margin: -2px -2px 0 0;
|
|
107
|
+
background: none;
|
|
108
|
+
border: 0;
|
|
109
|
+
border-radius: var(--radius-sm);
|
|
110
|
+
color: var(--text-muted);
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
}
|
|
113
|
+
.ui-toast__x:hover {
|
|
114
|
+
color: var(--text);
|
|
115
|
+
background: var(--surface-hover);
|
|
116
|
+
}
|
|
117
|
+
.ui-toast__x:focus-visible {
|
|
118
|
+
outline: 2px solid var(--focus);
|
|
119
|
+
outline-offset: 2px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* The SVG in the close button is decorative. Let clicks and the cursor fall
|
|
123
|
+
through to the button, so its pointer cursor always shows. */
|
|
124
|
+
.ui-toast__x svg,
|
|
125
|
+
.ui-toast__action svg {
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@keyframes ui-toast-in {
|
|
130
|
+
from {
|
|
131
|
+
opacity: 0;
|
|
132
|
+
transform: translateY(8px);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@media (prefers-reduced-motion: reduce) {
|
|
137
|
+
.ui-toast {
|
|
138
|
+
animation: none;
|
|
139
|
+
}
|
|
140
|
+
}
|