@kwtv/components 0.8.0 → 0.10.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.
@@ -1,8 +1,14 @@
1
1
  ---
2
+ import "../styles/filters.css";
3
+
2
4
  /**
3
5
  * Colour swatch. 30 px circle, 32 px on touch. A <button> carrying the colour
4
6
  * name in `label` (→ aria-label), because the circle alone tells a colour-blind
5
7
  * reader nothing. Selection adds a 2 px ring so it reads without colour.
8
+ * When selected, `selectedLabel` is added to the name after a comma.
9
+ *
10
+ * React and Preact islands write the same markup and import
11
+ * `@kwtv/components/styles/filters.css`.
6
12
  */
7
13
  interface Props {
8
14
  /** CSS colour value for the fill. */
@@ -11,6 +17,8 @@ interface Props {
11
17
  label: string;
12
18
  selected?: boolean;
13
19
  disabled?: boolean;
20
+ /** Added to the accessible name when selected. Defaults to "geselecteerd". */
21
+ selectedLabel?: string;
14
22
  type?: "button" | "submit";
15
23
  class?: string;
16
24
  }
@@ -20,6 +28,7 @@ const {
20
28
  label,
21
29
  selected = false,
22
30
  disabled = false,
31
+ selectedLabel = "geselecteerd",
23
32
  type = "button",
24
33
  class: className,
25
34
  ...rest
@@ -34,7 +43,7 @@ const classes = [
34
43
  .filter(Boolean)
35
44
  .join(" ");
36
45
 
37
- const ariaLabel = selected ? `${label}, geselecteerd` : label;
46
+ const ariaLabel = selected ? `${label}, ${selectedLabel}` : label;
38
47
  ---
39
48
 
40
49
  <button
@@ -46,59 +55,3 @@ const ariaLabel = selected ? `${label}, geselecteerd` : label;
46
55
  disabled={disabled}
47
56
  {...rest}
48
57
  ></button>
49
-
50
- <style>
51
- @layer reset, tokens, base, components, motion, utilities;
52
-
53
- @layer components {
54
- .ui-swatch {
55
- width: 30px;
56
- height: 30px;
57
- padding: 0;
58
- background: var(--swatch);
59
- border: 1px solid var(--border);
60
- border-radius: var(--radius-full);
61
- cursor: pointer;
62
- transition: box-shadow var(--duration-fast) var(--ease-standard);
63
- }
64
-
65
- .ui-swatch:hover,
66
- .ui-swatch.is-hover {
67
- box-shadow: 0 0 0 2px var(--border-strong);
68
- }
69
-
70
- .ui-swatch:focus-visible,
71
- .ui-swatch.is-focus {
72
- outline: 2px solid var(--focus);
73
- outline-offset: 2px;
74
- box-shadow: 0 0 0 4px var(--focus-outer);
75
- }
76
-
77
- /* Selected: 2 px ring, readable without colour. */
78
- .ui-swatch.is-selected,
79
- .ui-swatch[aria-pressed="true"] {
80
- box-shadow:
81
- 0 0 0 2px var(--surface),
82
- 0 0 0 4px var(--border-strong);
83
- }
84
-
85
- .ui-swatch.is-disabled,
86
- .ui-swatch:disabled {
87
- opacity: 0.5;
88
- cursor: not-allowed;
89
- }
90
-
91
- @media (prefers-reduced-motion: reduce) {
92
- .ui-swatch {
93
- transition: none;
94
- }
95
- }
96
-
97
- @media (max-width: 700px), (pointer: coarse) {
98
- .ui-swatch {
99
- width: 32px;
100
- height: 32px;
101
- }
102
- }
103
- }
104
- </style>
@@ -1,4 +1,4 @@
1
- /* Mobile menu drawer. All values from the design-system tokens. */
1
+ /* Menu drawer. All values from the design-system tokens. */
2
2
 
3
3
  @layer reset, tokens, base, components, motion, utilities;
4
4
 
@@ -68,4 +68,108 @@
68
68
  .ui-drawer__cta {
69
69
  margin-top: var(--space-2);
70
70
  }
71
+
72
+ .ui-drawer__close {
73
+ align-self: flex-end;
74
+ flex-shrink: 0;
75
+ }
76
+
77
+ /* Title row. The title sits left of the close button, on the row inset. */
78
+ .ui-drawer__head {
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: space-between;
82
+ gap: var(--space-3);
83
+ }
84
+
85
+ .ui-drawer__head .ui-drawer__close {
86
+ align-self: center;
87
+ }
88
+
89
+ .ui-drawer__title {
90
+ display: flex;
91
+ align-items: center;
92
+ min-height: 44px;
93
+ margin: 0;
94
+ padding: 0 var(--space-3);
95
+ font-family: var(--font-sans);
96
+ font-size: var(--text-base);
97
+ line-height: var(--leading-base);
98
+ font-weight: var(--weight-semibold);
99
+ color: var(--text);
100
+ }
101
+
102
+ /* Placement. The panel is fixed to one edge, over a scrim that stays dark
103
+ in both themes, like the modal scrim. */
104
+ .ui-drawer-scrim {
105
+ position: fixed;
106
+ inset: 0;
107
+ z-index: var(--z-modal);
108
+ display: flex;
109
+ background: color-mix(in srgb, var(--charcoal-xdark) 52%, transparent);
110
+ animation: ui-drawer-fade var(--duration-base) var(--ease-standard);
111
+ }
112
+
113
+ .ui-drawer-scrim--end {
114
+ justify-content: flex-end;
115
+ }
116
+
117
+ .ui-drawer-scrim--bottom {
118
+ align-items: flex-end;
119
+ }
120
+
121
+ .ui-drawer--start,
122
+ .ui-drawer--end,
123
+ .ui-drawer--bottom {
124
+ overflow-y: auto;
125
+ overscroll-behavior: contain;
126
+ box-shadow: var(--shadow-xl);
127
+ animation: ui-drawer-in var(--duration-slow) var(--ease-enter);
128
+ }
129
+
130
+ .ui-drawer--start,
131
+ .ui-drawer--end {
132
+ --_from: translateX(-100%);
133
+ width: min(360px, 100% - var(--space-8));
134
+ height: 100%;
135
+ }
136
+
137
+ .ui-drawer--end {
138
+ --_from: translateX(100%);
139
+ }
140
+
141
+ .ui-drawer--bottom {
142
+ --_from: translateY(100%);
143
+ width: 100%;
144
+ max-height: 85dvh;
145
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
146
+ }
147
+
148
+ /* Full width on small screens. Bottom and the panel under the header are */
149
+ /* already full width. */
150
+ @media (max-width: 700px) {
151
+ .ui-drawer--full.ui-drawer--start,
152
+ .ui-drawer--full.ui-drawer--end {
153
+ width: 100%;
154
+ }
155
+ }
156
+
157
+ @keyframes ui-drawer-fade {
158
+ from {
159
+ opacity: 0;
160
+ }
161
+ }
162
+
163
+ @keyframes ui-drawer-in {
164
+ from {
165
+ transform: var(--_from);
166
+ }
167
+ }
168
+
169
+ @media (prefers-reduced-motion: reduce) {
170
+ .ui-drawer-scrim,
171
+ .ui-drawer {
172
+ animation: none;
173
+ }
174
+ }
71
175
  }
@@ -1,15 +1,33 @@
1
- import { useEffect, useRef, type ReactNode } from "react";
1
+ import { useEffect, useId, useRef, type ReactNode } from "react";
2
+ import "../styles/controls.css";
2
3
  import "./Drawer.css";
3
4
 
4
5
  /**
5
- * Mobile menu drawer. A full-width panel under the header, opened from the menu
6
- * button. Rows sit on whitespace with no separators.
6
+ * Menu drawer. Without `placement` it is the mobile menu: a full-width panel
7
+ * under the header, opened from the menu button. With `placement` it is a
8
+ * panel fixed to the start, end or bottom edge, over a scrim. Rows sit on
9
+ * whitespace with no separators.
7
10
  *
8
- * Focus moves to the first row on open and returns to the trigger on close; the
9
- * page behind does not scroll while the drawer is open. The trigger is the
10
- * consumer's IconButton: flip its aria-expanded and swap its label between
11
- * "Menu openen" and "Menu sluiten". Pass that trigger element through
12
- * `triggerRef` so focus returns to it on close.
11
+ * While open, Tab and Shift+Tab stay inside the drawer, Escape closes it, and
12
+ * the rest of the page is inert and does not scroll. Focus moves to the first
13
+ * row on open. On close it returns to `triggerRef`, or else to whatever had
14
+ * focus before the drawer opened.
15
+ *
16
+ * Without `placement` the trigger sits outside the drawer and stays usable, so
17
+ * the menu button can close it again. It is part of the Tab cycle. The trigger
18
+ * is the consumer's IconButton: flip its aria-expanded and swap its label
19
+ * between "Menu openen" and "Menu sluiten".
20
+ *
21
+ * Pass `closeLabel` to show a close button at the top of the drawer. A drawer
22
+ * with a `placement` covers its trigger, so give it one.
23
+ *
24
+ * Pass `title` to show a heading in the top row, left of the close button.
25
+ * The title then names the drawer through aria-labelledby, in place of
26
+ * `label`. Pass `size="full"` to let a start or end drawer fill the viewport
27
+ * width on screens up to 700 px wide. Wider screens keep the 360 px panel.
28
+ * Without these two props the drawer renders as before.
29
+ *
30
+ * Uses only hooks and DOM calls that preact/compat supports.
13
31
  */
14
32
 
15
33
  export interface DrawerLink {
@@ -37,6 +55,42 @@ export interface DrawerProps {
37
55
  /** Sticky call to action at the bottom, usually the cart button. */
38
56
  cta?: ReactNode;
39
57
  label?: string;
58
+ /**
59
+ * Leave unset for the panel under the header. "start", "end" or "bottom"
60
+ * fix the drawer to that edge of the viewport, over a scrim.
61
+ */
62
+ placement?: "start" | "end" | "bottom";
63
+ /** Shows a close button with this accessible name, like "Menu sluiten". */
64
+ closeLabel?: string;
65
+ /**
66
+ * A visible h2 in the top row, beside the close button. It becomes the
67
+ * accessible name of the drawer, in place of `label`.
68
+ */
69
+ title?: string;
70
+ /**
71
+ * "full" fills the viewport width on screens up to 700 px wide. Leave unset
72
+ * for the default width.
73
+ */
74
+ size?: "default" | "full";
75
+ }
76
+
77
+ const FOCUSABLE =
78
+ 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
79
+
80
+ /** Sets `inert` on everything outside `root`, except branches holding `keep`. */
81
+ function inertOutside(root: HTMLElement, keep: HTMLElement | null) {
82
+ const changed: Element[] = [];
83
+ let node: HTMLElement = root;
84
+ while (node !== document.body && node.parentElement) {
85
+ for (const sibling of Array.from(node.parentElement.children)) {
86
+ if (sibling === node || sibling.hasAttribute("inert")) continue;
87
+ if (keep && sibling.contains(keep)) continue;
88
+ sibling.setAttribute("inert", "");
89
+ changed.push(sibling);
90
+ }
91
+ node = node.parentElement;
92
+ }
93
+ return () => changed.forEach((el) => el.removeAttribute("inert"));
40
94
  }
41
95
 
42
96
  export default function Drawer({
@@ -47,19 +101,37 @@ export default function Drawer({
47
101
  children,
48
102
  cta,
49
103
  label = "Menu",
104
+ placement,
105
+ closeLabel,
106
+ title,
107
+ size = "default",
50
108
  }: DrawerProps) {
51
- const navRef = useRef<HTMLElement>(null);
109
+ const titleId = useId();
110
+ const rootRef = useRef<HTMLElement>(null);
111
+ const scrimRef = useRef<HTMLDivElement>(null);
112
+ const onCloseRef = useRef(onClose);
113
+ onCloseRef.current = onClose;
52
114
 
53
- // Focus the first row on open, return to the trigger on close.
115
+ // Make the page inert, move focus in on open, and send it back on close.
54
116
  useEffect(() => {
55
117
  if (!open) return;
56
- const first = navRef.current?.querySelector<HTMLElement>(".ui-drawerlink");
57
- const raf = requestAnimationFrame(() => first?.focus());
118
+ const root = rootRef.current;
119
+ if (!root) return;
120
+ const previous = document.activeElement as HTMLElement | null;
121
+ const trigger = triggerRef?.current ?? null;
122
+ const outer = placement ? (scrimRef.current ?? root) : root;
123
+ const restore = inertOutside(outer, placement ? null : trigger);
124
+ const first =
125
+ root.querySelector<HTMLElement>(".ui-drawerlink") ??
126
+ root.querySelector<HTMLElement>(FOCUSABLE) ??
127
+ root;
128
+ const raf = requestAnimationFrame(() => first.focus());
58
129
  return () => {
59
130
  cancelAnimationFrame(raf);
60
- triggerRef?.current?.focus?.();
131
+ restore();
132
+ (trigger ?? previous)?.focus?.();
61
133
  };
62
- }, [open, triggerRef]);
134
+ }, [open, placement, triggerRef]);
63
135
 
64
136
  // Lock the page behind the drawer.
65
137
  useEffect(() => {
@@ -71,20 +143,90 @@ export default function Drawer({
71
143
  };
72
144
  }, [open]);
73
145
 
74
- // Escape closes.
146
+ // Escape closes. Tab and Shift+Tab cycle through the drawer, plus the
147
+ // trigger when the drawer sits under the header.
75
148
  useEffect(() => {
76
149
  if (!open) return;
77
150
  const onKey = (e: globalThis.KeyboardEvent) => {
78
- if (e.key === "Escape") onClose();
151
+ if (e.key === "Escape") {
152
+ e.preventDefault();
153
+ onCloseRef.current();
154
+ return;
155
+ }
156
+ if (e.key !== "Tab") return;
157
+ const root = rootRef.current;
158
+ if (!root) return;
159
+ const items = Array.from(
160
+ root.querySelectorAll<HTMLElement>(FOCUSABLE),
161
+ ).filter((el) => el.getClientRects().length > 0);
162
+ const trigger = placement ? null : triggerRef?.current;
163
+ if (trigger && trigger.isConnected) {
164
+ const triggerFirst =
165
+ trigger.compareDocumentPosition(root) &
166
+ Node.DOCUMENT_POSITION_FOLLOWING;
167
+ if (triggerFirst) items.unshift(trigger);
168
+ else items.push(trigger);
169
+ }
170
+ e.preventDefault();
171
+ if (items.length === 0) {
172
+ root.focus();
173
+ return;
174
+ }
175
+ const index = items.indexOf(document.activeElement as HTMLElement);
176
+ const next =
177
+ index === -1
178
+ ? e.shiftKey
179
+ ? items.length - 1
180
+ : 0
181
+ : (index + (e.shiftKey ? -1 : 1) + items.length) % items.length;
182
+ items[next].focus();
79
183
  };
80
184
  document.addEventListener("keydown", onKey);
81
185
  return () => document.removeEventListener("keydown", onKey);
82
- }, [open, onClose]);
186
+ }, [open, placement, triggerRef]);
83
187
 
84
188
  if (!open) return null;
85
189
 
86
- return (
87
- <nav className="ui-drawer" aria-label={label} ref={navRef}>
190
+ const closeButton = closeLabel && (
191
+ <button
192
+ className="ui-iconbtn ui-drawer__close"
193
+ type="button"
194
+ aria-label={closeLabel}
195
+ onClick={onClose}
196
+ >
197
+ <svg
198
+ width="20"
199
+ height="20"
200
+ viewBox="0 0 24 24"
201
+ fill="none"
202
+ stroke="currentColor"
203
+ strokeWidth="1.8"
204
+ strokeLinecap="round"
205
+ aria-hidden="true"
206
+ >
207
+ <path d="M7 7l10 10M17 7L7 17" />
208
+ </svg>
209
+ </button>
210
+ );
211
+
212
+ const panel = (
213
+ <nav
214
+ className={`ui-drawer${placement ? ` ui-drawer--${placement}` : ""}${size === "full" ? " ui-drawer--full" : ""}`}
215
+ aria-label={title ? undefined : label}
216
+ aria-labelledby={title ? titleId : undefined}
217
+ ref={rootRef}
218
+ tabIndex={-1}
219
+ >
220
+ {title ? (
221
+ <div className="ui-drawer__head">
222
+ <h2 className="ui-drawer__title" id={titleId}>
223
+ {title}
224
+ </h2>
225
+ {closeButton}
226
+ </div>
227
+ ) : (
228
+ closeButton
229
+ )}
88
230
  {groups
89
231
  ? groups.map((group, gi) => (
90
232
  <div className="ui-drawer__group" key={gi}>
@@ -108,4 +250,21 @@ export default function Drawer({
108
250
  {cta && <div className="ui-drawer__cta">{cta}</div>}
109
251
  </nav>
110
252
  );
253
+
254
+ if (!placement) return panel;
255
+
256
+ return (
257
+ <div
258
+ ref={scrimRef}
259
+ className={`ui-drawer-scrim ui-drawer-scrim--${placement}`}
260
+ onMouseDown={(e) => {
261
+ if (e.target !== e.currentTarget) return;
262
+ // Keep the press from moving focus, so focus can go back on close.
263
+ e.preventDefault();
264
+ onClose();
265
+ }}
266
+ >
267
+ {panel}
268
+ </div>
269
+ );
111
270
  }
@@ -0,0 +1,95 @@
1
+ /*
2
+ * Badge styles as plain, importable CSS. This is the single copy of the
3
+ * .ui-badge rules. Badge.astro imports it itself, so a pure-Astro app needs
4
+ * nothing extra.
5
+ *
6
+ * React and Preact islands cannot render Astro components. An island that
7
+ * shows a badge or a removable filter tag writes the same markup as the
8
+ * Astro component and imports this sheet once:
9
+ *
10
+ * import "@kwtv/components/styles/badge.css";
11
+ *
12
+ * Import `@kwtv/tokens/tokens.css` before this file.
13
+ */
14
+
15
+ @layer reset, tokens, base, components, motion, utilities;
16
+
17
+ @layer components {
18
+ .ui-badge {
19
+ display: inline-flex;
20
+ align-items: center;
21
+ gap: var(--space-1-5);
22
+ min-height: 24px;
23
+ padding: 0 var(--space-2);
24
+ border: 1px solid transparent;
25
+ border-radius: var(--radius-full);
26
+ font-family: var(--font-sans);
27
+ font-size: var(--text-xs);
28
+ font-weight: var(--weight-medium);
29
+ line-height: 1;
30
+ white-space: nowrap;
31
+ }
32
+
33
+ .ui-badge__icon {
34
+ display: inline-flex;
35
+ flex-shrink: 0;
36
+ }
37
+
38
+ .ui-badge--neutral {
39
+ background: var(--surface-accent);
40
+ color: var(--surface-accent-fg, var(--text));
41
+ }
42
+
43
+ .ui-badge--success {
44
+ background: var(--success-bg);
45
+ color: var(--success);
46
+ }
47
+
48
+ .ui-badge--warning {
49
+ background: var(--warning-bg);
50
+ color: var(--warning);
51
+ }
52
+
53
+ .ui-badge--danger {
54
+ background: var(--danger-bg);
55
+ color: var(--danger);
56
+ }
57
+
58
+ /* Outline keeps the strong border and full-strength text, so it reads */
59
+ /* without relying on a fill. */
60
+ .ui-badge--outline {
61
+ background: transparent;
62
+ border: 1px solid var(--border-strong);
63
+ color: var(--text);
64
+ }
65
+
66
+ .ui-badge--removable {
67
+ padding-right: var(--space-1);
68
+ }
69
+
70
+ /* Small × target grows with padding plus an equal negative margin, so the */
71
+ /* hit area grows without moving the layout. */
72
+ .ui-badge__x {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ padding: var(--space-2);
77
+ margin: calc(-1 * var(--space-2));
78
+ margin-left: 0;
79
+ background: transparent;
80
+ border: 0;
81
+ border-radius: var(--radius-sm);
82
+ color: inherit;
83
+ cursor: pointer;
84
+ }
85
+
86
+ .ui-badge__x:hover {
87
+ color: var(--danger);
88
+ }
89
+
90
+ .ui-badge__x:focus-visible {
91
+ outline: 2px solid var(--focus);
92
+ outline-offset: 2px;
93
+ box-shadow: 0 0 0 4px var(--focus-outer);
94
+ }
95
+ }
@@ -158,6 +158,9 @@
158
158
  border: 1px solid transparent;
159
159
  border-radius: var(--radius-md);
160
160
  color: var(--text);
161
+ /* IconButton renders a link when it has an href. Keep the count pill
162
+ free of the link underline. */
163
+ text-decoration: none;
161
164
  cursor: pointer;
162
165
  transition:
163
166
  background-color var(--duration-fast) var(--ease-standard),