@kwtv/components 0.7.1 → 0.8.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 CHANGED
@@ -10,7 +10,7 @@ there is no separate library build or consumer alias configuration.
10
10
  | Path | What it is |
11
11
  | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
12
12
  | `src/astro/*.astro` | Static components. Scoped styles, no client JS. |
13
- | `src/react/*.tsx` | Interactive islands. Select, Modal, Toast, Drawer, QuantityStepper, NewsCarousel. |
13
+ | `src/react/*.tsx` | Interactive islands. Select, Modal, Toast, Drawer, QuantityStepper, NewsCarousel, OrderStatus, OrderTimeline. |
14
14
  | `src/styles/base.css` | The shared primitives the React islands import: focus ring, touch targets, screen-reader helper. Astro components carry their own scoped styles. |
15
15
  | `src/styles/controls.css` | The one copy of the button and icon-button styles. `Button` and `IconButton` import it. React islands import it for the `ui-btn` / `ui-iconbtn` classes. |
16
16
  | `src/styles/navlink.css` | The one copy of the header nav link styles. `NavLink` and `NavDropdown` import it. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kwtv/components",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "KWTV UI components for Astro, with React islands. Built on the KWTV design tokens.",
@@ -39,6 +39,6 @@
39
39
  }
40
40
  },
41
41
  "dependencies": {
42
- "@kwtv/tokens": "^0.7.1"
42
+ "@kwtv/tokens": "^0.8.0"
43
43
  }
44
44
  }
@@ -38,7 +38,11 @@ const { summary, open = false, class: className, ...rest } = Astro.props;
38
38
  border-top: 1px solid var(--border);
39
39
  }
40
40
 
