@kwtv/components 0.7.2 → 0.9.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,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,74 @@
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
+ /* Placement. The panel is fixed to one edge, over a scrim that stays dark
78
+ in both themes, like the modal scrim. */
79
+ .ui-drawer-scrim {
80
+ position: fixed;
81
+ inset: 0;
82
+ z-index: var(--z-modal);
83
+ display: flex;
84
+ background: color-mix(in srgb, var(--charcoal-xdark) 52%, transparent);
85
+ animation: ui-drawer-fade var(--duration-base) var(--ease-standard);
86
+ }
87
+
88
+ .ui-drawer-scrim--end {
89
+ justify-content: flex-end;
90
+ }
91
+
92
+ .ui-drawer-scrim--bottom {
93
+ align-items: flex-end;
94
+ }
95
+
96
+ .ui-drawer--start,
97
+ .ui-drawer--end,
98
+ .ui-drawer--bottom {
99
+ overflow-y: auto;
100
+ overscroll-behavior: contain;
101
+ box-shadow: var(--shadow-xl);
102
+ animation: ui-drawer-in var(--duration-slow) var(--ease-enter);
103
+ }
104
+
105
+ .ui-drawer--start,
106
+ .ui-drawer--end {
107
+ --_from: translateX(-100%);
108
+ width: min(360px, 100% - var(--space-8));
109
+ height: 100%;
110
+ }
111
+
112
+ .ui-drawer--end {
113
+ --_from: translateX(100%);
114
+ }
115
+
116
+ .ui-drawer--bottom {
117
+ --_from: translateY(100%);
118
+ width: 100%;
119
+ max-height: 85dvh;
120
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
121
+ }
122
+
123
+ @keyframes ui-drawer-fade {
124
+ from {
125
+ opacity: 0;
126
+ }
127
+ }
128
+
129
+ @keyframes ui-drawer-in {
130
+ from {
131
+ transform: var(--_from);
132
+ }
133
+ }
134
+
135
+ @media (prefers-reduced-motion: reduce) {
136
+ .ui-drawer-scrim,
137
+ .ui-drawer {
138
+ animation: none;
139
+ }
140
+ }
71
141
  }
