@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.
- package/README.md +77 -0
- package/package.json +32 -0
- package/src/astro/Accordion.astro +25 -0
- package/src/astro/AccordionItem.astro +77 -0
- package/src/astro/ActiveFilterTags.astro +65 -0
- package/src/astro/Alert.astro +93 -0
- package/src/astro/Badge.astro +138 -0
- package/src/astro/Band.astro +83 -0
- package/src/astro/Breadcrumb.astro +74 -0
- package/src/astro/Button.astro +217 -0
- package/src/astro/Card.astro +115 -0
- package/src/astro/CartLine.astro +112 -0
- package/src/astro/Checkbox.astro +89 -0
- package/src/astro/Chip.astro +130 -0
- package/src/astro/ChipRail.astro +24 -0
- package/src/astro/DotGrid.astro +62 -0
- package/src/astro/EmptyState.astro +53 -0
- package/src/astro/FilterGroup.astro +58 -0
- package/src/astro/Filters.astro +84 -0
- package/src/astro/Footer.astro +237 -0
- package/src/astro/FooterColumn.astro +64 -0
- package/src/astro/Header.astro +116 -0
- package/src/astro/Icon.astro +72 -0
- package/src/astro/IconButton.astro +117 -0
- package/src/astro/Marquee.astro +72 -0
- package/src/astro/NavDropdown.astro +164 -0
- package/src/astro/NavLink.astro +94 -0
- package/src/astro/Newsletter.astro +118 -0
- package/src/astro/Pager.astro +195 -0
- package/src/astro/PickCard.astro +142 -0
- package/src/astro/ProductCard.astro +195 -0
- package/src/astro/Radio.astro +89 -0
- package/src/astro/Skeleton.astro +80 -0
- package/src/astro/Social.astro +55 -0
- package/src/astro/StepIndicator.astro +104 -0
- package/src/astro/Sticker.astro +80 -0
- package/src/astro/Swatch.astro +100 -0
- package/src/astro/SwipeCarousel.astro +14 -0
- package/src/astro/Table.astro +117 -0
- package/src/astro/TableCard.astro +63 -0
- package/src/astro/TextField.astro +231 -0
- package/src/astro/Totals.astro +92 -0
- package/src/react/Drawer.css +67 -0
- package/src/react/Drawer.tsx +115 -0
- package/src/react/Modal.css +116 -0
- package/src/react/Modal.tsx +152 -0
- package/src/react/NewsCarousel.css +86 -0
- package/src/react/NewsCarousel.tsx +97 -0
- package/src/react/QuantityStepper.css +93 -0
- package/src/react/QuantityStepper.tsx +143 -0
- package/src/react/Select.css +183 -0
- package/src/react/Select.tsx +389 -0
- package/src/react/Toast.css +140 -0
- package/src/react/Toast.tsx +157 -0
- package/src/styles/base.css +39 -0
- package/src/styles/controls.css +211 -0
- package/src/styles/motion.css +238 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from "./Icon.astro";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pager. Appears once a list passes its page threshold. The current page is a
|
|
6
|
+
* non-link with aria-current="page"; disabled arrows are spans, not links, so
|
|
7
|
+
* keyboard focus never lands on a dead end. On mobile it collapses to
|
|
8
|
+
* previous / current / next.
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
current: number;
|
|
12
|
+
total: number;
|
|
13
|
+
/** Builds the href for a page number. Defaults to `?page=N`. */
|
|
14
|
+
href?: (page: number) => string;
|
|
15
|
+
/**
|
|
16
|
+
* Swap pages without a navigation. When set, clicking a page updates the URL
|
|
17
|
+
* with history.pushState (no reload, no scroll reset) and dispatches a
|
|
18
|
+
* bubbling `ui:pagerchange` CustomEvent<{ page, url }> the app listens for to
|
|
19
|
+
* re-render its list in place. Back and forward still work. Links stay real
|
|
20
|
+
* <a>, so the pager works without JS and inside Astro View Transitions.
|
|
21
|
+
*/
|
|
22
|
+
preserveScroll?: boolean;
|
|
23
|
+
/** The query key the preserveScroll behaviour reads and writes. */
|
|
24
|
+
param?: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
class?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
current,
|
|
31
|
+
total,
|
|
32
|
+
href = (page: number) => `?page=${page}`,
|
|
33
|
+
preserveScroll = false,
|
|
34
|
+
param = "page",
|
|
35
|
+
label = "Paginering",
|
|
36
|
+
class: className,
|
|
37
|
+
...rest
|
|
38
|
+
} = Astro.props;
|
|
39
|
+
|
|
40
|
+
/** Page numbers with ellipsis: first, a window around current, last. */
|
|
41
|
+
function pages(current: number, total: number): (number | "…")[] {
|
|
42
|
+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
|
43
|
+
const out: (number | "…")[] = [1];
|
|
44
|
+
const start = Math.max(2, current - 1);
|
|
45
|
+
const end = Math.min(total - 1, current + 1);
|
|
46
|
+
if (start > 2) out.push("…");
|
|
47
|
+
for (let p = start; p <= end; p++) out.push(p);
|
|
48
|
+
if (end < total - 1) out.push("…");
|
|
49
|
+
out.push(total);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const list = pages(current, total);
|
|
54
|
+
const hasPrev = current > 1;
|
|
55
|
+
const hasNext = current < total;
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
<nav
|
|
59
|
+
class={["ui-pager", className].filter(Boolean).join(" ")}
|
|
60
|
+
aria-label={label}
|
|
61
|
+
data-ui-pager
|
|
62
|
+
data-ui-pager-preserve={preserveScroll ? "" : undefined}
|
|
63
|
+
data-ui-pager-param={param}
|
|
64
|
+
{...rest}
|
|
65
|
+
>
|
|
66
|
+
{hasPrev ? (
|
|
67
|
+
<a href={href(current - 1)} aria-label="Vorige pagina">
|
|
68
|
+
<Icon name="chevron-left" size={17} stroke={2.3} />
|
|
69
|
+
</a>
|
|
70
|
+
) : (
|
|
71
|
+
<span class="is-disabled" aria-hidden="true"><Icon name="chevron-left" size={17} stroke={2.3} /></span>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{list.map((p) =>
|
|
75
|
+
p === "…" ? (
|
|
76
|
+
<span class="ui-pager__gap">…</span>
|
|
77
|
+
) : p === current ? (
|
|
78
|
+
<a class="is-current" href={href(p)} aria-current="page">{p}</a>
|
|
79
|
+
) : (
|
|
80
|
+
<a href={href(p)}>{p}</a>
|
|
81
|
+
)
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{hasNext ? (
|
|
85
|
+
<a href={href(current + 1)} aria-label="Volgende pagina">
|
|
86
|
+
<Icon name="chevron-right" size={17} stroke={2.3} />
|
|
87
|
+
</a>
|
|
88
|
+
) : (
|
|
89
|
+
<span class="is-disabled" aria-hidden="true"><Icon name="chevron-right" size={17} stroke={2.3} /></span>
|
|
90
|
+
)}
|
|
91
|
+
</nav>
|
|
92
|
+
|
|
93
|
+
<script>
|
|
94
|
+
// preserveScroll behaviour. Bundled and hoisted by Astro, so it runs on first
|
|
95
|
+
// load and again on every astro:page-load (View Transitions). Self-guards:
|
|
96
|
+
// does nothing unless a [data-ui-pager-preserve] pager is on the page, so it
|
|
97
|
+
// is safe to ship on every Pager whether or not the prop is set.
|
|
98
|
+
function bindUiPager() {
|
|
99
|
+
if ((window as any).__uiPagerBound) return;
|
|
100
|
+
(window as any).__uiPagerBound = true;
|
|
101
|
+
|
|
102
|
+
function pageFrom(nav: Element, a: Element) {
|
|
103
|
+
const key = nav.getAttribute("data-ui-pager-param") || "page";
|
|
104
|
+
const url = new URL(a.getAttribute("href")!, location.href);
|
|
105
|
+
const raw = url.searchParams.get(key);
|
|
106
|
+
const n = raw == null ? NaN : parseInt(raw, 10);
|
|
107
|
+
return { key, url, page: Number.isFinite(n) ? n : null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Capture phase + stopImmediatePropagation so the Astro View Transitions
|
|
111
|
+
// router (which also listens on document and would scroll to top on a
|
|
112
|
+
// ?page= change) never sees the click.
|
|
113
|
+
document.addEventListener(
|
|
114
|
+
"click",
|
|
115
|
+
(e) => {
|
|
116
|
+
const t = e.target as Element | null;
|
|
117
|
+
const a = t && t.closest ? t.closest("a[href]") : null;
|
|
118
|
+
if (!a) return;
|
|
119
|
+
const nav = a.closest("[data-ui-pager][data-ui-pager-preserve]");
|
|
120
|
+
if (!nav) return;
|
|
121
|
+
const me = e as MouseEvent;
|
|
122
|
+
if (me.button !== 0 || me.metaKey || me.ctrlKey || me.shiftKey || me.altKey) return;
|
|
123
|
+
const { page, url } = pageFrom(nav, a);
|
|
124
|
+
if (page == null) return;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
e.stopImmediatePropagation();
|
|
127
|
+
history.pushState({ uiPager: true, page }, "", url.href);
|
|
128
|
+
window.dispatchEvent(new CustomEvent("ui:pagerchange", { detail: { page, url: url.href } }));
|
|
129
|
+
},
|
|
130
|
+
true,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Back and forward re-announce the query so the app re-renders in place.
|
|
134
|
+
window.addEventListener("popstate", () => {
|
|
135
|
+
const u = new URL(location.href);
|
|
136
|
+
const p = parseInt(u.searchParams.get("page") || "1", 10);
|
|
137
|
+
window.dispatchEvent(
|
|
138
|
+
new CustomEvent("ui:pagerchange", {
|
|
139
|
+
detail: { page: Number.isFinite(p) ? p : 1, url: u.href, popped: true },
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
bindUiPager();
|
|
146
|
+
document.addEventListener("astro:page-load", bindUiPager);
|
|
147
|
+
</script>
|
|
148
|
+
|
|
149
|
+
<style>
|
|
150
|
+
.ui-pager {
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: var(--space-1);
|
|
154
|
+
font-size: var(--text-sm);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.ui-pager a,
|
|
158
|
+
.ui-pager span:not(.ui-pager__gap) {
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
justify-content: center;
|
|
162
|
+
min-width: 36px;
|
|
163
|
+
height: 36px;
|
|
164
|
+
padding: 0 var(--space-1);
|
|
165
|
+
border-radius: var(--radius-md);
|
|
166
|
+
color: var(--link);
|
|
167
|
+
text-decoration: none;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.ui-pager a:hover {
|
|
171
|
+
background: var(--surface-hover);
|
|
172
|
+
color: var(--link-hover);
|
|
173
|
+
}
|
|
174
|
+
.ui-pager a:focus-visible {
|
|
175
|
+
outline: 2px solid var(--focus);
|
|
176
|
+
outline-offset: 2px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.ui-pager a.is-current {
|
|
180
|
+
background: var(--primary);
|
|
181
|
+
color: var(--primary-fg);
|
|
182
|
+
font-weight: var(--weight-semibold);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.ui-pager .is-disabled {
|
|
186
|
+
color: var(--border);
|
|
187
|
+
cursor: not-allowed;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.ui-pager__gap {
|
|
191
|
+
min-width: 24px;
|
|
192
|
+
text-align: center;
|
|
193
|
+
color: var(--text-muted);
|
|
194
|
+
}
|
|
195
|
+
</style>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Pick card. A radio with room to explain the consequence: pickup versus
|
|
4
|
+
* delivery, payment method. The card is a <label> wrapping a real radio, so
|
|
5
|
+
* keyboard arrows move between options. The note is part of the label, so it is
|
|
6
|
+
* announced with the choice. 2 px border when selected. Disabled keeps the
|
|
7
|
+
* reason visible.
|
|
8
|
+
*
|
|
9
|
+
* Give every card in a group the same `name`.
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
name: string;
|
|
13
|
+
value?: string;
|
|
14
|
+
title: string;
|
|
15
|
+
note?: string;
|
|
16
|
+
checked?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
id?: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
name,
|
|
24
|
+
value,
|
|
25
|
+
title,
|
|
26
|
+
note,
|
|
27
|
+
checked = false,
|
|
28
|
+
disabled = false,
|
|
29
|
+
id,
|
|
30
|
+
class: className,
|
|
31
|
+
...rest
|
|
32
|
+
} = Astro.props;
|
|
33
|
+
|
|
34
|
+
const classes = [
|
|
35
|
+
"ui-pick",
|
|
36
|
+
checked && "is-selected",
|
|
37
|
+
disabled && "is-disabled",
|
|
38
|
+
className,
|
|
39
|
+
]
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.join(" ");
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<label class={classes}>
|
|
45
|
+
<input
|
|
46
|
+
type="radio"
|
|
47
|
+
name={name}
|
|
48
|
+
value={value}
|
|
49
|
+
id={id}
|
|
50
|
+
checked={checked}
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
{...rest}
|
|
53
|
+
/>
|
|
54
|
+
<span class="ui-pick__text">
|
|
55
|
+
<b class="ui-pick__title">{title}</b>
|
|
56
|
+
{note ? <span class="ui-pick__note">{note}</span> : <slot />}
|
|
57
|
+
</span>
|
|
58
|
+
</label>
|
|
59
|
+
|
|
60
|
+
<style>
|
|
61
|
+
.ui-pick {
|
|
62
|
+
display: flex;
|
|
63
|
+
align-items: flex-start;
|
|
64
|
+
gap: var(--space-3);
|
|
65
|
+
padding: var(--space-4);
|
|
66
|
+
background: var(--surface);
|
|
67
|
+
border: 1px solid var(--border);
|
|
68
|
+
border-radius: var(--radius-lg);
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
transition:
|
|
71
|
+
border-color var(--duration-fast) var(--ease-standard),
|
|
72
|
+
background-color var(--duration-fast) var(--ease-standard);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.ui-pick input {
|
|
76
|
+
width: 18px;
|
|
77
|
+
height: 18px;
|
|
78
|
+
margin-top: 2px;
|
|
79
|
+
flex-shrink: 0;
|
|
80
|
+
accent-color: var(--primary);
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ui-pick input:focus-visible {
|
|
85
|
+
outline: 2px solid var(--focus);
|
|
86
|
+
outline-offset: 2px;
|
|
87
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ui-pick__text {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
gap: var(--space-1);
|
|
94
|
+
font-family: var(--font-sans);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.ui-pick__title {
|
|
98
|
+
font-size: var(--text-sm);
|
|
99
|
+
font-weight: var(--weight-semibold);
|
|
100
|
+
color: var(--text);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ui-pick__note {
|
|
104
|
+
font-size: var(--text-xs);
|
|
105
|
+
line-height: var(--leading-xs);
|
|
106
|
+
color: var(--text-muted);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.ui-pick:hover,
|
|
110
|
+
.ui-pick.is-hover {
|
|
111
|
+
border-color: var(--border-strong);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Selected: 2 px border. */
|
|
115
|
+
.ui-pick.is-selected {
|
|
116
|
+
border-width: 2px;
|
|
117
|
+
border-color: var(--border-strong);
|
|
118
|
+
background: var(--surface-accent);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.ui-pick.is-selected .ui-pick__title {
|
|
122
|
+
color: var(--surface-accent-fg, var(--text));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* The selected card sits on the sage accent surface, where --text-muted only
|
|
126
|
+
reaches 3.6:1. The accent surface carries its own muted role for exactly
|
|
127
|
+
this case. */
|
|
128
|
+
.ui-pick.is-selected .ui-pick__note {
|
|
129
|
+
color: var(--surface-accent-muted, var(--text));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.ui-pick.is-disabled {
|
|
133
|
+
opacity: 0.5;
|
|
134
|
+
cursor: not-allowed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@media (prefers-reduced-motion: reduce) {
|
|
138
|
+
.ui-pick {
|
|
139
|
+
transition: none;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
</style>
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Product card. The whole card is one link, so the reader hears name, stock and
|
|
4
|
+
* price as a single stop. A corner ribbon marks new, promo, last items or
|
|
5
|
+
* member favourite or sold-out stock, one ribbon at a time. Ribbons are
|
|
6
|
+
* decorative; sold-out text is repeated for assistive technology and the card
|
|
7
|
+
* stays reachable.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
href: string;
|
|
11
|
+
img: string;
|
|
12
|
+
alt: string;
|
|
13
|
+
title: string;
|
|
14
|
+
meta?: string;
|
|
15
|
+
price: string;
|
|
16
|
+
/** When set, `price` is rendered as the sale price and this as struck-through. */
|
|
17
|
+
oldPrice?: string;
|
|
18
|
+
ribbon?: "new" | "promo" | "last" | "fav";
|
|
19
|
+
soldOut?: boolean;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
href,
|
|
25
|
+
img,
|
|
26
|
+
alt,
|
|
27
|
+
title,
|
|
28
|
+
meta,
|
|
29
|
+
price,
|
|
30
|
+
oldPrice,
|
|
31
|
+
ribbon,
|
|
32
|
+
soldOut = false,
|
|
33
|
+
class: className,
|
|
34
|
+
...rest
|
|
35
|
+
} = Astro.props;
|
|
36
|
+
|
|
37
|
+
const ribbonLabel: Record<NonNullable<Props["ribbon"]>, string> = {
|
|
38
|
+
new: "Nieuw",
|
|
39
|
+
promo: "Promo",
|
|
40
|
+
last: "Laatste",
|
|
41
|
+
fav: "Favoriet",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const classes = ["ui-product", soldOut && "is-disabled", className].filter(Boolean).join(" ");
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<a class={classes} href={href} {...rest}>
|
|
48
|
+
{(ribbon || soldOut) && (
|
|
49
|
+
<span class={`ui-ribbon ui-ribbon--${soldOut ? "sold-out" : ribbon}`} aria-hidden="true">
|
|
50
|
+
<i>{soldOut ? "Uitverkocht" : ribbonLabel[ribbon!]}</i>
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
<img class="ui-product__img" loading="lazy" decoding="async" src={img} alt={alt} />
|
|
54
|
+
<span class="ui-product__body">
|
|
55
|
+
<span class="ui-product__title">{title}</span>
|
|
56
|
+
{meta && <span class="ui-product__meta">{meta}</span>}
|
|
57
|
+
{soldOut && <span class="ui-sr-only">Uitverkocht.</span>}
|
|
58
|
+
<span class="ui-product__price">
|
|
59
|
+
{oldPrice && <s>{oldPrice}</s>}
|
|
60
|
+
{price}
|
|
61
|
+
</span>
|
|
62
|
+
</span>
|
|
63
|
+
</a>
|
|
64
|
+
|
|
65
|
+
<style>
|
|
66
|
+
.ui-product {
|
|
67
|
+
position: relative;
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
border: 1px solid var(--border);
|
|
71
|
+
border-radius: var(--radius-lg);
|
|
72
|
+
overflow: hidden;
|
|
73
|
+
background: var(--surface);
|
|
74
|
+
color: var(--text);
|
|
75
|
+
text-decoration: none;
|
|
76
|
+
transition: border-color var(--duration-fast) var(--ease-standard);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.ui-product:hover,
|
|
80
|
+
.ui-product.is-hover {
|
|
81
|
+
border-color: var(--border-strong);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ui-product:focus-visible {
|
|
85
|
+
outline: 2px solid var(--focus);
|
|
86
|
+
outline-offset: 2px;
|
|
87
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ui-product__img {
|
|
91
|
+
display: block;
|
|
92
|
+
width: 100%;
|
|
93
|
+
aspect-ratio: 4 / 3;
|
|
94
|
+
object-fit: cover;
|
|
95
|
+
background: var(--surface-hover);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.ui-product__body {
|
|
99
|
+
display: flex;
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
gap: var(--space-1);
|
|
102
|
+
padding: var(--space-3) var(--space-4) var(--space-4);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.ui-product__title {
|
|
106
|
+
font-size: var(--text-base);
|
|
107
|
+
font-weight: var(--weight-semibold);
|
|
108
|
+
color: var(--text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.ui-product__meta {
|
|
112
|
+
font-size: var(--text-xs);
|
|
113
|
+
color: var(--text-muted);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.ui-product__price {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: baseline;
|
|
119
|
+
gap: var(--space-2);
|
|
120
|
+
margin-top: var(--space-1);
|
|
121
|
+
font-size: var(--text-lg);
|
|
122
|
+
font-weight: var(--weight-semibold);
|
|
123
|
+
font-variant-numeric: tabular-nums;
|
|
124
|
+
color: var(--text);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.ui-product__price s {
|
|
128
|
+
font-size: var(--text-sm);
|
|
129
|
+
font-weight: var(--weight-regular);
|
|
130
|
+
color: var(--text-muted);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Sold out: card stays readable and clickable. */
|
|
134
|
+
.ui-product.is-disabled .ui-product__img {
|
|
135
|
+
opacity: 0.7;
|
|
136
|
+
}
|
|
137
|
+
.ui-product.is-disabled .ui-product__price {
|
|
138
|
+
color: var(--text-muted);
|
|
139
|
+
font-weight: var(--weight-regular);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Ribbon. One per card, decorative. 112 px box. */
|
|
143
|
+
.ui-ribbon {
|
|
144
|
+
position: absolute;
|
|
145
|
+
top: 0;
|
|
146
|
+
left: 0;
|
|
147
|
+
width: 112px;
|
|
148
|
+
height: 112px;
|
|
149
|
+
overflow: hidden;
|
|
150
|
+
pointer-events: none;
|
|
151
|
+
z-index: 1;
|
|
152
|
+
}
|
|
153
|
+
.ui-ribbon i {
|
|
154
|
+
position: absolute;
|
|
155
|
+
top: 22px;
|
|
156
|
+
left: -34px;
|
|
157
|
+
width: 140px;
|
|
158
|
+
transform: rotate(-45deg);
|
|
159
|
+
text-align: center;
|
|
160
|
+
font-size: var(--text-2xs);
|
|
161
|
+
font-weight: var(--weight-bold);
|
|
162
|
+
letter-spacing: var(--tracking-wider);
|
|
163
|
+
text-transform: uppercase;
|
|
164
|
+
font-style: normal;
|
|
165
|
+
padding: var(--space-1) 0;
|
|
166
|
+
color: var(--primary-fg);
|
|
167
|
+
background: var(--primary);
|
|
168
|
+
}
|
|
169
|
+
.ui-ribbon--new i {
|
|
170
|
+
background: var(--primary);
|
|
171
|
+
color: var(--primary-fg);
|
|
172
|
+
}
|
|
173
|
+
.ui-ribbon--promo i {
|
|
174
|
+
background: var(--accent);
|
|
175
|
+
color: var(--accent-fg);
|
|
176
|
+
}
|
|
177
|
+
.ui-ribbon--last i {
|
|
178
|
+
background: var(--warning);
|
|
179
|
+
color: var(--primary-fg);
|
|
180
|
+
}
|
|
181
|
+
.ui-ribbon--fav i {
|
|
182
|
+
background: var(--surface-accent);
|
|
183
|
+
color: var(--surface-accent-fg);
|
|
184
|
+
}
|
|
185
|
+
.ui-ribbon--sold-out i {
|
|
186
|
+
background: var(--danger-bg);
|
|
187
|
+
color: var(--danger);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@media (prefers-reduced-motion: reduce) {
|
|
191
|
+
.ui-product {
|
|
192
|
+
transition: none;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
</style>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Radio. Native control tinted with accent-color, wrapped in a label so the
|
|
4
|
+
* whole row is the hit area. One answer per group: give every radio in the
|
|
5
|
+
* group the same `name`. 17 px box, 32 px row.
|
|
6
|
+
*
|
|
7
|
+
* Put the group in a <fieldset> with a <legend> in the consuming page so the
|
|
8
|
+
* question is announced before the options.
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
label?: string;
|
|
12
|
+
name: string;
|
|
13
|
+
value?: string;
|
|
14
|
+
checked?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
id?: string;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
label,
|
|
22
|
+
name,
|
|
23
|
+
value,
|
|
24
|
+
checked = false,
|
|
25
|
+
disabled = false,
|
|
26
|
+
id,
|
|
27
|
+
class: className,
|
|
28
|
+
...rest
|
|
29
|
+
} = Astro.props;
|
|
30
|
+
|
|
31
|
+
const classes = ["ui-check", disabled && "is-disabled", className]
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join(" ");
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<label class={classes}>
|
|
37
|
+
<input
|
|
38
|
+
type="radio"
|
|
39
|
+
name={name}
|
|
40
|
+
value={value}
|
|
41
|
+
id={id}
|
|
42
|
+
checked={checked}
|
|
43
|
+
disabled={disabled}
|
|
44
|
+
{...rest}
|
|
45
|
+
/>
|
|
46
|
+
<slot>{label}</slot>
|
|
47
|
+
</label>
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
.ui-check {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: var(--space-2);
|
|
54
|
+
min-height: 32px;
|
|
55
|
+
font-family: var(--font-sans);
|
|
56
|
+
font-size: var(--text-sm);
|
|
57
|
+
color: var(--text);
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.ui-check input {
|
|
62
|
+
width: 17px;
|
|
63
|
+
height: 17px;
|
|
64
|
+
flex-shrink: 0;
|
|
65
|
+
accent-color: var(--primary);
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.ui-check input:focus-visible {
|
|
70
|
+
outline: 2px solid var(--focus);
|
|
71
|
+
outline-offset: 2px;
|
|
72
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.ui-check.is-disabled {
|
|
76
|
+
cursor: not-allowed;
|
|
77
|
+
color: var(--text-muted);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.ui-check.is-disabled input {
|
|
81
|
+
cursor: not-allowed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@media (max-width: 700px), (pointer: coarse) {
|
|
85
|
+
.ui-check {
|
|
86
|
+
min-height: 40px;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Loading skeleton. Blocks match the real layout so nothing jumps when content
|
|
4
|
+
* arrives. The shimmer stops for readers who ask for reduced motion, leaving a
|
|
5
|
+
* flat block. Decorative: aria-hidden by default.
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
variant?: "img" | "title" | "line" | "block";
|
|
9
|
+
width?: string;
|
|
10
|
+
height?: string;
|
|
11
|
+
radius?: string;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { variant = "block", width, height, radius, class: className, ...rest } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const classes = ["ui-skel", `ui-skel--${variant}`, className].filter(Boolean).join(" ");
|
|
18
|
+
|
|
19
|
+
const style = [
|
|
20
|
+
width && `width:${width}`,
|
|
21
|
+
height && `height:${height}`,
|
|
22
|
+
radius && `border-radius:${radius}`,
|
|
23
|
+
]
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join(";");
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<span class={classes} style={style || undefined} aria-hidden="true" {...rest}></span>
|
|
29
|
+
|
|
30
|
+
<style>
|
|
31
|
+
.ui-skel {
|
|
32
|
+
display: block;
|
|
33
|
+
background: var(--surface-hover);
|
|
34
|
+
background-image: linear-gradient(
|
|
35
|
+
90deg,
|
|
36
|
+
var(--surface-hover) 0%,
|
|
37
|
+
var(--border) 50%,
|
|
38
|
+
var(--surface-hover) 100%
|
|
39
|
+
);
|
|
40
|
+
background-size: 200% 100%;
|
|
41
|
+
border-radius: var(--radius-md);
|
|
42
|
+
animation: ui-skel-shimmer 1400ms ease-in-out infinite;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.ui-skel--img {
|
|
46
|
+
width: 100%;
|
|
47
|
+
aspect-ratio: 4 / 3;
|
|
48
|
+
border-radius: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.ui-skel--title {
|
|
52
|
+
height: 0.875rem;
|
|
53
|
+
border-radius: var(--radius-sm);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ui-skel--line {
|
|
57
|
+
height: 0.625rem;
|
|
58
|
+
border-radius: var(--radius-sm);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.ui-skel--block {
|
|
62
|
+
height: 1rem;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@keyframes ui-skel-shimmer {
|
|
66
|
+
0% {
|
|
67
|
+
background-position: 200% 0;
|
|
68
|
+
}
|
|
69
|
+
100% {
|
|
70
|
+
background-position: -200% 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@media (prefers-reduced-motion: reduce) {
|
|
75
|
+
.ui-skel {
|
|
76
|
+
animation: none;
|
|
77
|
+
background-image: none;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
</style>
|