@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,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>