@@ -1,15 +1,27 @@
1
1
  import { useEffect, 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
+ * Uses only hooks and DOM calls that preact/compat supports.
13
25
  */
14
26
 
15
27
  export interface DrawerLink {
@@ -37,6 +49,32 @@ export interface DrawerProps {
37
49
  /** Sticky call to action at the bottom, usually the cart button. */
38
50
  cta?: ReactNode;
39
51
  label?: string;
52
+ /**
53
+ * Leave unset for the panel under the header. "start", "end" or "bottom"
54
+ * fix the drawer to that edge of the viewport, over a scrim.
55
+ */
56
+ placement?: "start" | "end" | "bottom";
57
+ /** Shows a close button with this accessible name, like "Menu sluiten". */
58
+ closeLabel?: string;
59
+ }
60
+
61
+ const FOCUSABLE =
62
+ 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
63
+
64
+ /** Sets `inert` on everything outside `root`, except branches holding `keep`. */
65
+ function inertOutside(root: HTMLElement, keep: HTMLElement | null) {
66
+ const changed: Element[] = [];
67
+ let node: HTMLElement = root;
68
+ while (node !== document.body && node.parentElement) {
69
+ for (const sibling of Array.from(node.parentElement.children)) {
70
+ if (sibling === node || sibling.hasAttribute("inert")) continue;
71
+ if (keep && sibling.contains(keep)) continue;
72
+ sibling.setAttribute("inert", "");
73
+ changed.push(sibling);
74
+ }
75
+ node = node.parentElement;
76
+ }
77
+ return () => changed.forEach((el) => el.removeAttribute("inert"));
40
78
  }
41
79
 
42
80
  export default function Drawer({
@@ -47,19 +85,34 @@ export default function Drawer({
47
85
  children,
48
86
  cta,
49
87
  label = "Menu",
88
+ placement,
89
+ closeLabel,
50
90
  }: DrawerProps) {
51
- const navRef = useRef<HTMLElement>(null);
91
+ const rootRef = useRef<HTMLElement>(null);
92
+ const scrimRef = useRef<HTMLDivElement>(null);
93
+ const onCloseRef = useRef(onClose);
94
+ onCloseRef.current = onClose;
52
95
 
53
- // Focus the first row on open, return to the trigger on close.
96
+ // Make the page inert, move focus in on open, and send it back on close.
54
97
  useEffect(() => {
55
98
  if (!open) return;
56
- const first = navRef.current?.querySelector<HTMLElement>(".ui-drawerlink");
57
- const raf = requestAnimationFrame(() => first?.focus());
99
+ const root = rootRef.current;
100
+ if (!root) return;
101
+ const previous = document.activeElement as HTMLElement | null;
102
+ const trigger = triggerRef?.current ?? null;
103
+ const outer = placement ? (scrimRef.current ?? root) : root;
104
+ const restore = inertOutside(outer, placement ? null : trigger);
105
+ const first =
106
+ root.querySelector<HTMLElement>(".ui-drawerlink") ??
107
+ root.querySelector<HTMLElement>(FOCUSABLE) ??
108
+ root;
109
+ const raf = requestAnimationFrame(() => first.focus());
58
110
  return () => {
59
111
  cancelAnimationFrame(raf);
60
- triggerRef?.current?.focus?.();
112
+ restore();
113
+ (trigger ?? previous)?.focus?.();
61
114
  };
62
- }, [open, triggerRef]);
115
+ }, [open, placement, triggerRef]);
63
116
 
64
117
  // Lock the page behind the drawer.
65
118
  useEffect(() => {
@@ -71,20 +124,78 @@ export default function Drawer({
71
124
  };
72
125
  }, [open]);
73
126
 
74
- // Escape closes.
127
+ // Escape closes. Tab and Shift+Tab cycle through the drawer, plus the
128
+ // trigger when the drawer sits under the header.
75
129
  useEffect(() => {
76
130
  if (!open) return;
77
131
  const onKey = (e: globalThis.KeyboardEvent) => {
78
- if (e.key === "Escape") onClose();
132
+ if (e.key === "Escape") {
133
+ e.preventDefault();
134
+ onCloseRef.current();
135
+ return;
136
+ }
137
+ if (e.key !== "Tab") return;
138
+ const root = rootRef.current;
139
+ if (!root) return;
140
+ const items = Array.from(
141
+ root.querySelectorAll<HTMLElement>(FOCUSABLE),
142
+ ).filter((el) => el.getClientRects().length > 0);
143
+ const trigger = placement ? null : triggerRef?.current;
144
+ if (trigger && trigger.isConnected) {
145
+ const triggerFirst =
146
+ trigger.compareDocumentPosition(root) &
147
+ Node.DOCUMENT_POSITION_FOLLOWING;
148
+ if (triggerFirst) items.unshift(trigger);
149
+ else items.push(trigger);
150
+ }
151
+ e.preventDefault();
152
+ if (items.length === 0) {
153
+ root.focus();
154
+ return;
155
+ }
156
+ const index = items.indexOf(document.activeElement as HTMLElement);
157
+ const next =
158
+ index === -1
159
+ ? e.shiftKey
160
+ ? items.length - 1
161
+ : 0
162
+ : (index + (e.shiftKey ? -1 : 1) + items.length) % items.length;
163
+ items[next].focus();
79
164
  };
80
165
  document.addEventListener("keydown", onKey);
81
166
  return () => document.removeEventListener("keydown", onKey);
82
- }, [open, onClose]);
167
+ }, [open, placement, triggerRef]);
83
168
 
84
169
  if (!open) return null;
85
170
 
86
- return (
87
- <nav className="ui-drawer" aria-label={label} ref={navRef}>
171
+ const panel = (
172
+ <nav
173
+ className={`ui-drawer${placement ? ` ui-drawer--${placement}` : ""}`}
174
+ aria-label={label}
175
+ ref={rootRef}
176
+ tabIndex={-1}
177
+ >
178
+ {closeLabel && (
179
+ <button
180
+ className="ui-iconbtn ui-drawer__close"
181
+ type="button"
182
+ aria-label={closeLabel}
183
+ onClick={onClose}
184
+ >
185
+ <svg
186
+ width="20"
187
+ height="20"
188
+ viewBox="0 0 24 24"
189
+ fill="none"
190
+ stroke="currentColor"
191
+ strokeWidth="1.8"
192
+ strokeLinecap="round"
193
+ aria-hidden="true"
194
+ >
195
+ <path d="M7 7l10 10M17 7L7 17" />
196
+ </svg>
197
+ </button>
198
+ )}
88
199
  {groups
89
200
  ? groups.map((group, gi) => (
90
201
  <div className="ui-drawer__group" key={gi}>
@@ -108,4 +219,21 @@ export default function Drawer({
108
219
  {cta && <div className="ui-drawer__cta">{cta}</div>}
109
220
  </nav>
110
221
  );
222
+
223
+ if (!placement) return panel;
224
+
225
+ return (
226
+ <div
227
+ ref={scrimRef}
228
+ className={`ui-drawer-scrim ui-drawer-scrim--${placement}`}
229
+ onMouseDown={(e) => {
230
+ if (e.target !== e.currentTarget) return;
231
+ // Keep the press from moving focus, so focus can go back on close.
232
+ e.preventDefault();
233
+ onClose();
234
+ }}
235
+ >
236
+ {panel}
237
+ </div>
238
+ );
111
239
  }
@@ -0,0 +1,110 @@
1
+ /* Order status. All values from the design-system tokens. */
2
+
3
+ @layer reset, tokens, base, components, motion, utilities;
4
+
5
+ @layer components {
6
+ .ui-ostatus {
7
+ display: flex;
8
+ flex-direction: column;
9
+ align-items: center;
10
+ gap: var(--space-3);
11
+ width: 100%;
12
+ max-width: 560px;
13
+ padding: var(--space-10) var(--space-8);
14
+ border: 1px solid var(--border);
15
+ border-radius: var(--radius-lg);
16
+ background: var(--surface);
17
+ color: var(--text);
18
+ font-family: var(--font-sans);
19
+ text-align: center;
20
+ }
21
+
22
+ .ui-ostatus__icon {
23
+ display: inline-flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ width: 56px;
27
+ height: 56px;
28
+ margin-bottom: var(--space-2);
29
+ border-radius: var(--radius-full);
30
+ }
31
+
32
+ .ui-ostatus--neutral .ui-ostatus__icon {
33
+ background: var(--surface-accent);
34
+ color: var(--surface-accent-fg);
35
+ }
36
+ .ui-ostatus--success .ui-ostatus__icon {
37
+ background: var(--success-bg);
38
+ color: var(--success);
39
+ }
40
+ .ui-ostatus--warning .ui-ostatus__icon {
41
+ background: var(--warning-bg);
42
+ color: var(--warning);
43
+ }
44
+ .ui-ostatus--danger .ui-ostatus__icon {
45
+ background: var(--danger-bg);
46
+ color: var(--danger);
47
+ }
48
+
49
+ .ui-ostatus__icon--spin svg {
50
+ animation: ui-ostatus-spin 900ms linear infinite;
51
+ }
52
+
53
+ @keyframes ui-ostatus-spin {
54
+ to {
55
+ transform: rotate(360deg);
56
+ }
57
+ }
58
+
59
+ .ui-ostatus__title {
60
+ margin: 0;
61
+ font-size: var(--text-2xl);
62
+ line-height: var(--leading-2xl);
63
+ font-weight: var(--weight-semibold);
64
+ letter-spacing: var(--tracking-tight);
65
+ }
66
+
67
+ .ui-ostatus__ref {
68
+ margin: 0;
69
+ font-size: var(--text-sm);
70
+ color: var(--text-muted);
71
+ }
72
+
73
+ .ui-ostatus__ref b {
74
+ font-weight: var(--weight-semibold);
75
+ font-variant-numeric: tabular-nums;
76
+ color: var(--text);
77
+ }
78
+
79
+ .ui-ostatus__body {
80
+ max-width: 44ch;
81
+ font-size: var(--text-base);
82
+ line-height: var(--leading-base);
83
+ color: var(--text-muted);
84
+ }
85
+
86
+ .ui-ostatus__body p {
87
+ margin: 0;
88
+ }
89
+
90
+ .ui-ostatus__actions {
91
+ display: flex;
92
+ flex-wrap: wrap;
93
+ justify-content: center;
94
+ gap: var(--space-3);
95
+ margin-top: var(--space-3);
96
+ }
97
+
98
+ @media (max-width: 700px) {
99
+ .ui-ostatus {
100
+ padding: var(--space-8) var(--space-5);
101
+ }
102
+ }
103
+
104
+ /* The ring stops. The icon and the title still say it is checking. */
105
+ @media (prefers-reduced-motion: reduce) {
106
+ .ui-ostatus__icon--spin svg {
107
+ animation: none !important;
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,144 @@
1
+ import type { ReactNode } from "react";
2
+ import "./OrderStatus.css";
3
+
4
+ /**
5
+ * Order status. The page Mollie sends the customer back to. The redirect says
6
+ * nothing about the payment, so the page starts at "checking" and the island
7
+ * around it asks the shop for the real status, then passes the new state in.
8
+ * It never shows success from the redirect alone.
9
+ *
10
+ * The block is one polite live region. Its role stays the same for every
11
+ * state, because a role that changes after load is not reliably announced. A
12
+ * failed payment is still read out, just without interrupting.
13
+ *
14
+ * Every state pairs an icon with a worded title. The body and the actions are
15
+ * the consumer's: what to do next, a retry button, a link to the order.
16
+ */
17
+
18
+ export type OrderStatusState =
19
+ "checking" | "paid" | "open" | "failed" | "expired" | "cancelled";
20
+
21
+ export interface OrderStatusProps {
22
+ state: OrderStatusState;
23
+ /** Overrides the default title for the state. */
24
+ title?: string;
25
+ /** Shown as "Bestelling #1088" under the title. */
26
+ orderNumber?: string;
27
+ orderLabel?: string;
28
+ /** h1 on the return page, h2 inside an account page. */
29
+ headingLevel?: 1 | 2;
30
+ children?: ReactNode;
31
+ /** Buttons or links, such as "Opnieuw betalen" or "Bekijk je bestelling". */
32
+ actions?: ReactNode;
33
+ className?: string;
34
+ }
35
+
36
+ const details: Record<
37
+ OrderStatusState,
38
+ { tone: string; icon: string; title: string }
39
+ > = {
40
+ checking: {
41
+ tone: "neutral",
42
+ icon: "busy",
43
+ title: "We controleren je betaling",
44
+ },
45
+ paid: {
46
+ tone: "success",
47
+ icon: "check",
48
+ title: "Bedankt, je betaling is gelukt",
49
+ },
50
+ open: { tone: "neutral", icon: "clock", title: "We wachten op je betaling" },
51
+ failed: {
52
+ tone: "danger",
53
+ icon: "alert-circle",
54
+ title: "Je betaling is niet gelukt",
55
+ },
56
+ expired: {
57
+ tone: "warning",
58
+ icon: "alert-triangle",
59
+ title: "Je betaling is verlopen",
60
+ },
61
+ cancelled: {
62
+ tone: "warning",
63
+ icon: "close",
64
+ title: "Je hebt de betaling geannuleerd",
65
+ },
66
+ };
67
+
68
+ // The same strokes as the Astro Icon component, so both read as one set.
69
+ const paths: Record<string, ReactNode> = {
70
+ busy: <path d="M12 3a9 9 0 1 0 9 9" />,
71
+ check: <path d="M5 13l4 4L19 7" />,
72
+ clock: (
73
+ <>
74
+ <circle cx="12" cy="12" r="9" />
75
+ <path d="M12 7v5l3 2" />
76
+ </>
77
+ ),
78
+ "alert-circle": (
79
+ <>
80
+ <circle cx="12" cy="12" r="9" />
81
+ <path d="M12 8v4.5M12 16v.5" />
82
+ </>
83
+ ),
84
+ "alert-triangle": <path d="M12 4l8.5 15h-17L12 4zM12 10v4M12 16.5v.5" />,
85
+ close: <path d="M7 7l10 10M17 7L7 17" />,
86
+ };
87
+
88
+ export function OrderStatus({
89
+ state,
90
+ title,
91
+ orderNumber,
92
+ orderLabel = "Bestelling",
93
+ headingLevel = 1,
94
+ children,
95
+ actions,
96
+ className,
97
+ }: OrderStatusProps) {
98
+ const detail = details[state];
99
+ const Heading = headingLevel === 1 ? "h1" : "h2";
100
+
101
+ return (
102
+ <div
103
+ className={["ui-ostatus", `ui-ostatus--${detail.tone}`, className]
104
+ .filter(Boolean)
105
+ .join(" ")}
106
+ role="status"
107
+ aria-busy={state === "checking" ? "true" : undefined}
108
+ data-state={state}
109
+ >
110
+ <span
111
+ className={[
112
+ "ui-ostatus__icon",
113
+ state === "checking" && "ui-ostatus__icon--spin",
114
+ ]
115
+ .filter(Boolean)
116
+ .join(" ")}
117
+ aria-hidden="true"
118
+ >
119
+ <svg
120
+ width="24"
121
+ height="24"
122
+ viewBox="0 0 24 24"
123
+ fill="none"
124
+ stroke="currentColor"
125
+ strokeWidth="2"
126
+ strokeLinecap="round"
127
+ strokeLinejoin="round"
128
+ >
129
+ {paths[detail.icon]}
130
+ </svg>
131
+ </span>
132
+ <Heading className="ui-ostatus__title">{title ?? detail.title}</Heading>
133
+ {orderNumber && (
134
+ <p className="ui-ostatus__ref">
135
+ {orderLabel} <b>{orderNumber}</b>
136
+ </p>
137
+ )}
138
+ {children && <div className="ui-ostatus__body">{children}</div>}
139
+ {actions && <div className="ui-ostatus__actions">{actions}</div>}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ export default OrderStatus;
@@ -0,0 +1,104 @@
1
+ /* Order timeline. All values from the design-system tokens. */
2
+
3
+ @layer reset, tokens, base, components, motion, utilities;
4
+
5
+ @layer components {
6
+ .ui-otime {
7
+ display: flex;
8
+ flex-direction: column;
9
+ margin: 0;
10
+ padding: 0;
11
+ list-style: none;
12
+ font-family: var(--font-sans);
13
+ color: var(--text);
14
+ }
15
+
16
+ .ui-otime__step {
17
+ position: relative;
18
+ display: flex;
19
+ gap: var(--space-3);
20
+ padding-bottom: var(--space-5);
21
+ }
22
+
23
+ .ui-otime__step:last-child {
24
+ padding-bottom: 0;
25
+ }
26
+
27
+ /* The line runs from under one dot to the next. */
28
+ .ui-otime__step:not(:last-child)::before {
29
+ content: "";
30
+ position: absolute;
31
+ top: 26px;
32
+ bottom: 2px;
33
+ left: 10px;
34
+ width: 2px;
35
+ border-radius: var(--radius-full);
36
+ background: var(--border);
37
+ }
38
+
39
+ .ui-otime__step.is-done:not(:last-child)::before {
40
+ background: var(--primary);
41
+ }
42
+
43
+ .ui-otime__dot {
44
+ display: inline-flex;
45
+ flex-shrink: 0;
46
+ align-items: center;
47
+ justify-content: center;
48
+ width: 22px;
49
+ height: 22px;
50
+ border: 2px solid var(--border-strong);
51
+ border-radius: var(--radius-full);
52
+ background: var(--surface);
53
+ color: var(--primary-fg);
54
+ }
55
+
56
+ .is-done .ui-otime__dot {
57
+ border-color: var(--primary);
58
+ background: var(--primary);
59
+ }
60
+
61
+ /* Current: a ring with a filled centre, so it reads apart from done. */
62
+ .is-current .ui-otime__dot {
63
+ border-color: var(--primary);
64
+ box-shadow: inset 0 0 0 4px var(--surface);
65
+ background: var(--primary);
66
+ }
67
+
68
+ .is-stopped .ui-otime__dot {
69
+ border-color: var(--danger);
70
+ background: var(--danger-bg);
71
+ color: var(--danger);
72
+ }
73
+
74
+ .ui-otime__text {
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: var(--space-0-5);
78
+ padding-top: 1px;
79
+ min-width: 0;
80
+ }
81
+
82
+ .ui-otime__label {
83
+ font-size: var(--text-sm);
84
+ line-height: var(--leading-sm);
85
+ font-weight: var(--weight-medium);
86
+ }
87
+
88
+ .is-current .ui-otime__label {
89
+ font-weight: var(--weight-semibold);
90
+ }
91
+
92
+ .is-todo .ui-otime__label {
93
+ color: var(--text-muted);
94
+ font-weight: var(--weight-regular);
95
+ }
96
+
97
+ .ui-otime__date,
98
+ .ui-otime__note {
99
+ font-size: var(--text-xs);
100
+ line-height: var(--leading-xs);
101
+ color: var(--text-muted);
102
+ font-variant-numeric: tabular-nums;
103
+ }
104
+ }