@kwtv/components 0.1.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.
Files changed (57) hide show
  1. package/README.md +77 -0
  2. package/package.json +32 -0
  3. package/src/astro/Accordion.astro +25 -0
  4. package/src/astro/AccordionItem.astro +77 -0
  5. package/src/astro/ActiveFilterTags.astro +65 -0
  6. package/src/astro/Alert.astro +93 -0
  7. package/src/astro/Badge.astro +138 -0
  8. package/src/astro/Band.astro +83 -0
  9. package/src/astro/Breadcrumb.astro +74 -0
  10. package/src/astro/Button.astro +217 -0
  11. package/src/astro/Card.astro +115 -0
  12. package/src/astro/CartLine.astro +112 -0
  13. package/src/astro/Checkbox.astro +89 -0
  14. package/src/astro/Chip.astro +130 -0
  15. package/src/astro/ChipRail.astro +24 -0
  16. package/src/astro/DotGrid.astro +62 -0
  17. package/src/astro/EmptyState.astro +53 -0
  18. package/src/astro/FilterGroup.astro +58 -0
  19. package/src/astro/Filters.astro +84 -0
  20. package/src/astro/Footer.astro +237 -0
  21. package/src/astro/FooterColumn.astro +64 -0
  22. package/src/astro/Header.astro +116 -0
  23. package/src/astro/Icon.astro +72 -0
  24. package/src/astro/IconButton.astro +117 -0
  25. package/src/astro/Marquee.astro +72 -0
  26. package/src/astro/NavDropdown.astro +164 -0
  27. package/src/astro/NavLink.astro +94 -0
  28. package/src/astro/Newsletter.astro +118 -0
  29. package/src/astro/Pager.astro +195 -0
  30. package/src/astro/PickCard.astro +142 -0
  31. package/src/astro/ProductCard.astro +195 -0
  32. package/src/astro/Radio.astro +89 -0
  33. package/src/astro/Skeleton.astro +80 -0
  34. package/src/astro/Social.astro +55 -0
  35. package/src/astro/StepIndicator.astro +104 -0
  36. package/src/astro/Sticker.astro +80 -0
  37. package/src/astro/Swatch.astro +100 -0
  38. package/src/astro/SwipeCarousel.astro +14 -0
  39. package/src/astro/Table.astro +117 -0
  40. package/src/astro/TableCard.astro +63 -0
  41. package/src/astro/TextField.astro +231 -0
  42. package/src/astro/Totals.astro +92 -0
  43. package/src/react/Drawer.css +67 -0
  44. package/src/react/Drawer.tsx +115 -0
  45. package/src/react/Modal.css +116 -0
  46. package/src/react/Modal.tsx +152 -0
  47. package/src/react/NewsCarousel.css +86 -0
  48. package/src/react/NewsCarousel.tsx +97 -0
  49. package/src/react/QuantityStepper.css +93 -0
  50. package/src/react/QuantityStepper.tsx +143 -0
  51. package/src/react/Select.css +183 -0
  52. package/src/react/Select.tsx +389 -0
  53. package/src/react/Toast.css +140 -0
  54. package/src/react/Toast.tsx +157 -0
  55. package/src/styles/base.css +39 -0
  56. package/src/styles/controls.css +211 -0
  57. package/src/styles/motion.css +238 -0
