@kwtv/components 0.9.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,11 +1,18 @@
1
1
  ---
2
2
  import Icon from "./Icon.astro";
3
+ import "../styles/pager.css";
3
4
 
4
5
  /**
5
6
  * Pager. Appears once a list passes its page threshold. The current page is a
6
7
  * non-link with aria-current="page"; disabled arrows are spans, not links, so
7
8
  * keyboard focus never lands on a dead end. On mobile it collapses to
8
9
  * previous / current / next.
10
+ *
11
+ * `previousLabel` and `nextLabel` name the arrow links. They default to the
12
+ * Dutch "Vorige pagina" and "Volgende pagina".
13
+ *
14
+ * React and Preact islands write the same markup and import
15
+ * `@kwtv/components/styles/pager.css`.
9
16
  */
10
17
  interface Props {
11
18
  current: number;
@@ -23,6 +30,10 @@ interface Props {
23
30
  /** The query key the preserveScroll behaviour reads and writes. */
24
31
  param?: string;
25
32
  label?: string;
33
+ /** Accessible name of the previous arrow. Defaults to "Vorige pagina". */
34
+ previousLabel?: string;
35
+ /** Accessible name of the next arrow. Defaults to "Volgende pagina". */
36
+ nextLabel?: string;
26
37
  class?: string;
27
38
  }
28
39
 
@@ -33,6 +44,8 @@ const {
33
44
  preserveScroll = false,
34
45
  param = "page",
35
46
  label = "Paginering",
47
+ previousLabel = "Vorige pagina",
48
+ nextLabel = "Volgende pagina",
36
49
  class: className,
37
50
  ...rest
38
51
  } = Astro.props;
@@ -64,7 +77,7 @@ const hasNext = current < total;
64
77
  {...rest}
65
78
  >
66
79
  {hasPrev ? (
67
- <a href={href(current - 1)} aria-label="Vorige pagina">
80
+ <a href={href(current - 1)} aria-label={previousLabel}>
68
81
  <Icon name="chevron-left" size={17} stroke={2.3} />
69
82
  </a>
70
83
  ) : (
@@ -86,7 +99,7 @@ const hasNext = current < total;
86
99
  )}
87
100
 
88
101
  {hasNext ? (
89
- <a href={href(current + 1)} aria-label="Volgende pagina">
102
+ <a href={href(current + 1)} aria-label={nextLabel}>
90
103
  <Icon name="chevron-right" size={17} stroke={2.3} />
91
104
  </a>
92
105
  ) : (
@@ -166,55 +179,3 @@ const hasNext = current < total;
166
179
  bindUiPager();
167
180
  document.addEventListener("astro:page-load", bindUiPager);
168
181
  </script>
169
-
170
- <style>
171
- @layer reset, tokens, base, components, motion, utilities;
172
-
173
- @layer components {
174
- .ui-pager {
175
- display: flex;
176
- align-items: center;
177
- gap: var(--space-1);
178
- font-size: var(--text-sm);
179
- }
180
-
181
- .ui-pager a,
182
- .ui-pager span:not(.ui-pager__gap) {
183
- display: inline-flex;
184
- align-items: center;
185
- justify-content: center;
186
- min-width: 36px;
187
- height: 36px;
188
- padding: 0 var(--space-1);
189
- border-radius: var(--radius-md);
190
- color: var(--link);
191
- text-decoration: none;
192
- }
193
-
194
- .ui-pager a:hover {
195
- background: var(--surface-hover);
196
- color: var(--link-hover);
197
- }
198
- .ui-pager a:focus-visible {
199
- outline: 2px solid var(--focus);
200
- outline-offset: 2px;
201
- }
202
-
203
- .ui-pager a.is-current {
204
- background: var(--primary);
205
- color: var(--primary-fg);
206
- font-weight: var(--weight-semibold);
207
- }
208
-
209
- .ui-pager .is-disabled {
210
- color: var(--border);
211
- cursor: not-allowed;
212
- }
213
-
214
- .ui-pager__gap {
215
- min-width: 24px;
216
- text-align: center;
217
- color: var(--text-muted);
218
- }
219
- }
220
- </style>
@@ -1,10 +1,19 @@
1
1
  ---
2
+ import "../styles/product-card.css";
3
+
2
4
  /**
3
5
  * Product card. The whole card is one link, so the reader hears name, stock and
4
6
  * price as a single stop. A corner ribbon marks new, promo, last items or
5
7
  * member favourite or sold-out stock, one ribbon at a time. Ribbons are
6
8
  * decorative; sold-out text is repeated for assistive technology and the card
7
9
  * stays reachable.
10
+ *
11
+ * The ribbon words and the sold-out text default to Dutch. Pass `newLabel`,
12
+ * `promoLabel`, `lastLabel`, `favLabel` and `soldOutLabel` for another
13
+ * language. The screen-reader text is `soldOutLabel` followed by a full stop.
14
+ *
15
+ * React and Preact islands write the same markup and import
16
+ * `@kwtv/components/styles/product-card.css`.
8
17
  */
9
18
  interface Props {
10
19
  href: string;
@@ -24,6 +33,16 @@ interface Props {
24
33
  sizes?: string;
25
34
  /** AVIF srcset — when provided, a `<picture>` with an AVIF `<source>` is rendered. */
26
35
  avifSrcset?: string;
36
+ /** Ribbon text for `ribbon="new"`. Defaults to "Nieuw". */
37
+ newLabel?: string;
38
+ /** Ribbon text for `ribbon="promo"`. Defaults to "Promo". */
39
+ promoLabel?: string;
40
+ /** Ribbon text for `ribbon="last"`. Defaults to "Laatste". */
41
+ lastLabel?: string;
42
+ /** Ribbon text for `ribbon="fav"`. Defaults to "Favoriet". */
43
+ favLabel?: string;
44
+ /** Sold-out ribbon and screen-reader text. Defaults to "Uitverkocht". */
45
+ soldOutLabel?: string;
27
46
  }
28
47
 
29
48
  const {
@@ -40,14 +59,19 @@ const {
40
59
  srcset,
41
60
  sizes,
42
61
  avifSrcset,
62
+ newLabel = "Nieuw",
63
+ promoLabel = "Promo",
64
+ lastLabel = "Laatste",
65
+ favLabel = "Favoriet",
66
+ soldOutLabel = "Uitverkocht",
43
67
  ...rest
44
68
  } = Astro.props;
45
69
 
46
70
  const ribbonLabel: Record<NonNullable<Props["ribbon"]>, string> = {
47
- new: "Nieuw",
48
- promo: "Promo",
49
- last: "Laatste",
50
- fav: "Favoriet",
71
+ new: newLabel,
72
+ promo: promoLabel,
73
+ last: lastLabel,
74
+ fav: favLabel,
51
75
  };
52
76
 
53
77
  const classes = ["ui-product", soldOut && "is-disabled", className]
@@ -61,7 +85,7 @@ const classes = ["ui-product", soldOut && "is-disabled", className]
61
85
  class={`ui-ribbon ui-ribbon--${soldOut ? "sold-out" : ribbon}`}
62
86
  aria-hidden="true"
63
87
  >
64
- <i>{soldOut ? "Uitverkocht" : ribbonLabel[ribbon!]}</i>
88
+ <i>{soldOut ? soldOutLabel : ribbonLabel[ribbon!]}</i>
65
89
  </span>
66
90
  )}
67
91
  {avifSrcset ? (
@@ -91,146 +115,10 @@ const classes = ["ui-product", soldOut && "is-disabled", className]
91
115
  <span class="ui-product__body">
92
116
  <span class="ui-product__title">{title}</span>
93
117
  {meta && <span class="ui-product__meta">{meta}</span>}
94
- {soldOut && <span class="ui-sr-only">Uitverkocht.</span>}
118
+ {soldOut && <span class="ui-sr-only">{soldOutLabel}.</span>}
95
119
  <span class="ui-product__price">
96
120
  {oldPrice && <s>{oldPrice}</s>}
97
121
  {price}
98
122
  </span>
99
123
  </span>
100
124
  </a>
101
-
102
- <style>
103
- @layer reset, tokens, base, components, motion, utilities;
104
-
105
- @layer components {
106
- .ui-product {
107
- position: relative;
108
- display: flex;
109
- flex-direction: column;
110
- border: 1px solid var(--border);
111
- border-radius: var(--radius-lg);
112
- overflow: hidden;
113
- background: var(--surface);
114
- color: var(--text);
115
- text-decoration: none;
116
- transition: border-color var(--duration-fast) var(--ease-standard);
117
- }
118
-
119
- .ui-product:hover,
120
- .ui-product.is-hover {
121
- border-color: var(--border-strong);
122
- }
123
-
124
- .ui-product:focus-visible {
125
- outline: 2px solid var(--focus);
126
- outline-offset: 2px;
127
- box-shadow: 0 0 0 4px var(--focus-outer);
128
- }
129
-
130
- .ui-product__img {
131
- display: block;
132
- width: 100%;
133
- aspect-ratio: 4 / 3;
134
- object-fit: cover;
135
- background: var(--surface-hover);
136
- }
137
-
138
- .ui-product__body {
139
- display: flex;
140
- flex-direction: column;
141
- gap: var(--space-1);
142
- padding: var(--space-3) var(--space-4) var(--space-4);
143
- }
144
-
145
- .ui-product__title {
146
- font-size: var(--text-base);
147
- font-weight: var(--weight-semibold);
148
- color: var(--text);
149
- }
150
-
151
- .ui-product__meta {
152
- font-size: var(--text-xs);
153
- color: var(--text-muted);
154
- }
155
-
156
- .ui-product__price {
157
- display: flex;
158
- align-items: baseline;
159
- gap: var(--space-2);
160
- margin-top: var(--space-1);
161
- font-size: var(--text-lg);
162
- font-weight: var(--weight-semibold);
163
- font-variant-numeric: tabular-nums;
164
- color: var(--text);
165
- }
166
-
167
- .ui-product__price s {
168
- font-size: var(--text-sm);
169
- font-weight: var(--weight-regular);
170
- color: var(--text-muted);
171
- }
172
-
173
- /* Sold out: card stays readable and clickable. */
174
- .ui-product.is-disabled .ui-product__img {
175
- opacity: 0.7;
176
- }
177
- .ui-product.is-disabled .ui-product__price {
178
- color: var(--text-muted);
179
- font-weight: var(--weight-regular);
180
- }
181
-
182
- /* Ribbon. One per card, decorative. 112 px box. */
183
- .ui-ribbon {
184
- position: absolute;
185
- top: 0;
186
- left: 0;
187
- width: 112px;
188
- height: 112px;
189
- overflow: hidden;
190
- pointer-events: none;
191
- z-index: 1;
192
- }
193
- .ui-ribbon i {
194
- position: absolute;
195
- top: 22px;
196
- left: -34px;
197
- width: 140px;
198
- transform: rotate(-45deg);
199
- text-align: center;
200
- font-size: var(--text-2xs);
201
- font-weight: var(--weight-bold);
202
- letter-spacing: var(--tracking-wider);
203
- text-transform: uppercase;
204
- font-style: normal;
205
- padding: var(--space-1) 0;
206
- color: var(--primary-fg);
207
- background: var(--primary);
208
- }
209
- .ui-ribbon--new i {
210
- background: var(--primary);
211
- color: var(--primary-fg);
212
- }
213
- .ui-ribbon--promo i {
214
- background: var(--accent);
215
- color: var(--accent-fg);
216
- }
217
- .ui-ribbon--last i {
218
- background: var(--warning);
219
- color: var(--primary-fg);
220
- }
221
- .ui-ribbon--fav i {
222
- background: var(--surface-accent);
223
- color: var(--surface-accent-fg);
224
- }
225
- .ui-ribbon--sold-out i {
226
- background: var(--danger-bg);
227
- color: var(--danger);
228
- }
229
-
230
- @media (prefers-reduced-motion: reduce) {
231
- .ui-product {
232
- transition: none;
233
- }
234
- }
235
- }
236
- </style>
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import "../styles/filters.css";
3
+
2
4
  /**
3
5
  * Radio. Native control tinted with accent-color, wrapped in a label so the
4
6
  * whole row is the hit area. One answer per group: give every radio in the
@@ -6,6 +8,9 @@
6
8
  *
7
9
  * Put the group in a <fieldset> with a <legend> in the consuming page so the
8
10
  * question is announced before the options.
11
+ *
12
+ * React and Preact islands write the same markup and import
13
+ * `@kwtv/components/styles/filters.css`.
9
14
  */
10
15
  interface Props {
11
16
  label?: string;
@@ -45,49 +50,3 @@ const classes = ["ui-check", disabled && "is-disabled", className]
45
50
  />
46
51
  <slot>{label}</slot>
47
52
  </label>
48
-
49
- <style>
50
- @layer reset, tokens, base, components, motion, utilities;
51
-
52
- @layer components {
53
- .ui-check {
54
- display: flex;
55
- align-items: center;
56
- gap: var(--space-2);
57
- min-height: 32px;
58
- font-family: var(--font-sans);
59
- font-size: var(--text-sm);
60
- color: var(--text);
61
- cursor: pointer;
62
- }
63
-
64
- .ui-check input {
65
- width: 17px;
66
- height: 17px;
67
- flex-shrink: 0;
68
- accent-color: var(--primary);
69
- cursor: pointer;
70
- }
71
-
72
- .ui-check input:focus-visible {
73
- outline: 2px solid var(--focus);
74
- outline-offset: 2px;
75
- box-shadow: 0 0 0 4px var(--focus-outer);
76
- }
77
-
78
- .ui-check.is-disabled {
79
- cursor: not-allowed;
80
- color: var(--text-muted);
81
- }
82
-
83
- .ui-check.is-disabled input {
84
- cursor: not-allowed;
85
- }
86
-
87
- @media (max-width: 700px), (pointer: coarse) {
88
- .ui-check {
89
- min-height: 40px;
90
- }
91
- }
92
- }
93
- </style>
@@ -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>
@@ -74,6 +74,31 @@
74
74
  flex-shrink: 0;
75
75
  }
76
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
+
77
102
  /* Placement. The panel is fixed to one edge, over a scrim that stays dark
78
103
  in both themes, like the modal scrim. */
79
104
  .ui-drawer-scrim {
@@ -120,6 +145,15 @@
120
145
  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
121
146
  }
122
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
+
123
157
  @keyframes ui-drawer-fade {
124
158
  from {
125
159
  opacity: 0;
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, type ReactNode } from "react";
1
+ import { useEffect, useId, useRef, type ReactNode } from "react";
2
2
  import "../styles/controls.css";
3
3
  import "./Drawer.css";
4
4
 
@@ -21,6 +21,12 @@ import "./Drawer.css";
21
21
  * Pass `closeLabel` to show a close button at the top of the drawer. A drawer
22
22
  * with a `placement` covers its trigger, so give it one.
23
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
+ *
24
30
  * Uses only hooks and DOM calls that preact/compat supports.
25
31
  */
26
32
 
@@ -56,6 +62,16 @@ export interface DrawerProps {
56
62
  placement?: "start" | "end" | "bottom";
57
63
  /** Shows a close button with this accessible name, like "Menu sluiten". */
58
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";
59
75
  }
60
76
 
61
77
  const FOCUSABLE =
@@ -87,7 +103,10 @@ export default function Drawer({
87
103
  label = "Menu",
88
104
  placement,
89
105
  closeLabel,
106
+ title,
107
+ size = "default",
90
108
  }: DrawerProps) {
109
+ const titleId = useId();
91
110
  const rootRef = useRef<HTMLElement>(null);
92
111
  const scrimRef = useRef<HTMLDivElement>(null);
93
112
  const onCloseRef = useRef(onClose);
@@ -168,33 +187,45 @@ export default function Drawer({
168
187
 
169
188
  if (!open) return null;
170
189
 
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
+
171
212
  const panel = (
172
213
  <nav
173
- className={`ui-drawer${placement ? ` ui-drawer--${placement}` : ""}`}
174
- aria-label={label}
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}
175
217
  ref={rootRef}
176
218
  tabIndex={-1}
177
219
  >
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>
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
198
229
  )}
199
230
  {groups
200
231
  ? groups.map((group, gi) => (