@kwtv/components 0.8.0 → 0.10.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 +92 -15
- package/package.json +2 -2
- package/src/astro/ActiveFilterTags.astro +4 -34
- package/src/astro/Alert.astro +4 -57
- package/src/astro/Badge.astro +4 -84
- package/src/astro/Checkbox.astro +5 -64
- package/src/astro/Chip.astro +14 -85
- package/src/astro/CopyChip.astro +22 -3
- package/src/astro/EmptyState.astro +5 -32
- package/src/astro/Filters.astro +11 -2
- package/src/astro/IconButton.astro +33 -14
- package/src/astro/Pager.astro +15 -54
- package/src/astro/ProductCard.astro +30 -142
- package/src/astro/Radio.astro +5 -46
- package/src/astro/Skeleton.astro +5 -56
- package/src/astro/Swatch.astro +10 -57
- package/src/react/Drawer.css +105 -1
- package/src/react/Drawer.tsx +178 -19
- package/src/styles/badge.css +95 -0
- package/src/styles/controls.css +3 -0
- package/src/styles/feedback.css +148 -0
- package/src/styles/filters.css +255 -0
- package/src/styles/pager.css +63 -0
- package/src/styles/product-card.css +148 -0
package/src/astro/Swatch.astro
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
---
|
|
2
|
+
import "../styles/filters.css";
|
|
3
|
+
|
|
2
4
|
/**
|
|
3
5
|
* Colour swatch. 30 px circle, 32 px on touch. A <button> carrying the colour
|
|
4
6
|
* name in `label` (→ aria-label), because the circle alone tells a colour-blind
|
|
5
7
|
* reader nothing. Selection adds a 2 px ring so it reads without colour.
|
|
8
|
+
* When selected, `selectedLabel` is added to the name after a comma.
|
|
9
|
+
*
|
|
10
|
+
* React and Preact islands write the same markup and import
|
|
11
|
+
* `@kwtv/components/styles/filters.css`.
|
|
6
12
|
*/
|
|
7
13
|
interface Props {
|
|
8
14
|
/** CSS colour value for the fill. */
|
|
@@ -11,6 +17,8 @@ interface Props {
|
|
|
11
17
|
label: string;
|
|
12
18
|
selected?: boolean;
|
|
13
19
|
disabled?: boolean;
|
|
20
|
+
/** Added to the accessible name when selected. Defaults to "geselecteerd". */
|
|
21
|
+
selectedLabel?: string;
|
|
14
22
|
type?: "button" | "submit";
|
|
15
23
|
class?: string;
|
|
16
24
|
}
|
|
@@ -20,6 +28,7 @@ const {
|
|
|
20
28
|
label,
|
|
21
29
|
selected = false,
|
|
22
30
|
disabled = false,
|
|
31
|
+
selectedLabel = "geselecteerd",
|
|
23
32
|
type = "button",
|
|
24
33
|
class: className,
|
|
25
34
|
...rest
|
|
@@ -34,7 +43,7 @@ const classes = [
|
|
|
34
43
|
.filter(Boolean)
|
|
35
44
|
.join(" ");
|
|
36
45
|
|
|
37
|
-
const ariaLabel = selected ? `${label},
|
|
46
|
+
const ariaLabel = selected ? `${label}, ${selectedLabel}` : label;
|
|
38
47
|
---
|
|
39
48
|
|
|
40
49
|
<button
|
|
@@ -46,59 +55,3 @@ const ariaLabel = selected ? `${label}, geselecteerd` : label;
|
|
|
46
55
|
disabled={disabled}
|
|
47
56
|
{...rest}
|
|
48
57
|
></button>
|
|
49
|
-
|
|
50
|
-
<style>
|
|
51
|
-
@layer reset, tokens, base, components, motion, utilities;
|
|
52
|
-
|
|
53
|
-
@layer components {
|
|
54
|
-
.ui-swatch {
|
|
55
|
-
width: 30px;
|
|
56
|
-
height: 30px;
|
|
57
|
-
padding: 0;
|
|
58
|
-
background: var(--swatch);
|
|
59
|
-
border: 1px solid var(--border);
|
|
60
|
-
border-radius: var(--radius-full);
|
|
61
|
-
cursor: pointer;
|
|
62
|
-
transition: box-shadow var(--duration-fast) var(--ease-standard);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
.ui-swatch:hover,
|
|
66
|
-
.ui-swatch.is-hover {
|
|
67
|
-
box-shadow: 0 0 0 2px var(--border-strong);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
.ui-swatch:focus-visible,
|
|
71
|
-
.ui-swatch.is-focus {
|
|
72
|
-
outline: 2px solid var(--focus);
|
|
73
|
-
outline-offset: 2px;
|
|
74
|
-
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/* Selected: 2 px ring, readable without colour. */
|
|
78
|
-
.ui-swatch.is-selected,
|
|
79
|
-
.ui-swatch[aria-pressed="true"] {
|
|
80
|
-
box-shadow:
|
|
81
|
-
0 0 0 2px var(--surface),
|
|
82
|
-
0 0 0 4px var(--border-strong);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
.ui-swatch.is-disabled,
|
|
86
|
-
.ui-swatch:disabled {
|
|
87
|
-
opacity: 0.5;
|
|
88
|
-
cursor: not-allowed;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
@media (prefers-reduced-motion: reduce) {
|
|
92
|
-
.ui-swatch {
|
|
93
|
-
transition: none;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
@media (max-width: 700px), (pointer: coarse) {
|
|
98
|
-
.ui-swatch {
|
|
99
|
-
width: 32px;
|
|
100
|
-
height: 32px;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
</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,108 @@
|
|
|
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
|
+
/* Title row. The title sits left of the close button, on the row inset. */
|
|
78
|
+
.ui-drawer__head {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: space-between;
|
|
82
|
+
gap: var(--space-3);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.ui-drawer__head .ui-drawer__close {
|
|
86
|
+
align-self: center;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.ui-drawer__title {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
min-height: 44px;
|
|
93
|
+
margin: 0;
|
|
94
|
+
padding: 0 var(--space-3);
|
|
95
|
+
font-family: var(--font-sans);
|
|
96
|
+
font-size: var(--text-base);
|
|
97
|
+
line-height: var(--leading-base);
|
|
98
|
+
font-weight: var(--weight-semibold);
|
|
99
|
+
color: var(--text);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Placement. The panel is fixed to one edge, over a scrim that stays dark
|
|
103
|
+
in both themes, like the modal scrim. */
|
|
104
|
+
.ui-drawer-scrim {
|
|
105
|
+
position: fixed;
|
|
106
|
+
inset: 0;
|
|
107
|
+
z-index: var(--z-modal);
|
|
108
|
+
display: flex;
|
|
109
|
+
background: color-mix(in srgb, var(--charcoal-xdark) 52%, transparent);
|
|
110
|
+
animation: ui-drawer-fade var(--duration-base) var(--ease-standard);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ui-drawer-scrim--end {
|
|
114
|
+
justify-content: flex-end;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.ui-drawer-scrim--bottom {
|
|
118
|
+
align-items: flex-end;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.ui-drawer--start,
|
|
122
|
+
.ui-drawer--end,
|
|
123
|
+
.ui-drawer--bottom {
|
|
124
|
+
overflow-y: auto;
|
|
125
|
+
overscroll-behavior: contain;
|
|
126
|
+
box-shadow: var(--shadow-xl);
|
|
127
|
+
animation: ui-drawer-in var(--duration-slow) var(--ease-enter);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.ui-drawer--start,
|
|
131
|
+
.ui-drawer--end {
|
|
132
|
+
--_from: translateX(-100%);
|
|
133
|
+
width: min(360px, 100% - var(--space-8));
|
|
134
|
+
height: 100%;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.ui-drawer--end {
|
|
138
|
+
--_from: translateX(100%);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.ui-drawer--bottom {
|
|
142
|
+
--_from: translateY(100%);
|
|
143
|
+
width: 100%;
|
|
144
|
+
max-height: 85dvh;
|
|
145
|
+
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Full width on small screens. Bottom and the panel under the header are */
|
|
149
|
+
/* already full width. */
|
|
150
|
+
@media (max-width: 700px) {
|
|
151
|
+
.ui-drawer--full.ui-drawer--start,
|
|
152
|
+
.ui-drawer--full.ui-drawer--end {
|
|
153
|
+
width: 100%;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@keyframes ui-drawer-fade {
|
|
158
|
+
from {
|
|
159
|
+
opacity: 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@keyframes ui-drawer-in {
|
|
164
|
+
from {
|
|
165
|
+
transform: var(--_from);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@media (prefers-reduced-motion: reduce) {
|
|
170
|
+
.ui-drawer-scrim,
|
|
171
|
+
.ui-drawer {
|
|
172
|
+
animation: none;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
71
175
|
}
|
package/src/react/Drawer.tsx
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
|
-
import { useEffect, useRef, type ReactNode } from "react";
|
|
1
|
+
import { useEffect, useId, 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
|
+
* Pass `title` to show a heading in the top row, left of the close button.
|
|
25
|
+
* The title then names the drawer through aria-labelledby, in place of
|
|
26
|
+
* `label`. Pass `size="full"` to let a start or end drawer fill the viewport
|
|
27
|
+
* width on screens up to 700 px wide. Wider screens keep the 360 px panel.
|
|
28
|
+
* Without these two props the drawer renders as before.
|
|
29
|
+
*
|
|
30
|
+
* Uses only hooks and DOM calls that preact/compat supports.
|
|
13
31
|
*/
|
|
14
32
|
|
|
15
33
|
export interface DrawerLink {
|
|
@@ -37,6 +55,42 @@ export interface DrawerProps {
|
|
|
37
55
|
/** Sticky call to action at the bottom, usually the cart button. */
|
|
38
56
|
cta?: ReactNode;
|
|
39
57
|
label?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Leave unset for the panel under the header. "start", "end" or "bottom"
|
|
60
|
+
* fix the drawer to that edge of the viewport, over a scrim.
|
|
61
|
+
*/
|
|
62
|
+
placement?: "start" | "end" | "bottom";
|
|
63
|
+
/** Shows a close button with this accessible name, like "Menu sluiten". */
|
|
64
|
+
closeLabel?: string;
|
|
65
|
+
/**
|
|
66
|
+
* A visible h2 in the top row, beside the close button. It becomes the
|
|
67
|
+
* accessible name of the drawer, in place of `label`.
|
|
68
|
+
*/
|
|
69
|
+
title?: string;
|
|
70
|
+
/**
|
|
71
|
+
* "full" fills the viewport width on screens up to 700 px wide. Leave unset
|
|
72
|
+
* for the default width.
|
|
73
|
+
*/
|
|
74
|
+
size?: "default" | "full";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const FOCUSABLE =
|
|
78
|
+
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
79
|
+
|
|
80
|
+
/** Sets `inert` on everything outside `root`, except branches holding `keep`. */
|
|
81
|
+
function inertOutside(root: HTMLElement, keep: HTMLElement | null) {
|
|
82
|
+
const changed: Element[] = [];
|
|
83
|
+
let node: HTMLElement = root;
|
|
84
|
+
while (node !== document.body && node.parentElement) {
|
|
85
|
+
for (const sibling of Array.from(node.parentElement.children)) {
|
|
86
|
+
if (sibling === node || sibling.hasAttribute("inert")) continue;
|
|
87
|
+
if (keep && sibling.contains(keep)) continue;
|
|
88
|
+
sibling.setAttribute("inert", "");
|
|
89
|
+
changed.push(sibling);
|
|
90
|
+
}
|
|
91
|
+
node = node.parentElement;
|
|
92
|
+
}
|
|
93
|
+
return () => changed.forEach((el) => el.removeAttribute("inert"));
|
|
40
94
|
}
|
|
41
95
|
|
|
42
96
|
export default function Drawer({
|
|
@@ -47,19 +101,37 @@ export default function Drawer({
|
|
|
47
101
|
children,
|
|
48
102
|
cta,
|
|
49
103
|
label = "Menu",
|
|
104
|
+
placement,
|
|
105
|
+
closeLabel,
|
|
106
|
+
title,
|
|
107
|
+
size = "default",
|
|
50
108
|
}: DrawerProps) {
|
|
51
|
-
const
|
|
109
|
+
const titleId = useId();
|
|
110
|
+
const rootRef = useRef<HTMLElement>(null);
|
|
111
|
+
const scrimRef = useRef<HTMLDivElement>(null);
|
|
112
|
+
const onCloseRef = useRef(onClose);
|
|
113
|
+
onCloseRef.current = onClose;
|
|
52
114
|
|
|
53
|
-
//
|
|
115
|
+
// Make the page inert, move focus in on open, and send it back on close.
|
|
54
116
|
useEffect(() => {
|
|
55
117
|
if (!open) return;
|
|
56
|
-
const
|
|
57
|
-
|
|
118
|
+
const root = rootRef.current;
|
|
119
|
+
if (!root) return;
|
|
120
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
121
|
+
const trigger = triggerRef?.current ?? null;
|
|
122
|
+
const outer = placement ? (scrimRef.current ?? root) : root;
|
|
123
|
+
const restore = inertOutside(outer, placement ? null : trigger);
|
|
124
|
+
const first =
|
|
125
|
+
root.querySelector<HTMLElement>(".ui-drawerlink") ??
|
|
126
|
+
root.querySelector<HTMLElement>(FOCUSABLE) ??
|
|
127
|
+
root;
|
|
128
|
+
const raf = requestAnimationFrame(() => first.focus());
|
|
58
129
|
return () => {
|
|
59
130
|
cancelAnimationFrame(raf);
|
|
60
|
-
|
|
131
|
+
restore();
|
|
132
|
+
(trigger ?? previous)?.focus?.();
|
|
61
133
|
};
|
|
62
|
-
}, [open, triggerRef]);
|
|
134
|
+
}, [open, placement, triggerRef]);
|
|
63
135
|
|
|
64
136
|
// Lock the page behind the drawer.
|
|
65
137
|
useEffect(() => {
|
|
@@ -71,20 +143,90 @@ export default function Drawer({
|
|
|
71
143
|
};
|
|
72
144
|
}, [open]);
|
|
73
145
|
|
|
74
|
-
// Escape closes.
|
|
146
|
+
// Escape closes. Tab and Shift+Tab cycle through the drawer, plus the
|
|
147
|
+
// trigger when the drawer sits under the header.
|
|
75
148
|
useEffect(() => {
|
|
76
149
|
if (!open) return;
|
|
77
150
|
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
78
|
-
if (e.key === "Escape")
|
|
151
|
+
if (e.key === "Escape") {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
onCloseRef.current();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (e.key !== "Tab") return;
|
|
157
|
+
const root = rootRef.current;
|
|
158
|
+
if (!root) return;
|
|
159
|
+
const items = Array.from(
|
|
160
|
+
root.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
161
|
+
).filter((el) => el.getClientRects().length > 0);
|
|
162
|
+
const trigger = placement ? null : triggerRef?.current;
|
|
163
|
+
if (trigger && trigger.isConnected) {
|
|
164
|
+
const triggerFirst =
|
|
165
|
+
trigger.compareDocumentPosition(root) &
|
|
166
|
+
Node.DOCUMENT_POSITION_FOLLOWING;
|
|
167
|
+
if (triggerFirst) items.unshift(trigger);
|
|
168
|
+
else items.push(trigger);
|
|
169
|
+
}
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
if (items.length === 0) {
|
|
172
|
+
root.focus();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
176
|
+
const next =
|
|
177
|
+
index === -1
|
|
178
|
+
? e.shiftKey
|
|
179
|
+
? items.length - 1
|
|
180
|
+
: 0
|
|
181
|
+
: (index + (e.shiftKey ? -1 : 1) + items.length) % items.length;
|
|
182
|
+
items[next].focus();
|
|
79
183
|
};
|
|
80
184
|
document.addEventListener("keydown", onKey);
|
|
81
185
|
return () => document.removeEventListener("keydown", onKey);
|
|
82
|
-
}, [open,
|
|
186
|
+
}, [open, placement, triggerRef]);
|
|
83
187
|
|
|
84
188
|
if (!open) return null;
|
|
85
189
|
|
|
86
|
-
|
|
87
|
-
<
|
|
190
|
+
const closeButton = closeLabel && (
|
|
191
|
+
<button
|
|
192
|
+
className="ui-iconbtn ui-drawer__close"
|
|
193
|
+
type="button"
|
|
194
|
+
aria-label={closeLabel}
|
|
195
|
+
onClick={onClose}
|
|
196
|
+
>
|
|
197
|
+
<svg
|
|
198
|
+
width="20"
|
|
199
|
+
height="20"
|
|
200
|
+
viewBox="0 0 24 24"
|
|
201
|
+
fill="none"
|
|
202
|
+
stroke="currentColor"
|
|
203
|
+
strokeWidth="1.8"
|
|
204
|
+
strokeLinecap="round"
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
>
|
|
207
|
+
<path d="M7 7l10 10M17 7L7 17" />
|
|
208
|
+
</svg>
|
|
209
|
+
</button>
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const panel = (
|
|
213
|
+
<nav
|
|
214
|
+
className={`ui-drawer${placement ? ` ui-drawer--${placement}` : ""}${size === "full" ? " ui-drawer--full" : ""}`}
|
|
215
|
+
aria-label={title ? undefined : label}
|
|
216
|
+
aria-labelledby={title ? titleId : undefined}
|
|
217
|
+
ref={rootRef}
|
|
218
|
+
tabIndex={-1}
|
|
219
|
+
>
|
|
220
|
+
{title ? (
|
|
221
|
+
<div className="ui-drawer__head">
|
|
222
|
+
<h2 className="ui-drawer__title" id={titleId}>
|
|
223
|
+
{title}
|
|
224
|
+
</h2>
|
|
225
|
+
{closeButton}
|
|
226
|
+
</div>
|
|
227
|
+
) : (
|
|
228
|
+
closeButton
|
|
229
|
+
)}
|
|
88
230
|
{groups
|
|
89
231
|
? groups.map((group, gi) => (
|
|
90
232
|
<div className="ui-drawer__group" key={gi}>
|
|
@@ -108,4 +250,21 @@ export default function Drawer({
|
|
|
108
250
|
{cta && <div className="ui-drawer__cta">{cta}</div>}
|
|
109
251
|
</nav>
|
|
110
252
|
);
|
|
253
|
+
|
|
254
|
+
if (!placement) return panel;
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div
|
|
258
|
+
ref={scrimRef}
|
|
259
|
+
className={`ui-drawer-scrim ui-drawer-scrim--${placement}`}
|
|
260
|
+
onMouseDown={(e) => {
|
|
261
|
+
if (e.target !== e.currentTarget) return;
|
|
262
|
+
// Keep the press from moving focus, so focus can go back on close.
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
onClose();
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
{panel}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
111
270
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Badge styles as plain, importable CSS. This is the single copy of the
|
|
3
|
+
* .ui-badge rules. Badge.astro imports it itself, so a pure-Astro app needs
|
|
4
|
+
* nothing extra.
|
|
5
|
+
*
|
|
6
|
+
* React and Preact islands cannot render Astro components. An island that
|
|
7
|
+
* shows a badge or a removable filter tag writes the same markup as the
|
|
8
|
+
* Astro component and imports this sheet once:
|
|
9
|
+
*
|
|
10
|
+
* import "@kwtv/components/styles/badge.css";
|
|
11
|
+
*
|
|
12
|
+
* Import `@kwtv/tokens/tokens.css` before this file.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
@layer reset, tokens, base, components, motion, utilities;
|
|
16
|
+
|
|
17
|
+
@layer components {
|
|
18
|
+
.ui-badge {
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: var(--space-1-5);
|
|
22
|
+
min-height: 24px;
|
|
23
|
+
padding: 0 var(--space-2);
|
|
24
|
+
border: 1px solid transparent;
|
|
25
|
+
border-radius: var(--radius-full);
|
|
26
|
+
font-family: var(--font-sans);
|
|
27
|
+
font-size: var(--text-xs);
|
|
28
|
+
font-weight: var(--weight-medium);
|
|
29
|
+
line-height: 1;
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ui-badge__icon {
|
|
34
|
+
display: inline-flex;
|
|
35
|
+
flex-shrink: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ui-badge--neutral {
|
|
39
|
+
background: var(--surface-accent);
|
|
40
|
+
color: var(--surface-accent-fg, var(--text));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.ui-badge--success {
|
|
44
|
+
background: var(--success-bg);
|
|
45
|
+
color: var(--success);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.ui-badge--warning {
|
|
49
|
+
background: var(--warning-bg);
|
|
50
|
+
color: var(--warning);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ui-badge--danger {
|
|
54
|
+
background: var(--danger-bg);
|
|
55
|
+
color: var(--danger);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Outline keeps the strong border and full-strength text, so it reads */
|
|
59
|
+
/* without relying on a fill. */
|
|
60
|
+
.ui-badge--outline {
|
|
61
|
+
background: transparent;
|
|
62
|
+
border: 1px solid var(--border-strong);
|
|
63
|
+
color: var(--text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.ui-badge--removable {
|
|
67
|
+
padding-right: var(--space-1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* Small × target grows with padding plus an equal negative margin, so the */
|
|
71
|
+
/* hit area grows without moving the layout. */
|
|
72
|
+
.ui-badge__x {
|
|
73
|
+
display: inline-flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
justify-content: center;
|
|
76
|
+
padding: var(--space-2);
|
|
77
|
+
margin: calc(-1 * var(--space-2));
|
|
78
|
+
margin-left: 0;
|
|
79
|
+
background: transparent;
|
|
80
|
+
border: 0;
|
|
81
|
+
border-radius: var(--radius-sm);
|
|
82
|
+
color: inherit;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ui-badge__x:hover {
|
|
87
|
+
color: var(--danger);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ui-badge__x:focus-visible {
|
|
91
|
+
outline: 2px solid var(--focus);
|
|
92
|
+
outline-offset: 2px;
|
|
93
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
94
|
+
}
|
|
95
|
+
}
|
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),
|