@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 +14 -7
- package/src/astro/Alert.astro +1 -1
- package/src/astro/Band.astro +20 -6
- package/src/astro/EmptyState.astro +3 -4
- package/src/astro/NavDropdown.astro +51 -4
- package/src/react/NewsCarousel.css +37 -1
- package/src/react/NewsCarousel.tsx +57 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kwtv/components",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
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": {
|
|
27
|
-
|
|
29
|
+
"react": {
|
|
30
|
+
"optional": true
|
|
31
|
+
},
|
|
32
|
+
"react-dom": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
28
35
|
},
|
|
29
36
|
"dependencies": {
|
|
30
|
-
"@kwtv/tokens": "^0.1.
|
|
37
|
+
"@kwtv/tokens": "^0.1.2"
|
|
31
38
|
}
|
|
32
39
|
}
|
package/src/astro/Alert.astro
CHANGED
|
@@ -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-
|
|
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);
|
package/src/astro/Band.astro
CHANGED
|
@@ -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
|
|
11
|
-
* as
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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>
|