@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,13 @@
1
1
  ---
2
+ import "../styles/feedback.css";
3
+
2
4
  /**
3
5
  * Empty state. Says what is missing and offers the one action that fixes it.
4
6
  * Never a shrug: an empty cart links back to the category page, an empty filter
5
7
  * result offers to clear filters.
8
+ *
9
+ * React and Preact islands write the same markup and import
10
+ * `@kwtv/components/styles/feedback.css`.
6
11
  */
7
12
  interface Props {
8
13
  title: string;
@@ -24,35 +29,3 @@ const hasAction = Astro.slots.has("action");
24
29
  </div>
25
30
  )}
26
31
  </div>
27
-
28
- <style>
29
- @layer reset, tokens, base, components, motion, utilities;
30
-
31
- @layer components {
32
- .ui-empty {
33
- display: flex;
34
- flex-direction: column;
35
- align-items: flex-start;
36
- gap: var(--space-2);
37
- padding: var(--space-8) var(--space-6);
38
- border: 1px dashed var(--border-strong);
39
- border-radius: var(--radius-lg);
40
- background: var(--surface);
41
- }
42
-
43
- .ui-empty__title {
44
- font-size: var(--text-lg);
45
- font-weight: var(--weight-semibold);
46
- color: var(--text);
47
- }
48
-
49
- .ui-empty__body {
50
- font-size: var(--text-sm);
51
- line-height: var(--leading-sm);
52
- color: var(--text-muted);
53
- }
54
-
55
- /* The flex `gap` above already spaces the action from the body; an added */
56
- /* margin here doubled that gap and threw off the rhythm between the two. */
57
- }
58
- </style>
@@ -8,21 +8,30 @@
8
8
  * with variant="sheet" and place the FilterGroups inside it. The result count
9
9
  * should be announced in a polite live region by the consumer when filters
10
10
  * change.
11
+ *
12
+ * `titleLabel` is the bar's eyebrow. It defaults to "Filters".
11
13
  */
12
14
  interface Props {
13
15
  /** Text for the clear-all action. Omit to hide it. */
14
16
  clearLabel?: string;
17
+ /** The eyebrow in the bar. Defaults to "Filters". */
18
+ titleLabel?: string;
15
19
  class?: string;
16
20
  }
17
21
 
18
- const { clearLabel = "Wissen", class: className, ...rest } = Astro.props;
22
+ const {
23
+ clearLabel = "Wissen",
24
+ titleLabel = "Filters",
25
+ class: className,
26
+ ...rest
27
+ } = Astro.props;
19
28
 
20
29
  const classes = ["ui-filters", className].filter(Boolean).join(" ");
21
30
  ---
22
31
 
23
32
  <aside class={classes} {...rest}>
24
33
  <div class="ui-filterbar">
