@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,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from "./Icon.astro";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Text field. Label above, hint below, error replacing the hint. Label, hint
|
|
6
|
+
* and error are wired to the input with aria-describedby; an invalid field also
|
|
7
|
+
* sets aria-invalid="true". Errors name the fix, not just the failure.
|
|
8
|
+
*
|
|
9
|
+
* Set `multiline` for a textarea (min 96 px, resizes vertically). Set `count`
|
|
10
|
+
* with `maxlength` to show an "n / max" counter.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
label?: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
value?: string;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
type?: string;
|
|
19
|
+
inputmode?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
id?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
valid?: boolean;
|
|
25
|
+
maxlength?: number;
|
|
26
|
+
/** Show an "n / max" counter. Needs maxlength. */
|
|
27
|
+
count?: boolean;
|
|
28
|
+
multiline?: boolean;
|
|
29
|
+
rows?: number;
|
|
30
|
+
class?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
label,
|
|
35
|
+
hint,
|
|
36
|
+
error,
|
|
37
|
+
value = "",
|
|
38
|
+
placeholder,
|
|
39
|
+
type = "text",
|
|
40
|
+
inputmode,
|
|
41
|
+
name,
|
|
42
|
+
id,
|
|
43
|
+
disabled = false,
|
|
44
|
+
required = false,
|
|
45
|
+
valid = false,
|
|
46
|
+
maxlength,
|
|
47
|
+
count = false,
|
|
48
|
+
multiline = false,
|
|
49
|
+
rows = 3,
|
|
50
|
+
class: className,
|
|
51
|
+
...rest
|
|
52
|
+
} = Astro.props;
|
|
53
|
+
|
|
54
|
+
const base = id ?? `field-${Math.random().toString(36).slice(2, 9)}`;
|
|
55
|
+
const hintId = `${base}-hint`;
|
|
56
|
+
const errorId = `${base}-error`;
|
|
57
|
+
const describedby = error ? errorId : hint ? hintId : undefined;
|
|
58
|
+
|
|
59
|
+
const inputClasses = [
|
|
60
|
+
"ui-input",
|
|
61
|
+
error && "is-invalid",
|
|
62
|
+
valid && !error && "is-valid",
|
|
63
|
+
]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(" ");
|
|
66
|
+
|
|
67
|
+
const charCount = count && maxlength ? `${String(value).length} / ${maxlength}` : null;
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
<label class={["ui-field", className].filter(Boolean).join(" ")}>
|
|
71
|
+
{label && (
|
|
72
|
+
<span class="ui-field__label">
|
|
73
|
+
{label}
|
|
74
|
+
{required && <span class="ui-field__req" aria-hidden="true">*</span>}
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{multiline ? (
|
|
79
|
+
<textarea
|
|
80
|
+
class={inputClasses}
|
|
81
|
+
id={base}
|
|
82
|
+
name={name}
|
|
83
|
+
rows={rows}
|
|
84
|
+
placeholder={placeholder}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
required={required}
|
|
87
|
+
maxlength={maxlength}
|
|
88
|
+
aria-invalid={error ? "true" : undefined}
|
|
89
|
+
aria-describedby={describedby}
|
|
90
|
+
{...rest}
|
|
91
|
+
>{value}</textarea>
|
|
92
|
+
) : (
|
|
93
|
+
<input
|
|
94
|
+
class={inputClasses}
|
|
95
|
+
id={base}
|
|
96
|
+
name={name}
|
|
97
|
+
type={type}
|
|
98
|
+
inputmode={inputmode as any}
|
|
99
|
+
value={value}
|
|
100
|
+
placeholder={placeholder}
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
required={required}
|
|
103
|
+
maxlength={maxlength}
|
|
104
|
+
aria-invalid={error ? "true" : undefined}
|
|
105
|
+
aria-describedby={describedby}
|
|
106
|
+
{...rest}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{error ? (
|
|
111
|
+
<span class="ui-field__error" id={errorId}>
|
|
112
|
+
<span aria-hidden="true">
|
|
113
|
+
<Icon name="alert-circle" size={12} stroke={2.2} />
|
|
114
|
+
</span>
|
|
115
|
+
{error}
|
|
116
|
+
</span>
|
|
117
|
+
) : (
|
|
118
|
+
hint && <span class="ui-field__hint" id={hintId}>{hint}</span>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{charCount && <span class="ui-field__count">{charCount}</span>}
|
|
122
|
+
</label>
|
|
123
|
+
|
|
124
|
+
<style>
|
|
125
|
+
.ui-field {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
gap: var(--space-1-5);
|
|
129
|
+
font-family: var(--font-sans);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.ui-field__label {
|
|
133
|
+
font-size: var(--text-sm);
|
|
134
|
+
font-weight: var(--weight-medium);
|
|
135
|
+
color: var(--text);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.ui-field__req {
|
|
139
|
+
color: var(--danger);
|
|
140
|
+
margin-left: 2px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.ui-input {
|
|
144
|
+
min-height: 40px;
|
|
145
|
+
padding: var(--space-2) var(--space-3);
|
|
146
|
+
background: var(--surface);
|
|
147
|
+
border: 1px solid var(--border);
|
|
148
|
+
border-radius: var(--radius-md);
|
|
149
|
+
font-family: var(--font-sans);
|
|
150
|
+
font-size: var(--text-sm);
|
|
151
|
+
color: var(--text);
|
|
152
|
+
transition:
|
|
153
|
+
border-color var(--duration-fast) var(--ease-standard),
|
|
154
|
+
box-shadow var(--duration-fast) var(--ease-standard);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
textarea.ui-input {
|
|
158
|
+
min-height: 96px;
|
|
159
|
+
resize: vertical;
|
|
160
|
+
line-height: var(--leading-sm);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.ui-input::placeholder {
|
|
164
|
+
color: var(--text-muted);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.ui-input:hover,
|
|
168
|
+
.ui-input.is-hover {
|
|
169
|
+
border-color: var(--border-strong);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.ui-input:focus-visible,
|
|
173
|
+
.ui-input.is-focus {
|
|
174
|
+
outline: 2px solid var(--focus);
|
|
175
|
+
outline-offset: 1px;
|
|
176
|
+
border-color: var(--border-strong);
|
|
177
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Invalid: 2 px danger border. */
|
|
181
|
+
.ui-input.is-invalid,
|
|
182
|
+
.ui-input[aria-invalid="true"] {
|
|
183
|
+
border-width: 2px;
|
|
184
|
+
border-color: var(--danger);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.ui-input.is-valid {
|
|
188
|
+
border-color: var(--success);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.ui-input:disabled,
|
|
192
|
+
.ui-input.is-disabled {
|
|
193
|
+
opacity: 0.5;
|
|
194
|
+
cursor: not-allowed;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.ui-field__hint {
|
|
198
|
+
font-size: var(--text-xs);
|
|
199
|
+
line-height: var(--leading-xs);
|
|
200
|
+
color: var(--text-muted);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.ui-field__error {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
gap: var(--space-1);
|
|
207
|
+
font-size: var(--text-xs);
|
|
208
|
+
line-height: var(--leading-xs);
|
|
209
|
+
color: var(--danger);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.ui-field__count {
|
|
213
|
+
align-self: flex-end;
|
|
214
|
+
font-size: var(--text-xs);
|
|
215
|
+
color: var(--text-muted);
|
|
216
|
+
font-variant-numeric: tabular-nums;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@media (prefers-reduced-motion: reduce) {
|
|
220
|
+
.ui-input {
|
|
221
|
+
transition: none;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* Mobile inputs grow to 44 px under a touch scope. */
|
|
226
|
+
@media (max-width: 700px), (pointer: coarse) {
|
|
227
|
+
.ui-input {
|
|
228
|
+
min-height: 44px;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
</style>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Order totals. A set of rows, label left and value right, with a final total
|
|
4
|
+
* row set larger. Money is tabular so the columns line up.
|
|
5
|
+
*
|
|
6
|
+
* This renders only the rows. Wrap it in a raised Card for the cart summary.
|
|
7
|
+
* Pass `rows` and `total`, or use the default slot for full control.
|
|
8
|
+
*/
|
|
9
|
+
interface Row {
|
|
10
|
+
label: string;
|
|
11
|
+
value: string;
|
|
12
|
+
/** Render this row's value at full text strength. */
|
|
13
|
+
emphasis?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
rows?: Row[];
|
|
18
|
+
total?: { label: string; value: string };
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { rows, total, class: className, ...rest } = Astro.props;
|
|
23
|
+
const hasSlot = Astro.slots.has("default");
|
|
24
|
+
|
|
25
|
+
const classes = ["ui-totals", className].filter(Boolean).join(" ");
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<div class={classes} {...rest}>
|
|
29
|
+
{hasSlot ? (
|
|
30
|
+
<slot />
|
|
31
|
+
) : (
|
|
32
|
+
<>
|
|
33
|
+
{rows?.map((row) => (
|
|
34
|
+
<div class="ui-trow">
|
|
35
|
+
<span>{row.label}</span>
|
|
36
|
+
<b data-emphasis={row.emphasis ? "true" : undefined}>{row.value}</b>
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
{total && (
|
|
40
|
+
<div class="ui-trow ui-trow--total">
|
|
41
|
+
<span>{total.label}</span>
|
|
42
|
+
<b>{total.value}</b>
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
</>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
.ui-totals {
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
gap: var(--space-2-5);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ui-trow {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: baseline;
|
|
59
|
+
justify-content: space-between;
|
|
60
|
+
gap: var(--space-4);
|
|
61
|
+
font-size: var(--text-sm);
|
|
62
|
+
color: var(--text-muted);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.ui-trow b {
|
|
66
|
+
font-weight: var(--weight-medium);
|
|
67
|
+
font-variant-numeric: tabular-nums;
|
|
68
|
+
text-align: right;
|
|
69
|
+
color: var(--text-muted);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.ui-trow b[data-emphasis] {
|
|
73
|
+
color: var(--text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.ui-trow--total {
|
|
77
|
+
padding-top: var(--space-3);
|
|
78
|
+
border-top: 1px solid var(--border);
|
|
79
|
+
font-size: var(--text-lg);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.ui-trow--total span {
|
|
83
|
+
color: var(--text);
|
|
84
|
+
font-weight: var(--weight-semibold);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.ui-trow--total b {
|
|
88
|
+
font-size: var(--text-2xl);
|
|
89
|
+
font-weight: var(--weight-semibold);
|
|
90
|
+
color: var(--text);
|
|
91
|
+
}
|
|
92
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* Mobile menu drawer. All values from the design-system tokens. */
|
|
2
|
+
|
|
3
|
+
.ui-drawer {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: var(--space-6);
|
|
7
|
+
padding: var(--space-4) var(--space-4) var(--space-6);
|
|
8
|
+
background: var(--bg);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.ui-drawer__group {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ui-drawer__grouptitle {
|
|
17
|
+
padding: 0 var(--space-3);
|
|
18
|
+
margin-bottom: var(--space-1);
|
|
19
|
+
font-family: var(--font-mono);
|
|
20
|
+
font-size: var(--text-2xs);
|
|
21
|
+
font-weight: var(--weight-semibold);
|
|
22
|
+
letter-spacing: var(--tracking-wider);
|
|
23
|
+
text-transform: uppercase;
|
|
24
|
+
color: var(--text-muted);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Rows. No separators. At least 44 px. Soft fill on hover, press, current. */
|
|
28
|
+
.ui-drawerlink {
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
justify-content: center;
|
|
32
|
+
gap: 2px;
|
|
33
|
+
min-height: 44px;
|
|
34
|
+
padding: var(--space-2) var(--space-3);
|
|
35
|
+
border-radius: var(--radius-md);
|
|
36
|
+
color: var(--text);
|
|
37
|
+
font-size: var(--text-base);
|
|
38
|
+
font-weight: var(--weight-medium);
|
|
39
|
+
text-decoration: none;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.ui-drawerlink small {
|
|
43
|
+
font-size: var(--text-xs);
|
|
44
|
+
font-weight: var(--weight-regular);
|
|
45
|
+
color: var(--text-muted);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.ui-drawerlink:hover,
|
|
49
|
+
.ui-drawerlink.is-hover,
|
|
50
|
+
.ui-drawerlink:active,
|
|
51
|
+
.ui-drawerlink.is-active {
|
|
52
|
+
background: var(--surface-hover);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ui-drawerlink.is-current {
|
|
56
|
+
background: var(--surface-hover);
|
|
57
|
+
font-weight: var(--weight-semibold);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.ui-drawerlink:focus-visible {
|
|
61
|
+
outline: 2px solid var(--focus);
|
|
62
|
+
outline-offset: -2px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.ui-drawer__cta {
|
|
66
|
+
margin-top: var(--space-2);
|
|
67
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
} from "react";
|
|
6
|
+
import "./Drawer.css";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mobile menu drawer. A full-width panel under the header, opened from the menu
|
|
10
|
+
* button. Rows sit on whitespace with no separators.
|
|
11
|
+
*
|
|
12
|
+
* Focus moves to the first row on open and returns to the trigger on close; the
|
|
13
|
+
* page behind does not scroll while the drawer is open. The trigger is the
|
|
14
|
+
* consumer's IconButton: flip its aria-expanded and swap its label between
|
|
15
|
+
* "Menu openen" and "Menu sluiten". Pass that trigger element through
|
|
16
|
+
* `triggerRef` so focus returns to it on close.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface DrawerLink {
|
|
20
|
+
label: string;
|
|
21
|
+
href: string;
|
|
22
|
+
/** Secondary line under the label. */
|
|
23
|
+
note?: string;
|
|
24
|
+
current?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DrawerGroup {
|
|
28
|
+
/** Optional group heading. */
|
|
29
|
+
title?: string;
|
|
30
|
+
links: DrawerLink[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DrawerProps {
|
|
34
|
+
open: boolean;
|
|
35
|
+
onClose: () => void;
|
|
36
|
+
/** The menu button, so focus returns to it on close. */
|
|
37
|
+
triggerRef?: { current: HTMLElement | null };
|
|
38
|
+
/** Data-driven groups, or compose your own rows through children. */
|
|
39
|
+
groups?: DrawerGroup[];
|
|
40
|
+
children?: ReactNode;
|
|
41
|
+
/** Sticky call to action at the bottom, usually the cart button. */
|
|
42
|
+
cta?: ReactNode;
|
|
43
|
+
label?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function Drawer({
|
|
47
|
+
open,
|
|
48
|
+
onClose,
|
|
49
|
+
triggerRef,
|
|
50
|
+
groups,
|
|
51
|
+
children,
|
|
52
|
+
cta,
|
|
53
|
+
label = "Menu",
|
|
54
|
+
}: DrawerProps) {
|
|
55
|
+
const navRef = useRef<HTMLElement>(null);
|
|
56
|
+
|
|
57
|
+
// Focus the first row on open, return to the trigger on close.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!open) return;
|
|
60
|
+
const first = navRef.current?.querySelector<HTMLElement>(".ui-drawerlink");
|
|
61
|
+
const raf = requestAnimationFrame(() => first?.focus());
|
|
62
|
+
return () => {
|
|
63
|
+
cancelAnimationFrame(raf);
|
|
64
|
+
triggerRef?.current?.focus?.();
|
|
65
|
+
};
|
|
66
|
+
}, [open, triggerRef]);
|
|
67
|
+
|
|
68
|
+
// Lock the page behind the drawer.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!open) return;
|
|
71
|
+
const prev = document.body.style.overflow;
|
|
72
|
+
document.body.style.overflow = "hidden";
|
|
73
|
+
return () => {
|
|
74
|
+
document.body.style.overflow = prev;
|
|
75
|
+
};
|
|
76
|
+
}, [open]);
|
|
77
|
+
|
|
78
|
+
// Escape closes.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!open) return;
|
|
81
|
+
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
82
|
+
if (e.key === "Escape") onClose();
|
|
83
|
+
};
|
|
84
|
+
document.addEventListener("keydown", onKey);
|
|
85
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
86
|
+
}, [open, onClose]);
|
|
87
|
+
|
|
88
|
+
if (!open) return null;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<nav className="ui-drawer" aria-label={label} ref={navRef}>
|
|
92
|
+
{groups
|
|
93
|
+
? groups.map((group, gi) => (
|
|
94
|
+
<div className="ui-drawer__group" key={gi}>
|
|
95
|
+
{group.title && (
|
|
96
|
+
<span className="ui-drawer__grouptitle">{group.title}</span>
|
|
97
|
+
)}
|
|
98
|
+
{group.links.map((link) => (
|
|
99
|
+
<a
|
|
100
|
+
key={link.href + link.label}
|
|
101
|
+
className={`ui-drawerlink${link.current ? " is-current" : ""}`}
|
|
102
|
+
href={link.href}
|
|
103
|
+
aria-current={link.current ? "page" : undefined}
|
|
104
|
+
>
|
|
105
|
+
{link.label}
|
|
106
|
+
{link.note && <small>{link.note}</small>}
|
|
107
|
+
</a>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
))
|
|
111
|
+
: children}
|
|
112
|
+
{cta && <div className="ui-drawer__cta">{cta}</div>}
|
|
113
|
+
</nav>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* Modal and sheet. All values from the design-system tokens. */
|
|
2
|
+
|
|
3
|
+
.ui-scrim {
|
|
4
|
+
position: fixed;
|
|
5
|
+
inset: 0;
|
|
6
|
+
z-index: var(--z-modal);
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
padding: var(--space-4);
|
|
11
|
+
/* A raw ramp step, not a role: the scrim must stay dark in both themes, and
|
|
12
|
+
every role that is dark in light mode goes light in dark mode. */
|
|
13
|
+
background: color-mix(in srgb, var(--charcoal-xdark) 52%, transparent);
|
|
14
|
+
animation: ui-scrim-in var(--duration-base) var(--ease-standard);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.ui-scrim--bottom {
|
|
18
|
+
align-items: flex-end;
|
|
19
|
+
padding: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.ui-modal {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
gap: var(--space-3);
|
|
26
|
+
width: 100%;
|
|
27
|
+
max-width: 440px;
|
|
28
|
+
padding: var(--space-6);
|
|
29
|
+
background: var(--surface);
|
|
30
|
+
border-radius: var(--radius-xl);
|
|
31
|
+
box-shadow: var(--shadow-xl);
|
|
32
|
+
animation: ui-modal-in var(--duration-base) var(--ease-enter);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.ui-modal:focus-visible {
|
|
36
|
+
outline: 2px solid var(--focus);
|
|
37
|
+
outline-offset: 2px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.ui-modal__title {
|
|
41
|
+
font-size: var(--text-xl);
|
|
42
|
+
font-weight: var(--weight-semibold);
|
|
43
|
+
letter-spacing: var(--tracking-tight);
|
|
44
|
+
color: var(--text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.ui-modal__body {
|
|
48
|
+
font-size: var(--text-sm);
|
|
49
|
+
line-height: var(--leading-sm);
|
|
50
|
+
color: var(--text-muted);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ui-modal__actions {
|
|
54
|
+
display: flex;
|
|
55
|
+
justify-content: flex-end;
|
|
56
|
+
gap: var(--space-2);
|
|
57
|
+
margin-top: var(--space-2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Bottom sheet. The safest choice sits at the bottom, under the thumb, so
|
|
61
|
+
stack the actions reversed there when the consumer orders them safe-first. */
|
|
62
|
+
.ui-sheet {
|
|
63
|
+
max-width: none;
|
|
64
|
+
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
|
65
|
+
padding-top: var(--space-3);
|
|
66
|
+
animation: ui-sheet-in var(--duration-slow) var(--ease-enter);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.ui-sheet__grip {
|
|
70
|
+
align-self: center;
|
|
71
|
+
width: 36px;
|
|
72
|
+
height: 4px;
|
|
73
|
+
margin-bottom: var(--space-2);
|
|
74
|
+
border-radius: var(--radius-full);
|
|
75
|
+
background: var(--border);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Force the sheet presentation on narrow screens for the plain dialog too. */
|
|
79
|
+
@media (max-width: 700px) {
|
|
80
|
+
.ui-scrim:not(.ui-scrim--bottom) {
|
|
81
|
+
align-items: flex-end;
|
|
82
|
+
padding: 0;
|
|
83
|
+
}
|
|
84
|
+
.ui-modal:not(.ui-sheet) {
|
|
85
|
+
max-width: none;
|
|
86
|
+
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
|
87
|
+
animation: ui-sheet-in var(--duration-slow) var(--ease-enter);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@keyframes ui-scrim-in {
|
|
92
|
+
from {
|
|
93
|
+
opacity: 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@keyframes ui-modal-in {
|
|
98
|
+
from {
|
|
99
|
+
opacity: 0;
|
|
100
|
+
transform: translateY(8px) scale(0.98);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@keyframes ui-sheet-in {
|
|
105
|
+
from {
|
|
106
|
+
transform: translateY(100%);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@media (prefers-reduced-motion: reduce) {
|
|
111
|
+
.ui-scrim,
|
|
112
|
+
.ui-modal,
|
|
113
|
+
.ui-sheet {
|
|
114
|
+
animation: none;
|
|
115
|
+
}
|
|
116
|
+
}
|