@kwtv/components 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/package.json +32 -0
- package/src/astro/Accordion.astro +25 -0
- package/src/astro/AccordionItem.astro +77 -0
- package/src/astro/ActiveFilterTags.astro +65 -0
- package/src/astro/Alert.astro +93 -0
- package/src/astro/Badge.astro +138 -0
- package/src/astro/Band.astro +83 -0
- package/src/astro/Breadcrumb.astro +74 -0
- package/src/astro/Button.astro +217 -0
- package/src/astro/Card.astro +115 -0
- package/src/astro/CartLine.astro +112 -0
- package/src/astro/Checkbox.astro +89 -0
- package/src/astro/Chip.astro +130 -0
- package/src/astro/ChipRail.astro +24 -0
- package/src/astro/DotGrid.astro +62 -0
- package/src/astro/EmptyState.astro +53 -0
- package/src/astro/FilterGroup.astro +58 -0
- package/src/astro/Filters.astro +84 -0
- package/src/astro/Footer.astro +237 -0
- package/src/astro/FooterColumn.astro +64 -0
- package/src/astro/Header.astro +116 -0
- package/src/astro/Icon.astro +72 -0
- package/src/astro/IconButton.astro +117 -0
- package/src/astro/Marquee.astro +72 -0
- package/src/astro/NavDropdown.astro +164 -0
- package/src/astro/NavLink.astro +94 -0
- package/src/astro/Newsletter.astro +118 -0
- package/src/astro/Pager.astro +195 -0
- package/src/astro/PickCard.astro +142 -0
- package/src/astro/ProductCard.astro +195 -0
- package/src/astro/Radio.astro +89 -0
- package/src/astro/Skeleton.astro +80 -0
- package/src/astro/Social.astro +55 -0
- package/src/astro/StepIndicator.astro +104 -0
- package/src/astro/Sticker.astro +80 -0
- package/src/astro/Swatch.astro +100 -0
- package/src/astro/SwipeCarousel.astro +14 -0
- package/src/astro/Table.astro +117 -0
- package/src/astro/TableCard.astro +63 -0
- package/src/astro/TextField.astro +231 -0
- package/src/astro/Totals.astro +92 -0
- package/src/react/Drawer.css +67 -0
- package/src/react/Drawer.tsx +115 -0
- package/src/react/Modal.css +116 -0
- package/src/react/Modal.tsx +152 -0
- package/src/react/NewsCarousel.css +86 -0
- package/src/react/NewsCarousel.tsx +97 -0
- package/src/react/QuantityStepper.css +93 -0
- package/src/react/QuantityStepper.tsx +143 -0
- package/src/react/Select.css +183 -0
- package/src/react/Select.tsx +389 -0
- package/src/react/Toast.css +140 -0
- package/src/react/Toast.tsx +157 -0
- package/src/styles/base.css +39 -0
- package/src/styles/controls.css +211 -0
- package/src/styles/motion.css +238 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
import "./Toast.css";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Toast. A short confirmation for something that already happened, with an undo
|
|
6
|
+
* where undo is possible. Never used for errors that need a decision.
|
|
7
|
+
*
|
|
8
|
+
* Success toasts use role="status" so they do not interrupt, and auto-dismiss
|
|
9
|
+
* after 8 s. Failures use role="alert" and stay until dismissed. Anything with
|
|
10
|
+
* an undo link stays long enough to reach the link by keyboard, and the action
|
|
11
|
+
* is always also available on the page itself.
|
|
12
|
+
*
|
|
13
|
+
* Usage. Render a single <ToastRegion> near the app root and feed it an array
|
|
14
|
+
* of toasts you own in state, or drop a bare <Toast> wherever you manage its
|
|
15
|
+
* lifetime yourself.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface ToastProps {
|
|
19
|
+
tone?: "success" | "danger" | "warning" | "info";
|
|
20
|
+
message: ReactNode;
|
|
21
|
+
/** The undo action. Presence keeps a success toast from auto-dismissing too fast. */
|
|
22
|
+
action?: { label: string; onClick: () => void };
|
|
23
|
+
onDismiss?: () => void;
|
|
24
|
+
/**
|
|
25
|
+
* Auto-dismiss delay in ms. Defaults to 5000 for every tone except danger,
|
|
26
|
+
* which stays until dismissed. Pass 0 to disable auto-dismiss for any tone,
|
|
27
|
+
* or any value to override.
|
|
28
|
+
*/
|
|
29
|
+
duration?: number;
|
|
30
|
+
/** Accessible label for the close button. */
|
|
31
|
+
closeLabel?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Toast({
|
|
35
|
+
tone = "success",
|
|
36
|
+
message,
|
|
37
|
+
action,
|
|
38
|
+
onDismiss,
|
|
39
|
+
duration,
|
|
40
|
+
closeLabel = "Melding sluiten",
|
|
41
|
+
}: ToastProps) {
|
|
42
|
+
const timer = useRef<ReturnType<typeof setTimeout>>();
|
|
43
|
+
|
|
44
|
+
// Auto-dismiss after 5s by default. Danger is the exception: it may need the
|
|
45
|
+
// reader to act, so it stays until dismissed unless a duration is passed.
|
|
46
|
+
// An explicit duration always wins (0 disables auto-dismiss for any tone).
|
|
47
|
+
const delay = duration ?? (tone === "danger" ? 0 : 5000);
|
|
48
|
+
const autoDismiss = delay > 0;
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!autoDismiss || delay <= 0 || !onDismiss) return;
|
|
52
|
+
timer.current = setTimeout(onDismiss, delay);
|
|
53
|
+
return () => clearTimeout(timer.current);
|
|
54
|
+
}, [autoDismiss, delay, onDismiss]);
|
|
55
|
+
|
|
56
|
+
const pause = () => clearTimeout(timer.current);
|
|
57
|
+
const resume = () => {
|
|
58
|
+
if (autoDismiss && delay > 0 && onDismiss) {
|
|
59
|
+
timer.current = setTimeout(onDismiss, delay);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
className={`ui-toast ui-toast--${tone}`}
|
|
66
|
+
role={tone === "danger" ? "alert" : "status"}
|
|
67
|
+
onMouseEnter={pause}
|
|
68
|
+
onMouseLeave={resume}
|
|
69
|
+
onFocus={pause}
|
|
70
|
+
onBlur={resume}
|
|
71
|
+
>
|
|
72
|
+
<span className="ui-toast__icon" aria-hidden="true">
|
|
73
|
+
{tone === "success" ? (
|
|
74
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
|
75
|
+
<path d="M5 13l4 4L19 7" />
|
|
76
|
+
</svg>
|
|
77
|
+
) : tone === "warning" ? (
|
|
78
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
79
|
+
<path d="M12 4l8.5 15h-17L12 4zM12 10v4M12 16.5v.5" />
|
|
80
|
+
</svg>
|
|
81
|
+
) : tone === "info" ? (
|
|
82
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
83
|
+
<circle cx="12" cy="12" r="9" />
|
|
84
|
+
<path d="M12 11v5M12 8v.5" />
|
|
85
|
+
</svg>
|
|
86
|
+
) : (
|
|
87
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
88
|
+
<circle cx="12" cy="12" r="9" />
|
|
89
|
+
<path d="M12 8v4.5M12 16v.5" />
|
|
90
|
+
</svg>
|
|
91
|
+
)}
|
|
92
|
+
</span>
|
|
93
|
+
<span className="ui-toast__msg">
|
|
94
|
+
{message}
|
|
95
|
+
{action && (
|
|
96
|
+
<>
|
|
97
|
+
{" "}
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className="ui-toast__action"
|
|
101
|
+
onClick={action.onClick}
|
|
102
|
+
>
|
|
103
|
+
{action.label}
|
|
104
|
+
</button>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</span>
|
|
108
|
+
{onDismiss && (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
className="ui-toast__x"
|
|
112
|
+
aria-label={closeLabel}
|
|
113
|
+
onClick={onDismiss}
|
|
114
|
+
>
|
|
115
|
+
<svg
|
|
116
|
+
width="14"
|
|
117
|
+
height="14"
|
|
118
|
+
viewBox="0 0 24 24"
|
|
119
|
+
fill="none"
|
|
120
|
+
stroke="currentColor"
|
|
121
|
+
strokeWidth="2.2"
|
|
122
|
+
strokeLinecap="round"
|
|
123
|
+
>
|
|
124
|
+
<path d="M7 7l10 10M17 7L7 17" />
|
|
125
|
+
</svg>
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ToastItem extends ToastProps {
|
|
133
|
+
id: string | number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ToastRegionProps {
|
|
137
|
+
toasts: ToastItem[];
|
|
138
|
+
onDismiss: (id: ToastItem["id"]) => void;
|
|
139
|
+
/** Where the stack sits. Bottom right on desktop by convention. */
|
|
140
|
+
placement?: "bottom-right" | "bottom-center";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function ToastRegion({
|
|
144
|
+
toasts,
|
|
145
|
+
onDismiss,
|
|
146
|
+
placement = "bottom-right",
|
|
147
|
+
}: ToastRegionProps) {
|
|
148
|
+
return (
|
|
149
|
+
<div className={`ui-toast-region ui-toast-region--${placement}`}>
|
|
150
|
+
{toasts.map(({ id, ...rest }) => (
|
|
151
|
+
<Toast key={id} {...rest} onDismiss={() => onDismiss(id)} />
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default Toast;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared primitives for the React islands. Astro components carry their own
|
|
3
|
+
* scoped styles and do not need this file. Import it once in the app root when
|
|
4
|
+
* you use any React component from this package.
|
|
5
|
+
*
|
|
6
|
+
* Everything here reads from the design-system token variables. Import
|
|
7
|
+
* `@kwtv/tokens/tokens.css` before this file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/* Screen-reader-only, still announced. */
|
|
11
|
+
.ui-sr-only {
|
|
12
|
+
position: absolute;
|
|
13
|
+
width: 1px;
|
|
14
|
+
height: 1px;
|
|
15
|
+
padding: 0;
|
|
16
|
+
margin: -1px;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
clip-path: inset(50%);
|
|
19
|
+
white-space: nowrap;
|
|
20
|
+
border: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* The two-tone focus ring. An outer halo keeps the ring visible on mid-tone
|
|
24
|
+
fills where a single colour would disappear. SC 2.4.7 and 2.4.11. */
|
|
25
|
+
.ui-focus-ring:focus-visible,
|
|
26
|
+
.ui-focus-ring.ui-focus-ring--pinned {
|
|
27
|
+
outline: 2px solid var(--focus);
|
|
28
|
+
outline-offset: 2px;
|
|
29
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Touch-target floor. Labelled rows and small controls hold 32 px, icon-only
|
|
33
|
+
controls hold 44 px, the mobile primary action holds 48 px. The lever is the
|
|
34
|
+
label or the row, not the native control inside it. */
|
|
35
|
+
@media (max-width: 700px), (pointer: coarse) {
|
|
36
|
+
.ui-touch :where(button, a, label, [role="button"]):not(.ui-touch--exempt) {
|
|
37
|
+
min-height: 32px;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Control styles as plain, importable CSS. The Astro components (Button.astro,
|
|
3
|
+
* IconButton.astro) carry these same rules scoped to themselves, so a pure-Astro
|
|
4
|
+
* app needs nothing here. React and Preact islands cannot see Astro's scoped
|
|
5
|
+
* styles, so any React code that renders the .ui-btn / .ui-iconbtn classes
|
|
6
|
+
* imports this sheet once:
|
|
7
|
+
*
|
|
8
|
+
* import "@kwtv/components/styles/controls.css";
|
|
9
|
+
*
|
|
10
|
+
* Values match the scoped versions exactly. Import `@kwtv/tokens/tokens.css`
|
|
11
|
+
* before this file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/* ---- Button --------------------------------------------------------------- */
|
|
15
|
+
.ui-btn {
|
|
16
|
+
--_pad-x: var(--space-5);
|
|
17
|
+
display: inline-flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
gap: var(--space-2);
|
|
21
|
+
position: relative;
|
|
22
|
+
min-height: 40px;
|
|
23
|
+
padding: var(--space-2) var(--_pad-x);
|
|
24
|
+
border: 1px solid transparent;
|
|
25
|
+
border-radius: var(--radius-md);
|
|
26
|
+
font-family: var(--font-sans);
|
|
27
|
+
font-size: var(--text-sm);
|
|
28
|
+
font-weight: var(--weight-semibold);
|
|
29
|
+
line-height: 1;
|
|
30
|
+
text-decoration: none;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
transition:
|
|
33
|
+
background-color var(--duration-fast) var(--ease-standard),
|
|
34
|
+
border-color var(--duration-fast) var(--ease-standard),
|
|
35
|
+
transform var(--duration-fast) var(--ease-standard);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ui-btn--sm {
|
|
39
|
+
min-height: 32px;
|
|
40
|
+
--_pad-x: var(--space-4);
|
|
41
|
+
font-size: var(--text-xs);
|
|
42
|
+
}
|
|
43
|
+
.ui-btn--md {
|
|
44
|
+
min-height: 40px;
|
|
45
|
+
}
|
|
46
|
+
.ui-btn--lg {
|
|
47
|
+
min-height: 48px;
|
|
48
|
+
--_pad-x: var(--space-6);
|
|
49
|
+
font-size: var(--text-base);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.ui-btn--block {
|
|
53
|
+
display: flex;
|
|
54
|
+
width: 100%;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.ui-btn--primary {
|
|
58
|
+
background: var(--primary);
|
|
59
|
+
color: var(--primary-fg);
|
|
60
|
+
}
|
|
61
|
+
.ui-btn--primary:hover,
|
|
62
|
+
.ui-btn--primary.is-hover {
|
|
63
|
+
background: var(--primary-hover);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.ui-btn--secondary {
|
|
67
|
+
background: transparent;
|
|
68
|
+
border-color: var(--border-strong);
|
|
69
|
+
color: var(--text);
|
|
70
|
+
}
|
|
71
|
+
.ui-btn--secondary:hover,
|
|
72
|
+
.ui-btn--secondary.is-hover {
|
|
73
|
+
background: var(--surface-hover);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.ui-btn--accent {
|
|
77
|
+
background: var(--accent);
|
|
78
|
+
color: var(--accent-fg);
|
|
79
|
+
}
|
|
80
|
+
.ui-btn--accent:hover,
|
|
81
|
+
.ui-btn--accent.is-hover {
|
|
82
|
+
background: var(--accent-hover);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.ui-btn--ghost {
|
|
86
|
+
background: transparent;
|
|
87
|
+
color: var(--link);
|
|
88
|
+
padding-inline: var(--space-3);
|
|
89
|
+
}
|
|
90
|
+
.ui-btn--ghost:hover,
|
|
91
|
+
.ui-btn--ghost.is-hover {
|
|
92
|
+
background: var(--surface-hover);
|
|
93
|
+
color: var(--link-hover);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.ui-btn:focus-visible,
|
|
97
|
+
.ui-btn.is-focus {
|
|
98
|
+
outline: 2px solid var(--focus);
|
|
99
|
+
outline-offset: 2px;
|
|
100
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ui-btn:active,
|
|
104
|
+
.ui-btn.is-active {
|
|
105
|
+
transform: translateY(1px);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.ui-btn:disabled,
|
|
109
|
+
.ui-btn[aria-disabled="true"] {
|
|
110
|
+
opacity: 0.5;
|
|
111
|
+
cursor: not-allowed;
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.ui-btn.is-loading {
|
|
116
|
+
cursor: progress;
|
|
117
|
+
}
|
|
118
|
+
.ui-btn.is-loading .ui-btn__label,
|
|
119
|
+
.ui-btn.is-loading .ui-btn__icon {
|
|
120
|
+
visibility: hidden;
|
|
121
|
+
}
|
|
122
|
+
.ui-btn__spinner {
|
|
123
|
+
position: absolute;
|
|
124
|
+
width: 16px;
|
|
125
|
+
height: 16px;
|
|
126
|
+
border: 2px solid currentColor;
|
|
127
|
+
border-top-color: transparent;
|
|
128
|
+
border-radius: var(--radius-full);
|
|
129
|
+
animation: ui-btn-spin var(--duration-slower) linear infinite;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.ui-btn__icon {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
font-size: 1em;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@keyframes ui-btn-spin {
|
|
138
|
+
to {
|
|
139
|
+
transform: rotate(360deg);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ---- Icon button ---------------------------------------------------------- */
|
|
144
|
+
.ui-iconbtn {
|
|
145
|
+
position: relative;
|
|
146
|
+
display: inline-flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
width: 44px;
|
|
150
|
+
height: 44px;
|
|
151
|
+
padding: 0;
|
|
152
|
+
background: transparent;
|
|
153
|
+
border: 1px solid transparent;
|
|
154
|
+
border-radius: var(--radius-md);
|
|
155
|
+
color: var(--text);
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
transition:
|
|
158
|
+
background-color var(--duration-fast) var(--ease-standard),
|
|
159
|
+
border-color var(--duration-fast) var(--ease-standard);
|
|
160
|
+
}
|
|
161
|
+
.ui-iconbtn:hover,
|
|
162
|
+
.ui-iconbtn.is-hover {
|
|
163
|
+
background: var(--surface-hover);
|
|
164
|
+
border-color: var(--border-strong);
|
|
165
|
+
}
|
|
166
|
+
.ui-iconbtn:focus-visible,
|
|
167
|
+
.ui-iconbtn.is-focus {
|
|
168
|
+
outline: 2px solid var(--focus);
|
|
169
|
+
outline-offset: 2px;
|
|
170
|
+
box-shadow: 0 0 0 4px var(--focus-outer);
|
|
171
|
+
}
|
|
172
|
+
.ui-iconbtn:active,
|
|
173
|
+
.ui-iconbtn.is-active {
|
|
174
|
+
background: var(--surface-hover);
|
|
175
|
+
}
|
|
176
|
+
.ui-iconbtn:disabled,
|
|
177
|
+
.ui-iconbtn.is-disabled {
|
|
178
|
+
opacity: 0.5;
|
|
179
|
+
cursor: not-allowed;
|
|
180
|
+
pointer-events: none;
|
|
181
|
+
}
|
|
182
|
+
.ui-iconbtn__count {
|
|
183
|
+
position: absolute;
|
|
184
|
+
top: 3px;
|
|
185
|
+
right: 3px;
|
|
186
|
+
display: inline-flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
justify-content: center;
|
|
189
|
+
min-width: 19px;
|
|
190
|
+
height: 19px;
|
|
191
|
+
padding: 0 var(--space-1);
|
|
192
|
+
background: var(--accent);
|
|
193
|
+
color: var(--accent-fg);
|
|
194
|
+
border-radius: var(--radius-full);
|
|
195
|
+
font-family: var(--font-sans);
|
|
196
|
+
font-size: var(--text-2xs);
|
|
197
|
+
font-weight: var(--weight-semibold);
|
|
198
|
+
line-height: 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@media (prefers-reduced-motion: reduce) {
|
|
202
|
+
.ui-btn {
|
|
203
|
+
transition: none;
|
|
204
|
+
}
|
|
205
|
+
.ui-btn__spinner {
|
|
206
|
+
animation-duration: 1200ms;
|
|
207
|
+
}
|
|
208
|
+
.ui-iconbtn {
|
|
209
|
+
transition: none;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Motion layer. Prefix `pl-`. The behaviour layer, kept apart from the
|
|
3
|
+
* components it attaches to: a card is .ui-card, the lift on it is .pl-lift.
|
|
4
|
+
*
|
|
5
|
+
* Set .pl-root on a screen root. That is where the accent tone and the motion
|
|
6
|
+
* kill switch live:
|
|
7
|
+
* <section class="pl-root" data-tone="sage"> ... (tone sets --play)
|
|
8
|
+
* <section class="pl-root" data-motion="off"> ... (pauses every animation)
|
|
9
|
+
*
|
|
10
|
+
* Every rule respects prefers-reduced-motion. Nothing here carries meaning on
|
|
11
|
+
* its own: the lift, the underline and the arrow all sit on elements that are
|
|
12
|
+
* already links or buttons.
|
|
13
|
+
*
|
|
14
|
+
* Import `@kwtv/tokens/tokens.css` before this file.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/* The default tone is the accent. Rust and Clay used to be the two choices
|
|
18
|
+
here; Burgundy replaced both, so the alternative tone is Sage instead. */
|
|
19
|
+
.pl-root {
|
|
20
|
+
--play: var(--accent);
|
|
21
|
+
}
|
|
22
|
+
.pl-root[data-tone="sage"] {
|
|
23
|
+
--play: var(--primary);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Hover lift. −5 px, shadow-lg, photo scales slightly. --tilt rotates, never
|
|
27
|
+
past 6deg, and never on text. */
|
|
28
|
+
.pl-lift {
|
|
29
|
+
--tilt: 0deg;
|
|
30
|
+
transition:
|
|
31
|
+
transform var(--duration-base) var(--ease-standard),
|
|
32
|
+
box-shadow var(--duration-base) var(--ease-standard);
|
|
33
|
+
}
|
|
34
|
+
.pl-lift:hover,
|
|
35
|
+
.pl-lift:focus-within {
|
|
36
|
+
transform: translateY(-5px) rotate(var(--tilt));
|
|
37
|
+
box-shadow: var(--shadow-lg);
|
|
38
|
+
}
|
|
39
|
+
.pl-lift :is(img, .ui-product__img) {
|
|
40
|
+
transition: transform var(--duration-base) var(--ease-standard);
|
|
41
|
+
}
|
|
42
|
+
.pl-lift:hover :is(img, .ui-product__img) {
|
|
43
|
+
transform: scale(1.04);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Drawn underline. 3 px, left to right, 200 ms. Sage in the hero. */
|
|
47
|
+
.pl-ul {
|
|
48
|
+
position: relative;
|
|
49
|
+
text-decoration: none;
|
|
50
|
+
color: inherit;
|
|
51
|
+
}
|
|
52
|
+
.pl-ul::after {
|
|
53
|
+
content: "";
|
|
54
|
+
position: absolute;
|
|
55
|
+
left: 0;
|
|
56
|
+
right: 0;
|
|
57
|
+
bottom: -2px;
|
|
58
|
+
height: 3px;
|
|
59
|
+
background: var(--play);
|
|
60
|
+
transform: scaleX(0);
|
|
61
|
+
transform-origin: left;
|
|
62
|
+
transition: transform var(--duration-base) var(--ease-standard);
|
|
63
|
+
}
|
|
64
|
+
.pl-ul:hover::after,
|
|
65
|
+
.pl-ul:focus-visible::after {
|
|
66
|
+
transform: scaleX(1);
|
|
67
|
+
}
|
|
68
|
+
.pl-hero .pl-ul::after {
|
|
69
|
+
background: var(--sage-text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Arrow nudge. +5 px on the parent's hover. */
|
|
73
|
+
.pl-arrow {
|
|
74
|
+
display: inline-flex;
|
|
75
|
+
transition: transform var(--duration-fast) var(--ease-standard);
|
|
76
|
+
}
|
|
77
|
+
:hover > .pl-arrow,
|
|
78
|
+
:focus-visible > .pl-arrow {
|
|
79
|
+
transform: translateX(5px);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Row nudge. +6 px, and a table row gets a 3 px edge. */
|
|
83
|
+
.pl-row {
|
|
84
|
+
display: block;
|
|
85
|
+
transition: transform var(--duration-fast) var(--ease-standard);
|
|
86
|
+
}
|
|
87
|
+
.pl-row:hover,
|
|
88
|
+
.pl-row:focus-visible {
|
|
89
|
+
transform: translateX(6px);
|
|
90
|
+
}
|
|
91
|
+
.pl-tr {
|
|
92
|
+
transition: box-shadow var(--duration-fast) var(--ease-standard);
|
|
93
|
+
}
|
|
94
|
+
.pl-tr:hover {
|
|
95
|
+
box-shadow: inset 3px 0 0 var(--play);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Entrance and press. Rise is staggered by animation-delay at the call site. */
|
|
99
|
+
.pl-rise {
|
|
100
|
+
animation: pl-rise var(--duration-slow) var(--ease-enter) both;
|
|
101
|
+
}
|
|
102
|
+
@keyframes pl-rise {
|
|
103
|
+
from {
|
|
104
|
+
opacity: 0;
|
|
105
|
+
transform: translateY(10px);
|
|
106
|
+
}
|
|
107
|
+
to {
|
|
108
|
+
opacity: 1;
|
|
109
|
+
transform: translateY(0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
.pl-press {
|
|
113
|
+
transition: transform var(--duration-fast) var(--ease-standard);
|
|
114
|
+
}
|
|
115
|
+
.pl-press:active {
|
|
116
|
+
transform: scale(0.97);
|
|
117
|
+
}
|
|
118
|
+
.pl-btn-bounce:hover {
|
|
119
|
+
animation: pl-wiggle var(--duration-slower) var(--ease-spring);
|
|
120
|
+
}
|
|
121
|
+
@keyframes pl-wiggle {
|
|
122
|
+
25% {
|
|
123
|
+
transform: rotate(-2deg);
|
|
124
|
+
}
|
|
125
|
+
75% {
|
|
126
|
+
transform: rotate(2deg);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* Decorative loops for the composed home-page pieces. All aria-hidden at the
|
|
131
|
+
call site. */
|
|
132
|
+
@keyframes pl-drift {
|
|
133
|
+
to {
|
|
134
|
+
background-position: 28px 28px;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
@keyframes pl-spin {
|
|
138
|
+
to {
|
|
139
|
+
transform: rotate(360deg);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
@keyframes pl-bob {
|
|
143
|
+
50% {
|
|
144
|
+
transform: translateY(4px);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
@keyframes pl-marquee {
|
|
148
|
+
to {
|
|
149
|
+
transform: translateX(-50%);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* Horizontal category rail. The current item is marked with data-on. */
|
|
154
|
+
.pl-chips {
|
|
155
|
+
display: flex;
|
|
156
|
+
gap: var(--space-2);
|
|
157
|
+
overflow-x: auto;
|
|
158
|
+
padding: var(--space-0-5, 2px) var(--space-5) var(--space-2);
|
|
159
|
+
scrollbar-width: none;
|
|
160
|
+
}
|
|
161
|
+
.pl-chips::-webkit-scrollbar {
|
|
162
|
+
display: none;
|
|
163
|
+
}
|
|
164
|
+
.pl-chip {
|
|
165
|
+
flex: none;
|
|
166
|
+
min-height: 44px;
|
|
167
|
+
padding: var(--space-2) var(--space-3-5, 14px);
|
|
168
|
+
display: inline-flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
border: 1px solid var(--border);
|
|
171
|
+
border-radius: var(--radius-full);
|
|
172
|
+
background: var(--surface);
|
|
173
|
+
color: var(--text);
|
|
174
|
+
font-size: var(--text-sm);
|
|
175
|
+
font-weight: var(--weight-medium);
|
|
176
|
+
text-decoration: none;
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
}
|
|
179
|
+
.pl-chip:hover {
|
|
180
|
+
border-color: var(--border-strong);
|
|
181
|
+
color: var(--text);
|
|
182
|
+
}
|
|
183
|
+
.pl-chip:focus-visible {
|
|
184
|
+
outline: 2px solid var(--focus);
|
|
185
|
+
outline-offset: 2px;
|
|
186
|
+
}
|
|
187
|
+
.pl-chip[data-on="1"] {
|
|
188
|
+
border-color: var(--sage-xdark);
|
|
189
|
+
background: var(--sage-xdark);
|
|
190
|
+
color: var(--ivory-light);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Mobile product rail. Consumers use a regular grid at wider viewports. */
|
|
194
|
+
.pl-carousel {
|
|
195
|
+
display: flex;
|
|
196
|
+
gap: var(--space-3);
|
|
197
|
+
overflow-x: auto;
|
|
198
|
+
scroll-snap-type: x mandatory;
|
|
199
|
+
-webkit-overflow-scrolling: touch;
|
|
200
|
+
scrollbar-width: none;
|
|
201
|
+
padding: 0 var(--space-5) var(--space-2);
|
|
202
|
+
}
|
|
203
|
+
.pl-carousel::-webkit-scrollbar {
|
|
204
|
+
display: none;
|
|
205
|
+
}
|
|
206
|
+
.pl-carousel > * {
|
|
207
|
+
flex: 0 0 86%;
|
|
208
|
+
scroll-snap-align: center;
|
|
209
|
+
}
|
|
210
|
+
.pl-carousel:focus-visible {
|
|
211
|
+
outline: 2px solid var(--focus);
|
|
212
|
+
outline-offset: 2px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* The kill switch and the OS setting both stop every loop and transition in
|
|
216
|
+
the layer, so it degrades to the plain components. */
|
|
217
|
+
.pl-root[data-motion="off"] *,
|
|
218
|
+
.pl-root[data-motion="off"] *::after,
|
|
219
|
+
.pl-root[data-motion="off"] *::before {
|
|
220
|
+
animation: none !important;
|
|
221
|
+
transition: none !important;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@media (prefers-reduced-motion: reduce) {
|
|
225
|
+
.pl-root *,
|
|
226
|
+
.pl-root *::after,
|
|
227
|
+
.pl-root *::before {
|
|
228
|
+
animation: none !important;
|
|
229
|
+
transition: none !important;
|
|
230
|
+
}
|
|
231
|
+
.pl-lift:hover,
|
|
232
|
+
.pl-lift:focus-within {
|
|
233
|
+
transform: none;
|
|
234
|
+
}
|
|
235
|
+
.pl-ul::after {
|
|
236
|
+
transform: scaleX(1);
|
|
237
|
+
}
|
|
238
|
+
}
|