@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 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
+ - [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
- 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,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
+ }
@@ -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
  }
@@ -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],
@@ -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
- if (!scrubbing || scrubbing.id !== event.pointerId)
504
+ const scrub = scrubbing;
505
+ if (!scrub || scrub.id !== event.pointerId)
442
506
  return;
443
- const travel = event.clientX - scrubbing.x;
444
- if (!scrubbing.moved && Math.abs(travel) < 2)
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
- scrubbing.moved = true;
447
- if (Number.isNaN(scrubbing.start))
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(scrubbing.start + Math.round(travel) * step);
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: [