@rtzll/reflow 0.1.0 → 0.3.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 +1 -1
- package/dist/basic.js +30 -1
- package/dist/breadcrumb.css +165 -0
- package/dist/breadcrumb.d.ts +30 -0
- package/dist/breadcrumb.js +164 -0
- package/dist/color-picker.css +29 -0
- package/dist/color-picker.js +90 -11
- package/dist/compositions.css +27 -7
- package/dist/compositions.js +67 -2
- package/dist/dates.css +108 -3
- package/dist/dates.js +16 -5
- package/dist/flows.css +69 -1
- package/dist/groups.css +34 -0
- package/dist/groups.js +9 -1
- package/dist/icon-data.d.ts +26 -0
- package/dist/icon-data.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/interactive.d.ts +9 -1
- package/dist/interactive.js +113 -9
- package/dist/selection-runs.d.ts +4 -0
- package/dist/selection-runs.js +221 -0
- package/dist/sidebar.css +8 -0
- package/dist/styles.css +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ import { Button, Dialog, Sidebar, Switch } from '@rtzll/reflow'
|
|
|
11
11
|
Serve `@rtzll/reflow/styles.css` with your app and enable Remix's browser runtime for interactive controls.
|
|
12
12
|
|
|
13
13
|
- [Installation and runtime setup](https://github.com/rtzll/reflow/blob/main/docs/integration.md)
|
|
14
|
-
- [
|
|
14
|
+
- [Components and roadmap](https://github.com/rtzll/reflow/blob/main/docs/components.md)
|
|
15
15
|
- [Customization](https://github.com/rtzll/reflow/blob/main/docs/customization.md)
|
|
16
16
|
- [Standalone Remix starter](https://github.com/rtzll/reflow/tree/main/examples/starter)
|
|
17
17
|
|
package/dist/basic.js
CHANGED
|
@@ -275,12 +275,41 @@ export function Switch(handle) {
|
|
|
275
275
|
] }), _jsx("span", { class: "rf-switch-track", "aria-hidden": "true", children: _jsx("span", { class: "rf-switch-thumb" }) }), label != null && _jsx("span", { class: "rf-choice-label", children: label })] }));
|
|
276
276
|
};
|
|
277
277
|
}
|
|
278
|
+
const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
279
|
+
/** Where keyboard focus goes when a banner holding it closes, as in the
|
|
280
|
+
* reference: the first focusable element after it, else the last before it. */
|
|
281
|
+
function focusNeighbour(banner) {
|
|
282
|
+
const candidates = Array.from(banner.ownerDocument.querySelectorAll(focusableSelector)).filter((node) => !banner.contains(node) && !node.closest('[inert]') && node.checkVisibility());
|
|
283
|
+
const after = candidates.find((node) => banner.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
284
|
+
const before = candidates
|
|
285
|
+
.filter((node) => banner.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_PRECEDING)
|
|
286
|
+
.pop();
|
|
287
|
+
(after ?? before)?.focus({ preventScroll: true });
|
|
288
|
+
}
|
|
278
289
|
export function Banner(handle) {
|
|
279
290
|
let dismissed = false;
|
|
291
|
+
let root = null;
|
|
292
|
+
let wasVisible = true;
|
|
293
|
+
// Set when the dismiss button held focus, so the close hands it on.
|
|
294
|
+
let handOff = false;
|
|
295
|
+
const rootRef = ref((node) => {
|
|
296
|
+
root = node;
|
|
297
|
+
});
|
|
280
298
|
return () => {
|
|
281
299
|
const { class: classProp, className, status = 'default', contrast = 'low', variant = 'inline', size, icon, dismissible, dismissLabel = 'Dismiss', open, onDismiss, role, children, ...props } = handle.props;
|
|
282
300
|
const visible = open ?? !dismissed;
|
|
283
|
-
|
|
301
|
+
if (wasVisible && !visible && handOff) {
|
|
302
|
+
handOff = false;
|
|
303
|
+
handle.queueTask(() => {
|
|
304
|
+
if (root)
|
|
305
|
+
focusNeighbour(root);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (visible)
|
|
309
|
+
handOff = false;
|
|
310
|
+
wasVisible = visible;
|
|
311
|
+
return (_jsx("div", { class: "rf-banner-collapse", "data-open": visible ? 'true' : 'false', "data-variant": variant, mix: rootRef, children: _jsx("div", { class: "rf-banner-overflow", inert: !visible, "aria-hidden": !visible || undefined, children: _jsxs("div", { ...props, class: classList('rf-banner', classProp, className), "data-slot": "banner", "data-size": size, "data-status": status, "data-contrast": contrast, role: role ?? (status === 'error' || status === 'warning' ? 'alert' : 'status'), children: [_jsx("span", { class: "rf-banner-icon", "aria-hidden": "true", children: icon ?? bannerIcon(status) }), _jsx("div", { class: "rf-banner-content", children: children }), dismissible && (_jsx("button", { type: "button", class: "rf-banner-dismiss", "aria-label": dismissLabel, mix: on('click', (event) => {
|
|
312
|
+
handOff = event.currentTarget === document.activeElement;
|
|
284
313
|
onDismiss?.();
|
|
285
314
|
if (open === undefined) {
|
|
286
315
|
dismissed = true;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/* Breadcrumb: a trail of steps on one fluid hover scope. Each step carries
|
|
2
|
+
its trailing separator, so steps sit flush and the highlight never goes
|
|
3
|
+
dark between them. Values from the reference: 28px steps (24px compact),
|
|
4
|
+
8px (6px) side padding, a 6px (4px) icon gap. */
|
|
5
|
+
.rf-breadcrumb {
|
|
6
|
+
--rf-crumb-height: 28px;
|
|
7
|
+
--rf-crumb-padding: 8px;
|
|
8
|
+
--rf-crumb-gap: 6px;
|
|
9
|
+
--rf-crumb-icon: 16px;
|
|
10
|
+
--rf-crumb-separator: 14px;
|
|
11
|
+
--rf-crumb-max: 12rem;
|
|
12
|
+
min-inline-size: 0;
|
|
13
|
+
}
|
|
14
|
+
.rf-breadcrumb[data-density='compact'],
|
|
15
|
+
[data-density='compact'] .rf-breadcrumb:not([data-density='default']) {
|
|
16
|
+
--rf-crumb-height: 24px;
|
|
17
|
+
--rf-crumb-padding: 6px;
|
|
18
|
+
--rf-crumb-gap: 4px;
|
|
19
|
+
--rf-crumb-icon: 14px;
|
|
20
|
+
--rf-crumb-separator: 12px;
|
|
21
|
+
--rf-crumb-max: 10rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.rf-breadcrumb-list {
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-wrap: nowrap;
|
|
27
|
+
align-items: center;
|
|
28
|
+
min-inline-size: 0;
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: 0;
|
|
31
|
+
list-style: none;
|
|
32
|
+
color: var(--rf-muted);
|
|
33
|
+
font-size: var(--rf-text-body);
|
|
34
|
+
line-height: var(--rf-leading-body);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Steps keep their width; only the current page gives way and truncates. */
|
|
38
|
+
.rf-breadcrumb-item {
|
|
39
|
+
display: inline-flex;
|
|
40
|
+
flex-shrink: 0;
|
|
41
|
+
align-items: center;
|
|
42
|
+
min-inline-size: 0;
|
|
43
|
+
}
|
|
44
|
+
.rf-breadcrumb-item:has(> [aria-current='page']) {
|
|
45
|
+
flex-shrink: 1;
|
|
46
|
+
}
|
|
47
|
+
.rf-breadcrumb-item > .rf-dropdown {
|
|
48
|
+
display: inline-flex;
|
|
49
|
+
min-inline-size: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.rf-breadcrumb-crumb {
|
|
53
|
+
appearance: none;
|
|
54
|
+
position: relative;
|
|
55
|
+
display: inline-flex;
|
|
56
|
+
flex-shrink: 0;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: var(--rf-crumb-gap);
|
|
59
|
+
box-sizing: border-box;
|
|
60
|
+
min-inline-size: 0;
|
|
61
|
+
max-inline-size: var(--rf-crumb-max);
|
|
62
|
+
block-size: var(--rf-crumb-height);
|
|
63
|
+
padding: 0 var(--rf-crumb-padding);
|
|
64
|
+
border: 0;
|
|
65
|
+
border-radius: var(--rf-item-radius, 8px);
|
|
66
|
+
outline: none;
|
|
67
|
+
background: transparent;
|
|
68
|
+
color: var(--rf-muted);
|
|
69
|
+
font: inherit;
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
transition: color 80ms ease;
|
|
74
|
+
}
|
|
75
|
+
/* The separator's glyph carries its own side bearing. */
|
|
76
|
+
.rf-breadcrumb-item:not(:last-child) .rf-breadcrumb-crumb:has(> .rf-breadcrumb-separator) {
|
|
77
|
+
padding-inline-end: var(--rf-crumb-gap);
|
|
78
|
+
}
|
|
79
|
+
.rf-breadcrumb-crumb > svg,
|
|
80
|
+
.rf-breadcrumb-crumb > :not(.rf-breadcrumb-separator) svg {
|
|
81
|
+
flex: none;
|
|
82
|
+
inline-size: var(--rf-crumb-icon);
|
|
83
|
+
block-size: var(--rf-crumb-icon);
|
|
84
|
+
}
|
|
85
|
+
.rf-breadcrumb-label {
|
|
86
|
+
min-inline-size: 0;
|
|
87
|
+
overflow: hidden;
|
|
88
|
+
text-overflow: ellipsis;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.rf-breadcrumb-page {
|
|
92
|
+
flex-shrink: 1;
|
|
93
|
+
max-inline-size: none;
|
|
94
|
+
color: var(--rf-fg);
|
|
95
|
+
cursor: default;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.rf-breadcrumb-item[data-rf-hovered] .rf-breadcrumb-crumb,
|
|
99
|
+
.rf-breadcrumb-crumb:focus-visible,
|
|
100
|
+
.rf-breadcrumb-crumb[aria-expanded='true'] {
|
|
101
|
+
color: var(--rf-fg);
|
|
102
|
+
}
|
|
103
|
+
.rf-breadcrumb-crumb:focus-visible {
|
|
104
|
+
outline: 1px solid var(--rf-accent, #6b97ff);
|
|
105
|
+
outline-offset: -1px;
|
|
106
|
+
}
|
|
107
|
+
@media (forced-colors: none) {
|
|
108
|
+
.rf-breadcrumb:has(> .rf-focus-ring[data-visible]) .rf-breadcrumb-crumb:focus-visible {
|
|
109
|
+
outline-color: transparent;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
@media (hover: hover) {
|
|
113
|
+
[data-hover='standard'] .rf-breadcrumb-crumb:not(.rf-breadcrumb-page):hover {
|
|
114
|
+
background: var(--rf-hover);
|
|
115
|
+
color: var(--rf-fg);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.rf-breadcrumb-hover {
|
|
120
|
+
border-radius: var(--rf-item-radius, 8px);
|
|
121
|
+
background: var(--rf-hover);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.rf-breadcrumb-separator {
|
|
125
|
+
display: inline-flex;
|
|
126
|
+
flex: none;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: center;
|
|
129
|
+
color: var(--rf-muted);
|
|
130
|
+
user-select: none;
|
|
131
|
+
}
|
|
132
|
+
.rf-breadcrumb-separator > svg {
|
|
133
|
+
inline-size: var(--rf-crumb-separator);
|
|
134
|
+
block-size: var(--rf-crumb-separator);
|
|
135
|
+
}
|
|
136
|
+
.rf-breadcrumb-item:last-child .rf-breadcrumb-separator {
|
|
137
|
+
display: none;
|
|
138
|
+
}
|
|
139
|
+
/* A step that opens a menu says so: its chevron turns down on hover, on
|
|
140
|
+
keyboard focus and while the menu is open. A link's chevron stays put. */
|
|
141
|
+
.rf-breadcrumb[data-separator='chevron'] [aria-haspopup] > .rf-breadcrumb-separator {
|
|
142
|
+
transition: transform 150ms ease-out;
|
|
143
|
+
}
|
|
144
|
+
.rf-breadcrumb[data-separator='chevron']
|
|
145
|
+
:is(
|
|
146
|
+
.rf-breadcrumb-item[data-rf-hovered] [aria-haspopup],
|
|
147
|
+
[aria-haspopup]:focus-visible,
|
|
148
|
+
[aria-haspopup][aria-expanded='true']
|
|
149
|
+
)
|
|
150
|
+
> .rf-breadcrumb-separator {
|
|
151
|
+
transform: rotate(90deg);
|
|
152
|
+
}
|
|
153
|
+
@media (prefers-reduced-motion: reduce) {
|
|
154
|
+
.rf-breadcrumb [aria-haspopup] > .rf-breadcrumb-separator,
|
|
155
|
+
.rf-breadcrumb-crumb {
|
|
156
|
+
transition: none;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* Step and ellipsis menus fit their entries, as in the reference, rather than
|
|
161
|
+
taking the fixed Dropdown width. */
|
|
162
|
+
.rf-breadcrumb .rf-dropdown-surface {
|
|
163
|
+
inline-size: max-content;
|
|
164
|
+
min-inline-size: 8rem;
|
|
165
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Handle, RemixNode } from 'remix/component';
|
|
2
|
+
import { type DropdownItem } from './interactive.tsx';
|
|
3
|
+
export interface BreadcrumbItem {
|
|
4
|
+
label: string;
|
|
5
|
+
/** Where the step goes. The last item is the current page and is not a link. */
|
|
6
|
+
href?: string;
|
|
7
|
+
icon?: RemixNode;
|
|
8
|
+
/** Turns a step before the current page into a menu trigger, such as its siblings. */
|
|
9
|
+
menu?: readonly DropdownItem[];
|
|
10
|
+
onMenuSelect?: (value: string, checked?: boolean) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface BreadcrumbProps {
|
|
13
|
+
/** The trail, ending with the current page. */
|
|
14
|
+
items: readonly BreadcrumbItem[];
|
|
15
|
+
/** Names the navigation landmark. Default "Breadcrumb". */
|
|
16
|
+
label?: string;
|
|
17
|
+
size?: 'default' | 'compact';
|
|
18
|
+
/** Trailing glyph of every step but the last. Defaults to a chevron; null drops it. */
|
|
19
|
+
separator?: RemixNode | null;
|
|
20
|
+
/** Hide middle steps behind an ellipsis menu while the trail is wider than its container. The first step, the last link and the current page always stay. */
|
|
21
|
+
collapse?: boolean;
|
|
22
|
+
/** Open step menus and the ellipsis menu when a mouse rests on them, as Dropdown's openOnHover. */
|
|
23
|
+
openOnHover?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A trail of links to the current page, as in the reference. Every step shares
|
|
27
|
+
* one fluid hover scope, and the separator is part of each step, so the
|
|
28
|
+
* highlight glides along the trail without going dark between steps.
|
|
29
|
+
*/
|
|
30
|
+
export declare function Breadcrumb(handle: Handle<BreadcrumbProps>): () => import("remix/component").RemixElement;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
|
|
2
|
+
import { attrs, on, ref } from 'remix/component';
|
|
3
|
+
import { Icon } from './icon.js';
|
|
4
|
+
import { Dropdown, fluidHover } from './interactive.js';
|
|
5
|
+
import { Tooltip } from './overlays.js';
|
|
6
|
+
const fallbackEllipsisWidth = 44;
|
|
7
|
+
/**
|
|
8
|
+
* A trail of links to the current page, as in the reference. Every step shares
|
|
9
|
+
* one fluid hover scope, and the separator is part of each step, so the
|
|
10
|
+
* highlight glides along the trail without going dark between steps.
|
|
11
|
+
*/
|
|
12
|
+
export function Breadcrumb(handle) {
|
|
13
|
+
let list = null;
|
|
14
|
+
let hidden = 0;
|
|
15
|
+
// Natural widths per step, remembered so a hidden step returns as soon as it fits.
|
|
16
|
+
let widths = [];
|
|
17
|
+
let ellipsisWidth = 0;
|
|
18
|
+
// Steps whose label is cut, by index, read as the pointer or keyboard focus
|
|
19
|
+
// arrives; and the step whose menu is open, whose full name stays hidden.
|
|
20
|
+
let cut = new Set();
|
|
21
|
+
let menuOpen;
|
|
22
|
+
const listRef = ref((node, signal) => {
|
|
23
|
+
if (!(node instanceof HTMLOListElement))
|
|
24
|
+
return;
|
|
25
|
+
list = node;
|
|
26
|
+
const observer = new ResizeObserver(() => measure());
|
|
27
|
+
if (node.parentElement)
|
|
28
|
+
observer.observe(node.parentElement);
|
|
29
|
+
const menus = new MutationObserver(() => {
|
|
30
|
+
const open = node.querySelector('[data-rf-step][aria-expanded="true"]');
|
|
31
|
+
const next = open ? Number(open.dataset.rfStep) : undefined;
|
|
32
|
+
if (next === menuOpen)
|
|
33
|
+
return;
|
|
34
|
+
menuOpen = next;
|
|
35
|
+
void handle.update();
|
|
36
|
+
});
|
|
37
|
+
menus.observe(node, { subtree: true, attributeFilter: ['aria-expanded'] });
|
|
38
|
+
signal.addEventListener('abort', () => {
|
|
39
|
+
observer.disconnect();
|
|
40
|
+
menus.disconnect();
|
|
41
|
+
if (list === node)
|
|
42
|
+
list = null;
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
function naturalWidth(item) {
|
|
46
|
+
let width = item.getBoundingClientRect().width;
|
|
47
|
+
// The current page shrinks to fit; add back what its label hides.
|
|
48
|
+
if (item.querySelector('[aria-current="page"]')) {
|
|
49
|
+
for (const label of item.querySelectorAll('.rf-breadcrumb-label')) {
|
|
50
|
+
width += label.scrollWidth - label.clientWidth;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return width;
|
|
54
|
+
}
|
|
55
|
+
function measure() {
|
|
56
|
+
const nav = list?.parentElement;
|
|
57
|
+
if (!list || !nav)
|
|
58
|
+
return;
|
|
59
|
+
const count = handle.props.items.length;
|
|
60
|
+
const items = Array.from(list.children);
|
|
61
|
+
const steps = items.filter((item) => !item.hasAttribute('data-ellipsis'));
|
|
62
|
+
const ellipsis = items.find((item) => item.hasAttribute('data-ellipsis'));
|
|
63
|
+
if (ellipsis)
|
|
64
|
+
ellipsisWidth = naturalWidth(ellipsis);
|
|
65
|
+
// The rendered steps are 0, then hidden + 1 onward.
|
|
66
|
+
steps.forEach((item, i) => {
|
|
67
|
+
const index = hidden > 0 && i > 0 ? i + hidden : i;
|
|
68
|
+
if (index < count)
|
|
69
|
+
widths[index] = naturalWidth(item);
|
|
70
|
+
});
|
|
71
|
+
let next = 0;
|
|
72
|
+
if (handle.props.collapse) {
|
|
73
|
+
const available = nav.clientWidth;
|
|
74
|
+
const width = (i) => widths[i] ?? 0;
|
|
75
|
+
let total = 0;
|
|
76
|
+
for (let i = 0; i < count; i++)
|
|
77
|
+
total += width(i);
|
|
78
|
+
const maxHidden = Math.max(0, count - 3);
|
|
79
|
+
if (total > available + 0.5) {
|
|
80
|
+
let shown = total + (ellipsisWidth || fallbackEllipsisWidth);
|
|
81
|
+
while (next < maxHidden && shown > available + 0.5) {
|
|
82
|
+
shown -= width(1 + next);
|
|
83
|
+
next++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (next !== hidden) {
|
|
88
|
+
hidden = next;
|
|
89
|
+
void handle.update();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Whether a step's label is cut, read as the pointer or keyboard focus
|
|
93
|
+
// arrives: cheap, and current when its tooltip could show.
|
|
94
|
+
function checkCut(event) {
|
|
95
|
+
const crumb = event.target instanceof Element ? event.target.closest('[data-rf-step]') : null;
|
|
96
|
+
const label = crumb?.querySelector('.rf-breadcrumb-label');
|
|
97
|
+
if (!crumb || !label)
|
|
98
|
+
return;
|
|
99
|
+
const index = Number(crumb.dataset.rfStep);
|
|
100
|
+
const isCut = label.scrollWidth > label.clientWidth + 0.5;
|
|
101
|
+
if (isCut === cut.has(index))
|
|
102
|
+
return;
|
|
103
|
+
cut = new Set(cut);
|
|
104
|
+
if (isCut)
|
|
105
|
+
cut.add(index);
|
|
106
|
+
else
|
|
107
|
+
cut.delete(index);
|
|
108
|
+
void handle.update();
|
|
109
|
+
}
|
|
110
|
+
return () => {
|
|
111
|
+
const { items, label = 'Breadcrumb', size, separator, collapse, openOnHover } = handle.props;
|
|
112
|
+
widths.length = items.length;
|
|
113
|
+
const hide = collapse ? Math.min(hidden, Math.max(0, items.length - 3)) : 0;
|
|
114
|
+
// Every render: a label change can change a step's width.
|
|
115
|
+
if (collapse)
|
|
116
|
+
handle.queueTask(() => measure());
|
|
117
|
+
const trailing = separator === null ? null : (_jsx("span", { class: "rf-breadcrumb-separator", "aria-hidden": "true", children: separator ?? _jsx(Icon, { name: "chevron-right" }) }));
|
|
118
|
+
const content = (item) => (_jsxs(_Fragment, { children: [item.icon, _jsx("span", { class: "rf-breadcrumb-label", children: item.label })] }));
|
|
119
|
+
// The full name of a cut step, in the library tooltip under it, as in
|
|
120
|
+
// the reference. It stays closed while the name fits and while the
|
|
121
|
+
// step's own menu is open, so it never stacks on the menu. The step is
|
|
122
|
+
// described by the tooltip only while its name is cut.
|
|
123
|
+
const withFullName = (item, index, render) => (_jsx(Tooltip, { content: item.label, side: "bottom", open: cut.has(index) && menuOpen !== index ? undefined : false, renderTrigger: (props) => render(cut.has(index) ? props : { 'aria-describedby': '', mix: props.mix }) }));
|
|
124
|
+
const described = (trigger) => trigger['aria-describedby'] || undefined;
|
|
125
|
+
const step = (item, index) => {
|
|
126
|
+
const last = index === items.length - 1;
|
|
127
|
+
if (last) {
|
|
128
|
+
return withFullName(item, index, (trigger) => (_jsx("span", { class: "rf-breadcrumb-crumb rf-breadcrumb-page", role: "link", "aria-disabled": "true", "aria-current": "page", "aria-describedby": described(trigger), "data-rf-step": index, mix: trigger.mix, children: content(item) })));
|
|
129
|
+
}
|
|
130
|
+
const menu = item.menu;
|
|
131
|
+
if (menu) {
|
|
132
|
+
return withFullName(item, index, (trigger) => (_jsx(Dropdown, { label: item.label, variant: "bare", triggerClass: "rf-breadcrumb-crumb", triggerMix: [
|
|
133
|
+
trigger.mix,
|
|
134
|
+
attrs({ 'data-rf-step': String(index) }),
|
|
135
|
+
described(trigger) && attrs({ 'aria-describedby': described(trigger) }),
|
|
136
|
+
], size: size, openOnHover: openOnHover, items: menu, onSelect: item.onMenuSelect, triggerContent: _jsxs(_Fragment, { children: [content(item), trailing] }) })));
|
|
137
|
+
}
|
|
138
|
+
return withFullName(item, index, (trigger) => (_jsxs("a", { class: "rf-breadcrumb-crumb", href: item.href, "aria-describedby": described(trigger), "data-rf-step": index, mix: trigger.mix, children: [content(item), trailing] })));
|
|
139
|
+
};
|
|
140
|
+
const hiddenItems = items.slice(1, 1 + hide);
|
|
141
|
+
const visible = items
|
|
142
|
+
.map((item, index) => ({ item, index }))
|
|
143
|
+
.filter(({ index }) => index === 0 || index > hide);
|
|
144
|
+
const ellipsis = hide > 0 && (_jsx("li", { class: "rf-breadcrumb-item", "data-rf-hover-item": true, "data-ellipsis": true, children: _jsx(Dropdown, { label: `Show ${hide} hidden ${hide === 1 ? 'step' : 'steps'}`, variant: "bare", triggerClass: "rf-breadcrumb-crumb", size: size, openOnHover: openOnHover, items: hiddenItems.map((hiddenItem, offset) => ({
|
|
145
|
+
value: String(offset),
|
|
146
|
+
label: hiddenItem.label,
|
|
147
|
+
icon: hiddenItem.icon,
|
|
148
|
+
disabled: !hiddenItem.href,
|
|
149
|
+
})), onSelect: (value) => {
|
|
150
|
+
const href = hiddenItems[Number(value)]?.href;
|
|
151
|
+
if (href)
|
|
152
|
+
window.location.assign(href);
|
|
153
|
+
}, triggerContent: _jsxs(_Fragment, { children: [_jsx(Icon, { name: "more-horizontal" }), trailing] }) }) }, "ellipsis"));
|
|
154
|
+
const rows = visible.flatMap(({ item, index }) => [
|
|
155
|
+
_jsx("li", { class: "rf-breadcrumb-item", "data-rf-hover-item": true, children: step(item, index) }, index),
|
|
156
|
+
index === 0 && ellipsis,
|
|
157
|
+
]);
|
|
158
|
+
return (_jsxs("nav", { class: "rf-breadcrumb", "aria-label": label, "data-density": size, "data-separator": separator === undefined ? 'chevron' : undefined, mix: [
|
|
159
|
+
fluidHover({ axis: 'x', focusSelector: ':has(:focus-visible)' }),
|
|
160
|
+
on('pointerover', checkCut),
|
|
161
|
+
on('focusin', checkCut),
|
|
162
|
+
], children: [_jsx("span", { class: "rf-highlight rf-breadcrumb-hover", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("span", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), _jsx("ol", { class: "rf-breadcrumb-list", mix: listRef, children: rows })] }));
|
|
163
|
+
};
|
|
164
|
+
}
|
package/dist/color-picker.css
CHANGED
|
@@ -234,6 +234,19 @@
|
|
|
234
234
|
padding: 0;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
.rf-color-format-surface .rf-menu-selection {
|
|
238
|
+
border-radius: var(--rf-radius, 8px);
|
|
239
|
+
background: var(--rf-active);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@media (forced-colors: none) {
|
|
243
|
+
.rf-color-format-surface
|
|
244
|
+
.rf-menu-list:has(> .rf-focus-ring[data-visible])
|
|
245
|
+
> .rf-menu-item:focus-visible {
|
|
246
|
+
outline-color: transparent;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
237
250
|
.rf-color-format-surface .rf-menu-item {
|
|
238
251
|
gap: 0;
|
|
239
252
|
min-block-size: 0;
|
|
@@ -284,6 +297,22 @@
|
|
|
284
297
|
cursor: ew-resize;
|
|
285
298
|
}
|
|
286
299
|
|
|
300
|
+
/* The drawn cursor of a locked-pointer scrub. */
|
|
301
|
+
.rf-scrub-cursor {
|
|
302
|
+
position: fixed;
|
|
303
|
+
inset: auto;
|
|
304
|
+
margin: 0;
|
|
305
|
+
padding: 0;
|
|
306
|
+
border: 0;
|
|
307
|
+
overflow: visible;
|
|
308
|
+
background: transparent;
|
|
309
|
+
pointer-events: none;
|
|
310
|
+
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.4));
|
|
311
|
+
}
|
|
312
|
+
.rf-scrub-cursor > svg {
|
|
313
|
+
display: block;
|
|
314
|
+
}
|
|
315
|
+
|
|
287
316
|
.rf-color-field[data-editing] .rf-color-field-body {
|
|
288
317
|
cursor: text;
|
|
289
318
|
}
|
package/dist/color-picker.js
CHANGED
|
@@ -358,10 +358,15 @@ function SaturationSquare(handle) {
|
|
|
358
358
|
// Format menu
|
|
359
359
|
// ---------------------------------------------------------------------------
|
|
360
360
|
function FormatMenu(handle) {
|
|
361
|
-
|
|
361
|
+
// As in the reference: the checked format keeps its own background, the
|
|
362
|
+
// hover grows out of it, and one focus ring glides between rows.
|
|
363
|
+
const highlight = fluidHover({
|
|
364
|
+
highlightedSelector: '[data-highlighted]',
|
|
365
|
+
selectedSelector: '[aria-checked="true"]',
|
|
366
|
+
});
|
|
362
367
|
return () => {
|
|
363
368
|
const { value, disabled, onChange } = handle.props;
|
|
364
|
-
return (_jsx("div", { class: "rf-color-format", mix: menu.onMenuSelect((event) => onChange((event.item.value ?? event.item.name))), children: _jsxs(menu.Context, { label: "Color format", children: [_jsxs("button", { type: "button", class: "rf-color-format-trigger", disabled: disabled, mix: menu.trigger({ placement: 'bottom-start', offset: 6 }), children: [_jsx("span", { children: formatLabels[value] }), _jsx(Icon, { name: "chevron-down", size: 14 })] }), _jsx("div", { class: "rf-menu-surface rf-dropdown-surface rf-color-format-surface", mix: menu.popover(), children: _jsxs("div", { class: "rf-menu-list", mix: [menu.list(), highlight], children: [_jsx("span", { class: "rf-highlight rf-menu-hover", "aria-hidden": "true", "data-rf-hover-layer": true }), formats.map((format) => (_jsx("div", { class: "rf-menu-item", "data-rf-hover-item": true, mix: menu.item({
|
|
369
|
+
return (_jsx("div", { class: "rf-color-format", mix: menu.onMenuSelect((event) => onChange((event.item.value ?? event.item.name))), children: _jsxs(menu.Context, { label: "Color format", children: [_jsxs("button", { type: "button", class: "rf-color-format-trigger", disabled: disabled, mix: menu.trigger({ placement: 'bottom-start', offset: 6 }), children: [_jsx("span", { children: formatLabels[value] }), _jsx(Icon, { name: "chevron-down", size: 14 })] }), _jsx("div", { class: "rf-menu-surface rf-dropdown-surface rf-color-format-surface", mix: menu.popover(), children: _jsxs("div", { class: "rf-menu-list", mix: [menu.list(), highlight], children: [_jsx("span", { class: "rf-highlight rf-menu-selection", "aria-hidden": "true", "data-rf-selection-layer": true }), _jsx("span", { class: "rf-highlight rf-menu-hover", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("span", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), formats.map((format) => (_jsx("div", { class: "rf-menu-item", "data-rf-hover-item": true, mix: menu.item({
|
|
365
370
|
name: format,
|
|
366
371
|
value: format,
|
|
367
372
|
label: formatLabels[format],
|
|
@@ -370,11 +375,64 @@ function FormatMenu(handle) {
|
|
|
370
375
|
}), children: _jsx("span", { class: "rf-menu-label", "data-label": formatLabels[format], children: _jsx("span", { children: formatLabels[format] }) }) }, format)))] }) })] }) }));
|
|
371
376
|
};
|
|
372
377
|
}
|
|
378
|
+
// The stand-in for the OS cursor while a channel scrub holds the pointer lock,
|
|
379
|
+
// as in the reference: a black ↔ arrow with a white stroke and a drop shadow,
|
|
380
|
+
// legible on any surface. It lives in the top layer so it also shows over a
|
|
381
|
+
// ColorPicker inside a popover.
|
|
382
|
+
function scrubCursor(doc) {
|
|
383
|
+
let cursor = doc.querySelector(':scope > body > .rf-scrub-cursor');
|
|
384
|
+
if (cursor)
|
|
385
|
+
return cursor;
|
|
386
|
+
cursor = doc.createElement('div');
|
|
387
|
+
cursor.className = 'rf-scrub-cursor';
|
|
388
|
+
cursor.setAttribute('popover', 'manual');
|
|
389
|
+
cursor.setAttribute('aria-hidden', 'true');
|
|
390
|
+
cursor.innerHTML =
|
|
391
|
+
'<svg width="24" height="14" viewBox="0 0 24 14" fill="#000" stroke="#fff" stroke-width="1"><path d="M0.5 7l5-5v3.5h13V2l5 5-5 5V8.5h-13V12l-5-5z"/></svg>';
|
|
392
|
+
doc.body.append(cursor);
|
|
393
|
+
return cursor;
|
|
394
|
+
}
|
|
373
395
|
function ColorField(handle) {
|
|
374
396
|
let draft;
|
|
375
397
|
let editing = false;
|
|
376
398
|
let scrubbing;
|
|
377
399
|
let input = null;
|
|
400
|
+
// Drag travel is unbounded under the pointer lock; the drawn cursor wraps
|
|
401
|
+
// at the viewport edges instead of stopping.
|
|
402
|
+
function placeCursor(scrub, element) {
|
|
403
|
+
const doc = element.ownerDocument;
|
|
404
|
+
const cursor = scrubCursor(doc);
|
|
405
|
+
const width = doc.documentElement.clientWidth;
|
|
406
|
+
const x = (((scrub.x + scrub.travel) % width) + width) % width;
|
|
407
|
+
cursor.style.left = `${x - 12}px`;
|
|
408
|
+
cursor.style.top = `${scrub.y - 7}px`;
|
|
409
|
+
}
|
|
410
|
+
function lockPointer(scrub, element) {
|
|
411
|
+
const doc = element.ownerDocument;
|
|
412
|
+
if (scrub.lock || !scrub.mouse || !element.requestPointerLock)
|
|
413
|
+
return;
|
|
414
|
+
scrub.lock = new AbortController();
|
|
415
|
+
doc.addEventListener('pointerlockchange', () => {
|
|
416
|
+
const cursor = scrubCursor(doc);
|
|
417
|
+
if (doc.pointerLockElement === element && scrubbing === scrub) {
|
|
418
|
+
placeCursor(scrub, element);
|
|
419
|
+
cursor.showPopover?.();
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
cursor.hidePopover?.();
|
|
423
|
+
scrub.lock?.abort();
|
|
424
|
+
}
|
|
425
|
+
}, { signal: scrub.lock.signal });
|
|
426
|
+
// Without the lock (refused, or unsupported) the scrub carries on with the
|
|
427
|
+
// OS cursor.
|
|
428
|
+
void Promise.resolve(element.requestPointerLock()).catch(() => scrub.lock?.abort());
|
|
429
|
+
}
|
|
430
|
+
function endScrub(element) {
|
|
431
|
+
const doc = element.ownerDocument;
|
|
432
|
+
if (doc.pointerLockElement === element)
|
|
433
|
+
doc.exitPointerLock();
|
|
434
|
+
doc.querySelector(':scope > body > .rf-scrub-cursor')?.hidePopover?.();
|
|
435
|
+
}
|
|
378
436
|
const inputRef = ref((node, signal) => {
|
|
379
437
|
if (!(node instanceof HTMLInputElement))
|
|
380
438
|
return;
|
|
@@ -431,26 +489,46 @@ function ColorField(handle) {
|
|
|
431
489
|
return;
|
|
432
490
|
scrubbing = {
|
|
433
491
|
id: event.pointerId,
|
|
434
|
-
x: event.clientX,
|
|
435
492
|
start: numeric(value),
|
|
493
|
+
x: event.clientX,
|
|
494
|
+
y: event.clientY,
|
|
495
|
+
lastX: event.clientX,
|
|
496
|
+
travel: 0,
|
|
436
497
|
moved: false,
|
|
498
|
+
mouse: event.pointerType === 'mouse',
|
|
499
|
+
locked: false,
|
|
437
500
|
};
|
|
438
501
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
439
502
|
}),
|
|
440
503
|
on('pointermove', (event) => {
|
|
441
|
-
|
|
504
|
+
const scrub = scrubbing;
|
|
505
|
+
if (!scrub || scrub.id !== event.pointerId)
|
|
442
506
|
return;
|
|
443
|
-
const
|
|
444
|
-
|
|
507
|
+
const element = event.currentTarget;
|
|
508
|
+
// Under the pointer lock clientX stands still; movementX carries the travel.
|
|
509
|
+
const locked = element.ownerDocument.pointerLockElement === element;
|
|
510
|
+
// The move that crosses into or out of the lock is not travel:
|
|
511
|
+
// granting the lock can warp the cursor, and leaving it lets
|
|
512
|
+
// clientX jump back to where the cursor really is.
|
|
513
|
+
if (locked === scrub.locked) {
|
|
514
|
+
scrub.travel += locked ? event.movementX : event.clientX - scrub.lastX;
|
|
515
|
+
}
|
|
516
|
+
scrub.locked = locked;
|
|
517
|
+
scrub.lastX = event.clientX;
|
|
518
|
+
if (!scrub.moved && Math.abs(scrub.travel) < 2)
|
|
445
519
|
return;
|
|
446
|
-
|
|
447
|
-
|
|
520
|
+
scrub.moved = true;
|
|
521
|
+
lockPointer(scrub, element);
|
|
522
|
+
if (locked)
|
|
523
|
+
placeCursor(scrub, element);
|
|
524
|
+
if (Number.isNaN(scrub.start))
|
|
448
525
|
return;
|
|
449
|
-
commitNumber(
|
|
526
|
+
commitNumber(scrub.start + Math.round(scrub.travel) * step);
|
|
450
527
|
}),
|
|
451
|
-
on('pointerup', () => {
|
|
528
|
+
on('pointerup', (event) => {
|
|
452
529
|
const moved = scrubbing?.moved;
|
|
453
530
|
scrubbing = undefined;
|
|
531
|
+
endScrub(event.currentTarget);
|
|
454
532
|
if (moved)
|
|
455
533
|
return;
|
|
456
534
|
editing = true;
|
|
@@ -460,8 +538,9 @@ function ColorField(handle) {
|
|
|
460
538
|
input?.select();
|
|
461
539
|
});
|
|
462
540
|
}),
|
|
463
|
-
on('pointercancel', () => {
|
|
541
|
+
on('pointercancel', (event) => {
|
|
464
542
|
scrubbing = undefined;
|
|
543
|
+
endScrub(event.currentTarget);
|
|
465
544
|
}),
|
|
466
545
|
]
|
|
467
546
|
: undefined, children: [prefix && (_jsx("span", { class: "rf-color-field-prefix", "aria-hidden": "true", children: prefix })), _jsx("input", { type: "text", "aria-label": ariaLabel, inputMode: scrub ? 'numeric' : 'text', autocomplete: "off", spellcheck: false, disabled: disabled, value: shown, mix: [
|