@kwtv/components 0.5.0 → 0.7.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.
- package/README.md +8 -7
- package/package.json +7 -2
- package/src/astro/Alert.astro +1 -1
- package/src/astro/Badge.astro +5 -1
- package/src/astro/Button.astro +1 -152
- package/src/astro/Checkbox.astro +22 -4
- package/src/astro/CopyChip.astro +128 -0
- package/src/astro/IconButton.astro +1 -75
- package/src/astro/NavDropdown.astro +3 -54
- package/src/astro/NavLink.astro +2 -69
- package/src/astro/ProductCard.astro +33 -7
- package/src/styles/base.css +6 -0
- package/src/styles/controls.css +9 -7
- package/src/styles/navlink.css +73 -0
package/README.md
CHANGED
|
@@ -7,13 +7,14 @@ there is no separate library build or consumer alias configuration.
|
|
|
7
7
|
|
|
8
8
|
## What ships
|
|
9
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` |
|
|
16
|
-
| `src/styles/
|
|
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` | The one copy of the button and icon-button styles. `Button` and `IconButton` import it. React islands import it for the `ui-btn` / `ui-iconbtn` classes. |
|
|
16
|
+
| `src/styles/navlink.css` | The one copy of the header nav link styles. `NavLink` and `NavDropdown` import it. |
|
|
17
|
+
| `src/styles/motion.css` | The `pl-` motion layer: lift, underline, arrow and row nudges, rise, marquee, chip rail and swipe carousel. |
|
|
17
18
|
|
|
18
19
|
## Install
|
|
19
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kwtv/components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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.",
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"src"
|
|
17
17
|
],
|
|
18
|
+
"sideEffects": [
|
|
19
|
+
"**/*.css",
|
|
20
|
+
"**/*.astro",
|
|
21
|
+
"./src/react/*.tsx"
|
|
22
|
+
],
|
|
18
23
|
"exports": {
|
|
19
24
|
"./astro/*.astro": "./src/astro/*.astro",
|
|
20
25
|
"./react/*.tsx": "./src/react/*.tsx",
|
|
@@ -34,6 +39,6 @@
|
|
|
34
39
|
}
|
|
35
40
|
},
|
|
36
41
|
"dependencies": {
|
|
37
|
-
"@kwtv/tokens": "^0.
|
|
42
|
+
"@kwtv/tokens": "^0.7.0"
|
|
38
43
|
}
|
|
39
44
|
}
|
package/src/astro/Alert.astro
CHANGED
|
@@ -60,7 +60,7 @@ const classes = ["ui-alert", `ui-alert--${tone}`, className]
|
|
|
60
60
|
display: inline-flex;
|
|
61
61
|
flex-shrink: 0;
|
|
62
62
|
align-self: flex-start;
|
|
63
|
-
margin-top: calc((var(--leading-sm, 1.375rem) -
|
|
63
|
+
margin-top: calc((var(--leading-sm, 1.375rem) - 16px) / 2);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/* Body keeps full strength; the left rule carries the tone. */
|
package/src/astro/Badge.astro
CHANGED
|
@@ -12,6 +12,9 @@ import Icon from "./Icon.astro";
|
|
|
12
12
|
*/
|
|
13
13
|
interface Props {
|
|
14
14
|
tone?: "neutral" | "success" | "warning" | "danger" | "outline";
|
|
15
|
+
/** Show the tone's default status icon. Defaults to true for status tones
|
|
16
|
+
* (success/warning/danger); pass false to suppress it for label-only use. */
|
|
17
|
+
icon?: boolean;
|
|
15
18
|
removable?: boolean;
|
|
16
19
|
/** aria-label for the × button when removable. Name the filter it clears. */
|
|
17
20
|
removeLabel?: string;
|
|
@@ -20,6 +23,7 @@ interface Props {
|
|
|
20
23
|
|
|
21
24
|
const {
|
|
22
25
|
tone = "neutral",
|
|
26
|
+
icon: showIcon = true,
|
|
23
27
|
removable = false,
|
|
24
28
|
removeLabel = "Verwijderen",
|
|
25
29
|
class: className,
|
|
@@ -31,7 +35,7 @@ const icons: Record<string, string> = {
|
|
|
31
35
|
warning: "alert-triangle",
|
|
32
36
|
danger: "close",
|
|
33
37
|
};
|
|
34
|
-
const icon = icons[tone];
|
|
38
|
+
const icon = showIcon ? icons[tone] : undefined;
|
|
35
39
|
|
|
36
40
|
const classes = [
|
|
37
41
|
"ui-badge",
|
package/src/astro/Button.astro
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Icon from "./Icon.astro";
|
|
3
|
+
import "../styles/controls.css";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Button. Four hierarchies, three sizes. One primary action per view.
|
|
@@ -69,155 +70,3 @@ const tagProps = href
|
|
|
69
70
|
)}
|
|
70
71
|
{loading && <span class="ui-btn__spinner" aria-hidden="true"></span>}
|
|
71
72
|
</Tag>
|
|
72
|
-
|
|
73
|
-
<style>
|
|
74
|
-
@layer reset, tokens, base, components, motion, utilities;
|
|
75
|
-
|
|
76
|
-
@layer components {
|
|
77
|
-
.ui-btn {
|
|
78
|
-
--_pad-x: var(--space-5);
|
|
79
|
-
display: inline-flex;
|
|
80
|
-
align-items: center;
|
|
81
|
-
justify-content: center;
|
|
82
|
-
gap: var(--space-2);
|
|
83
|
-
position: relative;
|
|
84
|
-
min-height: 40px;
|
|
85
|
-
padding: var(--space-2) var(--_pad-x);
|
|
86
|
-
border: 1px solid transparent;
|
|
87
|
-
border-radius: var(--radius-md);
|
|
88
|
-
font-family: var(--font-sans);
|
|
89
|
-
font-size: var(--text-sm);
|
|
90
|
-
font-weight: var(--weight-semibold);
|
|
91
|
-
line-height: 1;
|
|
92
|
-
text-decoration: none;
|
|
93
|
-
cursor: pointer;
|
|
94
|
-
transition:
|
|
95
|
-
background-color var(--duration-fast) var(--ease-standard),
|
|
96
|
-
border-color var(--duration-fast) var(--ease-standard),
|
|
97
|
-
transform var(--duration-fast) var(--ease-standard);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/* Sizes: 32 / 40 / 48 px. */
|
|
101
|
-
.ui-btn--sm {
|
|
102
|
-
min-height: 32px;
|
|
103
|
-
--_pad-x: var(--space-4);
|
|
104
|
-
font-size: var(--text-xs);
|
|
105
|
-
}
|
|
106
|
-
.ui-btn--md {
|
|
107
|
-
min-height: 40px;
|
|
108
|
-
}
|
|
109
|
-
.ui-btn--lg {
|
|
110
|
-
min-height: 48px;
|
|
111
|
-
--_pad-x: var(--space-6);
|
|
112
|
-
font-size: var(--text-base);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.ui-btn--block {
|
|
116
|
-
display: flex;
|
|
117
|
-
width: 100%;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/* Primary. */
|
|
121
|
-
.ui-btn--primary {
|
|
122
|
-
background: var(--primary);
|
|
123
|
-
color: var(--primary-fg);
|
|
124
|
-
}
|
|
125
|
-
.ui-btn--primary:hover,
|
|
126
|
-
.ui-btn--primary.is-hover {
|
|
127
|
-
background: var(--primary-hover);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/* Secondary. */
|
|
131
|
-
.ui-btn--secondary {
|
|
132
|
-
background: transparent;
|
|
133
|
-
border-color: var(--border-strong);
|
|
134
|
-
color: var(--text);
|
|
135
|
-
}
|
|
136
|
-
.ui-btn--secondary:hover,
|
|
137
|
-
.ui-btn--secondary.is-hover {
|
|
138
|
-
background: var(--surface-hover);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/* Accent. One call to action per page. */
|
|
142
|
-
.ui-btn--accent {
|
|
143
|
-
background: var(--accent);
|
|
144
|
-
color: var(--accent-fg);
|
|
145
|
-
}
|
|
146
|
-
.ui-btn--accent:hover,
|
|
147
|
-
.ui-btn--accent.is-hover {
|
|
148
|
-
background: var(--accent-hover);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/* Ghost. Low-weight actions. */
|
|
152
|
-
.ui-btn--ghost {
|
|
153
|
-
background: transparent;
|
|
154
|
-
color: var(--link);
|
|
155
|
-
padding-inline: var(--space-3);
|
|
156
|
-
}
|
|
157
|
-
.ui-btn--ghost:hover,
|
|
158
|
-
.ui-btn--ghost.is-hover {
|
|
159
|
-
background: var(--surface-hover);
|
|
160
|
-
color: var(--link-hover);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/* Focus: 2 px ring plus 4 px halo. */
|
|
164
|
-
.ui-btn:focus-visible,
|
|
165
|
-
.ui-btn.is-focus {
|
|
166
|
-
outline: 2px solid var(--focus);
|
|
167
|
-
outline-offset: 2px;
|
|
168
|
-
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/* Pressed: 1 px downward shift. */
|
|
172
|
-
.ui-btn:active,
|
|
173
|
-
.ui-btn.is-active {
|
|
174
|
-
transform: translateY(1px);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/* Disabled. */
|
|
178
|
-
.ui-btn:disabled,
|
|
179
|
-
.ui-btn[aria-disabled="true"] {
|
|
180
|
-
opacity: 0.5;
|
|
181
|
-
cursor: not-allowed;
|
|
182
|
-
pointer-events: none;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/* Loading: label hidden, spinner shown. */
|
|
186
|
-
.ui-btn.is-loading {
|
|
187
|
-
cursor: progress;
|
|
188
|
-
}
|
|
189
|
-
.ui-btn.is-loading .ui-btn__label,
|
|
190
|
-
.ui-btn.is-loading .ui-btn__icon {
|
|
191
|
-
visibility: hidden;
|
|
192
|
-
}
|
|
193
|
-
.ui-btn__spinner {
|
|
194
|
-
position: absolute;
|
|
195
|
-
width: 16px;
|
|
196
|
-
height: 16px;
|
|
197
|
-
border: 2px solid currentColor;
|
|
198
|
-
border-top-color: transparent;
|
|
199
|
-
border-radius: var(--radius-full);
|
|
200
|
-
animation: ui-btn-spin var(--duration-slower) linear infinite;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
.ui-btn__icon {
|
|
204
|
-
display: inline-flex;
|
|
205
|
-
font-size: 1em;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
@keyframes ui-btn-spin {
|
|
209
|
-
to {
|
|
210
|
-
transform: rotate(360deg);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
@media (prefers-reduced-motion: reduce) {
|
|
215
|
-
.ui-btn {
|
|
216
|
-
transition: none;
|
|
217
|
-
}
|
|
218
|
-
.ui-btn__spinner {
|
|
219
|
-
animation-duration: 1200ms;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
</style>
|
package/src/astro/Checkbox.astro
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Checkbox.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Checkbox. Custom-styled native control: appearance:none box with a CSS
|
|
4
|
+
* checkmark SVG, so the checked state is consistent across surfaces and themes.
|
|
5
|
+
* Wrapped in a label so the whole row is the hit area. 17 px box, 32 px row.
|
|
6
|
+
* Disabled rows keep their text legible.
|
|
6
7
|
*
|
|
7
8
|
* When several checkboxes form a question, put them in a <fieldset> with a
|
|
8
9
|
* <legend> in the consuming page so the question is announced first.
|
|
@@ -62,12 +63,28 @@ const classes = ["ui-check", disabled && "is-disabled", className]
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
.ui-check input {
|
|
66
|
+
appearance: none;
|
|
67
|
+
-webkit-appearance: none;
|
|
65
68
|
width: 17px;
|
|
66
69
|
height: 17px;
|
|
67
70
|
flex-shrink: 0;
|
|
68
71
|
margin-top: calc((var(--leading-sm, 1.375rem) - 17px) / 2);
|
|
69
|
-
|
|
72
|
+
border: 1.5px solid var(--border-strong);
|
|
73
|
+
border-radius: var(--radius-xs, 3px);
|
|
74
|
+
background: var(--surface);
|
|
70
75
|
cursor: pointer;
|
|
76
|
+
transition:
|
|
77
|
+
background-color var(--duration-fast, 100ms) var(--ease-standard, ease),
|
|
78
|
+
border-color var(--duration-fast, 100ms) var(--ease-standard, ease);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.ui-check input:checked {
|
|
82
|
+
background: var(--primary);
|
|
83
|
+
border-color: var(--primary);
|
|
84
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 10' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 5 4.5 8.5 11 1'/%3E%3C/svg%3E");
|
|
85
|
+
background-repeat: no-repeat;
|
|
86
|
+
background-position: center;
|
|
87
|
+
background-size: 10px 8px;
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
.ui-check input:focus-visible {
|
|
@@ -83,6 +100,7 @@ const classes = ["ui-check", disabled && "is-disabled", className]
|
|
|
83
100
|
|
|
84
101
|
.ui-check.is-disabled input {
|
|
85
102
|
cursor: not-allowed;
|
|
103
|
+
opacity: 0.45;
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
@media (max-width: 700px), (pointer: coarse) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CopyChip. Displays a short text value (typically an email or code string)
|
|
4
|
+
* in a monospace pill with a copy-to-clipboard button. Renders the text as
|
|
5
|
+
* static HTML; the button is injected client-side by the bundled script, so
|
|
6
|
+
* the component degrades cleanly without JavaScript.
|
|
7
|
+
*
|
|
8
|
+
* @prop {string} text The value to display and copy.
|
|
9
|
+
* @prop {string} [label] Accessible label for the copy button (default: "Copy").
|
|
10
|
+
* @prop {string} [class]
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
text: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { text, label = "Copy", class: className, ...rest } = Astro.props;
|
|
19
|
+
const classes = ["ui-copy-chip", className].filter(Boolean).join(" ");
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<span class={classes} data-copy={text} {...rest}>
|
|
23
|
+
<span class="ui-copy-chip__text">{text}</span>
|
|
24
|
+
</span>
|
|
25
|
+
|
|
26
|
+
<script>
|
|
27
|
+
function initCopyChip(el: HTMLElement) {
|
|
28
|
+
if (el.querySelector(".ui-copy-chip__btn")) return;
|
|
29
|
+
|
|
30
|
+
const text = el.getAttribute("data-copy") ?? "";
|
|
31
|
+
const label = el.getAttribute("data-label") ?? "Copy";
|
|
32
|
+
|
|
33
|
+
const COPY_ICON = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M3 11V3a1 1 0 0 1 1-1h8"/></svg>`;
|
|
34
|
+
const CHECK_ICON = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="2 8 6 12 14 4"/></svg>`;
|
|
35
|
+
|
|
36
|
+
const btn = document.createElement("button");
|
|
37
|
+
btn.className = "ui-copy-chip__btn";
|
|
38
|
+
btn.type = "button";
|
|
39
|
+
btn.setAttribute("aria-label", label);
|
|
40
|
+
btn.innerHTML = COPY_ICON;
|
|
41
|
+
|
|
42
|
+
btn.addEventListener("click", async () => {
|
|
43
|
+
try {
|
|
44
|
+
await navigator.clipboard.writeText(text);
|
|
45
|
+
btn.innerHTML = CHECK_ICON;
|
|
46
|
+
btn.setAttribute("aria-label", "Copied");
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
btn.innerHTML = COPY_ICON;
|
|
49
|
+
btn.setAttribute("aria-label", label);
|
|
50
|
+
}, 2000);
|
|
51
|
+
} catch (_e) {
|
|
52
|
+
// Clipboard API unavailable.
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
el.appendChild(btn);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function initAll(root: Document | HTMLElement) {
|
|
60
|
+
root
|
|
61
|
+
.querySelectorAll<HTMLElement>(".ui-copy-chip:not([data-copy-ready])")
|
|
62
|
+
.forEach((el) => {
|
|
63
|
+
el.setAttribute("data-copy-ready", "");
|
|
64
|
+
initCopyChip(el);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
initAll(document);
|
|
69
|
+
document.addEventListener("astro:page-load", () => initAll(document));
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<style>
|
|
73
|
+
@layer reset, tokens, base, components, motion, utilities;
|
|
74
|
+
|
|
75
|
+
@layer components {
|
|
76
|
+
.ui-copy-chip {
|
|
77
|
+
display: inline-flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
gap: var(--space-1);
|
|
80
|
+
padding: 1px var(--space-2);
|
|
81
|
+
border: 1px solid var(--border);
|
|
82
|
+
border-radius: var(--radius-sm);
|
|
83
|
+
background: var(--surface-hover);
|
|
84
|
+
font-family: var(--font-mono, monospace);
|
|
85
|
+
font-size: 0.875em;
|
|
86
|
+
color: var(--text);
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
vertical-align: middle;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.ui-copy-chip__text {
|
|
92
|
+
user-select: all;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.ui-copy-chip__btn {
|
|
96
|
+
display: inline-flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
justify-content: center;
|
|
99
|
+
width: 20px;
|
|
100
|
+
height: 20px;
|
|
101
|
+
padding: 0;
|
|
102
|
+
border: none;
|
|
103
|
+
border-radius: var(--radius-xs);
|
|
104
|
+
background: transparent;
|
|
105
|
+
color: var(--text-muted);
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
flex-shrink: 0;
|
|
108
|
+
transition:
|
|
109
|
+
color var(--duration-fast, 100ms) var(--ease-standard, ease),
|
|
110
|
+
background-color var(--duration-fast, 100ms) var(--ease-standard, ease);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ui-copy-chip__btn:hover {
|
|
114
|
+
color: var(--text);
|
|
115
|
+
background: var(--border);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.ui-copy-chip__btn:focus-visible {
|
|
119
|
+
outline: 2px solid var(--focus);
|
|
120
|
+
outline-offset: 2px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.ui-copy-chip__btn svg {
|
|
124
|
+
width: 13px;
|
|
125
|
+
height: 13px;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Icon from "./Icon.astro";
|
|
3
|
+
import "../styles/controls.css";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Icon button. Square 44 px control for the header: cart and menu. Always
|
|
@@ -46,78 +47,3 @@ const classes = ["ui-iconbtn", className].filter(Boolean).join(" ");
|
|
|
46
47
|
</span>
|
|
47
48
|
)}
|
|
48
49
|
</button>
|
|
49
|
-
|
|
50
|
-
<style>
|
|
51
|
-
@layer reset, tokens, base, components, motion, utilities;
|
|
52
|
-
|
|
53
|
-
@layer components {
|
|
54
|
-
.ui-iconbtn {
|
|
55
|
-
position: relative;
|
|
56
|
-
display: inline-flex;
|
|
57
|
-
align-items: center;
|
|
58
|
-
justify-content: center;
|
|
59
|
-
width: 44px;
|
|
60
|
-
height: 44px;
|
|
61
|
-
padding: 0;
|
|
62
|
-
background: transparent;
|
|
63
|
-
border: 1px solid transparent;
|
|
64
|
-
border-radius: var(--radius-md);
|
|
65
|
-
color: var(--text);
|
|
66
|
-
cursor: pointer;
|
|
67
|
-
transition:
|
|
68
|
-
background-color var(--duration-fast) var(--ease-standard),
|
|
69
|
-
border-color var(--duration-fast) var(--ease-standard);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
.ui-iconbtn:hover,
|
|
73
|
-
.ui-iconbtn.is-hover {
|
|
74
|
-
background: var(--surface-hover);
|
|
75
|
-
border-color: var(--border-strong);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.ui-iconbtn:focus-visible,
|
|
79
|
-
.ui-iconbtn.is-focus {
|
|
80
|
-
outline: 2px solid var(--focus);
|
|
81
|
-
outline-offset: 2px;
|
|
82
|
-
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
.ui-iconbtn:active,
|
|
86
|
-
.ui-iconbtn.is-active {
|
|
87
|
-
background: var(--surface-hover);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
.ui-iconbtn:disabled,
|
|
91
|
-
.ui-iconbtn.is-disabled {
|
|
92
|
-
opacity: 0.5;
|
|
93
|
-
cursor: not-allowed;
|
|
94
|
-
pointer-events: none;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/* Count pill: 19 px, accent fill, top-right. */
|
|
98
|
-
.ui-iconbtn__count {
|
|
99
|
-
position: absolute;
|
|
100
|
-
top: 3px;
|
|
101
|
-
right: 3px;
|
|
102
|
-
display: inline-flex;
|
|
103
|
-
align-items: center;
|
|
104
|
-
justify-content: center;
|
|
105
|
-
min-width: 19px;
|
|
106
|
-
height: 19px;
|
|
107
|
-
padding: 0 var(--space-1);
|
|
108
|
-
background: var(--accent);
|
|
109
|
-
color: var(--accent-fg);
|
|
110
|
-
border-radius: var(--radius-full);
|
|
111
|
-
font-family: var(--font-sans);
|
|
112
|
-
font-size: var(--text-2xs);
|
|
113
|
-
font-weight: var(--weight-semibold);
|
|
114
|
-
line-height: 1;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
@media (prefers-reduced-motion: reduce) {
|
|
118
|
-
.ui-iconbtn {
|
|
119
|
-
transition: none;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Icon from "./Icon.astro";
|
|
3
|
+
import "../styles/navlink.css";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A header navigation group: a button trigger plus a panel of links. The panel
|
|
@@ -104,51 +105,8 @@ const classes = ["ui-navgroup", open && "is-open", className]
|
|
|
104
105
|
display: inline-block;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
/* The trigger
|
|
108
|
-
/*
|
|
109
|
-
/* own markup. Without this block the button falls back to the browser default */
|
|
110
|
-
/* chrome, a grey fill and a border. Restated here so the trigger matches the */
|
|
111
|
-
/* sibling links it sits next to. */
|
|
112
|
-
.ui-navlink {
|
|
113
|
-
position: relative;
|
|
114
|
-
display: inline-flex;
|
|
115
|
-
align-items: center;
|
|
116
|
-
gap: var(--space-1-5);
|
|
117
|
-
padding: 7px 10px;
|
|
118
|
-
margin: -7px -10px;
|
|
119
|
-
border-radius: var(--radius-sm);
|
|
120
|
-
font-family: var(--font-sans);
|
|
121
|
-
font-size: var(--text-sm);
|
|
122
|
-
font-weight: var(--weight-medium);
|
|
123
|
-
line-height: 1;
|
|
124
|
-
color: var(--text);
|
|
125
|
-
text-decoration: none;
|
|
126
|
-
background: none;
|
|
127
|
-
border: 0;
|
|
128
|
-
cursor: pointer;
|
|
129
|
-
transition: color var(--duration-fast) var(--ease-standard);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
.ui-navlink::after {
|
|
133
|
-
content: "";
|
|
134
|
-
position: absolute;
|
|
135
|
-
left: 10px;
|
|
136
|
-
right: 10px;
|
|
137
|
-
bottom: 2px;
|
|
138
|
-
height: 2px;
|
|
139
|
-
background: currentColor;
|
|
140
|
-
opacity: 0;
|
|
141
|
-
transition: opacity var(--duration-fast) var(--ease-standard);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
.ui-navlink:hover,
|
|
145
|
-
.ui-navlink.is-hover {
|
|
146
|
-
color: var(--link-hover);
|
|
147
|
-
}
|
|
148
|
-
.ui-navlink:hover::after,
|
|
149
|
-
.ui-navlink.is-hover::after {
|
|
150
|
-
opacity: 1;
|
|
151
|
-
}
|
|
108
|
+
/* The trigger is a .ui-navlink, styled in styles/navlink.css. Only the */
|
|
109
|
+
/* group's own states are here. */
|
|
152
110
|
|
|
153
111
|
/* While the panel is open the trigger reads as active, the same way the */
|
|
154
112
|
/* current page does. */
|
|
@@ -158,13 +116,6 @@ const classes = ["ui-navgroup", open && "is-open", className]
|
|
|
158
116
|
opacity: 1;
|
|
159
117
|
}
|
|
160
118
|
|
|
161
|
-
.ui-navlink:focus-visible,
|
|
162
|
-
.ui-navlink.is-focus {
|
|
163
|
-
outline: 2px solid var(--focus);
|
|
164
|
-
outline-offset: 2px;
|
|
165
|
-
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
119
|
/* The chevron turns over when the panel is open. */
|
|
169
120
|
.ui-navlink > span {
|
|
170
121
|
display: inline-flex;
|
|
@@ -221,8 +172,6 @@ const classes = ["ui-navgroup", open && "is-open", className]
|
|
|
221
172
|
|
|
222
173
|
@media (prefers-reduced-motion: reduce) {
|
|
223
174
|
.ui-navpanel :global(a),
|
|
224
|
-
.ui-navlink,
|
|
225
|
-
.ui-navlink::after,
|
|
226
175
|
.ui-navlink > span {
|
|
227
176
|
transition: none;
|
|
228
177
|
}
|
package/src/astro/NavLink.astro
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import "../styles/navlink.css";
|
|
3
|
+
|
|
2
4
|
/**
|
|
3
5
|
* A navigation link for the header. Pads out to a 34 px row so the focus ring
|
|
4
6
|
* never crowds the text, with an equal negative margin so the header rhythm is
|
|
@@ -26,72 +28,3 @@ const classes = ["ui-navlink", current && "is-current", className]
|
|
|
26
28
|
>
|
|
27
29
|
<slot />
|
|
28
30
|
</a>
|
|
29
|
-
|
|
30
|
-
<style>
|
|
31
|
-
@layer reset, tokens, base, components, motion, utilities;
|
|
32
|
-
|
|
33
|
-
@layer components {
|
|
34
|
-
.ui-navlink {
|
|
35
|
-
position: relative;
|
|
36
|
-
display: inline-flex;
|
|
37
|
-
align-items: center;
|
|
38
|
-
gap: var(--space-1-5);
|
|
39
|
-
/* 7 px block / 10 px inline padding with an equal negative margin, so the focus ring has room but the header rhythm does not shift. */
|
|
40
|
-
padding: 7px 10px;
|
|
41
|
-
margin: -7px -10px;
|
|
42
|
-
border-radius: var(--radius-sm);
|
|
43
|
-
font-family: var(--font-sans);
|
|
44
|
-
font-size: var(--text-sm);
|
|
45
|
-
font-weight: var(--weight-medium);
|
|
46
|
-
line-height: 1;
|
|
47
|
-
color: var(--text);
|
|
48
|
-
text-decoration: none;
|
|
49
|
-
background: none;
|
|
50
|
-
border: 0;
|
|
51
|
-
cursor: pointer;
|
|
52
|
-
transition: color var(--duration-fast) var(--ease-standard);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.ui-navlink::after {
|
|
56
|
-
content: "";
|
|
57
|
-
position: absolute;
|
|
58
|
-
left: 10px;
|
|
59
|
-
right: 10px;
|
|
60
|
-
bottom: 2px;
|
|
61
|
-
height: 2px;
|
|
62
|
-
background: currentColor;
|
|
63
|
-
opacity: 0;
|
|
64
|
-
transition: opacity var(--duration-fast) var(--ease-standard);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.ui-navlink:hover,
|
|
68
|
-
.ui-navlink.is-hover {
|
|
69
|
-
color: var(--link-hover);
|
|
70
|
-
}
|
|
71
|
-
.ui-navlink:hover::after,
|
|
72
|
-
.ui-navlink.is-hover::after {
|
|
73
|
-
opacity: 1;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
.ui-navlink.is-current {
|
|
77
|
-
font-weight: var(--weight-semibold);
|
|
78
|
-
}
|
|
79
|
-
.ui-navlink.is-current::after {
|
|
80
|
-
opacity: 1;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.ui-navlink:focus-visible,
|
|
84
|
-
.ui-navlink.is-focus {
|
|
85
|
-
outline: 2px solid var(--focus);
|
|
86
|
-
outline-offset: 2px;
|
|
87
|
-
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
@media (prefers-reduced-motion: reduce) {
|
|
91
|
-
.ui-navlink,
|
|
92
|
-
.ui-navlink::after {
|
|
93
|
-
transition: none;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
</style>
|
|
@@ -18,6 +18,12 @@ interface Props {
|
|
|
18
18
|
ribbon?: "new" | "promo" | "last" | "fav";
|
|
19
19
|
soldOut?: boolean;
|
|
20
20
|
class?: string;
|
|
21
|
+
/** Responsive srcset for the product image (original format). */
|
|
22
|
+
srcset?: string;
|
|
23
|
+
/** Sizes descriptor matching the card's layout width. */
|
|
24
|
+
sizes?: string;
|
|
25
|
+
/** AVIF srcset — when provided, a `<picture>` with an AVIF `<source>` is rendered. */
|
|
26
|
+
avifSrcset?: string;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const {
|
|
@@ -31,6 +37,9 @@ const {
|
|
|
31
37
|
ribbon,
|
|
32
38
|
soldOut = false,
|
|
33
39
|
class: className,
|
|
40
|
+
srcset,
|
|
41
|
+
sizes,
|
|
42
|
+
avifSrcset,
|
|
34
43
|
...rest
|
|
35
44
|
} = Astro.props;
|
|
36
45
|
|
|
@@ -55,13 +64,30 @@ const classes = ["ui-product", soldOut && "is-disabled", className]
|
|
|
55
64
|
<i>{soldOut ? "Uitverkocht" : ribbonLabel[ribbon!]}</i>
|
|
56
65
|
</span>
|
|
57
66
|
)}
|
|
58
|
-
|
|
59
|
-
class="ui-product__img"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
{avifSrcset ? (
|
|
68
|
+
<picture class="ui-product__img">
|
|
69
|
+
<source type="image/avif" srcset={avifSrcset} sizes={sizes} />
|
|
70
|
+
<img
|
|
71
|
+
src={img}
|
|
72
|
+
srcset={srcset}
|
|
73
|
+
sizes={sizes}
|
|
74
|
+
alt={alt}
|
|
75
|
+
loading="lazy"
|
|
76
|
+
decoding="async"
|
|
77
|
+
style="width:100%;height:100%;object-fit:inherit;"
|
|
78
|
+
/>
|
|
79
|
+
</picture>
|
|
80
|
+
) : (
|
|
81
|
+
<img
|
|
82
|
+
class="ui-product__img"
|
|
83
|
+
loading="lazy"
|
|
84
|
+
decoding="async"
|
|
85
|
+
src={img}
|
|
86
|
+
srcset={srcset}
|
|
87
|
+
sizes={sizes}
|
|
88
|
+
alt={alt}
|
|
89
|
+
/>
|
|
90
|
+
)}
|
|
65
91
|
<span class="ui-product__body">
|
|
66
92
|
<span class="ui-product__title">{title}</span>
|
|
67
93
|
{meta && <span class="ui-product__meta">{meta}</span>}
|
package/src/styles/base.css
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
@layer reset, tokens, base, components, motion, utilities;
|
|
11
11
|
|
|
12
12
|
@layer base {
|
|
13
|
+
/* Text selection: sage fill instead of the browser default blue. */
|
|
14
|
+
::selection {
|
|
15
|
+
background-color: var(--surface-accent);
|
|
16
|
+
color: var(--surface-accent-fg);
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
/* Screen-reader-only, still announced. */
|
|
14
20
|
.ui-sr-only {
|
|
15
21
|
position: absolute;
|
package/src/styles/controls.css
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Control styles as plain, importable CSS.
|
|
3
|
-
*
|
|
4
|
-
* app needs nothing
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Control styles as plain, importable CSS. This is the single copy of the
|
|
3
|
+
* .ui-btn and .ui-iconbtn rules. Button.astro and IconButton.astro import it
|
|
4
|
+
* themselves, so a pure-Astro app needs nothing extra. Bundlers emit it once,
|
|
5
|
+
* however many components and pages pull it in.
|
|
6
|
+
*
|
|
7
|
+
* React and Preact islands cannot see Astro components, so any React code that
|
|
8
|
+
* renders the .ui-btn / .ui-iconbtn classes without an Astro Button on the
|
|
9
|
+
* page imports this sheet once:
|
|
7
10
|
*
|
|
8
11
|
* import "@kwtv/components/styles/controls.css";
|
|
9
12
|
*
|
|
10
|
-
*
|
|
11
|
-
* before this file.
|
|
13
|
+
* Import `@kwtv/tokens/tokens.css` before this file.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
@layer reset, tokens, base, components, motion, utilities;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Header navigation link. This is the single copy of the .ui-navlink rules.
|
|
3
|
+
* NavLink.astro and NavDropdown.astro import it, so a bundler emits it once.
|
|
4
|
+
* A plain sheet, not scoped, because NavDropdown's trigger is a <button> that
|
|
5
|
+
* carries the same class. Import `@kwtv/tokens/tokens.css` before this file.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
@layer reset, tokens, base, components, motion, utilities;
|
|
9
|
+
|
|
10
|
+
@layer components {
|
|
11
|
+
.ui-navlink {
|
|
12
|
+
position: relative;
|
|
13
|
+
display: inline-flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: var(--space-1-5);
|
|
16
|
+
/* 7 px block / 10 px inline padding with an equal negative margin, so the focus ring has room but the header rhythm does not shift. */
|
|
17
|
+
padding: 7px 10px;
|
|
18
|
+
margin: -7px -10px;
|
|
19
|
+
border-radius: var(--radius-sm);
|
|
20
|
+
font-family: var(--font-sans);
|
|
21
|
+
font-size: var(--text-sm);
|
|
22
|
+
font-weight: var(--weight-medium);
|
|
23
|
+
line-height: 1;
|
|
24
|
+
color: var(--text);
|
|
25
|
+
text-decoration: none;
|
|
26
|
+
background: none;
|
|
27
|
+
border: 0;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
transition: color var(--duration-fast) var(--ease-standard);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ui-navlink::after {
|
|
33
|
+
content: "";
|
|
34
|
+
position: absolute;
|
|
35
|
+
left: 10px;
|
|
36
|
+
right: 10px;
|
|
37
|
+
bottom: 2px;
|
|
38
|
+
height: 2px;
|
|
39
|
+
background: currentColor;
|
|
40
|
+
opacity: 0;
|
|
41
|
+
transition: opacity var(--duration-fast) var(--ease-standard);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ui-navlink:hover,
|
|
45
|
+
.ui-navlink.is-hover {
|
|
46
|
+
color: var(--link-hover);
|
|
47
|
+
}
|
|
48
|
+
.ui-navlink:hover::after,
|
|
49
|
+
.ui-navlink.is-hover::after {
|
|
50
|
+
opacity: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ui-navlink.is-current {
|
|
54
|
+
font-weight: var(--weight-semibold);
|
|
55
|
+
}
|
|
56
|
+
.ui-navlink.is-current::after {
|
|
57
|
+
opacity: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.ui-navlink:focus-visible,
|
|
61
|
+
.ui-navlink.is-focus {
|
|
62
|
+
outline: 2px solid var(--focus);
|
|
63
|
+
outline-offset: 2px;
|
|
64
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@media (prefers-reduced-motion: reduce) {
|
|
68
|
+
.ui-navlink,
|
|
69
|
+
.ui-navlink::after {
|
|
70
|
+
transition: none;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|