@kwtv/components 0.1.0 → 0.1.2

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.0",
3
+ "version": "0.1.2",
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.",
@@ -9,9 +9,12 @@
9
9
  "url": "git+https://github.com/KWTV-willebroek/design-system.git",
10
10
  "directory": "packages/components"
11
11
  },
12
- "homepage": "https://kwtv-explorer.vercel.app",
13
- "publishConfig": { "access": "public" },
14
- "files": ["src"],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
15
18
  "exports": {
16
19
  "./astro/*.astro": "./src/astro/*.astro",
17
20
  "./react/*.tsx": "./src/react/*.tsx",
@@ -23,10 +26,14 @@
23
26
  "react-dom": ">=18"
24
27
  },
25
28
  "peerDependenciesMeta": {
26
- "react": { "optional": true },
27
- "react-dom": { "optional": true }
29
+ "react": {
30
+ "optional": true
31
+ },
32
+ "react-dom": {
33
+ "optional": true
34
+ }
28
35
  },
29
36
  "dependencies": {
30
- "@kwtv/tokens": "^0.1.0"
37
+ "@kwtv/tokens": "^0.1.2"
31
38
  }
32
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
@@ -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>
@@ -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;
@@ -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 {
@@ -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>