@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
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @kwtv/components
2
+
3
+ UI components for the club website and the webshop. Built on the `@kwtv/tokens`
4
+ token package. Static components are written in Astro. The handful that hold
5
+ state are React islands. The package ships source files for Astro to compile;
6
+ there is no separate library build or consumer alias configuration.
7
+
8
+ ## What ships
9
+
10
+ | Path | What it is |
11
+ | --- | --- |
12
+ | `src/astro/*.astro` | Static components. Scoped styles, no client JS. |
13
+ | `src/react/*.tsx` | Interactive islands. Select, Modal, Toast, Drawer, QuantityStepper, NewsCarousel. |
14
+ | `src/styles/base.css` | The shared primitives the React islands import: focus ring, touch targets, screen-reader helper. Astro components carry their own scoped styles. |
15
+ | `src/styles/controls.css` | Button and icon-button styles for React islands that render the `ui-btn` / `ui-iconbtn` classes. |
16
+ | `src/styles/motion.css` | The `pl-` motion layer: lift, underline, arrow and row nudges, rise, marquee, chip rail and swipe carousel. |
17
+
18
+ ## Install
19
+
20
+ In an Astro project:
21
+
22
+ ```bash
23
+ npm install @kwtv/components @kwtv/tokens
24
+ ```
25
+
26
+ The package depends on `@kwtv/tokens` for the tokens. Import the token CSS once
27
+ in your app root, then use the components.
28
+
29
+ ```astro
30
+ ---
31
+ import "@kwtv/tokens/tokens.css";
32
+ import Button from "@kwtv/components/astro/Button.astro";
33
+ ---
34
+ <Button variant="primary" size="md">In de winkelwagen</Button>
35
+ ```
36
+
37
+ To use React islands, add Astro's React integration if the app does not
38
+ already have it:
39
+
40
+ ```bash
41
+ npx astro add react
42
+ ```
43
+
44
+ React and React DOM are optional peer dependencies because static Astro
45
+ components do not need them. Astro itself is a required peer dependency.
46
+ The package is verified against Astro 4 and React 18 in CI.
47
+
48
+ Then import an island:
49
+
50
+ ```astro
51
+ ---
52
+ import Select from "@kwtv/components/react/Select.tsx";
53
+ ---
54
+ <Select client:load label="Groep" options={[{ value: "r1", label: "Recreatief 8–10 jaar" }]} />
55
+ ```
56
+
57
+ The React islands need their shared stylesheets once in the app. Import
58
+ `controls.css` too if any React code renders the `ui-btn` / `ui-iconbtn`
59
+ classes (Astro cannot pass its scoped styles into a React island):
60
+
61
+ ```ts
62
+ import "@kwtv/components/styles/base.css";
63
+ import "@kwtv/components/styles/controls.css";
64
+ ```
65
+
66
+ ## Conventions
67
+
68
+ - Use the `.astro`, `.tsx` and `.css` extensions shown in these examples.
69
+ - Import `@kwtv/components/styles/motion.css` when using the motion utilities.
70
+ - Class names keep the `ui-` prefix from the component library so markup reads
71
+ the same across both sites. Astro scopes them; the prefix is for readability
72
+ and for consumer overrides.
73
+ - Colour, space, type, radius, shadow and motion all come from the token CSS
74
+ variables. Components never hardcode a hex value.
75
+ - Status is never colour alone. Every status pairs an icon with a word.
76
+ - Every interactive element carries a visible focus ring and meets the touch
77
+ target minimums from the token system.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@kwtv/components",
3
+ "version": "0.1.0",
4
+ "license": "UNLICENSED",
5
+ "type": "module",
6
+ "description": "KWTV UI components for Astro, with React islands. Built on the KWTV design tokens.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/KWTV-willebroek/design-system.git",
10
+ "directory": "packages/components"
11
+ },
12
+ "homepage": "https://kwtv-explorer.vercel.app",
13
+ "publishConfig": { "access": "public" },
14
+ "files": ["src"],
15
+ "exports": {
16
+ "./astro/*.astro": "./src/astro/*.astro",
17
+ "./react/*.tsx": "./src/react/*.tsx",
18
+ "./styles/*.css": "./src/styles/*.css"
19
+ },
20
+ "peerDependencies": {
21
+ "astro": ">=4",
22
+ "react": ">=18",
23
+ "react-dom": ">=18"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "react": { "optional": true },
27
+ "react-dom": { "optional": true }
28
+ },
29
+ "dependencies": {
30
+ "@kwtv/tokens": "^0.1.0"
31
+ }
32
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ /**
3
+ * Accordion wrapper. Holds AccordionItem children. Native <details> gives
4
+ * keyboard operation and expanded state for free.
5
+ */
6
+ interface Props {
7
+ class?: string;
8
+ }
9
+
10
+ const { class: className, ...rest } = Astro.props;
11
+ ---
12
+
13
+ <div class={["ui-accordion", className].filter(Boolean).join(" ")} {...rest}>
14
+ <slot />
15
+ </div>
16
+
17
+ <style>
18
+ .ui-accordion {
19
+ display: flex;
20
+ flex-direction: column;
21
+ border: 1px solid var(--border);
22
+ border-radius: var(--radius-lg);
23
+ overflow: hidden;
24
+ }
25
+ </style>
@@ -0,0 +1,77 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+
4
+ /**
5
+ * One row of an Accordion. Native <details>/<summary>. The chevron rotates but
6
+ * is aria-hidden; the summary text alone says what opens.
7
+ */
8
+ interface Props {
9
+ summary: string;
10
+ open?: boolean;
11
+ class?: string;
12
+ }
13
+
14
+ const { summary, open = false, class: className, ...rest } = Astro.props;
15
+ ---
16
+
17
+ <details class={["ui-accordion__item", className].filter(Boolean).join(" ")} open={open} {...rest}>
18
+ <summary class="ui-accordion__sum">
19
+ {summary}
20
+ <span class="ui-accordion__chev" aria-hidden="true">
21
+ <Icon name="chevron-down" size={14} stroke={2.2} />
22
+ </span>
23
+ </summary>
24
+ <div class="ui-accordion__body"><slot /></div>
25
+ </details>
26
+
27
+ <style>
28
+ .ui-accordion__item + .ui-accordion__item {
29
+ border-top: 1px solid var(--border);
30
+ }
31
+
32
+ .ui-accordion__sum {
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ gap: var(--space-3);
37
+ min-height: 44px;
38
+ padding: var(--space-2) var(--space-4);
39
+ font-size: var(--text-sm);
40
+ font-weight: var(--weight-medium);
41
+ color: var(--text);
42
+ cursor: pointer;
43
+ list-style: none;
44
+ }
45
+ .ui-accordion__sum::-webkit-details-marker {
46
+ display: none;
47
+ }
48
+ .ui-accordion__sum:hover {
49
+ background: var(--surface-hover);
50
+ }
51
+ .ui-accordion__sum:focus-visible {
52
+ outline: 2px solid var(--focus);
53
+ outline-offset: -2px;
54
+ }
55
+
56
+ .ui-accordion__chev {
57
+ display: inline-flex;
58
+ color: var(--text-muted);
59
+ transition: transform var(--duration-fast) var(--ease-standard);
60
+ }
61
+ .ui-accordion__item[open] .ui-accordion__chev {
62
+ transform: rotate(180deg);
63
+ }
64
+
65
+ .ui-accordion__body {
66
+ padding: 0 var(--space-4) var(--space-4);
67
+ font-size: var(--text-sm);
68
+ line-height: var(--leading-sm);
69
+ color: var(--text-muted);
70
+ }
71
+
72
+ @media (prefers-reduced-motion: reduce) {
73
+ .ui-accordion__chev {
74
+ transition: none;
75
+ }
76
+ }
77
+ </style>
@@ -0,0 +1,65 @@
1
+ ---
2
+ import Badge from "./Badge.astro";
3
+
4
+ /**
5
+ * The active filters repeated as removable tags above the grid, so the reader
6
+ * can undo one at a time. Each tag is an outline Badge with a labelled × that
7
+ * names the filter it clears. A trailing clear-all action removes them at once.
8
+ *
9
+ * The row wraps. The consumer wires each removeLabel to the matching filter.
10
+ */
11
+ interface Tag {
12
+ label: string;
13
+ /** aria-label for this tag's × button, e.g. "Filter maat 140 verwijderen". */
14
+ removeLabel: string;
15
+ }
16
+
17
+ interface Props {
18
+ tags: Tag[];
19
+ clearAllLabel?: string;
20
+ class?: string;
21
+ }
22
+
23
+ const { tags, clearAllLabel = "Alles wissen", class: className, ...rest } = Astro.props;
24
+
25
+ const classes = ["ui-filtertags", className].filter(Boolean).join(" ");
26
+ ---
27
+
28
+ <div class={classes} {...rest}>
29
+ {tags.map((tag) => (
30
+ <Badge tone="outline" removable removeLabel={tag.removeLabel}>{tag.label}</Badge>
31
+ ))}
32
+ {clearAllLabel && tags.length > 0 && (
33
+ <button class="ui-filtertags__clear" type="button">{clearAllLabel}</button>
34
+ )}
35
+ </div>
36
+
37
+ <style>
38
+ .ui-filtertags {
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ align-items: center;
42
+ gap: var(--space-2);
43
+ }
44
+
45
+ .ui-filtertags__clear {
46
+ padding: var(--space-1-5) var(--space-2);
47
+ background: transparent;
48
+ border: 0;
49
+ border-radius: var(--radius-sm);
50
+ font-family: var(--font-sans);
51
+ font-size: var(--text-xs);
52
+ font-weight: var(--weight-semibold);
53
+ color: var(--link);
54
+ cursor: pointer;
55
+ }
56
+ .ui-filtertags__clear:hover {
57
+ background: var(--surface-hover);
58
+ color: var(--link-hover);
59
+ }
60
+ .ui-filtertags__clear:focus-visible {
61
+ outline: 2px solid var(--focus);
62
+ outline-offset: 2px;
63
+ box-shadow: 0 0 0 4px var(--focus-outer);
64
+ }
65
+ </style>
@@ -0,0 +1,93 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+
4
+ /**
5
+ * Alert. Inline message tied to what the reader is looking at. Never used for
6
+ * marketing. Only danger gets role="alert": it interrupts the screen reader,
7
+ * so it is reserved for something the reader must handle now. Info and success
8
+ * are read in document order.
9
+ *
10
+ * Every alert carries both an icon and a worded tone. Body keeps full strength.
11
+ */
12
+ interface Props {
13
+ tone?: "info" | "success" | "warning" | "danger";
14
+ title?: string;
15
+ class?: string;
16
+ }
17
+
18
+ const { tone = "info", title, class: className, ...rest } = Astro.props;
19
+
20
+ const icons: Record<string, { name: string; stroke: number }> = {
21
+ info: { name: "info", stroke: 1.8 },
22
+ success: { name: "check", stroke: 2.2 },
23
+ warning: { name: "alert-triangle", stroke: 1.9 },
24
+ danger: { name: "alert-circle", stroke: 2 },
25
+ };
26
+ const icon = icons[tone];
27
+
28
+ const classes = ["ui-alert", `ui-alert--${tone}`, className]
29
+ .filter(Boolean)
30
+ .join(" ");
31
+ ---
32
+
33
+ <div class={classes} role={tone === "danger" ? "alert" : undefined} {...rest}>
34
+ <span class="ui-alert__icon" aria-hidden="true">
35
+ <Icon name={icon.name as any} size={16} stroke={icon.stroke} />
36
+ </span>
37
+ <span class="ui-alert__body">
38
+ {title && <b class="ui-alert__title">{title}</b>}
39
+ <slot />
40
+ </span>
41
+ </div>
42
+
43
+ <style>
44
+ .ui-alert {
45
+ display: flex;
46
+ align-items: flex-start;
47
+ gap: var(--space-2-5, 0.625rem);
48
+ padding: var(--space-3) var(--space-4);
49
+ border-left: 3px solid currentColor;
50
+ border-radius: var(--radius-sm);
51
+ font-family: var(--font-sans);
52
+ font-size: var(--text-sm);
53
+ line-height: var(--leading-sm);
54
+ }
55
+
56
+ .ui-alert__icon {
57
+ display: inline-flex;
58
+ flex-shrink: 0;
59
+ margin-top: 1px;
60
+ }
61
+
62
+ /* Body keeps full strength; the left rule carries the tone. */
63
+ .ui-alert__body {
64
+ color: var(--text);
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: var(--space-1);
68
+ }
69
+
70
+ .ui-alert__title {
71
+ font-weight: var(--weight-semibold);
72
+ }
73
+
74
+ .ui-alert--info {
75
+ background: var(--surface-accent);
76
+ color: var(--surface-accent-fg, var(--primary));
77
+ }
78
+
79
+ .ui-alert--success {
80
+ background: var(--success-bg);
81
+ color: var(--success);
82
+ }
83
+
84
+ .ui-alert--warning {
85
+ background: var(--warning-bg);
86
+ color: var(--warning);
87
+ }
88
+
89
+ .ui-alert--danger {
90
+ background: var(--danger-bg);
91
+ color: var(--danger);
92
+ }
93
+ </style>
@@ -0,0 +1,138 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+
4
+ /**
5
+ * Badge. Status and stock. Each status tone carries an icon so the meaning
6
+ * survives without colour. The outline variant doubles as a removable filter
7
+ * tag: when `removable`, the × is a real <button> labelled with the filter it
8
+ * clears. A badge is not a control; only the × is.
9
+ *
10
+ * Stock badges should repeat the exact number in text rather than only shading
11
+ * the pill.
12
+ */
13
+ interface Props {
14
+ tone?: "neutral" | "success" | "warning" | "danger" | "outline";
15
+ removable?: boolean;
16
+ /** aria-label for the × button when removable. Name the filter it clears. */
17
+ removeLabel?: string;
18
+ class?: string;
19
+ }
20
+
21
+ const {
22
+ tone = "neutral",
23
+ removable = false,
24
+ removeLabel = "Verwijderen",
25
+ class: className,
26
+ ...rest
27
+ } = Astro.props;
28
+
29
+ const icons: Record<string, string> = {
30
+ success: "check",
31
+ warning: "alert-triangle",
32
+ danger: "close",
33
+ };
34
+ const icon = icons[tone];
35
+
36
+ const classes = [
37
+ "ui-badge",
38
+ `ui-badge--${tone}`,
39
+ removable && "ui-badge--removable",
40
+ className,
41
+ ]
42
+ .filter(Boolean)
43
+ .join(" ");
44
+ ---
45
+
46
+ <span class={classes} {...rest}>
47
+ {icon && (
48
+ <span class="ui-badge__icon" aria-hidden="true">
49
+ <Icon name={icon as any} size={12} stroke={2.2} />
50
+ </span>
51
+ )}
52
+ <slot />
53
+ {removable && (
54
+ <button class="ui-badge__x" type="button" aria-label={removeLabel}>
55
+ <Icon name="close" size={12} stroke={2.2} />
56
+ </button>
57
+ )}
58
+ </span>
59
+
60
+ <style>
61
+ .ui-badge {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ gap: var(--space-1-5);
65
+ min-height: 24px;
66
+ padding: 0 var(--space-2);
67
+ border: 1px solid transparent;
68
+ border-radius: var(--radius-full);
69
+ font-family: var(--font-sans);
70
+ font-size: var(--text-xs);
71
+ font-weight: var(--weight-medium);
72
+ line-height: 1;
73
+ white-space: nowrap;
74
+ }
75
+
76
+ .ui-badge__icon {
77
+ display: inline-flex;
78
+ flex-shrink: 0;
79
+ }
80
+
81
+ .ui-badge--neutral {
82
+ background: var(--surface-accent);
83
+ color: var(--surface-accent-fg, var(--text));
84
+ }
85
+
86
+ .ui-badge--success {
87
+ background: var(--success-bg);
88
+ color: var(--success);
89
+ }
90
+
91
+ .ui-badge--warning {
92
+ background: var(--warning-bg);
93
+ color: var(--warning);
94
+ }
95
+
96
+ .ui-badge--danger {
97
+ background: var(--danger-bg);
98
+ color: var(--danger);
99
+ }
100
+
101
+ /* Outline keeps the strong border and full-strength text, so it reads
102
+ without relying on a fill. */
103
+ .ui-badge--outline {
104
+ background: transparent;
105
+ border: 1px solid var(--border-strong);
106
+ color: var(--text);
107
+ }
108
+
109
+ .ui-badge--removable {
110
+ padding-right: var(--space-1);
111
+ }
112
+
113
+ /* Small × target grows with padding plus an equal negative margin, so the
114
+ hit area grows without moving the layout. */
115
+ .ui-badge__x {
116
+ display: inline-flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ padding: var(--space-2);
120
+ margin: calc(-1 * var(--space-2));
121
+ margin-left: 0;
122
+ background: transparent;
123
+ border: 0;
124
+ border-radius: var(--radius-sm);
125
+ color: inherit;
126
+ cursor: pointer;
127
+ }
128
+
129
+ .ui-badge__x:hover {
130
+ color: var(--danger);
131
+ }
132
+
133
+ .ui-badge__x:focus-visible {
134
+ outline: 2px solid var(--focus);
135
+ outline-offset: 2px;
136
+ box-shadow: 0 0 0 4px var(--focus-outer);
137
+ }
138
+ </style>
@@ -0,0 +1,83 @@
1
+ ---
2
+ /**
3
+ * Accent band. A full-width sage section, for breaking up a long page.
4
+ *
5
+ * Sage is a surface colour, not a text colour, so the band flips every text
6
+ * role inside it to the accent foreground rather than leaving them on --text.
7
+ * That is the whole reason this is a component and not a background utility:
8
+ * putting body text on sage without the flip fails AA.
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.
12
+ */
13
+ interface Props {
14
+ /** Renders as <section> by default. Use "div" when the band is not a
15
+ * standalone region of the page. */
16
+ as?: "section" | "div" | "aside";
17
+ class?: string;
18
+ }
19
+
20
+ const { as: Tag = "section", class: className, ...rest } = Astro.props;
21
+
22
+ const classes = ["ui-band", className].filter(Boolean).join(" ");
23
+ ---
24
+
25
+ <Tag class={classes} {...rest}>
26
+ <slot />
27
+ </Tag>
28
+
29
+ <style>
30
+ .ui-band {
31
+ background: var(--surface-accent);
32
+ border-top: 1px solid var(--border-strong);
33
+ color: var(--surface-accent-fg);
34
+ }
35
+
36
+ /* Every text role inside the band moves to the accent foreground. Sage is a
37
+ mid tone: --text clears it, but --text-muted and --link do not. */
38
+ .ui-band :global(p),
39
+ .ui-band :global(span),
40
+ .ui-band :global(a),
41
+ .ui-band :global(td),
42
+ .ui-band :global(th),
43
+ .ui-band :global(strong),
44
+ .ui-band :global(b),
45
+ .ui-band :global(label),
46
+ .ui-band :global(.ui-eyebrow),
47
+ .ui-band :global(.ui-card__eyebrow),
48
+ .ui-band :global(.ui-card__body) {
49
+ color: var(--surface-accent-fg);
50
+ }
51
+
52
+ /* Secondary copy gets the band's own muted role, which clears 4.5 on sage.
53
+ --text-muted only reaches 3.6 here. */
54
+ .ui-band :global(small),
55
+ .ui-band :global(.ui-band__note) {
56
+ color: var(--surface-accent-muted);
57
+ }
58
+
59
+ /* A card on the band returns to paper, so it still reads as a raised thing. */
60
+ .ui-band :global(.ui-card),
61
+ .ui-band :global(.ui-card--raised) {
62
+ background: var(--surface);
63
+ border-color: var(--border);
64
+ }
65
+
66
+ /* Buttons keep their own label colours: they carry their own fill, so the
67
+ band's foreground flip must not reach inside them. */
68
+ .ui-band :global(.ui-btn--primary),
69
+ .ui-band :global(.ui-btn--primary span) {
70
+ color: var(--primary-fg);
71
+ }
72
+
73
+ .ui-band :global(.ui-btn--accent),
74
+ .ui-band :global(.ui-btn--accent span) {
75
+ color: var(--accent-fg);
76
+ }
77
+
78
+ .ui-band :global(.ui-btn--secondary),
79
+ .ui-band :global(.ui-btn--secondary span) {
80
+ color: var(--surface-accent-fg);
81
+ border-color: var(--border-strong);
82
+ }
83
+ </style>
@@ -0,0 +1,74 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+
4
+ /**
5
+ * Breadcrumb. Sits in a labelled nav. The current page is a non-link with
6
+ * aria-current="page"; separators are decorative.
7
+ */
8
+ interface Crumb {
9
+ label: string;
10
+ href?: string;
11
+ }
12
+
13
+ interface Props {
14
+ items: Crumb[];
15
+ label?: string;
16
+ class?: string;
17
+ }
18
+
19
+ const { items, label = "Kruimelpad", class: className, ...rest } = Astro.props;
20
+ const lastIndex = items.length - 1;
21
+ ---
22
+
23
+ <nav class={["ui-crumbs", className].filter(Boolean).join(" ")} aria-label={label} {...rest}>
24
+ {items.map((item, i) => (
25
+ <>
26
+ {item.href && i !== lastIndex ? (
27
+ <a href={item.href}>{item.label}</a>
28
+ ) : (
29
+ <span aria-current="page">{item.label}</span>
30
+ )}
31
+ {i !== lastIndex && (
32
+ <span class="ui-crumbs__sep" aria-hidden="true">
33
+ <Icon name="chevron-right" size={18} stroke={2.2} />
34
+ </span>
35
+ )}
36
+ </>
37
+ ))}
38
+ </nav>
39
+
40
+ <style>
41
+ .ui-crumbs {
42
+ display: flex;
43
+ flex-wrap: wrap;
44
+ align-items: center;
45
+ gap: var(--space-1);
46
+ font-size: var(--text-xs);
47
+ line-height: var(--leading-xs);
48
+ color: var(--text-muted);
49
+ }
50
+
51
+ .ui-crumbs a {
52
+ color: var(--link);
53
+ text-decoration: none;
54
+ }
55
+ .ui-crumbs a:hover {
56
+ color: var(--link-hover);
57
+ text-decoration: underline;
58
+ }
59
+ .ui-crumbs a:focus-visible {
60
+ outline: 2px solid var(--focus);
61
+ outline-offset: 2px;
62
+ border-radius: var(--radius-sm);
63
+ }
64
+
65
+ .ui-crumbs [aria-current="page"] {
66
+ color: var(--text);
67
+ font-weight: var(--weight-medium);
68
+ }
69
+
70
+ .ui-crumbs__sep {
71
+ display: inline-flex;
72
+ color: var(--border-strong);
73
+ }
74
+ </style>