@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 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 33 components and known gaps](https://github.com/rtzll/reflow/blob/main/docs/coverage.md)
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
- return (_jsx("div", { class: "rf-banner-collapse", "data-open": visible ? 'true' : 'false', "data-variant": variant, 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', () => {
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
+ }
@@ -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;
@@ -358,10 +358,15 @@ function SaturationSquare(handle) {
358
358
  // Format menu
359
359
  // ---------------------------------------------------------------------------
360
360
  function FormatMenu(handle) {
361
- const highlight = fluidHover({ highlightedSelector: '[data-highlighted]' });
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
- border-block-start: 1px solid color-mix(in oklab, var(--rf-border) 60%, transparent);
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;
@@ -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';
@@ -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;
@@ -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: menu.trigger({
293
- placement: `${handle.props.side ?? 'bottom'}-${align}`,
294
- offset: 6,
295
- }), 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: menu.trigger({
296
- placement: `${handle.props.side ?? 'bottom'}-${align}`,
297
- offset: 6,
298
- }), children: handle.props.triggerContent ?? label })), _jsx(DropdownSurface, { inline: handle.props.inline, items: displayedItems, emptyMessage: handle.props.emptyMessage, search: searchable || handle.props.creatable
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
@@ -9,4 +9,5 @@
9
9
  @import './flows.css';
10
10
  @import './range-color.css';
11
11
  @import './color-picker.css';
12
+ @import './breadcrumb.css';
12
13
  @import './systems.css';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtzll/reflow",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",