@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.
@@ -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,21 @@ 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
+ handle.props.triggerMix,
380
+ ], 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: [
381
+ menu.trigger({
382
+ placement: `${handle.props.side ?? 'bottom'}-${align}`,
383
+ offset: 6,
384
+ }),
385
+ hoverMix,
386
+ handle.props.triggerMix,
387
+ ], 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
388
  ? { query, setQuery, placeholder: searchPlaceholder }
300
389
  : undefined })] }) }));
301
390
  };
@@ -306,6 +395,7 @@ function matchesMenuQuery(item, query) {
306
395
  }
307
396
  function DropdownSurface(handle) {
308
397
  const context = handle.context.get(menu.Context);
398
+ handle.props.hover?.control.attach(context);
309
399
  const highlight = fluidHover({ highlightedSelector: '[data-highlighted]' });
310
400
  const checked = new Map();
311
401
  // Remix UI 0.13.1 unconditionally closes after its activation path. Keep
@@ -361,6 +451,20 @@ function DropdownSurface(handle) {
361
451
  : handle.props.items;
362
452
  return (_jsxs("div", { class: "rf-menu-surface rf-dropdown-surface", "data-inline": handle.props.inline || undefined, mix: [
363
453
  !handle.props.inline && menu.popover(),
454
+ // A hover-opened menu stays while the pointer is over it, submenus
455
+ // included (they render inside this surface), and closes once the
456
+ // pointer leaves both the menu and its trigger.
457
+ handle.props.hover && [
458
+ on('pointerenter', (event) => {
459
+ if (event.pointerType === 'mouse')
460
+ handle.props.hover?.control.cancel();
461
+ }),
462
+ on('pointerleave', (event) => {
463
+ const hover = handle.props.hover;
464
+ if (event.pointerType === 'mouse' && hover)
465
+ hover.control.leave(hover.closeDelay);
466
+ }),
467
+ ],
364
468
  search &&
365
469
  on('toggle', (event) => {
366
470
  if (event.newState === 'open')
@@ -415,7 +519,7 @@ function DropdownSurface(handle) {
415
519
  ? search.query.slice(0, -1)
416
520
  : search.query + event.key);
417
521
  }, true),
418
- ], children: [_jsx("span", { class: "rf-highlight rf-menu-hover", "aria-hidden": "true", "data-rf-hover-layer": true }), handle.props.inline && (_jsx("span", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true })), items.length === 0 && search && (_jsx("div", { class: "rf-menu-empty", role: "status", children: handle.props.emptyMessage ?? 'No results' })), items.map((item, index) => (_jsxs("div", { role: "none", class: "rf-menu-entry", children: [item.separatorBefore && (index > 0 || !normalized) && (_jsx("div", { class: "rf-menu-separator", role: "separator" })), item.group && item.group !== items[index - 1]?.group && (_jsx("div", { class: "rf-menu-group-label", children: item.group })), item.children && item.children.length > 0 ? (_jsxs(menu.Context, { label: item.label, children: [_jsx("div", { class: "rf-menu-item", "data-rf-hover-item": true, mix: menu.submenuTrigger({
522
+ ], children: [_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 }), items.length === 0 && search && (_jsx("div", { class: "rf-menu-empty", role: "status", children: handle.props.emptyMessage ?? 'No results' })), items.map((item, index) => (_jsxs("div", { role: "none", class: "rf-menu-entry", children: [item.separatorBefore && (index > 0 || !normalized) && (_jsx("div", { class: "rf-menu-separator", role: "separator" })), item.group && item.group !== items[index - 1]?.group && (_jsx("div", { class: "rf-menu-group-label", children: item.group })), item.children && item.children.length > 0 ? (_jsxs(menu.Context, { label: item.label, children: [_jsx("div", { class: "rf-menu-item", "data-rf-hover-item": true, mix: menu.submenuTrigger({
419
523
  value: item.value,
420
524
  label: item.label,
421
525
  disabled: item.disabled,
@@ -0,0 +1,4 @@
1
+ export declare function selectionRuns(): {
2
+ mix: import("remix/component").MixinDescriptor<Element, [callback: (node: Element, signal: AbortSignal) => void], import("remix/component").ElementProps>;
3
+ sync: () => void;
4
+ };
@@ -0,0 +1,221 @@
1
+ import { ref } from 'remix/component';
2
+ // The reference's merged-background timing: the inner corners trail the edges
3
+ // by 70ms, and a merge commits once the halves have met.
4
+ const cornerDelay = 70;
5
+ const springMs = 200;
6
+ const convergeMs = springMs + cornerDelay + 80;
7
+ /** Groups sorted row indices into runs, keeping each run's id while any of its rows had one. */
8
+ function assignRuns(sorted, previous, last) {
9
+ const spans = [];
10
+ for (const index of sorted) {
11
+ const span = spans[spans.length - 1];
12
+ if (span && index === span.end + 1)
13
+ span.end = index;
14
+ else
15
+ spans.push({ start: index, end: index });
16
+ }
17
+ const used = new Set();
18
+ const byRow = new Map();
19
+ const runs = spans.map((span) => {
20
+ let id;
21
+ for (let i = span.start; i <= span.end && id === undefined; i++) {
22
+ const prior = previous.get(i);
23
+ if (prior !== undefined && !used.has(prior))
24
+ id = prior;
25
+ }
26
+ id ??= ++last.id;
27
+ used.add(id);
28
+ for (let i = span.start; i <= span.end; i++)
29
+ byRow.set(i, id);
30
+ return { ...span, id };
31
+ });
32
+ return { runs, byRow };
33
+ }
34
+ /** Two runs inside `outer`, separated by exactly one row: the shape one click merges or splits. */
35
+ function bridge(outer, runs) {
36
+ const inside = runs
37
+ .filter((run) => run.start >= outer.start && run.end <= outer.end)
38
+ .sort((a, b) => a.start - b.start);
39
+ if (inside.length !== 2)
40
+ return null;
41
+ const [up, low] = inside;
42
+ return low.start === up.end + 2 ? { up, low, gap: up.end + 1 } : null;
43
+ }
44
+ export function selectionRuns() {
45
+ let host = null;
46
+ let layer = null;
47
+ let runs = [];
48
+ let byRow = new Map();
49
+ const last = { id: 0 };
50
+ const blocks = new Map();
51
+ // Halves absorbed by a merge, dropped when the merge commits.
52
+ const ghosts = new Set();
53
+ let commit;
54
+ let frame = 0;
55
+ function rowBoxes(rows) {
56
+ const parent = host.getBoundingClientRect();
57
+ // Translate back into local CSS pixels when a preview scales its contents.
58
+ const scaleX = parent.width / host.offsetWidth || 1;
59
+ const scaleY = parent.height / host.offsetHeight || 1;
60
+ return rows.map((row) => {
61
+ const rect = row.getBoundingClientRect();
62
+ return {
63
+ top: (rect.top - parent.top) / scaleY - host.clientTop,
64
+ left: (rect.left - parent.left) / scaleX - host.clientLeft,
65
+ width: rect.width / scaleX,
66
+ height: rect.height / scaleY,
67
+ };
68
+ });
69
+ }
70
+ function paint(block, box, radii, { instant = false, delayCorners = false } = {}) {
71
+ block.toggleAttribute('data-instant', instant);
72
+ block.style.setProperty('--rf-corner-delay', delayCorners ? `${cornerDelay}ms` : '0ms');
73
+ block.style.top = `${box.top}px`;
74
+ block.style.left = `${box.left}px`;
75
+ block.style.width = `${box.width}px`;
76
+ block.style.height = `${box.height}px`;
77
+ block.style.borderRadius = radii.map((r) => `${r}px`).join(' ');
78
+ if (instant)
79
+ void block.offsetWidth;
80
+ }
81
+ function create() {
82
+ const block = host.ownerDocument.createElement('span');
83
+ block.className = 'rf-choice-run';
84
+ block.setAttribute('data-instant', '');
85
+ layer.append(block);
86
+ return block;
87
+ }
88
+ function drop(block, instant) {
89
+ ghosts.delete(block);
90
+ if (instant) {
91
+ block.remove();
92
+ return;
93
+ }
94
+ block.setAttribute('data-leaving', '');
95
+ setTimeout(() => block.remove(), 200);
96
+ }
97
+ function sync() {
98
+ frame = 0;
99
+ if (!host || !layer)
100
+ return;
101
+ const rows = Array.from(host.querySelectorAll(':scope > .rf-choice-group-row'));
102
+ const checked = rows.flatMap((row, index) => row.querySelector('input:checked') ? [index] : []);
103
+ const previous = runs;
104
+ const next = assignRuns(checked, byRow, last);
105
+ runs = next.runs;
106
+ byRow = next.byRow;
107
+ const boxes = rowBoxes(rows);
108
+ const radius = parseFloat(getComputedStyle(rows[0] ?? host).borderTopLeftRadius) || 0;
109
+ const full = [radius, radius, radius, radius];
110
+ const span = (run) => {
111
+ const start = boxes[run.start];
112
+ const end = boxes[run.end];
113
+ return {
114
+ top: start.top,
115
+ left: Math.min(start.left, end.left),
116
+ width: Math.max(start.width, end.width),
117
+ height: end.top + end.height - start.top,
118
+ };
119
+ };
120
+ // A merge in flight that this change undid leaves its ghost behind.
121
+ clearTimeout(commit);
122
+ for (const ghost of ghosts)
123
+ drop(ghost, true);
124
+ // Runs that are gone fade out, unless a merge absorbs them below.
125
+ const live = new Set(runs.map((run) => run.id));
126
+ const merges = runs.flatMap((run) => {
127
+ const pair = bridge(run, previous);
128
+ return pair && (pair.up.id === run.id || pair.low.id === run.id) ? [{ run, ...pair }] : [];
129
+ });
130
+ const splits = previous.flatMap((run) => {
131
+ const pair = bridge(run, runs);
132
+ return pair ? [{ run, ...pair }] : [];
133
+ });
134
+ const absorbed = new Set(merges.map(({ run, up, low }) => (run.id === up.id ? low.id : up.id)));
135
+ // Blocks of runs a merge absorbed, kept to glide in as the lower half.
136
+ const retired = new Map();
137
+ for (const [id, block] of blocks) {
138
+ if (live.has(id))
139
+ continue;
140
+ blocks.delete(id);
141
+ if (absorbed.has(id))
142
+ retired.set(id, block);
143
+ else
144
+ drop(block, false);
145
+ }
146
+ for (const run of runs) {
147
+ const fresh = !blocks.has(run.id);
148
+ const block = blocks.get(run.id) ?? create();
149
+ blocks.set(run.id, block);
150
+ if (fresh)
151
+ paint(block, span(run), full, { instant: true });
152
+ else
153
+ paint(block, span(run), full);
154
+ }
155
+ for (const { run, up, low, gap } of merges) {
156
+ const otherId = run.id === up.id ? low.id : up.id;
157
+ const ghost = retired.get(otherId);
158
+ const survivor = blocks.get(run.id);
159
+ const box = span(run);
160
+ const middle = boxes[gap].top + boxes[gap].height / 2;
161
+ // The halves glide to the middle of the bridge row, inner corners straightening.
162
+ paint(survivor, { ...box, height: middle - box.top }, [radius, radius, 0, 0], {
163
+ delayCorners: true,
164
+ });
165
+ if (ghost) {
166
+ ghosts.add(ghost);
167
+ paint(ghost, { ...box, top: middle, height: box.top + box.height - middle }, [0, 0, radius, radius], { delayCorners: true });
168
+ }
169
+ commit = setTimeout(() => {
170
+ // One block over the whole run, swapped in without visible motion.
171
+ paint(survivor, span(run), full, { instant: true });
172
+ for (const ghost of ghosts)
173
+ drop(ghost, true);
174
+ }, convergeMs);
175
+ }
176
+ for (const { run, up, low, gap } of splits) {
177
+ const upper = blocks.get(up.id);
178
+ const lower = blocks.get(low.id);
179
+ if (!upper || !lower)
180
+ continue;
181
+ const box = span(run);
182
+ const middle = boxes[gap].top + boxes[gap].height / 2;
183
+ // Snap into two abutting halves at the seam, then glide apart.
184
+ paint(upper, { ...box, height: middle - box.top }, [radius, radius, 0, 0], {
185
+ instant: true,
186
+ });
187
+ paint(lower, { ...box, top: middle, height: box.top + box.height - middle }, [0, 0, radius, radius], { instant: true });
188
+ paint(upper, span(up), full);
189
+ paint(lower, span(low), full);
190
+ }
191
+ }
192
+ function schedule() {
193
+ if (!frame)
194
+ frame = requestAnimationFrame(sync);
195
+ }
196
+ const mix = ref((element, signal) => {
197
+ if (!(element instanceof HTMLElement))
198
+ return;
199
+ host = element;
200
+ layer = element.querySelector(':scope > [data-rf-selection-runs]');
201
+ if (!layer)
202
+ return;
203
+ host.setAttribute('data-rf-selection-blocks', '');
204
+ const size = new ResizeObserver(schedule);
205
+ size.observe(element);
206
+ element.addEventListener('change', () => sync(), { signal });
207
+ element.closest('form')?.addEventListener('reset', () => setTimeout(sync), { signal });
208
+ sync();
209
+ signal.addEventListener('abort', () => {
210
+ size.disconnect();
211
+ cancelAnimationFrame(frame);
212
+ clearTimeout(commit);
213
+ if (host === element) {
214
+ host.removeAttribute('data-rf-selection-blocks');
215
+ host = null;
216
+ layer = null;
217
+ }
218
+ });
219
+ });
220
+ return { mix, sync: schedule };
221
+ }
package/dist/sidebar.css CHANGED
@@ -184,6 +184,14 @@
184
184
  }
185
185
  }
186
186
  }
187
+ /* These menus mark keyboard focus with the row highlight, not the moving ring. */
188
+ :is(.rf-sidebar-header, .rf-sidebar-footer)
189
+ .rf-sidebar-row
190
+ + .rf-dropdown-surface
191
+ > .rf-menu-list
192
+ > .rf-focus-ring {
193
+ display: none;
194
+ }
187
195
  .rf-sidebar-row {
188
196
  position: relative;
189
197
  display: flex;
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.3.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",