@@ -0,0 +1,152 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useRef,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ } from "react";
9
+ import "./Modal.css";
10
+
11
+ /**
12
+ * Modal and sheet. Only for a decision that cannot wait.
13
+ *
14
+ * role="dialog" with aria-modal="true" and aria-labelledby pointing at the
15
+ * title. Focus moves into the dialog on open, stays trapped, and returns to the
16
+ * trigger on close. Escape closes. The destructive action is never the
17
+ * auto-focused one: focus lands on the panel, and the first Tab reaches the
18
+ * safe action first when actions are ordered safe-then-destructive.
19
+ *
20
+ * On mobile (<= 700px) the same component becomes a bottom sheet. Pass
21
+ * variant="sheet" to force the sheet presentation at any width, as the filter
22
+ * panel does.
23
+ */
24
+
25
+ export interface ModalProps {
26
+ open: boolean;
27
+ onClose: () => void;
28
+ title: string;
29
+ children: ReactNode;
30
+ /** Footer actions. Order them safe first, destructive last. */
31
+ actions?: ReactNode;
32
+ /** "dialog" is a centred dialog on desktop, sheet on mobile. "sheet" is always a bottom sheet. */
33
+ variant?: "dialog" | "sheet";
34
+ /** Hide the visible title (still used as the accessible name). */
35
+ hideTitle?: boolean;
36
+ className?: string;
37
+ }
38
+
39
+ const FOCUSABLE =
40
+ 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
41
+
42
+ export default function Modal({
43
+ open,
44
+ onClose,
45
+ title,
46
+ children,
47
+ actions,
48
+ variant = "dialog",
49
+ hideTitle = false,
50
+ className,
51
+ }: ModalProps) {
52
+ const panelRef = useRef<HTMLDivElement>(null);
53
+ const previouslyFocused = useRef<HTMLElement | null>(null);
54
+ const titleId = useId();
55
+
56
+ // Move focus in on open, return it on close.
57
+ useEffect(() => {
58
+ if (!open) return;
59
+ previouslyFocused.current = document.activeElement as HTMLElement | null;
60
+ // Focus the panel itself, not the first action, so a destructive button is
61
+ // never the auto-focused target.
62
+ const panel = panelRef.current;
63
+ const raf = requestAnimationFrame(() => panel?.focus());
64
+ return () => {
65
+ cancelAnimationFrame(raf);
66
+ previouslyFocused.current?.focus?.();
67
+ };
68
+ }, [open]);
69
+
70
+ // Lock body scroll while open.
71
+ useEffect(() => {
72
+ if (!open) return;
73
+ const prev = document.body.style.overflow;
74
+ document.body.style.overflow = "hidden";
75
+ return () => {
76
+ document.body.style.overflow = prev;
77
+ };
78
+ }, [open]);
79
+
80
+ const onKeyDown = useCallback(
81
+ (e: KeyboardEvent) => {
82
+ if (e.key === "Escape") {
83
+ e.preventDefault();
84
+ onClose();
85
+ return;
86
+ }
87
+ if (e.key !== "Tab") return;
88
+ const panel = panelRef.current;
89
+ if (!panel) return;
90
+ const items = Array.from(
91
+ panel.querySelectorAll<HTMLElement>(FOCUSABLE),
92
+ ).filter((el) => el.offsetParent !== null || el === document.activeElement);
93
+ if (items.length === 0) {
94
+ e.preventDefault();
95
+ panel.focus();
96
+ return;
97
+ }
98
+ const first = items[0];
99
+ const last = items[items.length - 1];
100
+ const active = document.activeElement;
101
+ if (e.shiftKey) {
102
+ if (active === first || active === panel) {
103
+ e.preventDefault();
104
+ last.focus();
105
+ }
106
+ } else if (active === last) {
107
+ e.preventDefault();
108
+ first.focus();
109
+ }
110
+ },
111
+ [onClose],
112
+ );
113
+
114
+ if (!open) return null;
115
+
116
+ const isSheet = variant === "sheet";
117
+
118
+ return (
119
+ <div
120
+ className={`ui-scrim${isSheet ? " ui-scrim--bottom" : ""}`}
121
+ onMouseDown={(e) => {
122
+ if (e.target === e.currentTarget) onClose();
123
+ }}
124
+ >
125
+ <div
126
+ ref={panelRef}
127
+ className={[
128
+ "ui-modal",
129
+ isSheet && "ui-sheet",
130
+ className,
131
+ ]
132
+ .filter(Boolean)
133
+ .join(" ")}
134
+ role="dialog"
135
+ aria-modal="true"
136
+ aria-labelledby={titleId}
137
+ tabIndex={-1}
138
+ onKeyDown={onKeyDown}
139
+ >
140
+ {isSheet && <span className="ui-sheet__grip" aria-hidden="true" />}
141
+ <b
142
+ className={`ui-modal__title${hideTitle ? " ui-sr-only" : ""}`}
143
+ id={titleId}
144
+ >
145
+ {title}
146
+ </b>
147
+ <div className="ui-modal__body">{children}</div>
148
+ {actions && <div className="ui-modal__actions">{actions}</div>}
149
+ </div>
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,86 @@
1
+ /* News carousel. Playful layer, prefix pl-. All values from the tokens. */
2
+
3
+ .pl-news {
4
+ position: relative;
5
+ display: grid;
6
+ }
7
+
8
+ /* Items stack; the active one fades in. Off-screen items cannot be reached. */
9
+ .pl-news-item {
10
+ grid-area: 1 / 1;
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: var(--space-1);
14
+ padding: var(--space-4);
15
+ border-radius: var(--radius-lg);
16
+ background: var(--surface);
17
+ border: 1px solid var(--border);
18
+ text-decoration: none;
19
+ color: var(--text);
20
+ opacity: 0;
21
+ pointer-events: none;
22
+ transition: opacity 420ms var(--ease-standard);
23
+ }
24
+
25
+ .pl-news-item[data-on] {
26
+ opacity: 1;
27
+ pointer-events: auto;
28
+ }
29
+
30
+ .pl-news-item:focus-visible {
31
+ outline: 2px solid var(--focus);
32
+ outline-offset: 2px;
33
+ }
34
+
35
+ .ui-eyebrow {
36
+ font-family: var(--font-mono);
37
+ font-size: var(--text-2xs);
38
+ font-weight: var(--weight-semibold);
39
+ letter-spacing: var(--tracking-wider);
40
+ text-transform: uppercase;
41
+ color: var(--text-muted);
42
+ }
43
+
44
+ .ui-newsrow__title {
45
+ font-size: var(--text-lg);
46
+ font-weight: var(--weight-semibold);
47
+ letter-spacing: var(--tracking-tight);
48
+ color: var(--text);
49
+ }
50
+
51
+ .pl-news-dots {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: var(--space-1-5);
55
+ margin-top: var(--space-2);
56
+ }
57
+
58
+ .pl-news-dot {
59
+ width: 7px;
60
+ height: 7px;
61
+ padding: 0;
62
+ border: 0;
63
+ border-radius: var(--radius-full);
64
+ background: var(--border);
65
+ cursor: pointer;
66
+ transition:
67
+ width var(--duration-base) var(--ease-enter),
68
+ background-color var(--duration-base) var(--ease-standard);
69
+ outline-offset: 4px;
70
+ }
71
+
72
+ .pl-news-dot[data-on] {
73
+ width: 22px;
74
+ background: var(--sage-xdark);
75
+ }
76
+
77
+ .pl-news-dot:focus-visible {
78
+ outline: 2px solid var(--focus);
79
+ }
80
+
81
+ @media (prefers-reduced-motion: reduce) {
82
+ .pl-news-item,
83
+ .pl-news-dot {
84
+ transition: none;
85
+ }
86
+ }
@@ -0,0 +1,97 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import "./NewsCarousel.css";
3
+
4
+ /**
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.
9
+ */
10
+
11
+ export interface NewsItem {
12
+ eyebrow: string;
13
+ title: string;
14
+ href: string;
15
+ }
16
+
17
+ export interface NewsCarouselProps {
18
+ items: NewsItem[];
19
+ /** Advance interval in ms. */
20
+ interval?: number;
21
+ /** Accessible label builder for each dot. */
22
+ dotLabel?: (index: number, item: NewsItem) => string;
23
+ }
24
+
25
+ const prefersReducedMotion = () =>
26
+ typeof window !== "undefined" &&
27
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
28
+
29
+ export default function NewsCarousel({
30
+ items,
31
+ interval = 5000,
32
+ dotLabel = (i) => `Nieuwsbericht ${i + 1}`,
33
+ }: NewsCarouselProps) {
34
+ const [active, setActive] = useState(0);
35
+ const paused = useRef(false);
36
+
37
+ useEffect(() => {
38
+ if (items.length <= 1 || prefersReducedMotion()) return;
39
+
40
+ const tick = () => {
41
+ // document.hidden pauses the loop while the tab is in the background.
42
+ if (!paused.current && !document.hidden) {
43
+ setActive((a) => (a + 1) % items.length);
44
+ }
45
+ };
46
+ const timer = setInterval(tick, interval);
47
+ return () => clearInterval(timer);
48
+ }, [items.length, interval]);
49
+
50
+ const pause = () => {
51
+ paused.current = true;
52
+ };
53
+ const resume = () => {
54
+ paused.current = false;
55
+ };
56
+
57
+ return (
58
+ <div>
59
+ <div
60
+ className="pl-news"
61
+ onMouseEnter={pause}
62
+ onMouseLeave={resume}
63
+ onFocusCapture={pause}
64
+ onBlurCapture={resume}
65
+ >
66
+ {items.map((item, i) => (
67
+ <a
68
+ key={item.href + item.title}
69
+ className="pl-news-item"
70
+ href={item.href}
71
+ data-on={i === active ? "1" : undefined}
72
+ aria-hidden={i === active ? undefined : "true"}
73
+ tabIndex={i === active ? undefined : -1}
74
+ >
75
+ <span className="ui-eyebrow">{item.eyebrow}</span>
76
+ <span className="ui-newsrow__title">{item.title}</span>
77
+ </a>
78
+ ))}
79
+ </div>
80
+ {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
+ ))}
93
+ </div>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,93 @@
1
+ /* Quantity stepper. All values from the design-system tokens. */
2
+
3
+ .ui-qty {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ border: 1px solid var(--border);
7
+ border-radius: var(--radius-md);
8
+ background: var(--surface);
9
+ overflow: hidden;
10
+ }
11
+
12
+ .ui-qty button {
13
+ display: inline-flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ width: 40px;
17
+ height: 40px;
18
+ background: none;
19
+ border: 0;
20
+ color: var(--text);
21
+ font-size: var(--text-lg);
22
+ line-height: 1;
23
+ cursor: pointer;
24
+ transition: background-color var(--duration-fast) var(--ease-standard);
25
+ }
26
+
27
+ .ui-qty button:hover:not(:disabled) {
28
+ background: var(--surface-hover);
29
+ }
30
+
31
+ .ui-qty button:focus-visible {
32
+ outline: 2px solid var(--focus);
33
+ outline-offset: -2px;
34
+ }
35
+
36
+ .ui-qty button:disabled {
37
+ opacity: 0.4;
38
+ cursor: not-allowed;
39
+ }
40
+
41
+ /* The bin, shown instead of minus when the last one would be removed.
42
+ --danger, because it destroys the line rather than decrementing it. The
43
+ icon is not the only signal: the button carries an aria-label, and hover
44
+ fills it so the intent is obvious before the click. */
45
+ /* `.ui-qty button` is (0,1,1) and would otherwise win over a lone class, so
46
+ these are scoped the same way rather than reaching for !important. */
47
+ .ui-qty button.ui-qty__remove {
48
+ color: var(--danger);
49
+ }
50
+ .ui-qty button.ui-qty__remove:hover:not(:disabled) {
51
+ background: var(--danger-bg);
52
+ }
53
+ .ui-qty__remove svg {
54
+ display: block;
55
+ }
56
+
57
+ .ui-qty__val {
58
+ min-width: 40px;
59
+ text-align: center;
60
+ font-size: var(--text-sm);
61
+ font-weight: var(--weight-semibold);
62
+ font-variant-numeric: tabular-nums;
63
+ color: var(--text);
64
+ }
65
+
66
+ /* Large, 48 px, for the product page. */
67
+ .ui-qty--lg button {
68
+ width: 48px;
69
+ height: 48px;
70
+ font-size: var(--text-xl);
71
+ }
72
+ .ui-qty--lg .ui-qty__val {
73
+ min-width: 48px;
74
+ font-size: var(--text-base);
75
+ }
76
+
77
+ .ui-sr-only {
78
+ position: absolute;
79
+ width: 1px;
80
+ height: 1px;
81
+ padding: 0;
82
+ margin: -1px;
83
+ overflow: hidden;
84
+ clip-path: inset(50%);
85
+ white-space: nowrap;
86
+ border: 0;
87
+ }
88
+
89
+ @media (prefers-reduced-motion: reduce) {
90
+ .ui-qty button {
91
+ transition: none;
92
+ }
93
+ }
@@ -0,0 +1,143 @@
1
+ import { useRef, useState, useEffect } from "react";
2
+ import "./QuantityStepper.css";
3
+
4
+ /**
5
+ * Quantity stepper. Minus, value, plus. The value is text, not an input, so it
6
+ * is read as part of the row; the buttons carry "Minder" / "Meer" labels. When
7
+ * plus is disabled at the stock limit, the consumer states the reason next to
8
+ * it, never implied by the grey button alone.
9
+ *
10
+ * 40 px default for the cart, 48 px (size="lg") next to the add-to-cart button
11
+ * on a product page.
12
+ *
13
+ * At the minimum there is nothing sensible for minus to do, so `atMinAction`
14
+ * picks between the two honest answers:
15
+ *
16
+ * "disable" the default. Minus greys out. Correct anywhere the quantity is
17
+ * just a number, such as a product page, where removing is not a
18
+ * thing the control can do.
19
+ * "remove" minus becomes a bin that removes the line. For the cart, where
20
+ * stepping below one really does mean "take this out". Needs
21
+ * onRemove; without it the prop falls back to "disable" rather
22
+ * than rendering a button that does nothing.
23
+ *
24
+ * The bin is not a second confirmation step. If removal is hard to undo, the
25
+ * consumer follows it with an undo affordance, which is what the Toast is for.
26
+ */
27
+
28
+ export interface QuantityStepperProps {
29
+ value: number;
30
+ min?: number;
31
+ max?: number;
32
+ onChange?: (value: number) => void;
33
+ size?: "md" | "lg";
34
+ decrementLabel?: string;
35
+ incrementLabel?: string;
36
+ /** Template for the polite announcement. Receives the new value. */
37
+ announce?: (value: number) => string;
38
+ /**
39
+ * What the minus button does once `value` is at `min`.
40
+ * "remove" requires `onRemove`.
41
+ */
42
+ atMinAction?: "disable" | "remove";
43
+ /** Called instead of onChange when the bin is pressed. */
44
+ onRemove?: () => void;
45
+ removeLabel?: string;
46
+ /** Announced after a remove. The row usually disappears, so the message is
47
+ * the only thing left to say what happened. */
48
+ announceRemove?: string;
49
+ }
50
+
51
+ export default function QuantityStepper({
52
+ value,
53
+ min = 1,
54
+ max = Infinity,
55
+ onChange,
56
+ size = "md",
57
+ decrementLabel = "Minder",
58
+ incrementLabel = "Meer",
59
+ announce = (v) => `Aantal: ${v}`,
60
+ atMinAction = "disable",
61
+ onRemove,
62
+ removeLabel = "Verwijderen",
63
+ announceRemove = "Artikel verwijderd",
64
+ }: QuantityStepperProps) {
65
+ const [message, setMessage] = useState("");
66
+ const firstRender = useRef(true);
67
+
68
+ useEffect(() => {
69
+ if (firstRender.current) {
70
+ firstRender.current = false;
71
+ return;
72
+ }
73
+ setMessage(announce(value));
74
+ }, [value, announce]);
75
+
76
+ const atMin = value <= min;
77
+ const atMax = value >= max;
78
+
79
+ // "remove" without a handler would render a bin that does nothing, which is
80
+ // worse than a disabled minus: it looks like the row can be removed and then
81
+ // silently fails. Fall back instead.
82
+ const removes = atMin && atMinAction === "remove" && Boolean(onRemove);
83
+
84
+ const step = (delta: number) => {
85
+ const next = Math.min(max, Math.max(min, value + delta));
86
+ if (next !== value) onChange?.(next);
87
+ };
88
+
89
+ const handleRemove = () => {
90
+ // Say it before the row goes. Once the node is gone the live region goes
91
+ // with it and the announcement is lost.
92
+ setMessage(announceRemove);
93
+ onRemove?.();
94
+ };
95
+
96
+ return (
97
+ <div className={`ui-qty${size === "lg" ? " ui-qty--lg" : ""}`}>
98
+ {removes ? (
99
+ <button
100
+ type="button"
101
+ className="ui-qty__remove"
102
+ aria-label={removeLabel}
103
+ onClick={handleRemove}
104
+ >
105
+ <svg
106
+ width="17"
107
+ height="17"
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ strokeWidth="1.8"
112
+ strokeLinecap="round"
113
+ strokeLinejoin="round"
114
+ aria-hidden="true"
115
+ >
116
+ <path d="M4 7h16M10 4h4M9 7v12M15 7v12M6 7l1 13h10l1-13" />
117
+ </svg>
118
+ </button>
119
+ ) : (
120
+ <button
121
+ type="button"
122
+ aria-label={decrementLabel}
123
+ disabled={atMin}
124
+ onClick={() => step(-1)}
125
+ >
126
+ −
127
+ </button>
128
+ )}
129
+ <span className="ui-qty__val">{value}</span>
130
+ <button
131
+ type="button"
132
+ aria-label={incrementLabel}
133
+ disabled={atMax}
134
+ onClick={() => step(1)}
135
+ >
136
+ +
137
+ </button>
138
+ <span className="ui-sr-only" role="status" aria-live="polite">
139
+ {message}
140
+ </span>
141
+ </div>
142
+ );
143
+ }