@kwtv/components 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kwtv/components",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.",
@@ -34,6 +34,6 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
- "@kwtv/tokens": "^0.1.1"
37
+ "@kwtv/tokens": "^0.1.3"
38
38
  }
39
39
  }
@@ -47,7 +47,7 @@ const classes = ["ui-alert", `ui-alert--${tone}`, className]
47
47
  gap: var(--space-2-5, 0.625rem);
48
48
  padding: var(--space-3) var(--space-4);
49
49
  border-left: 3px solid currentColor;
50
- border-radius: var(--radius-sm);
50
+ border-radius: var(--radius-md);
51
51
  font-family: var(--font-sans);
52
52
  font-size: var(--text-sm);
53
53
  line-height: var(--leading-sm);
@@ -7,8 +7,12 @@
7
7
  * That is the whole reason this is a component and not a background utility:
8
8
  * putting body text on sage without the flip fails AA.
9
9
  *
10
- * Cards placed on the band fall back to the paper surface, so they still read
11
- * as cards rather than dissolving into the band behind them.
10
+ * Cards placed on the band move to a lighter tint of the band's own surface,
11
+ * so they read as part of the band rather than a return to plain paper.
12
+ *
13
+ * Corner radius matches every other surface in the system (Card, PickCard,
14
+ * NavDropdown's panel): --radius-lg. A band that sits inset in a page, next
15
+ * to a rounded card, needs the same rounding or the two read as unrelated.
12
16
  */
13
17
  interface Props {
14
18
  /** Renders as <section> by default. Use "div" when the band is not a
@@ -29,7 +33,8 @@ const classes = ["ui-band", className].filter(Boolean).join(" ");
29
33
  <style>
30
34
  .ui-band {
31
35
  background: var(--surface-accent);
32
- border-top: 1px solid var(--border-strong);
36
+ border: 1px solid var(--border-strong);
37
+ border-radius: var(--radius-lg);
33
38
  color: var(--surface-accent-fg);
34
39
  }
35
40
 
@@ -56,11 +61,20 @@ const classes = ["ui-band", className].filter(Boolean).join(" ");
56
61
  color: var(--surface-accent-muted);
57
62
  }
58
63
 
59
- /* A card on the band returns to paper, so it still reads as a raised thing. */
64
+ /* A card on the band moves to a lighter tint of the band's own surface,
65
+ rather than to plain paper, so it still reads as part of the band. It
66
+ carries its own foreground role: --surface-accent-fg is tuned for the
67
+ band's own, darker/mid tint and is not guaranteed to clear AA on this
68
+ lighter one. */
60
69
  .ui-band :global(.ui-card),
61
70
  .ui-band :global(.ui-card--raised) {
62
- background: var(--surface);
63
- border-color: var(--border);
71
+ background: var(--surface-accent-subtle);
72
+ border-color: var(--border-strong);
73
+ }
74
+ .ui-band :global(.ui-card__eyebrow),
75
+ .ui-band :global(.ui-card__title),
76
+ .ui-band :global(.ui-card__body) {
77
+ color: var(--surface-accent-subtle-fg);
64
78
  }
65
79
 
66
80
  /* Buttons keep their own label colours: they carry their own fill, so the
@@ -1,5 +1,8 @@
1
1
  ---
2
- /** Decorative drifting dot field used behind short hero copy. */
2
+ /** Decorative drifting dot field used behind short hero copy.
3
+ * Override --dotgrid-color and --dotgrid-opacity for a different backing surface;
4
+ * verify the brightest moving dot against the copy, not only the flat surface.
5
+ */
3
6
  interface Props {
4
7
  compact?: boolean;
5
8
  class?: string;
@@ -29,9 +32,9 @@ const classes = ["pl-dotgrid", compact && "pl-dotgrid--compact", className]
29
32
  .pl-dotgrid__dots {
30
33
  position: absolute;
31
34
  inset: 0;
32
- background-image: radial-gradient(var(--sage-light) 1.4px, transparent 1.6px);
35
+ background-image: radial-gradient(var(--dotgrid-color, var(--sage-light)) 1.4px, transparent 1.6px);
33
36
  background-size: 28px 28px;
34
- opacity: 0.7;
37
+ opacity: var(--dotgrid-opacity, var(--dotgrid-theme-opacity, .7));
35
38
  animation: pl-drift 6s linear infinite;
36
39
  }
37
40
 
@@ -54,6 +57,12 @@ const classes = ["pl-dotgrid", compact && "pl-dotgrid--compact", className]
54
57
  font-size: var(--text-base);
55
58
  }
56
59
 
60
+ /* Explicit themes win over OS preference, as in the token cascade. */
61
+ :global([data-theme="dark"]) .pl-dotgrid { --dotgrid-theme-opacity: .2; }
62
+ @media (prefers-color-scheme: dark) {
63
+ :global(:root:not([data-theme])) .pl-dotgrid { --dotgrid-theme-opacity: .2; }
64
+ }
65
+
57
66
  @media (prefers-reduced-motion: reduce) {
58
67
  .pl-dotgrid__dots {
59
68
  animation: none;
@@ -29,7 +29,7 @@ const hasAction = Astro.slots.has("action");
29
29
  flex-direction: column;
30
30
  align-items: flex-start;
31
31
  gap: var(--space-2);
32
- padding: var(--space-10) var(--space-6);
32
+ padding: var(--space-8) var(--space-6);
33
33
  border: 1px dashed var(--border-strong);
34
34
  border-radius: var(--radius-lg);
35
35
  background: var(--surface);
@@ -47,7 +47,6 @@ const hasAction = Astro.slots.has("action");
47
47
  color: var(--text-muted);
48
48
  }
49
49
 
50
- .ui-empty__action {
51
- margin-top: var(--space-2);
52
- }
50
+ /* The flex `gap` above already spaces the action from the body; an added
51
+ margin here doubled that gap and threw off the rhythm between the two. */
53
52
  </style>
@@ -52,8 +52,8 @@ const classes = ["ui-footer", `ui-footer--${variant}`, className]
52
52
  grid-template-columns: 1.5fr 1fr 1.3fr;
53
53
  gap: var(--space-10);
54
54
  padding: var(--space-10);
55
- background: var(--sage-xdark);
56
- color: var(--ivory-light);
55
+ background: var(--surface-inverse);
56
+ color: var(--surface-inverse-fg);
57
57
  }
58
58
 
59
59
  .ui-footer__brandblock {
@@ -71,13 +71,13 @@ const classes = ["ui-footer", `ui-footer--${variant}`, className]
71
71
  }
72
72
  .ui-footer__brand :global(span) {
73
73
  font-weight: var(--weight-regular);
74
- color: var(--sage-light);
74
+ color: var(--surface-inverse-muted);
75
75
  }
76
76
 
77
77
  .ui-footer__addr {
78
78
  font-size: var(--text-sm);
79
79
  line-height: var(--leading-sm);
80
- color: var(--ivory-light);
80
+ color: var(--surface-inverse-fg);
81
81
  }
82
82
  .ui-footer__addr :global(a) {
83
83
  color: inherit;
@@ -101,9 +101,9 @@ const classes = ["ui-footer", `ui-footer--${variant}`, className]
101
101
  flex-wrap: wrap;
102
102
  margin-top: var(--space-4);
103
103
  padding-top: var(--space-5);
104
- border-top: 1px solid var(--sage-text);
104
+ border-top: 1px solid var(--surface-inverse-border);
105
105
  font-size: var(--text-xs);
106
- color: var(--sage-light);
106
+ color: var(--surface-inverse-muted);
107
107
  }
108
108
 
109
109
  .ui-footer__policies {
@@ -112,7 +112,7 @@ const classes = ["ui-footer", `ui-footer--${variant}`, className]
112
112
  gap: var(--space-4);
113
113
  }
114
114
  .ui-footer__bar :global(a) {
115
- color: var(--ivory-light);
115
+ color: var(--surface-inverse-fg);
116
116
  text-decoration: underline;
117
117
  text-underline-offset: 2px;
118
118
  }
@@ -156,7 +156,7 @@ const classes = ["ui-footer", `ui-footer--${variant}`, className]
156
156
  order: 1;
157
157
  font-size: var(--text-lg);
158
158
  font-weight: var(--weight-regular);
159
- color: var(--sage-light);
159
+ color: var(--surface-inverse-muted);
160
160
  }
161
161
  .ui-footer--mobile.ui-footer--site .ui-footer__addr {
162
162
  order: 2;
@@ -178,7 +178,7 @@ const classes = ["ui-footer", `ui-footer--${variant}`, className]
178
178
  .ui-footer--mobile.ui-footer--site .ui-footer__social :global(.ui-social) {
179
179
  width: 48px;
180
180
  height: 48px;
181
- border: 1px solid var(--sage-text);
181
+ border: 1px solid var(--surface-inverse-border);
182
182
  border-radius: var(--radius-full);
183
183
  }
184
184
  .ui-footer--mobile.ui-footer--site :global(.ui-footer__newsletter) {
@@ -38,11 +38,11 @@ const { items, divider = "✦", class: className } = Astro.props;
38
38
 
39
39
  <style>
40
40
  .ui-marquee {
41
- background: var(--sage-xdark);
42
- color: var(--ivory-light);
41
+ background: var(--surface-banner);
42
+ color: var(--surface-inverse-fg);
43
43
  overflow: hidden;
44
44
  padding-block: var(--space-3);
45
- border-block: 1px solid var(--sage-text);
45
+ border-block: 1px solid var(--surface-inverse-border);
46
46
  transform: rotate(-1.2deg) scale(1.02);
47
47
  }
48
48
  .ui-marquee__track {
@@ -3,9 +3,13 @@ import Icon from "./Icon.astro";
3
3
 
4
4
  /**
5
5
  * A header navigation group: a button trigger plus a panel of links. The panel
6
- * opens on hover and on :focus-within, so it is reachable without a mouse. The
7
- * trigger is a real <button> with aria-expanded; for static rendering the open
8
- * state is CSS-driven. Pass `open` to pin it open for specimens.
6
+ * opens on hover and on :focus-within for mouse and keyboard users, but a tap
7
+ * on a touchscreen fires neither, and Safari does not focus a <button> on
8
+ * click either: without more, those users could never open it. So the trigger
9
+ * also carries a click handler that toggles `.is-open` directly, with an
10
+ * outside-click and Escape to close. That script is what makes the dropdown
11
+ * reachable by tap; the CSS-only hover/focus-within path keeps working
12
+ * underneath it for everyone else. Pass `open` to pin it open for specimens.
9
13
  *
10
14
  * Put <a> links in the default slot; they fill the panel.
11
15
  */
@@ -22,7 +26,7 @@ const classes = ["ui-navgroup", open && "is-open", className]
22
26
  .join(" ");
23
27
  ---
24
28
 
25
- <div class={classes} {...rest}>
29
+ <div class={classes} data-navdropdown {...rest}>
26
30
  <button class="ui-navlink" type="button" aria-expanded={open ? "true" : "false"}>
27
31
  {label}
28
32
  <span aria-hidden="true"><Icon name="chevron-down" size={11} stroke={2.4} /></span>
@@ -32,6 +36,49 @@ const classes = ["ui-navgroup", open && "is-open", className]
32
36
  </div>
33
37
  </div>
34
38
 
39
+ <script>
40
+ // Click-to-toggle, on top of the CSS hover/focus-within path: the only way
41
+ // a touchscreen tap, or a Safari click (which does not focus a <button>),
42
+ // can open the panel at all.
43
+ document.querySelectorAll<HTMLElement>("[data-navdropdown]").forEach((group) => {
44
+ const trigger = group.querySelector<HTMLButtonElement>(".ui-navlink");
45
+ if (!trigger) return;
46
+
47
+ const close = () => {
48
+ group.classList.remove("is-open");
49
+ trigger.setAttribute("aria-expanded", "false");
50
+ };
51
+
52
+ trigger.addEventListener("click", (event) => {
53
+ const willOpen = !group.classList.contains("is-open");
54
+ document.querySelectorAll<HTMLElement>("[data-navdropdown].is-open").forEach((other) => {
55
+ if (other === group) return;
56
+ other.classList.remove("is-open");
57
+ other.querySelector<HTMLButtonElement>(".ui-navlink")?.setAttribute("aria-expanded", "false");
58
+ });
59
+ group.classList.toggle("is-open", willOpen);
60
+ trigger.setAttribute("aria-expanded", String(willOpen));
61
+ event.stopPropagation();
62
+ });
63
+
64
+ group.addEventListener("keydown", (event) => {
65
+ if (event.key === "Escape") {
66
+ close();
67
+ trigger.focus();
68
+ }
69
+ });
70
+ });
71
+
72
+ document.addEventListener("click", (event) => {
73
+ document.querySelectorAll<HTMLElement>("[data-navdropdown].is-open").forEach((group) => {
74
+ if (!group.contains(event.target as Node)) {
75
+ group.classList.remove("is-open");
76
+ group.querySelector<HTMLButtonElement>(".ui-navlink")?.setAttribute("aria-expanded", "false");
77
+ }
78
+ });
79
+ });
80
+ </script>
81
+
35
82
  <style>
36
83
  .ui-navgroup {
37
84
  position: relative;
@@ -69,21 +69,21 @@ const classes = ["ui-news", stacked && "ui-news--stacked", className]
69
69
  min-width: 0;
70
70
  min-height: 48px;
71
71
  padding: 0 var(--space-4);
72
- background: var(--ivory-xlight);
73
- border: 1px solid transparent;
72
+ background: var(--surface-light);
73
+ border: 1px solid var(--border-strong-light);
74
74
  border-radius: var(--radius-md);
75
75
  font-family: var(--font-sans);
76
76
  font-size: var(--text-sm);
77
- color: var(--text);
77
+ color: var(--text-light);
78
78
  }
79
79
  .ui-news__input::placeholder {
80
- color: var(--text-muted);
80
+ color: var(--text-muted-light);
81
81
  }
82
82
  .ui-news__input:focus-visible,
83
83
  .ui-news__input.is-focus {
84
- outline: 2px solid var(--ivory-xlight);
84
+ outline: 2px solid var(--focus);
85
85
  outline-offset: 2px;
86
- box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.25);
86
+ box-shadow: 0 0 0 4px var(--focus-outer);
87
87
  }
88
88
 
89
89
  .ui-news__btn {
@@ -105,9 +105,9 @@ const classes = ["ui-news", stacked && "ui-news--stacked", className]
105
105
  background: var(--sage-xlight);
106
106
  }
107
107
  .ui-news__btn:focus-visible {
108
- outline: 2px solid var(--ivory-xlight);
108
+ outline: 2px solid var(--focus);
109
109
  outline-offset: 2px;
110
- box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.25);
110
+ box-shadow: 0 0 0 4px var(--focus-outer);
111
111
  }
112
112
 
113
113
  @media (prefers-reduced-motion: reduce) {
@@ -14,7 +14,7 @@ const classes = ["pl-sticker", `pl-sticker--${size}`, className]
14
14
 
15
15
  <span class={classes} aria-hidden="true" {...rest}>
16
16
  <svg class="pl-sticker__disc" viewBox="0 0 112 112">
17
- <circle cx="56" cy="56" r="52" fill="var(--sage-xdark)"></circle>
17
+ <circle cx="56" cy="56" r="52" fill="var(--surface-inverse)"></circle>
18
18
  <circle class="pl-sticker__ring" cx="56" cy="56" r="47" fill="none" stroke="var(--sage-light)" stroke-width="1.5" stroke-dasharray="5 7"></circle>
19
19
  </svg>
20
20
  <span class="pl-sticker__content">
@@ -58,7 +58,7 @@ const classes = ["pl-sticker", `pl-sticker--${size}`, className]
58
58
  align-items: center;
59
59
  justify-content: center;
60
60
  gap: var(--space-1);
61
- color: var(--ivory-light);
61
+ color: var(--surface-inverse-fg);
62
62
  text-align: center;
63
63
  font-size: var(--text-xs);
64
64
  font-weight: var(--weight-semibold);
@@ -144,7 +144,7 @@ const charCount = count && maxlength ? `${String(value).length} / ${maxlength}`
144
144
  min-height: 40px;
145
145
  padding: var(--space-2) var(--space-3);
146
146
  background: var(--surface);
147
- border: 1px solid var(--border);
147
+ border: 1px solid var(--border-strong);
148
148
  border-radius: var(--radius-md);
149
149
  font-family: var(--font-sans);
150
150
  font-size: var(--text-sm);
@@ -32,6 +32,43 @@
32
32
  outline-offset: 2px;
33
33
  }
34
34
 
35
+ /* Prev/next arrows. Sit in the controls row beside the dots, never over the
36
+ item itself, so they never compete with the item's own link target. */
37
+ .pl-news-controls {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: flex-start;
41
+ gap: var(--space-3);
42
+ margin-top: var(--space-2);
43
+ }
44
+
45
+ .pl-news-nav {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ width: 28px;
50
+ height: 28px;
51
+ padding: 0;
52
+ border: 1px solid var(--border);
53
+ border-radius: var(--radius-full);
54
+ background: var(--surface);
55
+ color: var(--text);
56
+ cursor: pointer;
57
+ transition:
58
+ background-color var(--duration-fast) var(--ease-standard),
59
+ border-color var(--duration-fast) var(--ease-standard);
60
+ }
61
+
62
+ .pl-news-nav:hover {
63
+ background: var(--surface-hover);
64
+ border-color: var(--border-strong);
65
+ }
66
+
67
+ .pl-news-nav:focus-visible {
68
+ outline: 2px solid var(--focus);
69
+ outline-offset: 2px;
70
+ }
71
+
35
72
  .ui-eyebrow {
36
73
  font-family: var(--font-mono);
37
74
  font-size: var(--text-2xs);
@@ -52,7 +89,6 @@
52
89
  display: flex;
53
90
  align-items: center;
54
91
  gap: var(--space-1-5);
55
- margin-top: var(--space-2);
56
92
  }
57
93
 
58
94
  .pl-news-dot {
@@ -61,7 +97,7 @@
61
97
  padding: 0;
62
98
  border: 0;
63
99
  border-radius: var(--radius-full);
64
- background: var(--border);
100
+ background: var(--border-strong);
65
101
  cursor: pointer;
66
102
  transition:
67
103
  width var(--duration-base) var(--ease-enter),
@@ -71,7 +107,7 @@
71
107
 
72
108
  .pl-news-dot[data-on] {
73
109
  width: 22px;
74
- background: var(--sage-xdark);
110
+ background: var(--primary);
75
111
  }
76
112
 
77
113
  .pl-news-dot:focus-visible {
@@ -3,9 +3,11 @@ import "./NewsCarousel.css";
3
3
 
4
4
  /**
5
5
  * News carousel. Cross-fades between items on a timer. Dots are real buttons
6
- * with a label per item. The off-screen item is pointer-events:none and cannot
7
- * be tabbed into. The loop pauses on hover, on focus, and when the tab is
8
- * hidden, and stops entirely under prefers-reduced-motion.
6
+ * with a label per item; prev/next arrows sit either side of the dots and
7
+ * step one item at a time, rather than sitting over the item and competing
8
+ * with its own link target. The off-screen item is pointer-events:none and
9
+ * cannot be tabbed into. The loop pauses on hover, on focus, and when the tab
10
+ * is hidden, and stops entirely under prefers-reduced-motion.
9
11
  */
10
12
 
11
13
  export interface NewsItem {
@@ -20,8 +22,24 @@ export interface NewsCarouselProps {
20
22
  interval?: number;
21
23
  /** Accessible label builder for each dot. */
22
24
  dotLabel?: (index: number, item: NewsItem) => string;
25
+ /** Accessible label for the previous-item control. */
26
+ prevLabel?: string;
27
+ /** Accessible label for the next-item control. */
28
+ nextLabel?: string;
23
29
  }
24
30
 
31
+ const ChevronIcon = ({ direction }: { direction: "left" | "right" }) => (
32
+ <svg width={18} height={18} viewBox="0 0 24 24" fill="none" aria-hidden="true">
33
+ <path
34
+ d={direction === "left" ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"}
35
+ stroke="currentColor"
36
+ strokeWidth={2.2}
37
+ strokeLinecap="round"
38
+ strokeLinejoin="round"
39
+ />
40
+ </svg>
41
+ );
42
+
25
43
  const prefersReducedMotion = () =>
26
44
  typeof window !== "undefined" &&
27
45
  window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
@@ -30,6 +48,8 @@ export default function NewsCarousel({
30
48
  items,
31
49
  interval = 5000,
32
50
  dotLabel = (i) => `Nieuwsbericht ${i + 1}`,
51
+ prevLabel = "Vorig nieuwsbericht",
52
+ nextLabel = "Volgend nieuwsbericht",
33
53
  }: NewsCarouselProps) {
34
54
  const [active, setActive] = useState(0);
35
55
  const paused = useRef(false);
@@ -54,6 +74,10 @@ export default function NewsCarousel({
54
74
  paused.current = false;
55
75
  };
56
76
 
77
+ const step = (delta: number) => {
78
+ setActive((a) => (a + delta + items.length) % items.length);
79
+ };
80
+
57
81
  return (
58
82
  <div>
59
83
  <div
@@ -78,18 +102,36 @@ export default function NewsCarousel({
78
102
  ))}
79
103
  </div>
80
104
  {items.length > 1 && (
81
- <div className="pl-news-dots">
82
- {items.map((item, i) => (
83
- <button
84
- key={i}
85
- type="button"
86
- className="pl-news-dot"
87
- data-on={i === active ? "1" : undefined}
88
- aria-label={dotLabel(i, item)}
89
- aria-current={i === active ? "true" : undefined}
90
- onClick={() => setActive(i)}
91
- />
92
- ))}
105
+ <div className="pl-news-controls">
106
+ <button
107
+ type="button"
108
+ className="pl-news-nav pl-news-nav--prev"
109
+ aria-label={prevLabel}
110
+ onClick={() => step(-1)}
111
+ >
112
+ <ChevronIcon direction="left" />
113
+ </button>
114
+ <div className="pl-news-dots">
115
+ {items.map((item, i) => (
116
+ <button
117
+ key={i}
118
+ type="button"
119
+ className="pl-news-dot"
120
+ data-on={i === active ? "1" : undefined}
121
+ aria-label={dotLabel(i, item)}
122
+ aria-current={i === active ? "true" : undefined}
123
+ onClick={() => setActive(i)}
124
+ />
125
+ ))}
126
+ </div>
127
+ <button
128
+ type="button"
129
+ className="pl-news-nav pl-news-nav--next"
130
+ aria-label={nextLabel}
131
+ onClick={() => step(1)}
132
+ >
133
+ <ChevronIcon direction="right" />
134
+ </button>
93
135
  </div>
94
136
  )}
95
137
  </div>
@@ -46,7 +46,7 @@
46
46
  min-height: 40px;
47
47
  padding: 0 var(--space-3);
48
48
  background: var(--surface);
49
- border: 1px solid var(--border);
49
+ border: 1px solid var(--border-strong);
50
50
  border-radius: var(--radius-md);
51
51
  color: var(--text);
52
52
  font-family: var(--font-sans);