@kwtv/components 0.7.2 → 0.9.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 +10 -8
- package/package.json +2 -2
- package/src/astro/AccountNav.astro +206 -0
- package/src/astro/Alert.astro +4 -57
- package/src/astro/AuthCard.astro +131 -0
- package/src/astro/EmptyState.astro +5 -32
- package/src/astro/IconButton.astro +33 -14
- package/src/astro/Skeleton.astro +5 -56
- package/src/react/Drawer.css +71 -1
- package/src/react/Drawer.tsx +146 -18
- package/src/react/OrderStatus.css +110 -0
- package/src/react/OrderStatus.tsx +144 -0
- package/src/react/OrderTimeline.css +104 -0
- package/src/react/OrderTimeline.tsx +112 -0
- package/src/styles/controls.css +3 -0
- package/src/styles/feedback.css +148 -0
package/README.md
CHANGED
|
@@ -7,14 +7,15 @@ 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` | 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/
|
|
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, OrderStatus, OrderTimeline. |
|
|
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/feedback.css` | The one copy of the alert, empty state and skeleton styles. `Alert`, `EmptyState` and `Skeleton` import it. React islands import it for the `ui-alert`, `ui-empty` and `ui-skel` classes. |
|
|
18
|
+
| `src/styles/motion.css` | The `pl-` motion layer: lift, underline, arrow and row nudges, rise, marquee, chip rail and swipe carousel. |
|
|
18
19
|
|
|
19
20
|
## Install
|
|
20
21
|
|
|
@@ -70,6 +71,7 @@ classes (Astro cannot pass its scoped styles into a React island):
|
|
|
70
71
|
```ts
|
|
71
72
|
import "@kwtv/components/styles/base.css";
|
|
72
73
|
import "@kwtv/components/styles/controls.css";
|
|
74
|
+
import "@kwtv/components/styles/feedback.css";
|
|
73
75
|
```
|
|
74
76
|
|
|
75
77
|
## Cascade layers
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kwtv/components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.",
|
|
@@ -39,6 +39,6 @@
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@kwtv/tokens": "^0.
|
|
42
|
+
"@kwtv/tokens": "^0.9.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Account navigation. The side list on the webshop account pages: overview,
|
|
4
|
+
* orders, details, password. Sign out sits apart at the end, so it is never
|
|
5
|
+
* hit by accident on the way down the list.
|
|
6
|
+
*
|
|
7
|
+
* The current page carries aria-current="page" and a filled row, not colour
|
|
8
|
+
* alone: the row also gets the strong weight. Below 700 px the list turns into
|
|
9
|
+
* one row that scrolls sideways, with sign out last. `mobile` pins that row in
|
|
10
|
+
* the explorer and in other narrow shells.
|
|
11
|
+
*
|
|
12
|
+
* Sign out is a link to a sign-out page or a form button in the `signout`
|
|
13
|
+
* slot. It ends a session, so a plain GET link that signs out on load is not
|
|
14
|
+
* enough; the page behind it should post.
|
|
15
|
+
*/
|
|
16
|
+
interface Item {
|
|
17
|
+
label: string;
|
|
18
|
+
href: string;
|
|
19
|
+
current?: boolean;
|
|
20
|
+
/** A count after the label, such as open orders. Spoken with the label. */
|
|
21
|
+
count?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
items: Item[];
|
|
26
|
+
/** Names the landmark, e.g. "Mijn account". */
|
|
27
|
+
label: string;
|
|
28
|
+
mobile?: boolean;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { items, label, mobile = false, class: className, ...rest } = Astro.props;
|
|
33
|
+
const hasSignout = Astro.slots.has("signout");
|
|
34
|
+
|
|
35
|
+
const classes = ["ui-accnav", mobile && "ui-accnav--mobile", className]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join(" ");
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
<nav class={classes} aria-label={label} {...rest}>
|
|
41
|
+
<ul class="ui-accnav__list">
|
|
42
|
+
{items.map((item) => (
|
|
43
|
+
<li>
|
|
44
|
+
<a
|
|
45
|
+
class="ui-accnav__link"
|
|
46
|
+
href={item.href}
|
|
47
|
+
aria-current={item.current ? "page" : undefined}
|
|
48
|
+
>
|
|
49
|
+
<span>{item.label}</span>
|
|
50
|
+
{item.count !== undefined && (
|
|
51
|
+
<span class="ui-accnav__count">{item.count}</span>
|
|
52
|
+
)}
|
|
53
|
+
</a>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
{hasSignout && (
|
|
58
|
+
<div class="ui-accnav__end">
|
|
59
|
+
<slot name="signout" />
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</nav>
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
@layer reset, tokens, base, components, motion, utilities;
|
|
66
|
+
|
|
67
|
+
@layer components {
|
|
68
|
+
.ui-accnav {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
gap: var(--space-3);
|
|
72
|
+
width: 100%;
|
|
73
|
+
max-width: 260px;
|
|
74
|
+
font-family: var(--font-sans);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ui-accnav__list {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
gap: var(--space-1);
|
|
81
|
+
margin: 0;
|
|
82
|
+
padding: 0;
|
|
83
|
+
list-style: none;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ui-accnav__link,
|
|
87
|
+
.ui-accnav__end :global(a),
|
|
88
|
+
.ui-accnav__end :global(button) {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: space-between;
|
|
92
|
+
gap: var(--space-3);
|
|
93
|
+
min-height: 44px;
|
|
94
|
+
padding: 0 var(--space-3);
|
|
95
|
+
border: 0;
|
|
96
|
+
border-radius: var(--radius-md);
|
|
97
|
+
background: transparent;
|
|
98
|
+
color: var(--text);
|
|
99
|
+
font: inherit;
|
|
100
|
+
font-size: var(--text-sm);
|
|
101
|
+
text-decoration: none;
|
|
102
|
+
text-align: left;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
transition: background-color var(--duration-fast) var(--ease-standard);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.ui-accnav__link:hover,
|
|
108
|
+
.ui-accnav__end :global(a:hover),
|
|
109
|
+
.ui-accnav__end :global(button:hover) {
|
|
110
|
+
background: var(--surface-hover);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ui-accnav__link:focus-visible,
|
|
114
|
+
.ui-accnav__end :global(a:focus-visible),
|
|
115
|
+
.ui-accnav__end :global(button:focus-visible) {
|
|
116
|
+
outline: 2px solid var(--focus);
|
|
117
|
+
outline-offset: 2px;
|
|
118
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.ui-accnav__link[aria-current="page"] {
|
|
122
|
+
background: var(--surface-accent);
|
|
123
|
+
color: var(--surface-accent-fg);
|
|
124
|
+
font-weight: var(--weight-semibold);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.ui-accnav__count {
|
|
128
|
+
min-width: 22px;
|
|
129
|
+
padding: 0 var(--space-1-5);
|
|
130
|
+
border-radius: var(--radius-full);
|
|
131
|
+
background: var(--surface);
|
|
132
|
+
border: 1px solid var(--border);
|
|
133
|
+
font-size: var(--text-xs);
|
|
134
|
+
line-height: 20px;
|
|
135
|
+
font-weight: var(--weight-medium);
|
|
136
|
+
font-variant-numeric: tabular-nums;
|
|
137
|
+
text-align: center;
|
|
138
|
+
color: var(--text);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.ui-accnav__end {
|
|
142
|
+
padding-top: var(--space-3);
|
|
143
|
+
border-top: 1px solid var(--border);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.ui-accnav__end :global(a),
|
|
147
|
+
.ui-accnav__end :global(button) {
|
|
148
|
+
width: 100%;
|
|
149
|
+
color: var(--text-muted);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* One row that scrolls sideways. Sign out stays last in the row. */
|
|
153
|
+
.ui-accnav--mobile {
|
|
154
|
+
flex-direction: row;
|
|
155
|
+
align-items: center;
|
|
156
|
+
max-width: none;
|
|
157
|
+
overflow-x: auto;
|
|
158
|
+
scrollbar-width: none;
|
|
159
|
+
/* Room for the focus ring, which the scroll box would clip. */
|
|
160
|
+
padding: var(--space-1);
|
|
161
|
+
}
|
|
162
|
+
.ui-accnav--mobile .ui-accnav__list {
|
|
163
|
+
flex-direction: row;
|
|
164
|
+
}
|
|
165
|
+
.ui-accnav--mobile .ui-accnav__link,
|
|
166
|
+
.ui-accnav--mobile .ui-accnav__end > :global(*) {
|
|
167
|
+
white-space: nowrap;
|
|
168
|
+
}
|
|
169
|
+
.ui-accnav--mobile .ui-accnav__end {
|
|
170
|
+
padding: 0 0 0 var(--space-3);
|
|
171
|
+
border-top: 0;
|
|
172
|
+
border-left: 1px solid var(--border);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@media (max-width: 700px) {
|
|
176
|
+
.ui-accnav {
|
|
177
|
+
flex-direction: row;
|
|
178
|
+
align-items: center;
|
|
179
|
+
max-width: none;
|
|
180
|
+
overflow-x: auto;
|
|
181
|
+
scrollbar-width: none;
|
|
182
|
+
padding: var(--space-1);
|
|
183
|
+
}
|
|
184
|
+
.ui-accnav__list {
|
|
185
|
+
flex-direction: row;
|
|
186
|
+
}
|
|
187
|
+
.ui-accnav__link,
|
|
188
|
+
.ui-accnav__end > :global(*) {
|
|
189
|
+
white-space: nowrap;
|
|
190
|
+
}
|
|
191
|
+
.ui-accnav__end {
|
|
192
|
+
padding: 0 0 0 var(--space-3);
|
|
193
|
+
border-top: 0;
|
|
194
|
+
border-left: 1px solid var(--border);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@media (prefers-reduced-motion: reduce) {
|
|
199
|
+
.ui-accnav__link,
|
|
200
|
+
.ui-accnav__end :global(a),
|
|
201
|
+
.ui-accnav__end :global(button) {
|
|
202
|
+
transition: none;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
</style>
|
package/src/astro/Alert.astro
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Icon from "./Icon.astro";
|
|
3
|
+
import "../styles/feedback.css";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Alert. Inline message tied to what the reader is looking at. Never used for
|
|
@@ -8,6 +9,9 @@ import Icon from "./Icon.astro";
|
|
|
8
9
|
* are read in document order.
|
|
9
10
|
*
|
|
10
11
|
* Every alert carries both an icon and a worded tone. Body keeps full strength.
|
|
12
|
+
*
|
|
13
|
+
* React and Preact islands write the same markup and import
|
|
14
|
+
* `@kwtv/components/styles/feedback.css`.
|
|
11
15
|
*/
|
|
12
16
|
interface Props {
|
|
13
17
|
tone?: "info" | "success" | "warning" | "danger";
|
|
@@ -39,60 +43,3 @@ const classes = ["ui-alert", `ui-alert--${tone}`, className]
|
|
|
39
43
|
<slot />
|
|
40
44
|
</span>
|
|
41
45
|
</div>
|
|
42
|
-
|
|
43
|
-
<style>
|
|
44
|
-
@layer reset, tokens, base, components, motion, utilities;
|
|
45
|
-
|
|
46
|
-
@layer components {
|
|
47
|
-
.ui-alert {
|
|
48
|
-
display: flex;
|
|
49
|
-
align-items: center;
|
|
50
|
-
gap: var(--space-2-5, 0.625rem);
|
|
51
|
-
padding: var(--space-3) var(--space-4);
|
|
52
|
-
border-left: 3px solid currentColor;
|
|
53
|
-
border-radius: var(--radius-md);
|
|
54
|
-
font-family: var(--font-sans);
|
|
55
|
-
font-size: var(--text-sm);
|
|
56
|
-
line-height: var(--leading-sm);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
.ui-alert__icon {
|
|
60
|
-
display: inline-flex;
|
|
61
|
-
flex-shrink: 0;
|
|
62
|
-
align-self: flex-start;
|
|
63
|
-
margin-top: calc((var(--leading-sm, 1.375rem) - 16px) / 2);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/* Body keeps full strength; the left rule carries the tone. */
|
|
67
|
-
.ui-alert__body {
|
|
68
|
-
color: var(--text);
|
|
69
|
-
display: flex;
|
|
70
|
-
flex-direction: column;
|
|
71
|
-
gap: var(--space-1);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
.ui-alert__title {
|
|
75
|
-
font-weight: var(--weight-semibold);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.ui-alert--info {
|
|
79
|
-
background: var(--surface-accent);
|
|
80
|
-
color: var(--surface-accent-fg, var(--primary));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.ui-alert--success {
|
|
84
|
-
background: var(--success-bg);
|
|
85
|
-
color: var(--success);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.ui-alert--warning {
|
|
89
|
-
background: var(--warning-bg);
|
|
90
|
-
color: var(--warning);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
.ui-alert--danger {
|
|
94
|
-
background: var(--danger-bg);
|
|
95
|
-
color: var(--danger);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
</style>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Auth card. The frame for sign-in, registration and password reset in the
|
|
4
|
+
* webshop account: a title, a short intro, an optional message, the form and a
|
|
5
|
+
* footer line that switches to the other task. One task per card.
|
|
6
|
+
*
|
|
7
|
+
* The form is the default slot, so the page owns its fields, its submit and its
|
|
8
|
+
* script. Children of the slot stack with the form gap, a <form> included.
|
|
9
|
+
*
|
|
10
|
+
* Put an Alert in the `message` slot. After a failed sign-in, keep the wording
|
|
11
|
+
* neutral: never say which of the two fields was wrong, and never confirm that
|
|
12
|
+
* an email address has an account.
|
|
13
|
+
*/
|
|
14
|
+
interface Props {
|
|
15
|
+
title: string;
|
|
16
|
+
/** One or two sentences under the title. */
|
|
17
|
+
intro?: string;
|
|
18
|
+
/** h1 on a page of its own, h2 when the card sits inside checkout. */
|
|
19
|
+
titleTag?: "h1" | "h2";
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
title,
|
|
25
|
+
intro,
|
|
26
|
+
titleTag = "h1",
|
|
27
|
+
class: className,
|
|
28
|
+
...rest
|
|
29
|
+
} = Astro.props;
|
|
30
|
+
|
|
31
|
+
const Title = titleTag;
|
|
32
|
+
const hasMessage = Astro.slots.has("message");
|
|
33
|
+
const hasFooter = Astro.slots.has("footer");
|
|
34
|
+
|
|
35
|
+
const classes = ["ui-auth", className].filter(Boolean).join(" ");
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<section class={classes} {...rest}>
|
|
39
|
+
<div class="ui-auth__hd">
|
|
40
|
+
<Title class="ui-auth__title">{title}</Title>
|
|
41
|
+
{intro && <p class="ui-auth__intro">{intro}</p>}
|
|
42
|
+
</div>
|
|
43
|
+
{hasMessage && <slot name="message" />}
|
|
44
|
+
<div class="ui-auth__body">
|
|
45
|
+
<slot />
|
|
46
|
+
</div>
|
|
47
|
+
{hasFooter && (
|
|
48
|
+
<div class="ui-auth__footer">
|
|
49
|
+
<slot name="footer" />
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
@layer reset, tokens, base, components, motion, utilities;
|
|
56
|
+
|
|
57
|
+
@layer components {
|
|
58
|
+
.ui-auth {
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: var(--space-5);
|
|
62
|
+
width: 100%;
|
|
63
|
+
max-width: 440px;
|
|
64
|
+
padding: var(--space-8);
|
|
65
|
+
border: 1px solid var(--border);
|
|
66
|
+
border-radius: var(--radius-lg);
|
|
67
|
+
background: var(--surface);
|
|
68
|
+
color: var(--text);
|
|
69
|
+
font-family: var(--font-sans);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.ui-auth__hd {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
gap: var(--space-2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ui-auth__title {
|
|
79
|
+
margin: 0;
|
|
80
|
+
font-size: var(--text-2xl);
|
|
81
|
+
line-height: var(--leading-2xl);
|
|
82
|
+
font-weight: var(--weight-semibold);
|
|
83
|
+
letter-spacing: var(--tracking-tight);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ui-auth__intro {
|
|
87
|
+
margin: 0;
|
|
88
|
+
font-size: var(--text-sm);
|
|
89
|
+
line-height: var(--leading-sm);
|
|
90
|
+
color: var(--text-muted);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* The page's own <form> is usually the only child. It stacks the same */
|
|
94
|
+
/* way as loose fields, so both read alike. */
|
|
95
|
+
.ui-auth__body,
|
|
96
|
+
.ui-auth__body > :global(form) {
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
gap: var(--space-4);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.ui-auth__footer {
|
|
103
|
+
padding-top: var(--space-5);
|
|
104
|
+
border-top: 1px solid var(--border);
|
|
105
|
+
font-size: var(--text-sm);
|
|
106
|
+
line-height: var(--leading-sm);
|
|
107
|
+
color: var(--text-muted);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.ui-auth__footer :global(a) {
|
|
111
|
+
color: var(--link);
|
|
112
|
+
font-weight: var(--weight-medium);
|
|
113
|
+
}
|
|
114
|
+
.ui-auth__footer :global(a:hover) {
|
|
115
|
+
color: var(--link-hover);
|
|
116
|
+
}
|
|
117
|
+
.ui-auth__footer :global(a:focus-visible) {
|
|
118
|
+
outline: 2px solid var(--focus);
|
|
119
|
+
outline-offset: 2px;
|
|
120
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
121
|
+
border-radius: var(--radius-sm);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* A phone keeps the frame but gives the fields the width. */
|
|
125
|
+
@media (max-width: 700px) {
|
|
126
|
+
.ui-auth {
|
|
127
|
+
padding: var(--space-6) var(--space-5);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
+
import "../styles/feedback.css";
|
|
3
|
+
|
|
2
4
|
/**
|
|
3
5
|
* Empty state. Says what is missing and offers the one action that fixes it.
|
|
4
6
|
* Never a shrug: an empty cart links back to the category page, an empty filter
|
|
5
7
|
* result offers to clear filters.
|
|
8
|
+
*
|
|
9
|
+
* React and Preact islands write the same markup and import
|
|
10
|
+
* `@kwtv/components/styles/feedback.css`.
|
|
6
11
|
*/
|
|
7
12
|
interface Props {
|
|
8
13
|
title: string;
|
|
@@ -24,35 +29,3 @@ const hasAction = Astro.slots.has("action");
|
|
|
24
29
|
</div>
|
|
25
30
|
)}
|
|
26
31
|
</div>
|
|
27
|
-
|
|
28
|
-
<style>
|
|
29
|
-
@layer reset, tokens, base, components, motion, utilities;
|
|
30
|
-
|
|
31
|
-
@layer components {
|
|
32
|
-
.ui-empty {
|
|
33
|
-
display: flex;
|
|
34
|
-
flex-direction: column;
|
|
35
|
-
align-items: flex-start;
|
|
36
|
-
gap: var(--space-2);
|
|
37
|
-
padding: var(--space-8) var(--space-6);
|
|
38
|
-
border: 1px dashed var(--border-strong);
|
|
39
|
-
border-radius: var(--radius-lg);
|
|
40
|
-
background: var(--surface);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
.ui-empty__title {
|
|
44
|
-
font-size: var(--text-lg);
|
|
45
|
-
font-weight: var(--weight-semibold);
|
|
46
|
-
color: var(--text);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
.ui-empty__body {
|
|
50
|
-
font-size: var(--text-sm);
|
|
51
|
-
line-height: var(--leading-sm);
|
|
52
|
-
color: var(--text-muted);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/* The flex `gap` above already spaces the action from the body; an added */
|
|
56
|
-
/* margin here doubled that gap and threw off the rhythm between the two. */
|
|
57
|
-
}
|
|
58
|
-
</style>
|
|
@@ -8,6 +8,9 @@ import "../styles/controls.css";
|
|
|
8
8
|
* The count pill is decorative (aria-hidden); the number must be repeated in
|
|
9
9
|
* the label by the consumer, so it is announced once, not twice.
|
|
10
10
|
*
|
|
11
|
+
* Pass `href` to render a link with the same look, for a control that goes to
|
|
12
|
+
* a page, like the cart. The link gets no `type` and no `aria-expanded`.
|
|
13
|
+
*
|
|
11
14
|
* Pass `icon` to use the shared icon set, or a custom SVG via the default slot.
|
|
12
15
|
*/
|
|
13
16
|
interface Props {
|
|
@@ -15,7 +18,11 @@ interface Props {
|
|
|
15
18
|
icon?: string;
|
|
16
19
|
/** Decorative count pill. Repeat the number in `label`. */
|
|
17
20
|
count?: number;
|
|
21
|
+
/** Renders an `<a>` to this URL instead of a `<button>`. */
|
|
22
|
+
href?: string;
|
|
23
|
+
/** Button only. Ignored when `href` is set. */
|
|
18
24
|
type?: "button" | "submit" | "reset";
|
|
25
|
+
/** Button only. Ignored when `href` is set. */
|
|
19
26
|
expanded?: boolean;
|
|
20
27
|
class?: string;
|
|
21
28
|
}
|
|
@@ -24,6 +31,7 @@ const {
|
|
|
24
31
|
label,
|
|
25
32
|
icon,
|
|
26
33
|
count,
|
|
34
|
+
href,
|
|
27
35
|
type = "button",
|
|
28
36
|
expanded,
|
|
29
37
|
class: className,
|
|
@@ -33,17 +41,28 @@ const {
|
|
|
33
41
|
const classes = ["ui-iconbtn", className].filter(Boolean).join(" ");
|
|
34
42
|
---
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
class={classes}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
{href ? (
|
|
45
|
+
<a class={classes} href={href} aria-label={label} {...rest}>
|
|
46
|
+
{icon ? <Icon name={icon as any} size={20} stroke={1.7} /> : <slot />}
|
|
47
|
+
{count !== undefined && (
|
|
48
|
+
<span class="ui-iconbtn__count" aria-hidden="true">
|
|
49
|
+
{count}
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
</a>
|
|
53
|
+
) : (
|
|
54
|
+
<button
|
|
55
|
+
class={classes}
|
|
56
|
+
type={type}
|
|
57
|
+
aria-label={label}
|
|
58
|
+
aria-expanded={expanded === undefined ? undefined : String(expanded)}
|
|
59
|
+
{...rest}
|
|
60
|
+
>
|
|
61
|
+
{icon ? <Icon name={icon as any} size={20} stroke={1.7} /> : <slot />}
|
|
62
|
+
{count !== undefined && (
|
|
63
|
+
<span class="ui-iconbtn__count" aria-hidden="true">
|
|
64
|
+
{count}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</button>
|
|
68
|
+
)}
|
package/src/astro/Skeleton.astro
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
+
import "../styles/feedback.css";
|
|
3
|
+
|
|
2
4
|
/**
|
|
3
5
|
* Loading skeleton. Blocks match the real layout so nothing jumps when content
|
|
4
6
|
* arrives. The shimmer stops for readers who ask for reduced motion, leaving a
|
|
5
7
|
* flat block. Decorative: aria-hidden by default.
|
|
8
|
+
*
|
|
9
|
+
* React and Preact islands write the same markup and import
|
|
10
|
+
* `@kwtv/components/styles/feedback.css`.
|
|
6
11
|
*/
|
|
7
12
|
interface Props {
|
|
8
13
|
variant?: "img" | "title" | "line" | "block";
|
|
@@ -40,59 +45,3 @@ const style = [
|
|
|
40
45
|
aria-hidden="true"
|
|
41
46
|
{...rest}
|
|
42
47
|
></span>
|
|
43
|
-
|
|
44
|
-
<style>
|
|
45
|
-
@layer reset, tokens, base, components, motion, utilities;
|
|
46
|
-
|
|
47
|
-
@layer components {
|
|
48
|
-
.ui-skel {
|
|
49
|
-
display: block;
|
|
50
|
-
background: var(--surface-hover);
|
|
51
|
-
background-image: linear-gradient(
|
|
52
|
-
90deg,
|
|
53
|
-
var(--surface-hover) 0%,
|
|
54
|
-
var(--border) 50%,
|
|
55
|
-
var(--surface-hover) 100%
|
|
56
|
-
);
|
|
57
|
-
background-size: 200% 100%;
|
|
58
|
-
border-radius: var(--radius-md);
|
|
59
|
-
animation: ui-skel-shimmer 1400ms ease-in-out infinite;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.ui-skel--img {
|
|
63
|
-
width: 100%;
|
|
64
|
-
aspect-ratio: 4 / 3;
|
|
65
|
-
border-radius: 0;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
.ui-skel--title {
|
|
69
|
-
height: 0.875rem;
|
|
70
|
-
border-radius: var(--radius-sm);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.ui-skel--line {
|
|
74
|
-
height: 0.625rem;
|
|
75
|
-
border-radius: var(--radius-sm);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.ui-skel--block {
|
|
79
|
-
height: 1rem;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
@keyframes ui-skel-shimmer {
|
|
83
|
-
0% {
|
|
84
|
-
background-position: 200% 0;
|
|
85
|
-
}
|
|
86
|
-
100% {
|
|
87
|
-
background-position: -200% 0;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
@media (prefers-reduced-motion: reduce) {
|
|
92
|
-
.ui-skel {
|
|
93
|
-
animation: none;
|
|
94
|
-
background-image: none;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
</style>
|