@rtzll/reflow 0.1.0 → 0.2.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 +133 -0
- package/dist/color-picker.css +13 -0
- package/dist/color-picker.js +7 -2
- package/dist/flows.css +66 -1
- package/dist/icon-data.d.ts +13 -0
- package/dist/icon-data.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/interactive.d.ts +6 -0
- package/dist/interactive.js +110 -8
- 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
|
-
- [All
|
|
14
|
+
- [All 35 components and known gaps](https://github.com/rtzll/reflow/blob/main/docs/coverage.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,133 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
|
|
2
|
+
import { on, ref } from 'remix/component';
|
|
3
|
+
import { Icon } from './icon.js';
|
|
4
|
+
import { Dropdown, fluidHover } from './interactive.js';
|
|
5
|
+
const fallbackEllipsisWidth = 44;
|
|
6
|
+
/**
|
|
7
|
+
* A trail of links to the current page, as in the reference. Every step shares
|
|
8
|
+
* one fluid hover scope, and the separator is part of each step, so the
|
|
9
|
+
* highlight glides along the trail without going dark between steps.
|
|
10
|
+
*/
|
|
11
|
+
export function Breadcrumb(handle) {
|
|
12
|
+
let list = null;
|
|
13
|
+
let hidden = 0;
|
|
14
|
+
// Natural widths per step, remembered so a hidden step returns as soon as it fits.
|
|
15
|
+
let widths = [];
|
|
16
|
+
let ellipsisWidth = 0;
|
|
17
|
+
const listRef = ref((node, signal) => {
|
|
18
|
+
if (!(node instanceof HTMLOListElement))
|
|
19
|
+
return;
|
|
20
|
+
list = node;
|
|
21
|
+
const observer = new ResizeObserver(() => measure());
|
|
22
|
+
if (node.parentElement)
|
|
23
|
+
observer.observe(node.parentElement);
|
|
24
|
+
signal.addEventListener('abort', () => {
|
|
25
|
+
observer.disconnect();
|
|
26
|
+
if (list === node)
|
|
27
|
+
list = null;
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
function naturalWidth(item) {
|
|
31
|
+
let width = item.getBoundingClientRect().width;
|
|
32
|
+
// The current page shrinks to fit; add back what its label hides.
|
|
33
|
+
if (item.querySelector('[aria-current="page"]')) {
|
|
34
|
+
for (const label of item.querySelectorAll('.rf-breadcrumb-label')) {
|
|
35
|
+
width += label.scrollWidth - label.clientWidth;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return width;
|
|
39
|
+
}
|
|
40
|
+
function measure() {
|
|
41
|
+
const nav = list?.parentElement;
|
|
42
|
+
if (!list || !nav)
|
|
43
|
+
return;
|
|
44
|
+
const count = handle.props.items.length;
|
|
45
|
+
const items = Array.from(list.children);
|
|
46
|
+
const steps = items.filter((item) => !item.hasAttribute('data-ellipsis'));
|
|
47
|
+
const ellipsis = items.find((item) => item.hasAttribute('data-ellipsis'));
|
|
48
|
+
if (ellipsis)
|
|
49
|
+
ellipsisWidth = naturalWidth(ellipsis);
|
|
50
|
+
// The rendered steps are 0, then hidden + 1 onward.
|
|
51
|
+
steps.forEach((item, i) => {
|
|
52
|
+
const index = hidden > 0 && i > 0 ? i + hidden : i;
|
|
53
|
+
if (index < count)
|
|
54
|
+
widths[index] = naturalWidth(item);
|
|
55
|
+
});
|
|
56
|
+
let next = 0;
|
|
57
|
+
if (handle.props.collapse) {
|
|
58
|
+
const available = nav.clientWidth;
|
|
59
|
+
const width = (i) => widths[i] ?? 0;
|
|
60
|
+
let total = 0;
|
|
61
|
+
for (let i = 0; i < count; i++)
|
|
62
|
+
total += width(i);
|
|
63
|
+
const maxHidden = Math.max(0, count - 3);
|
|
64
|
+
if (total > available + 0.5) {
|
|
65
|
+
let shown = total + (ellipsisWidth || fallbackEllipsisWidth);
|
|
66
|
+
while (next < maxHidden && shown > available + 0.5) {
|
|
67
|
+
shown -= width(1 + next);
|
|
68
|
+
next++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (next !== hidden) {
|
|
73
|
+
hidden = next;
|
|
74
|
+
void handle.update();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Show the full name of a cut step on hover and focus.
|
|
78
|
+
function showFullName(event) {
|
|
79
|
+
const crumb = event.target instanceof Element
|
|
80
|
+
? event.target.closest('.rf-breadcrumb-crumb')
|
|
81
|
+
: null;
|
|
82
|
+
const label = crumb?.querySelector('.rf-breadcrumb-label');
|
|
83
|
+
if (!crumb || !label)
|
|
84
|
+
return;
|
|
85
|
+
if (label.scrollWidth > label.clientWidth + 0.5)
|
|
86
|
+
crumb.title = label.textContent ?? '';
|
|
87
|
+
else
|
|
88
|
+
crumb.removeAttribute('title');
|
|
89
|
+
}
|
|
90
|
+
return () => {
|
|
91
|
+
const { items, label = 'Breadcrumb', size, separator, collapse, openOnHover } = handle.props;
|
|
92
|
+
widths.length = items.length;
|
|
93
|
+
const hide = collapse ? Math.min(hidden, Math.max(0, items.length - 3)) : 0;
|
|
94
|
+
// Every render: a label change can change a step's width.
|
|
95
|
+
if (collapse)
|
|
96
|
+
handle.queueTask(() => measure());
|
|
97
|
+
const trailing = separator === null ? null : (_jsx("span", { class: "rf-breadcrumb-separator", "aria-hidden": "true", children: separator ?? _jsx(Icon, { name: "chevron-right" }) }));
|
|
98
|
+
const content = (item) => (_jsxs(_Fragment, { children: [item.icon, _jsx("span", { class: "rf-breadcrumb-label", children: item.label })] }));
|
|
99
|
+
const step = (item, index) => {
|
|
100
|
+
const last = index === items.length - 1;
|
|
101
|
+
if (last) {
|
|
102
|
+
return (_jsx("span", { class: "rf-breadcrumb-crumb rf-breadcrumb-page", role: "link", "aria-disabled": "true", "aria-current": "page", children: content(item) }));
|
|
103
|
+
}
|
|
104
|
+
if (item.menu) {
|
|
105
|
+
return (_jsx(Dropdown, { label: item.label, variant: "bare", triggerClass: "rf-breadcrumb-crumb", size: size, openOnHover: openOnHover, items: item.menu, onSelect: item.onMenuSelect, triggerContent: _jsxs(_Fragment, { children: [content(item), trailing] }) }));
|
|
106
|
+
}
|
|
107
|
+
return (_jsxs("a", { class: "rf-breadcrumb-crumb", href: item.href, children: [content(item), trailing] }));
|
|
108
|
+
};
|
|
109
|
+
const hiddenItems = items.slice(1, 1 + hide);
|
|
110
|
+
const visible = items
|
|
111
|
+
.map((item, index) => ({ item, index }))
|
|
112
|
+
.filter(({ index }) => index === 0 || index > hide);
|
|
113
|
+
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) => ({
|
|
114
|
+
value: String(offset),
|
|
115
|
+
label: hiddenItem.label,
|
|
116
|
+
icon: hiddenItem.icon,
|
|
117
|
+
disabled: !hiddenItem.href,
|
|
118
|
+
})), onSelect: (value) => {
|
|
119
|
+
const href = hiddenItems[Number(value)]?.href;
|
|
120
|
+
if (href)
|
|
121
|
+
window.location.assign(href);
|
|
122
|
+
}, triggerContent: _jsxs(_Fragment, { children: [_jsx(Icon, { name: "more-horizontal" }), trailing] }) }) }, "ellipsis"));
|
|
123
|
+
const rows = visible.flatMap(({ item, index }) => [
|
|
124
|
+
_jsx("li", { class: "rf-breadcrumb-item", "data-rf-hover-item": true, children: step(item, index) }, index),
|
|
125
|
+
index === 0 && ellipsis,
|
|
126
|
+
]);
|
|
127
|
+
return (_jsxs("nav", { class: "rf-breadcrumb", "aria-label": label, "data-density": size, "data-separator": separator === undefined ? 'chevron' : undefined, mix: [
|
|
128
|
+
fluidHover({ axis: 'x', focusSelector: ':has(:focus-visible)' }),
|
|
129
|
+
on('pointerover', showFullName),
|
|
130
|
+
on('focusin', showFullName),
|
|
131
|
+
], 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 })] }));
|
|
132
|
+
};
|
|
133
|
+
}
|
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;
|
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],
|
package/dist/flows.css
CHANGED
|
@@ -131,7 +131,72 @@
|
|
|
131
131
|
flex: 1 1 auto;
|
|
132
132
|
flex-direction: column;
|
|
133
133
|
min-block-size: 0;
|
|
134
|
-
|
|
134
|
+
/* Room for the header hairline, drawn by ::before so it can strengthen.
|
|
135
|
+
The scroll root clips its overflow, so the line cannot sit on a border. */
|
|
136
|
+
padding-block-start: 1px;
|
|
137
|
+
--rf-scroll-divider-size: 48px;
|
|
138
|
+
}
|
|
139
|
+
/* Scroll dividers, as in the reference: the top hairline closes the header
|
|
140
|
+
and strengthens once rows pass under it; the bottom hairline stands while
|
|
141
|
+
rows continue below and goes at the end, which is why the footer draws no
|
|
142
|
+
line of its own. Without a footer the list is the panel's last child and
|
|
143
|
+
that line would double the panel edge, so it is dropped. The lines ride the
|
|
144
|
+
scroll root because the viewport's fade mask would erase them. */
|
|
145
|
+
.rf-command-scroll::before,
|
|
146
|
+
.rf-command-scroll::after {
|
|
147
|
+
content: '';
|
|
148
|
+
position: absolute;
|
|
149
|
+
z-index: 1;
|
|
150
|
+
inset-inline: 0;
|
|
151
|
+
block-size: 1px;
|
|
152
|
+
pointer-events: none;
|
|
153
|
+
}
|
|
154
|
+
.rf-command-scroll::before {
|
|
155
|
+
inset-block-start: 0;
|
|
156
|
+
background: color-mix(in oklab, var(--rf-border) 60%, transparent);
|
|
157
|
+
}
|
|
158
|
+
.rf-command-scroll::after {
|
|
159
|
+
inset-block-end: 0;
|
|
160
|
+
background: var(--rf-border);
|
|
161
|
+
opacity: 0;
|
|
162
|
+
}
|
|
163
|
+
@keyframes rf-scroll-divider-start {
|
|
164
|
+
to {
|
|
165
|
+
background: var(--rf-border);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
@keyframes rf-scroll-divider-end {
|
|
169
|
+
from {
|
|
170
|
+
opacity: 1;
|
|
171
|
+
}
|
|
172
|
+
to {
|
|
173
|
+
opacity: 0;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
@supports (timeline-scope: --rf-command-scroller) {
|
|
177
|
+
.rf-command-scroll {
|
|
178
|
+
timeline-scope: --rf-command-scroller;
|
|
179
|
+
}
|
|
180
|
+
.rf-command-scroll > .rf-scroll-viewport {
|
|
181
|
+
scroll-timeline-name: --rf-command-scroller;
|
|
182
|
+
}
|
|
183
|
+
/* Keep the timeline separate, as in systems.css: CSS transformers can emit
|
|
184
|
+
a shorthand timeline syntax that browsers do not yet support. */
|
|
185
|
+
.rf-command-scroll::before {
|
|
186
|
+
--rf-scroll-divider-animation: rf-scroll-divider-start 1ms linear both;
|
|
187
|
+
animation: var(--rf-scroll-divider-animation);
|
|
188
|
+
animation-timeline: --rf-command-scroller;
|
|
189
|
+
animation-range: 0 var(--rf-scroll-divider-size);
|
|
190
|
+
}
|
|
191
|
+
.rf-command-scroll::after {
|
|
192
|
+
--rf-scroll-divider-animation: rf-scroll-divider-end 1ms linear both;
|
|
193
|
+
animation: var(--rf-scroll-divider-animation);
|
|
194
|
+
animation-timeline: --rf-command-scroller;
|
|
195
|
+
animation-range: calc(100% - var(--rf-scroll-divider-size)) 100%;
|
|
196
|
+
}
|
|
197
|
+
.rf-command-scroll:last-child::after {
|
|
198
|
+
content: none;
|
|
199
|
+
}
|
|
135
200
|
}
|
|
136
201
|
.rf-command-scroll > .rf-scroll-viewport {
|
|
137
202
|
flex: 1 1 auto;
|
package/dist/icon-data.d.ts
CHANGED
|
@@ -398,4 +398,17 @@ export declare const defaultIcons: {
|
|
|
398
398
|
readonly cy: '19';
|
|
399
399
|
readonly r: '1';
|
|
400
400
|
}]];
|
|
401
|
+
readonly 'more-horizontal': readonly [readonly ['circle', {
|
|
402
|
+
readonly cx: '12';
|
|
403
|
+
readonly cy: '12';
|
|
404
|
+
readonly r: '1';
|
|
405
|
+
}], readonly ['circle', {
|
|
406
|
+
readonly cx: '19';
|
|
407
|
+
readonly cy: '12';
|
|
408
|
+
readonly r: '1';
|
|
409
|
+
}], readonly ['circle', {
|
|
410
|
+
readonly cx: '5';
|
|
411
|
+
readonly cy: '12';
|
|
412
|
+
readonly r: '1';
|
|
413
|
+
}]];
|
|
401
414
|
};
|
package/dist/icon-data.js
CHANGED
|
@@ -314,4 +314,9 @@ export const defaultIcons = {
|
|
|
314
314
|
['circle', { cx: '12', cy: '5', r: '1' }],
|
|
315
315
|
['circle', { cx: '12', cy: '19', r: '1' }],
|
|
316
316
|
],
|
|
317
|
+
'more-horizontal': [
|
|
318
|
+
['circle', { cx: '12', cy: '12', r: '1' }],
|
|
319
|
+
['circle', { cx: '19', cy: '12', r: '1' }],
|
|
320
|
+
['circle', { cx: '5', cy: '12', r: '1' }],
|
|
321
|
+
],
|
|
317
322
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './dates.tsx';
|
|
|
9
9
|
export * from './flows.tsx';
|
|
10
10
|
export * from './range-color.tsx';
|
|
11
11
|
export * from './color-picker.tsx';
|
|
12
|
+
export * from './breadcrumb.tsx';
|
|
12
13
|
export * from './systems.ts';
|
|
13
14
|
export * from './queued-stack.tsx';
|
|
14
15
|
export * from './scroll-area.tsx';
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export * from './dates.js';
|
|
|
9
9
|
export * from './flows.js';
|
|
10
10
|
export * from './range-color.js';
|
|
11
11
|
export * from './color-picker.js';
|
|
12
|
+
export * from './breadcrumb.js';
|
|
12
13
|
export * from './systems.js';
|
|
13
14
|
export * from './queued-stack.js';
|
|
14
15
|
export * from './scroll-area.js';
|
package/dist/interactive.d.ts
CHANGED
|
@@ -73,5 +73,11 @@ export interface DropdownProps {
|
|
|
73
73
|
chevron?: boolean;
|
|
74
74
|
/** Text shown when a search matches nothing. */
|
|
75
75
|
emptyMessage?: string;
|
|
76
|
+
/** Opens the menu when a mouse rests on the trigger and closes it once the pointer leaves the trigger and the menu. A click pins it open; touch and the keyboard open it as usual. */
|
|
77
|
+
openOnHover?: boolean;
|
|
78
|
+
/** With openOnHover: ms the pointer rests on the trigger before the menu opens. Default 100. */
|
|
79
|
+
delay?: number;
|
|
80
|
+
/** With openOnHover: ms before the menu closes once the pointer has left, long enough to cross the gap to the menu. Default 150. */
|
|
81
|
+
closeDelay?: number;
|
|
76
82
|
}
|
|
77
83
|
export declare function Dropdown(handle: Handle<DropdownProps>): () => import("remix/component").RemixElement;
|
package/dist/interactive.js
CHANGED
|
@@ -250,7 +250,86 @@ export function Tabs(handle) {
|
|
|
250
250
|
], children: [_jsx("span", { class: "rf-highlight rf-tab-selection", "aria-hidden": "true", "data-rf-selection-layer": true }), _jsx("span", { class: "rf-highlight rf-tab-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 }), items.map((item) => (_jsxs("button", { class: "rf-tab", type: "button", "aria-label": item.label, "data-rf-hover-item": true, "data-active-label": activeLabel && item.icon != null ? '' : undefined, mix: tabs.tab({ name: item.value, disabled: item.disabled }), children: [item.icon != null && (_jsx("span", { class: "rf-tab-icon", "aria-hidden": "true", children: item.icon })), _jsx("span", { class: "rf-tab-label", "data-label": item.label, children: _jsx("span", { children: item.label }) })] }, item.value)))] }), items.map((item) => (_jsx("div", { class: "rf-tab-panel", tabIndex: item.panel != null ? 0 : undefined, mix: tabs.panel({ name: item.value }), children: item.panel }, item.value)))] }) }));
|
|
251
251
|
};
|
|
252
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* One timer shared by a hover-opening trigger and its menu surface, as in the
|
|
255
|
+
* reference. A menu opened by hover closes when the pointer leaves both; one
|
|
256
|
+
* opened by a click or the keyboard, or pinned by a click, stays until it is
|
|
257
|
+
* dismissed as usual. A hover open is a preview, so it leaves focus in place.
|
|
258
|
+
*/
|
|
259
|
+
function createHoverControl(signal) {
|
|
260
|
+
let timer;
|
|
261
|
+
let menu;
|
|
262
|
+
// True while the open menu is one the hover timer opened.
|
|
263
|
+
let hoverOpened = false;
|
|
264
|
+
const cancel = () => clearTimeout(timer);
|
|
265
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
266
|
+
return {
|
|
267
|
+
attach(next) {
|
|
268
|
+
menu = next;
|
|
269
|
+
},
|
|
270
|
+
cancel,
|
|
271
|
+
enter(delay) {
|
|
272
|
+
cancel();
|
|
273
|
+
const open = () => {
|
|
274
|
+
// A menu already open from a click or a pin stays the reader's.
|
|
275
|
+
if (!menu || menu.isOpen)
|
|
276
|
+
return;
|
|
277
|
+
// The menu ignores opens while a close is still settling; keep
|
|
278
|
+
// asking while the pointer rests on the trigger.
|
|
279
|
+
if (menu.state !== 'idle') {
|
|
280
|
+
timer = setTimeout(open, 16);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
hoverOpened = true;
|
|
284
|
+
void menu.open({ strategy: 'none' });
|
|
285
|
+
};
|
|
286
|
+
timer = setTimeout(open, delay);
|
|
287
|
+
},
|
|
288
|
+
leave(closeDelay) {
|
|
289
|
+
cancel();
|
|
290
|
+
timer = setTimeout(() => {
|
|
291
|
+
if (!menu || !hoverOpened)
|
|
292
|
+
return;
|
|
293
|
+
hoverOpened = false;
|
|
294
|
+
if (menu.isOpen)
|
|
295
|
+
void menu.closeAll({ focusRoot: false });
|
|
296
|
+
}, closeDelay);
|
|
297
|
+
},
|
|
298
|
+
/** A click on a hover-opened trigger pins the menu instead of closing it. Returns true when it pinned. */
|
|
299
|
+
pin() {
|
|
300
|
+
cancel();
|
|
301
|
+
const pinned = hoverOpened && !!menu?.isOpen;
|
|
302
|
+
hoverOpened = false;
|
|
303
|
+
return pinned;
|
|
304
|
+
},
|
|
305
|
+
/** Any other open (click, keyboard) belongs to the reader. */
|
|
306
|
+
release() {
|
|
307
|
+
hoverOpened = false;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function hoverTrigger(hover, delay, closeDelay) {
|
|
312
|
+
return [
|
|
313
|
+
on('pointerenter', (event) => {
|
|
314
|
+
if (event.pointerType === 'mouse')
|
|
315
|
+
hover.enter(delay);
|
|
316
|
+
}),
|
|
317
|
+
on('pointerleave', (event) => {
|
|
318
|
+
if (event.pointerType === 'mouse')
|
|
319
|
+
hover.leave(closeDelay);
|
|
320
|
+
}),
|
|
321
|
+
// Capture runs before the menu trigger's own click toggle.
|
|
322
|
+
on('click', (event) => {
|
|
323
|
+
if (!hover.pin())
|
|
324
|
+
return;
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
event.stopImmediatePropagation();
|
|
327
|
+
}, true),
|
|
328
|
+
on('keydown', () => hover.release()),
|
|
329
|
+
];
|
|
330
|
+
}
|
|
253
331
|
export function Dropdown(handle) {
|
|
332
|
+
const hover = createHoverControl(handle.signal);
|
|
254
333
|
let query = '';
|
|
255
334
|
let created = [];
|
|
256
335
|
const createValue = `${handle.id}-create`;
|
|
@@ -261,7 +340,9 @@ export function Dropdown(handle) {
|
|
|
261
340
|
void handle.update();
|
|
262
341
|
}
|
|
263
342
|
return () => {
|
|
264
|
-
const { label, items, size, align = 'start', variant = 'outline', searchable, searchPlaceholder = 'Search…', } = handle.props;
|
|
343
|
+
const { label, items, size, align = 'start', variant = 'outline', searchable, searchPlaceholder = 'Search…', delay = 100, closeDelay = 150, } = handle.props;
|
|
344
|
+
const hovering = !!handle.props.openOnHover && !handle.props.inline;
|
|
345
|
+
const hoverMix = hovering ? hoverTrigger(hover, delay, closeDelay) : undefined;
|
|
265
346
|
const allItems = [
|
|
266
347
|
...items,
|
|
267
348
|
...created.filter((entry) => !items.some((item) => item.value === entry.value)),
|
|
@@ -289,13 +370,19 @@ export function Dropdown(handle) {
|
|
|
289
370
|
return;
|
|
290
371
|
}
|
|
291
372
|
handle.props.onSelect?.(event.item.value ?? event.item.name, event.item.checked);
|
|
292
|
-
}), children: _jsxs(menu.Context, { label: label, children: [!handle.props.inline && variant === 'bare' && (_jsx("button", { type: "button", "aria-label": label, class: `rf-dropdown-trigger ${handle.props.triggerClass ?? ''}`, mix:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
373
|
+
}), children: _jsxs(menu.Context, { label: label, children: [!handle.props.inline && variant === 'bare' && (_jsx("button", { type: "button", "aria-label": label, class: `rf-dropdown-trigger ${handle.props.triggerClass ?? ''}`, mix: [
|
|
374
|
+
menu.trigger({
|
|
375
|
+
placement: `${handle.props.side ?? 'bottom'}-${align}`,
|
|
376
|
+
offset: 6,
|
|
377
|
+
}),
|
|
378
|
+
hoverMix,
|
|
379
|
+
], children: handle.props.triggerContent ?? label })), !handle.props.inline && variant !== 'bare' && (_jsx(Button, { class: "rf-dropdown-trigger", variant: variant === 'ghost' ? 'ghost' : 'tertiary', size: size, trailingIcon: handle.props.chevron === false ? undefined : (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4", stroke: "currentColor", "stroke-linecap": "round", "stroke-linejoin": "round" }) })), mix: [
|
|
380
|
+
menu.trigger({
|
|
381
|
+
placement: `${handle.props.side ?? 'bottom'}-${align}`,
|
|
382
|
+
offset: 6,
|
|
383
|
+
}),
|
|
384
|
+
hoverMix,
|
|
385
|
+
], children: handle.props.triggerContent ?? label })), _jsx(DropdownSurface, { inline: handle.props.inline, hover: hovering ? { control: hover, closeDelay } : undefined, items: displayedItems, emptyMessage: handle.props.emptyMessage, search: searchable || handle.props.creatable
|
|
299
386
|
? { query, setQuery, placeholder: searchPlaceholder }
|
|
300
387
|
: undefined })] }) }));
|
|
301
388
|
};
|
|
@@ -306,6 +393,7 @@ function matchesMenuQuery(item, query) {
|
|
|
306
393
|
}
|
|
307
394
|
function DropdownSurface(handle) {
|
|
308
395
|
const context = handle.context.get(menu.Context);
|
|
396
|
+
handle.props.hover?.control.attach(context);
|
|
309
397
|
const highlight = fluidHover({ highlightedSelector: '[data-highlighted]' });
|
|
310
398
|
const checked = new Map();
|
|
311
399
|
// Remix UI 0.13.1 unconditionally closes after its activation path. Keep
|
|
@@ -361,6 +449,20 @@ function DropdownSurface(handle) {
|
|
|
361
449
|
: handle.props.items;
|
|
362
450
|
return (_jsxs("div", { class: "rf-menu-surface rf-dropdown-surface", "data-inline": handle.props.inline || undefined, mix: [
|
|
363
451
|
!handle.props.inline && menu.popover(),
|
|
452
|
+
// A hover-opened menu stays while the pointer is over it, submenus
|
|
453
|
+
// included (they render inside this surface), and closes once the
|
|
454
|
+
// pointer leaves both the menu and its trigger.
|
|
455
|
+
handle.props.hover && [
|
|
456
|
+
on('pointerenter', (event) => {
|
|
457
|
+
if (event.pointerType === 'mouse')
|
|
458
|
+
handle.props.hover?.control.cancel();
|
|
459
|
+
}),
|
|
460
|
+
on('pointerleave', (event) => {
|
|
461
|
+
const hover = handle.props.hover;
|
|
462
|
+
if (event.pointerType === 'mouse' && hover)
|
|
463
|
+
hover.control.leave(hover.closeDelay);
|
|
464
|
+
}),
|
|
465
|
+
],
|
|
364
466
|
search &&
|
|
365
467
|
on('toggle', (event) => {
|
|
366
468
|
if (event.newState === 'open')
|
package/dist/styles.css
CHANGED