25
- <span class="ui-eyebrow">Filters</span>
34
+ <span class="ui-eyebrow">{titleLabel}</span>
26
35
  {clearLabel && (
27
36
  <slot name="clear">
28
37
  <button class="ui-filters__clear" type="button">
@@ -8,6 +8,9 @@ import "../styles/controls.css";
8
8
  * The count pill is decorative (aria-hidden); the number must be repeated in
9
9
  * the label by the consumer, so it is announced once, not twice.
10
10
  *
11
+ * Pass `href` to render a link with the same look, for a control that goes to
12
+ * a page, like the cart. The link gets no `type` and no `aria-expanded`.
13
+ *
11
14
  * Pass `icon` to use the shared icon set, or a custom SVG via the default slot.
12
15
  */
13
16
  interface Props {
@@ -15,7 +18,11 @@ interface Props {
15
18
  icon?: string;
16
19
  /** Decorative count pill. Repeat the number in `label`. */
17
20
  count?: number;
21
+ /** Renders an `<a>` to this URL instead of a `<button>`. */
22
+ href?: string;
23
+ /** Button only. Ignored when `href` is set. */
18
24
  type?: "button" | "submit" | "reset";
25
+ /** Button only. Ignored when `href` is set. */
19
26
  expanded?: boolean;
20
27
  class?: string;
21
28
  }
@@ -24,6 +31,7 @@ const {
24
31
  label,
25
32
  icon,
26
33
  count,
34
+ href,
27
35
  type = "button",
28
36
  expanded,
29
37
  class: className,
@@ -33,17 +41,28 @@ const {
33
41
  const classes = ["ui-iconbtn", className].filter(Boolean).join(" ");
34
42
  ---
35
43
 
36
- <button
37
- class={classes}
38
- type={type}
39
- aria-label={label}
40
- aria-expanded={expanded === undefined ? undefined : String(expanded)}
41
- {...rest}
42
- >
43
- {icon ? <Icon name={icon as any} size={20} stroke={1.7} /> : <slot />}
44
- {count !== undefined && (
45
- <span class="ui-iconbtn__count" aria-hidden="true">
46
- {count}
47
- </span>
48
- )}
49
- </button>
44
+ {href ? (
45
+ <a class={classes} href={href} aria-label={label} {...rest}>
46
+ {icon ? <Icon name={icon as any} size={20} stroke={1.7} /> : <slot />}
47
+ {count !== undefined && (
48
+ <span class="ui-iconbtn__count" aria-hidden="true">
49
+ {count}
50
+ </span>
51
+ )}
52
+ </a>
53
+ ) : (
54
+ <button
55
+ class={classes}
56
+ type={type}
57
+ aria-label={label}
58
+ aria-expanded={expanded === undefined ? undefined : String(expanded)}
59
+ {...rest}
60
+ >
61
+ {icon ? <Icon name={icon as any} size={20} stroke={1.7} /> : <slot />}
62
+ {count !== undefined && (
63
+ <span class="ui-iconbtn__count" aria-hidden="true">
64
+ {count}
65
+ </span>
66
+ )}
67
+ </button>
68
+ )}
@@ -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,13 @@
1
1
  ---
2
+ import "../styles/feedback.css";
3
+
2
4
  /**
3
5
  * Loading skeleton. Blocks match the real layout so nothing jumps when content
4
6
  * arrives. The shimmer stops for readers who ask for reduced motion, leaving a
5
7
  * flat block. Decorative: aria-hidden by default.
8
+ *
9
+ * React and Preact islands write the same markup and import
10
+ * `@kwtv/components/styles/feedback.css`.
6
11
  */
7
12
  interface Props {
8
13
  variant?: "img" | "title" | "line" | "block";
@@ -40,59 +45,3 @@ const style = [
40
45
  aria-hidden="true"
41
46
  {...rest}
42
47
  ></span>
43
-
44
- <style>
45
- @layer reset, tokens, base, components, motion, utilities;
46
-
47
- @layer components {
48
- .ui-skel {
49
- display: block;
50
- background: var(--surface-hover);
51
- background-image: linear-gradient(
52
- 90deg,
53
- var(--surface-hover) 0%,
54
- var(--border) 50%,
55
- var(--surface-hover) 100%
56
- );
57
- background-size: 200% 100%;
58
- border-radius: var(--radius-md);
59
- animation: ui-skel-shimmer 1400ms ease-in-out infinite;
60
- }
61
-
62
- .ui-skel--img {
63
- width: 100%;
64
- aspect-ratio: 4 / 3;
65
- border-radius: 0;
66
- }
67
-
68
- .ui-skel--title {
69
- height: 0.875rem;
70
- border-radius: var(--radius-sm);
71
- }
72
-
73
- .ui-skel--line {
74
- height: 0.625rem;
75
- border-radius: var(--radius-sm);
76
- }
77
-
78
- .ui-skel--block {
79
- height: 1rem;
80
- }
81
-
82
- @keyframes ui-skel-shimmer {
83
- 0% {
84
- background-position: 200% 0;
85
- }
86
- 100% {
87
- background-position: -200% 0;
88
- }
89
- }
90
-
91
- @media (prefers-reduced-motion: reduce) {
92
- .ui-skel {
93
- animation: none;
94
- background-image: none;
95
- }
96
- }
97
- }
98
- </style>