@kwtv/components 0.8.0 → 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/Alert.astro +4 -57
- 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/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, 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/
|
|
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
|
}
|
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>
|
|
@@ -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>
|
package/src/react/Drawer.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* Menu drawer. All values from the design-system tokens. */
|
|
2
2
|
|
|
3
3
|
@layer reset, tokens, base, components, motion, utilities;
|
|
4
4
|
|
|
@@ -68,4 +68,74 @@
|
|
|
68
68
|
.ui-drawer__cta {
|
|
69
69
|
margin-top: var(--space-2);
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
.ui-drawer__close {
|
|
73
|
+
align-self: flex-end;
|
|
74
|
+
flex-shrink: 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Placement. The panel is fixed to one edge, over a scrim that stays dark
|
|
78
|
+
in both themes, like the modal scrim. */
|
|
79
|
+
.ui-drawer-scrim {
|
|
80
|
+
position: fixed;
|
|
81
|
+
inset: 0;
|
|
82
|
+
z-index: var(--z-modal);
|
|
83
|
+
display: flex;
|
|
84
|
+
background: color-mix(in srgb, var(--charcoal-xdark) 52%, transparent);
|
|
85
|
+
animation: ui-drawer-fade var(--duration-base) var(--ease-standard);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.ui-drawer-scrim--end {
|
|
89
|
+
justify-content: flex-end;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.ui-drawer-scrim--bottom {
|
|
93
|
+
align-items: flex-end;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.ui-drawer--start,
|
|
97
|
+
.ui-drawer--end,
|
|
98
|
+
.ui-drawer--bottom {
|
|
99
|
+
overflow-y: auto;
|
|
100
|
+
overscroll-behavior: contain;
|
|
101
|
+
box-shadow: var(--shadow-xl);
|
|
102
|
+
animation: ui-drawer-in var(--duration-slow) var(--ease-enter);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.ui-drawer--start,
|
|
106
|
+
.ui-drawer--end {
|
|
107
|
+
--_from: translateX(-100%);
|
|
108
|
+
width: min(360px, 100% - var(--space-8));
|
|
109
|
+
height: 100%;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.ui-drawer--end {
|
|
113
|
+
--_from: translateX(100%);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.ui-drawer--bottom {
|
|
117
|
+
--_from: translateY(100%);
|
|
118
|
+
width: 100%;
|
|
119
|
+
max-height: 85dvh;
|
|
120
|
+
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@keyframes ui-drawer-fade {
|
|
124
|
+
from {
|
|
125
|
+
opacity: 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@keyframes ui-drawer-in {
|
|
130
|
+
from {
|
|
131
|
+
transform: var(--_from);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@media (prefers-reduced-motion: reduce) {
|
|
136
|
+
.ui-drawer-scrim,
|
|
137
|
+
.ui-drawer {
|
|
138
|
+
animation: none;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
71
141
|
}
|
package/src/react/Drawer.tsx
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
import "../styles/controls.css";
|
|
2
3
|
import "./Drawer.css";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* Menu drawer. Without `placement` it is the mobile menu: a full-width panel
|
|
7
|
+
* under the header, opened from the menu button. With `placement` it is a
|
|
8
|
+
* panel fixed to the start, end or bottom edge, over a scrim. Rows sit on
|
|
9
|
+
* whitespace with no separators.
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* While open, Tab and Shift+Tab stay inside the drawer, Escape closes it, and
|
|
12
|
+
* the rest of the page is inert and does not scroll. Focus moves to the first
|
|
13
|
+
* row on open. On close it returns to `triggerRef`, or else to whatever had
|
|
14
|
+
* focus before the drawer opened.
|
|
15
|
+
*
|
|
16
|
+
* Without `placement` the trigger sits outside the drawer and stays usable, so
|
|
17
|
+
* the menu button can close it again. It is part of the Tab cycle. The trigger
|
|
18
|
+
* is the consumer's IconButton: flip its aria-expanded and swap its label
|
|
19
|
+
* between "Menu openen" and "Menu sluiten".
|
|
20
|
+
*
|
|
21
|
+
* Pass `closeLabel` to show a close button at the top of the drawer. A drawer
|
|
22
|
+
* with a `placement` covers its trigger, so give it one.
|
|
23
|
+
*
|
|
24
|
+
* Uses only hooks and DOM calls that preact/compat supports.
|
|
13
25
|
*/
|
|
14
26
|
|
|
15
27
|
export interface DrawerLink {
|
|
@@ -37,6 +49,32 @@ export interface DrawerProps {
|
|
|
37
49
|
/** Sticky call to action at the bottom, usually the cart button. */
|
|
38
50
|
cta?: ReactNode;
|
|
39
51
|
label?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Leave unset for the panel under the header. "start", "end" or "bottom"
|
|
54
|
+
* fix the drawer to that edge of the viewport, over a scrim.
|
|
55
|
+
*/
|
|
56
|
+
placement?: "start" | "end" | "bottom";
|
|
57
|
+
/** Shows a close button with this accessible name, like "Menu sluiten". */
|
|
58
|
+
closeLabel?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FOCUSABLE =
|
|
62
|
+
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
63
|
+
|
|
64
|
+
/** Sets `inert` on everything outside `root`, except branches holding `keep`. */
|
|
65
|
+
function inertOutside(root: HTMLElement, keep: HTMLElement | null) {
|
|
66
|
+
const changed: Element[] = [];
|
|
67
|
+
let node: HTMLElement = root;
|
|
68
|
+
while (node !== document.body && node.parentElement) {
|
|
69
|
+
for (const sibling of Array.from(node.parentElement.children)) {
|
|
70
|
+
if (sibling === node || sibling.hasAttribute("inert")) continue;
|
|
71
|
+
if (keep && sibling.contains(keep)) continue;
|
|
72
|
+
sibling.setAttribute("inert", "");
|
|
73
|
+
changed.push(sibling);
|
|
74
|
+
}
|
|
75
|
+
node = node.parentElement;
|
|
76
|
+
}
|
|
77
|
+
return () => changed.forEach((el) => el.removeAttribute("inert"));
|
|
40
78
|
}
|
|
41
79
|
|
|
42
80
|
export default function Drawer({
|
|
@@ -47,19 +85,34 @@ export default function Drawer({
|
|
|
47
85
|
children,
|
|
48
86
|
cta,
|
|
49
87
|
label = "Menu",
|
|
88
|
+
placement,
|
|
89
|
+
closeLabel,
|
|
50
90
|
}: DrawerProps) {
|
|
51
|
-
const
|
|
91
|
+
const rootRef = useRef<HTMLElement>(null);
|
|
92
|
+
const scrimRef = useRef<HTMLDivElement>(null);
|
|
93
|
+
const onCloseRef = useRef(onClose);
|
|
94
|
+
onCloseRef.current = onClose;
|
|
52
95
|
|
|
53
|
-
//
|
|
96
|
+
// Make the page inert, move focus in on open, and send it back on close.
|
|
54
97
|
useEffect(() => {
|
|
55
98
|
if (!open) return;
|
|
56
|
-
const
|
|
57
|
-
|
|
99
|
+
const root = rootRef.current;
|
|
100
|
+
if (!root) return;
|
|
101
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
102
|
+
const trigger = triggerRef?.current ?? null;
|
|
103
|
+
const outer = placement ? (scrimRef.current ?? root) : root;
|
|
104
|
+
const restore = inertOutside(outer, placement ? null : trigger);
|
|
105
|
+
const first =
|
|
106
|
+
root.querySelector<HTMLElement>(".ui-drawerlink") ??
|
|
107
|
+
root.querySelector<HTMLElement>(FOCUSABLE) ??
|
|
108
|
+
root;
|
|
109
|
+
const raf = requestAnimationFrame(() => first.focus());
|
|
58
110
|
return () => {
|
|
59
111
|
cancelAnimationFrame(raf);
|
|
60
|
-
|
|
112
|
+
restore();
|
|
113
|
+
(trigger ?? previous)?.focus?.();
|
|
61
114
|
};
|
|
62
|
-
}, [open, triggerRef]);
|
|
115
|
+
}, [open, placement, triggerRef]);
|
|
63
116
|
|
|
64
117
|
// Lock the page behind the drawer.
|
|
65
118
|
useEffect(() => {
|
|
@@ -71,20 +124,78 @@ export default function Drawer({
|
|
|
71
124
|
};
|
|
72
125
|
}, [open]);
|
|
73
126
|
|
|
74
|
-
// Escape closes.
|
|
127
|
+
// Escape closes. Tab and Shift+Tab cycle through the drawer, plus the
|
|
128
|
+
// trigger when the drawer sits under the header.
|
|
75
129
|
useEffect(() => {
|
|
76
130
|
if (!open) return;
|
|
77
131
|
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
78
|
-
if (e.key === "Escape")
|
|
132
|
+
if (e.key === "Escape") {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
onCloseRef.current();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (e.key !== "Tab") return;
|
|
138
|
+
const root = rootRef.current;
|
|
139
|
+
if (!root) return;
|
|
140
|
+
const items = Array.from(
|
|
141
|
+
root.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
142
|
+
).filter((el) => el.getClientRects().length > 0);
|
|
143
|
+
const trigger = placement ? null : triggerRef?.current;
|
|
144
|
+
if (trigger && trigger.isConnected) {
|
|
145
|
+
const triggerFirst =
|
|
146
|
+
trigger.compareDocumentPosition(root) &
|
|
147
|
+
Node.DOCUMENT_POSITION_FOLLOWING;
|
|
148
|
+
if (triggerFirst) items.unshift(trigger);
|
|
149
|
+
else items.push(trigger);
|
|
150
|
+
}
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
if (items.length === 0) {
|
|
153
|
+
root.focus();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
157
|
+
const next =
|
|
158
|
+
index === -1
|
|
159
|
+
? e.shiftKey
|
|
160
|
+
? items.length - 1
|
|
161
|
+
: 0
|
|
162
|
+
: (index + (e.shiftKey ? -1 : 1) + items.length) % items.length;
|
|
163
|
+
items[next].focus();
|
|
79
164
|
};
|
|
80
165
|
document.addEventListener("keydown", onKey);
|
|
81
166
|
return () => document.removeEventListener("keydown", onKey);
|
|
82
|
-
}, [open,
|
|
167
|
+
}, [open, placement, triggerRef]);
|
|
83
168
|
|
|
84
169
|
if (!open) return null;
|
|
85
170
|
|
|
86
|
-
|
|
87
|
-
<nav
|
|
171
|
+
const panel = (
|
|
172
|
+
<nav
|
|
173
|
+
className={`ui-drawer${placement ? ` ui-drawer--${placement}` : ""}`}
|
|
174
|
+
aria-label={label}
|
|
175
|
+
ref={rootRef}
|
|
176
|
+
tabIndex={-1}
|
|
177
|
+
>
|
|
178
|
+
{closeLabel && (
|
|
179
|
+
<button
|
|
180
|
+
className="ui-iconbtn ui-drawer__close"
|
|
181
|
+
type="button"
|
|
182
|
+
aria-label={closeLabel}
|
|
183
|
+
onClick={onClose}
|
|
184
|
+
>
|
|
185
|
+
<svg
|
|
186
|
+
width="20"
|
|
187
|
+
height="20"
|
|
188
|
+
viewBox="0 0 24 24"
|
|
189
|
+
fill="none"
|
|
190
|
+
stroke="currentColor"
|
|
191
|
+
strokeWidth="1.8"
|
|
192
|
+
strokeLinecap="round"
|
|
193
|
+
aria-hidden="true"
|
|
194
|
+
>
|
|
195
|
+
<path d="M7 7l10 10M17 7L7 17" />
|
|
196
|
+
</svg>
|
|
197
|
+
</button>
|
|
198
|
+
)}
|
|
88
199
|
{groups
|
|
89
200
|
? groups.map((group, gi) => (
|
|
90
201
|
<div className="ui-drawer__group" key={gi}>
|
|
@@ -108,4 +219,21 @@ export default function Drawer({
|
|
|
108
219
|
{cta && <div className="ui-drawer__cta">{cta}</div>}
|
|
109
220
|
</nav>
|
|
110
221
|
);
|
|
222
|
+
|
|
223
|
+
if (!placement) return panel;
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div
|
|
227
|
+
ref={scrimRef}
|
|
228
|
+
className={`ui-drawer-scrim ui-drawer-scrim--${placement}`}
|
|
229
|
+
onMouseDown={(e) => {
|
|
230
|
+
if (e.target !== e.currentTarget) return;
|
|
231
|
+
// Keep the press from moving focus, so focus can go back on close.
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
onClose();
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
{panel}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
111
239
|
}
|
package/src/styles/controls.css
CHANGED
|
@@ -158,6 +158,9 @@
|
|
|
158
158
|
border: 1px solid transparent;
|
|
159
159
|
border-radius: var(--radius-md);
|
|
160
160
|
color: var(--text);
|
|
161
|
+
/* IconButton renders a link when it has an href. Keep the count pill
|
|
162
|
+
free of the link underline. */
|
|
163
|
+
text-decoration: none;
|
|
161
164
|
cursor: pointer;
|
|
162
165
|
transition:
|
|
163
166
|
background-color var(--duration-fast) var(--ease-standard),
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Feedback styles as plain, importable CSS. This is the single copy of the
|
|
3
|
+
* .ui-alert, .ui-empty and .ui-skel rules. Alert.astro, EmptyState.astro and
|
|
4
|
+
* Skeleton.astro import it themselves, so a pure-Astro app needs nothing extra.
|
|
5
|
+
* Bundlers emit it once, however many components and pages pull it in.
|
|
6
|
+
*
|
|
7
|
+
* React and Preact islands cannot render Astro components. An island that
|
|
8
|
+
* shows an alert, an empty state or a loading skeleton writes the same markup
|
|
9
|
+
* as the Astro component and imports this sheet once:
|
|
10
|
+
*
|
|
11
|
+
* import "@kwtv/components/styles/feedback.css";
|
|
12
|
+
*
|
|
13
|
+
* Import `@kwtv/tokens/tokens.css` before this file.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
@layer reset, tokens, base, components, motion, utilities;
|
|
17
|
+
|
|
18
|
+
@layer components {
|
|
19
|
+
/* ---- Alert ---------------------------------------------------------------- */
|
|
20
|
+
.ui-alert {
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
gap: var(--space-2-5, 0.625rem);
|
|
24
|
+
padding: var(--space-3) var(--space-4);
|
|
25
|
+
border-left: 3px solid currentColor;
|
|
26
|
+
border-radius: var(--radius-md);
|
|
27
|
+
font-family: var(--font-sans);
|
|
28
|
+
font-size: var(--text-sm);
|
|
29
|
+
line-height: var(--leading-sm);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ui-alert__icon {
|
|
33
|
+
display: inline-flex;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
align-self: flex-start;
|
|
36
|
+
margin-top: calc((var(--leading-sm, 1.375rem) - 16px) / 2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Body keeps full strength; the left rule carries the tone. */
|
|
40
|
+
.ui-alert__body {
|
|
41
|
+
color: var(--text);
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
gap: var(--space-1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.ui-alert__title {
|
|
48
|
+
font-weight: var(--weight-semibold);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.ui-alert--info {
|
|
52
|
+
background: var(--surface-accent);
|
|
53
|
+
color: var(--surface-accent-fg, var(--primary));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ui-alert--success {
|
|
57
|
+
background: var(--success-bg);
|
|
58
|
+
color: var(--success);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.ui-alert--warning {
|
|
62
|
+
background: var(--warning-bg);
|
|
63
|
+
color: var(--warning);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.ui-alert--danger {
|
|
67
|
+
background: var(--danger-bg);
|
|
68
|
+
color: var(--danger);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ---- Empty state ---------------------------------------------------------- */
|
|
72
|
+
.ui-empty {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
align-items: flex-start;
|
|
76
|
+
gap: var(--space-2);
|
|
77
|
+
padding: var(--space-8) var(--space-6);
|
|
78
|
+
border: 1px dashed var(--border-strong);
|
|
79
|
+
border-radius: var(--radius-lg);
|
|
80
|
+
background: var(--surface);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.ui-empty__title {
|
|
84
|
+
font-size: var(--text-lg);
|
|
85
|
+
font-weight: var(--weight-semibold);
|
|
86
|
+
color: var(--text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.ui-empty__body {
|
|
90
|
+
font-size: var(--text-sm);
|
|
91
|
+
line-height: var(--leading-sm);
|
|
92
|
+
color: var(--text-muted);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* The flex gap above spaces the action from the body. No extra margin. */
|
|
96
|
+
|
|
97
|
+
/* ---- Skeleton ------------------------------------------------------------- */
|
|
98
|
+
.ui-skel {
|
|
99
|
+
display: block;
|
|
100
|
+
background: var(--surface-hover);
|
|
101
|
+
background-image: linear-gradient(
|
|
102
|
+
90deg,
|
|
103
|
+
var(--surface-hover) 0%,
|
|
104
|
+
var(--border) 50%,
|
|
105
|
+
var(--surface-hover) 100%
|
|
106
|
+
);
|
|
107
|
+
background-size: 200% 100%;
|
|
108
|
+
border-radius: var(--radius-md);
|
|
109
|
+
animation: ui-skel-shimmer 1400ms ease-in-out infinite;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.ui-skel--img {
|
|
113
|
+
width: 100%;
|
|
114
|
+
aspect-ratio: 4 / 3;
|
|
115
|
+
border-radius: 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.ui-skel--title {
|
|
119
|
+
height: 0.875rem;
|
|
120
|
+
border-radius: var(--radius-sm);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.ui-skel--line {
|
|
124
|
+
height: 0.625rem;
|
|
125
|
+
border-radius: var(--radius-sm);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.ui-skel--block {
|
|
129
|
+
height: 1rem;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@keyframes ui-skel-shimmer {
|
|
133
|
+
0% {
|
|
134
|
+
background-position: 200% 0;
|
|
135
|
+
}
|
|
136
|
+
100% {
|
|
137
|
+
background-position: -200% 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* The shimmer stops for readers who ask for reduced motion. */
|
|
142
|
+
@media (prefers-reduced-motion: reduce) {
|
|
143
|
+
.ui-skel {
|
|
144
|
+
animation: none;
|
|
145
|
+
background-image: none;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|