41
+ /* Set here, not left to the page reset. A summary inherits through the */
42
+ /* details element's shadow slot, so a reset that uses `inherit` leaves it */
43
+ /* content-box and the row grows past 44px. */
41
44
  .ui-accordion__sum {
45
+ box-sizing: border-box;
42
46
  display: flex;
43
47
  align-items: center;
44
48
  justify-content: space-between;
@@ -0,0 +1,206 @@
1
+ ---
2
+ /**
3
+ * Account navigation. The side list on the webshop account pages: overview,
4
+ * orders, details, password. Sign out sits apart at the end, so it is never
5
+ * hit by accident on the way down the list.
6
+ *
7
+ * The current page carries aria-current="page" and a filled row, not colour
8
+ * alone: the row also gets the strong weight. Below 700 px the list turns into
9
+ * one row that scrolls sideways, with sign out last. `mobile` pins that row in
10
+ * the explorer and in other narrow shells.
11
+ *
12
+ * Sign out is a link to a sign-out page or a form button in the `signout`
13
+ * slot. It ends a session, so a plain GET link that signs out on load is not
14
+ * enough; the page behind it should post.
15
+ */
16
+ interface Item {
17
+ label: string;
18
+ href: string;
19
+ current?: boolean;
20
+ /** A count after the label, such as open orders. Spoken with the label. */
21
+ count?: number;
22
+ }
23
+
24
+ interface Props {
25
+ items: Item[];
26
+ /** Names the landmark, e.g. "Mijn account". */
27
+ label: string;
28
+ mobile?: boolean;
29
+ class?: string;
30
+ }
31
+
32
+ const { items, label, mobile = false, class: className, ...rest } = Astro.props;
33
+ const hasSignout = Astro.slots.has("signout");
34
+
35
+ const classes = ["ui-accnav", mobile && "ui-accnav--mobile", className]
36
+ .filter(Boolean)
37
+ .join(" ");
38
+ ---
39
+
40
+ <nav class={classes} aria-label={label} {...rest}>
41
+ <ul class="ui-accnav__list">
42
+ {items.map((item) => (
43
+ <li>
44
+ <a
45
+ class="ui-accnav__link"
46
+ href={item.href}
47
+ aria-current={item.current ? "page" : undefined}
48
+ >
49
+ <span>{item.label}</span>
50
+ {item.count !== undefined && (
51
+ <span class="ui-accnav__count">{item.count}</span>
52
+ )}
53
+ </a>
54
+ </li>
55
+ ))}
56
+ </ul>
57
+ {hasSignout && (
58
+ <div class="ui-accnav__end">
59
+ <slot name="signout" />
60
+ </div>
61
+ )}
62
+ </nav>
63
+
64
+ <style>
65
+ @layer reset, tokens, base, components, motion, utilities;
66
+
67
+ @layer components {
68
+ .ui-accnav {
69
+ display: flex;
70
+ flex-direction: column;
71
+ gap: var(--space-3);
72
+ width: 100%;
73
+ max-width: 260px;
74
+ font-family: var(--font-sans);
75
+ }
76
+
77
+ .ui-accnav__list {
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: var(--space-1);
81
+ margin: 0;
82
+ padding: 0;
83
+ list-style: none;
84
+ }
85
+
86
+ .ui-accnav__link,
87
+ .ui-accnav__end :global(a),
88
+ .ui-accnav__end :global(button) {
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ gap: var(--space-3);
93
+ min-height: 44px;
94
+ padding: 0 var(--space-3);
95
+ border: 0;
96
+ border-radius: var(--radius-md);
97
+ background: transparent;
98
+ color: var(--text);
99
+ font: inherit;
100
+ font-size: var(--text-sm);
101
+ text-decoration: none;
102
+ text-align: left;
103
+ cursor: pointer;
104
+ transition: background-color var(--duration-fast) var(--ease-standard);
105
+ }
106
+
107
+ .ui-accnav__link:hover,
108
+ .ui-accnav__end :global(a:hover),
109
+ .ui-accnav__end :global(button:hover) {
110
+ background: var(--surface-hover);
111
+ }
112
+
113
+ .ui-accnav__link:focus-visible,
114
+ .ui-accnav__end :global(a:focus-visible),
115
+ .ui-accnav__end :global(button:focus-visible) {
116
+ outline: 2px solid var(--focus);
117
+ outline-offset: 2px;
118
+ box-shadow: 0 0 0 4px var(--focus-outer);
119
+ }
120
+
121
+ .ui-accnav__link[aria-current="page"] {
122
+ background: var(--surface-accent);
123
+ color: var(--surface-accent-fg);
124
+ font-weight: var(--weight-semibold);
125
+ }
126
+
127
+ .ui-accnav__count {
128
+ min-width: 22px;
129
+ padding: 0 var(--space-1-5);
130
+ border-radius: var(--radius-full);
131
+ background: var(--surface);
132
+ border: 1px solid var(--border);
133
+ font-size: var(--text-xs);
134
+ line-height: 20px;
135
+ font-weight: var(--weight-medium);
136
+ font-variant-numeric: tabular-nums;
137
+ text-align: center;
138
+ color: var(--text);
139
+ }
140
+
141
+ .ui-accnav__end {
142
+ padding-top: var(--space-3);
143
+ border-top: 1px solid var(--border);
144
+ }
145
+
146
+ .ui-accnav__end :global(a),
147
+ .ui-accnav__end :global(button) {
148
+ width: 100%;
149
+ color: var(--text-muted);
150
+ }
151
+
152
+ /* One row that scrolls sideways. Sign out stays last in the row. */
153
+ .ui-accnav--mobile {
154
+ flex-direction: row;
155
+ align-items: center;
156
+ max-width: none;
157
+ overflow-x: auto;
158
+ scrollbar-width: none;
159
+ /* Room for the focus ring, which the scroll box would clip. */
160
+ padding: var(--space-1);
161
+ }
162
+ .ui-accnav--mobile .ui-accnav__list {
163
+ flex-direction: row;
164
+ }
165
+ .ui-accnav--mobile .ui-accnav__link,
166
+ .ui-accnav--mobile .ui-accnav__end > :global(*) {
167
+ white-space: nowrap;
168
+ }
169
+ .ui-accnav--mobile .ui-accnav__end {
170
+ padding: 0 0 0 var(--space-3);
171
+ border-top: 0;
172
+ border-left: 1px solid var(--border);
173
+ }
174
+
175
+ @media (max-width: 700px) {
176
+ .ui-accnav {
177
+ flex-direction: row;
178
+ align-items: center;
179
+ max-width: none;
180
+ overflow-x: auto;
181
+ scrollbar-width: none;
182
+ padding: var(--space-1);
183
+ }
184
+ .ui-accnav__list {
185
+ flex-direction: row;
186
+ }
187
+ .ui-accnav__link,
188
+ .ui-accnav__end > :global(*) {
189
+ white-space: nowrap;
190
+ }
191
+ .ui-accnav__end {
192
+ padding: 0 0 0 var(--space-3);
193
+ border-top: 0;
194
+ border-left: 1px solid var(--border);
195
+ }
196
+ }
197
+
198
+ @media (prefers-reduced-motion: reduce) {
199
+ .ui-accnav__link,
200
+ .ui-accnav__end :global(a),
201
+ .ui-accnav__end :global(button) {
202
+ transition: none;
203
+ }
204
+ }
205
+ }
206
+ </style>
@@ -0,0 +1,131 @@
1
+ ---
2
+ /**
3
+ * Auth card. The frame for sign-in, registration and password reset in the
4
+ * webshop account: a title, a short intro, an optional message, the form and a
5
+ * footer line that switches to the other task. One task per card.
6
+ *
7
+ * The form is the default slot, so the page owns its fields, its submit and its
8
+ * script. Children of the slot stack with the form gap, a <form> included.
9
+ *
10
+ * Put an Alert in the `message` slot. After a failed sign-in, keep the wording
11
+ * neutral: never say which of the two fields was wrong, and never confirm that
12
+ * an email address has an account.
13
+ */
14
+ interface Props {
15
+ title: string;
16
+ /** One or two sentences under the title. */
17
+ intro?: string;
18
+ /** h1 on a page of its own, h2 when the card sits inside checkout. */
19
+ titleTag?: "h1" | "h2";
20
+ class?: string;
21
+ }
22
+
23
+ const {
24
+ title,
25
+ intro,
26
+ titleTag = "h1",
27
+ class: className,
28
+ ...rest
29
+ } = Astro.props;
30
+
31
+ const Title = titleTag;
32
+ const hasMessage = Astro.slots.has("message");
33
+ const hasFooter = Astro.slots.has("footer");
34
+
35
+ const classes = ["ui-auth", className].filter(Boolean).join(" ");
36
+ ---
37
+
38
+ <section class={classes} {...rest}>
39
+ <div class="ui-auth__hd">
40
+ <Title class="ui-auth__title">{title}</Title>
41
+ {intro && <p class="ui-auth__intro">{intro}</p>}
42
+ </div>
43
+ {hasMessage && <slot name="message" />}
44
+ <div class="ui-auth__body">
45
+ <slot />
46
+ </div>
47
+ {hasFooter && (
48
+ <div class="ui-auth__footer">
49
+ <slot name="footer" />
50
+ </div>
51
+ )}
52
+ </section>
53
+
54
+ <style>
55
+ @layer reset, tokens, base, components, motion, utilities;
56
+
57
+ @layer components {
58
+ .ui-auth {
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: var(--space-5);
62
+ width: 100%;
63
+ max-width: 440px;
64
+ padding: var(--space-8);
65
+ border: 1px solid var(--border);
66
+ border-radius: var(--radius-lg);
67
+ background: var(--surface);
68
+ color: var(--text);
69
+ font-family: var(--font-sans);
70
+ }
71
+
72
+ .ui-auth__hd {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: var(--space-2);
76
+ }
77
+
78
+ .ui-auth__title {
79
+ margin: 0;
80
+ font-size: var(--text-2xl);
81
+ line-height: var(--leading-2xl);
82
+ font-weight: var(--weight-semibold);
83
+ letter-spacing: var(--tracking-tight);
84
+ }
85
+
86
+ .ui-auth__intro {
87
+ margin: 0;
88
+ font-size: var(--text-sm);
89
+ line-height: var(--leading-sm);
90
+ color: var(--text-muted);
91
+ }
92
+
93
+ /* The page's own <form> is usually the only child. It stacks the same */
94
+ /* way as loose fields, so both read alike. */
95
+ .ui-auth__body,
96
+ .ui-auth__body > :global(form) {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: var(--space-4);
100
+ }
101
+
102
+ .ui-auth__footer {
103
+ padding-top: var(--space-5);
104
+ border-top: 1px solid var(--border);
105
+ font-size: var(--text-sm);
106
+ line-height: var(--leading-sm);
107
+ color: var(--text-muted);
108
+ }
109
+
110
+ .ui-auth__footer :global(a) {
111
+ color: var(--link);
112
+ font-weight: var(--weight-medium);
113
+ }
114
+ .ui-auth__footer :global(a:hover) {
115
+ color: var(--link-hover);
116
+ }
117
+ .ui-auth__footer :global(a:focus-visible) {
118
+ outline: 2px solid var(--focus);
119
+ outline-offset: 2px;
120
+ box-shadow: 0 0 0 4px var(--focus-outer);
121
+ border-radius: var(--radius-sm);
122
+ }
123
+
124
+ /* A phone keeps the frame but gives the fields the width. */
125
+ @media (max-width: 700px) {
126
+ .ui-auth {
127
+ padding: var(--space-6) var(--space-5);
128
+ }
129
+ }
130
+ }
131
+ </style>
@@ -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
+ }
@@ -0,0 +1,112 @@
1
+ import "./OrderTimeline.css";
2
+
3
+ /**
4
+ * Order timeline. Where one order is, for the customer: paid, being prepared,
5
+ * ready for pickup, picked up. One step per row, top to bottom, each with the
6
+ * date it happened. A React component because account pages are rendered in
7
+ * the browser from live data.
8
+ *
9
+ * A step is done, current or still to come. "stopped" ends the line for an
10
+ * order that will not go on, such as a cancelled one; the steps after it are
11
+ * left out by the consumer. Every state is a mark plus words, never colour
12
+ * alone: the current step carries aria-current="step" and the state is spelled
13
+ * out for screen readers.
14
+ */
15
+
16
+ export type OrderStepState = "done" | "current" | "todo" | "stopped";
17
+
18
+ export interface OrderStep {
19
+ label: string;
20
+ state: OrderStepState;
21
+ /** When it happened, e.g. "4 januari 2026, 14:32". Empty for steps to come. */
22
+ date?: string;
23
+ /** One line under the date, such as where to pick up. */
24
+ note?: string;
25
+ }
26
+
27
+ export interface OrderTimelineProps {
28
+ steps: OrderStep[];
29
+ /** Names the list, e.g. "Verloop van bestelling #1088". */
30
+ label: string;
31
+ /** Words read after each step's label. */
32
+ stateWords?: Record<OrderStepState, string>;
33
+ className?: string;
34
+ }
35
+
36
+ const defaultWords: Record<OrderStepState, string> = {
37
+ done: "afgerond",
38
+ current: "nu",
39
+ todo: "nog niet",
40
+ stopped: "gestopt",
41
+ };
42
+
43
+ function Mark({ state }: { state: OrderStepState }) {
44
+ if (state === "done") {
45
+ return (
46
+ <svg
47
+ width="12"
48
+ height="12"
49
+ viewBox="0 0 24 24"
50
+ fill="none"
51
+ stroke="currentColor"
52
+ strokeWidth="3"
53
+ strokeLinecap="round"
54
+ strokeLinejoin="round"
55
+ >
56
+ <path d="M5 13l4 4L19 7" />
57
+ </svg>
58
+ );
59
+ }
60
+ if (state === "stopped") {
61
+ return (
62
+ <svg
63
+ width="12"
64
+ height="12"
65
+ viewBox="0 0 24 24"
66
+ fill="none"
67
+ stroke="currentColor"
68
+ strokeWidth="3"
69
+ strokeLinecap="round"
70
+ >
71
+ <path d="M7 7l10 10M17 7L7 17" />
72
+ </svg>
73
+ );
74
+ }
75
+ return null;
76
+ }
77
+
78
+ export function OrderTimeline({
79
+ steps,
80
+ label,
81
+ stateWords = defaultWords,
82
+ className,
83
+ }: OrderTimelineProps) {
84
+ return (
85
+ <ol
86
+ className={["ui-otime", className].filter(Boolean).join(" ")}
87
+ aria-label={label}
88
+ >
89
+ {steps.map((step, i) => (
90
+ <li
91
+ key={i}
92
+ className={`ui-otime__step is-${step.state}`}
93
+ aria-current={step.state === "current" ? "step" : undefined}
94
+ >
95
+ <span className="ui-otime__dot" aria-hidden="true">
96
+ <Mark state={step.state} />
97
+ </span>
98
+ <span className="ui-otime__text">
99
+ <b className="ui-otime__label">
100
+ {step.label}
101
+ <span className="ui-sr-only">, {stateWords[step.state]}</span>
102
+ </b>
103
+ {step.date && <span className="ui-otime__date">{step.date}</span>}
104
+ {step.note && <span className="ui-otime__note">{step.note}</span>}
105
+ </span>
106
+ </li>
107
+ ))}
108
+ </ol>
109
+ );
110
+ }
111
+
112
+ export default OrderTimeline;