@rtzll/reflow 0.2.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 35 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
 
@@ -1,7 +1,8 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
2
- import { on, ref } from 'remix/component';
2
+ import { attrs, on, ref } from 'remix/component';
3
3
  import { Icon } from './icon.js';
4
4
  import { Dropdown, fluidHover } from './interactive.js';
5
+ import { Tooltip } from './overlays.js';
5
6
  const fallbackEllipsisWidth = 44;
6
7
  /**
7
8
  * A trail of links to the current page, as in the reference. Every step shares
@@ -14,6 +15,10 @@ export function Breadcrumb(handle) {
14
15
  // Natural widths per step, remembered so a hidden step returns as soon as it fits.
15
16
  let widths = [];
16
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;
17
22
  const listRef = ref((node, signal) => {
18
23
  if (!(node instanceof HTMLOListElement))
19
24
  return;
@@ -21,8 +26,18 @@ export function Breadcrumb(handle) {
21
26
  const observer = new ResizeObserver(() => measure());
22
27
  if (node.parentElement)
23
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'] });
24
38
  signal.addEventListener('abort', () => {
25
39
  observer.disconnect();
40
+ menus.disconnect();
26
41
  if (list === node)
27
42
  list = null;
28
43
  });
@@ -74,18 +89,23 @@ export function Breadcrumb(handle) {
74
89
  void handle.update();
75
90
  }
76
91
  }
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;
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;
82
96
  const label = crumb?.querySelector('.rf-breadcrumb-label');
83
97
  if (!crumb || !label)
84
98
  return;
85
- if (label.scrollWidth > label.clientWidth + 0.5)
86
- crumb.title = label.textContent ?? '';
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);
87
106
  else
88
- crumb.removeAttribute('title');
107
+ cut.delete(index);
108
+ void handle.update();
89
109
  }
90
110
  return () => {
91
111
  const { items, label = 'Breadcrumb', size, separator, collapse, openOnHover } = handle.props;
@@ -96,15 +116,26 @@ export function Breadcrumb(handle) {
96
116
  handle.queueTask(() => measure());
97
117
  const trailing = separator === null ? null : (_jsx("span", { class: "rf-breadcrumb-separator", "aria-hidden": "true", children: separator ?? _jsx(Icon, { name: "chevron-right" }) }));
98
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;
99
125
  const step = (item, index) => {
100
126
  const last = index === items.length - 1;
101
127
  if (last) {
102
- return (_jsx("span", { class: "rf-breadcrumb-crumb rf-breadcrumb-page", role: "link", "aria-disabled": "true", "aria-current": "page", children: content(item) }));
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) })));
103
129
  }
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] }) }));
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] }) })));
106
137
  }
107
- return (_jsxs("a", { class: "rf-breadcrumb-crumb", href: item.href, children: [content(item), trailing] }));
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] })));
108
139
  };
109
140
  const hiddenItems = items.slice(1, 1 + hide);
110
141
  const visible = items
@@ -126,8 +157,8 @@ export function Breadcrumb(handle) {
126
157
  ]);
127
158
  return (_jsxs("nav", { class: "rf-breadcrumb", "aria-label": label, "data-density": size, "data-separator": separator === undefined ? 'chevron' : undefined, mix: [
128
159
  fluidHover({ axis: 'x', focusSelector: ':has(:focus-visible)' }),
129
- on('pointerover', showFullName),
130
- on('focusin', showFullName),
160
+ on('pointerover', checkCut),
161
+ on('focusin', checkCut),
131
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 })] }));
132
163
  };
133
164
  }
@@ -297,6 +297,22 @@
297
297
  cursor: ew-resize;
298
298
  }
299
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
+
300
316
  .rf-color-field[data-editing] .rf-color-field-body {
301
317
  cursor: text;
302
318
  }
@@ -375,11 +375,64 @@ function FormatMenu(handle) {
375
375
  }), children: _jsx("span", { class: "rf-menu-label", "data-label": formatLabels[format], children: _jsx("span", { children: formatLabels[format] }) }) }, format)))] }) })] }) }));
376
376
  };
377
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
+ }
378
395
  function ColorField(handle) {
379
396
  let draft;
380
397
  let editing = false;
381
398
  let scrubbing;
382
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
+ }
383
436
  const inputRef = ref((node, signal) => {
384
437
  if (!(node instanceof HTMLInputElement))
385
438
  return;
@@ -436,26 +489,46 @@ function ColorField(handle) {
436
489
  return;
437
490
  scrubbing = {
438
491
  id: event.pointerId,
439
- x: event.clientX,
440
492
  start: numeric(value),
493
+ x: event.clientX,
494
+ y: event.clientY,
495
+ lastX: event.clientX,
496
+ travel: 0,
441
497
  moved: false,
498
+ mouse: event.pointerType === 'mouse',
499
+ locked: false,
442
500
  };
443
501
  event.currentTarget.setPointerCapture(event.pointerId);
444
502
  }),
445
503
  on('pointermove', (event) => {
446
- if (!scrubbing || scrubbing.id !== event.pointerId)
504
+ const scrub = scrubbing;
505
+ if (!scrub || scrub.id !== event.pointerId)
447
506
  return;
448
- const travel = event.clientX - scrubbing.x;
449
- 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)
450
519
  return;
451
- scrubbing.moved = true;
452
- 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))
453
525
  return;
454
- commitNumber(scrubbing.start + Math.round(travel) * step);
526
+ commitNumber(scrub.start + Math.round(scrub.travel) * step);
455
527
  }),
456
- on('pointerup', () => {
528
+ on('pointerup', (event) => {
457
529
  const moved = scrubbing?.moved;
458
530
  scrubbing = undefined;
531
+ endScrub(event.currentTarget);
459
532
  if (moved)
460
533
  return;
461
534
  editing = true;
@@ -465,8 +538,9 @@ function ColorField(handle) {
465
538
  input?.select();
466
539
  });
467
540
  }),
468
- on('pointercancel', () => {
541
+ on('pointercancel', (event) => {
469
542
  scrubbing = undefined;
543
+ endScrub(event.currentTarget);
470
544
  }),
471
545
  ]
472
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: [
@@ -730,10 +730,12 @@ html[data-rf-message-transition]::view-transition-old(root) {
730
730
  column-gap: 10px;
731
731
  min-height: 0;
732
732
  overflow: clip;
733
+ /* As in the reference: the step opens its height on the slow spring, and
734
+ its icon and text fade in once that space starts opening. */
735
+ animation: rf-thinking-step-enter 240ms ease-out 80ms both;
733
736
  }
734
737
  .rf-thinking-timeline-row {
735
738
  padding: 6px 8px 6px 0;
736
- animation: rf-thinking-step-enter 240ms ease-out var(--rf-step-delay, 0ms) both;
737
739
  }
738
740
  .rf-thinking-timeline > li {
739
741
  padding-inline-start: 8px;
@@ -748,7 +750,8 @@ html[data-rf-message-transition]::view-transition-old(root) {
748
750
  display: flex;
749
751
  flex-direction: column;
750
752
  align-items: center;
751
- padding-top: 8px;
753
+ /* The connector stops where the step's text ends, as in the reference. */
754
+ padding-block: 8px 6px;
752
755
  color: var(--rf-muted);
753
756
  }
754
757
  .rf-thinking-timeline-icon > svg {
@@ -843,7 +846,6 @@ html[data-rf-message-transition]::view-transition-old(root) {
843
846
  @keyframes rf-thinking-step-enter {
844
847
  from {
845
848
  opacity: 0;
846
- translate: 0 -3px;
847
849
  }
848
850
  }
849
851
 
@@ -1051,18 +1053,36 @@ html[data-rf-message-transition]::view-transition-old(root) {
1051
1053
  color: var(--rf-muted);
1052
1054
  font: var(--rf-text-caption) / var(--rf-leading-caption) var(--rf-font);
1053
1055
  }
1054
- .rf-thinking-step-source,
1056
+ /* As in the reference: a source chip scales up on the moderate spring as it
1057
+ unblurs; an image fades in as it unblurs. */
1058
+ .rf-thinking-step-source {
1059
+ animation:
1060
+ rf-thinking-source-enter var(--rf-spring-moderate) var(--rf-thinking-part-delay, 0ms) both,
1061
+ rf-thinking-part-unblur 120ms ease-out var(--rf-thinking-part-delay, 0ms) both;
1062
+ }
1055
1063
  .rf-thinking-step-image {
1056
- animation: rf-thinking-part-enter 200ms ease-out var(--rf-thinking-part-delay, 0ms) both;
1064
+ animation:
1065
+ rf-thinking-part-fade 200ms ease-out var(--rf-thinking-part-delay, 0ms) both,
1066
+ rf-thinking-part-unblur 150ms ease-out var(--rf-thinking-part-delay, 0ms) both;
1057
1067
  }
1058
- @keyframes rf-thinking-part-enter {
1068
+ @keyframes rf-thinking-source-enter {
1059
1069
  from {
1060
1070
  opacity: 0;
1071
+ scale: 0.85;
1072
+ }
1073
+ }
1074
+ @keyframes rf-thinking-part-fade {
1075
+ from {
1076
+ opacity: 0;
1077
+ }
1078
+ }
1079
+ @keyframes rf-thinking-part-unblur {
1080
+ from {
1061
1081
  filter: blur(4px);
1062
1082
  }
1063
1083
  }
1064
1084
  @media (prefers-reduced-motion: reduce) {
1065
- .rf-thinking-timeline-row,
1085
+ .rf-thinking-step-body,
1066
1086
  .rf-thinking-step-source,
1067
1087
  .rf-thinking-step-image {
1068
1088
  animation: none;
@@ -2,10 +2,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "remix/compone
2
2
  import { Icon } from './icon.js';
3
3
  import { messageTransitionName, transitionMessage } from './message-transition.js';
4
4
  import { on, ref } from 'remix/component';
5
+ import { prefersReducedMotion } from '@remix-run/ui/animation';
5
6
  import { Badge, Button, Textarea } from './basic.js';
6
7
  import { Accordion } from './groups.js';
7
8
  import { QueuedStack } from './queued-stack.js';
8
9
  import { fluidHover } from './interactive.js';
10
+ import { motion } from './systems.js';
9
11
  import { Tooltip } from './overlays.js';
10
12
  export function CarouselDots(handle) {
11
13
  const trackVisibility = ref((element, signal) => {
@@ -23,9 +25,72 @@ export function CarouselDots(handle) {
23
25
  };
24
26
  }
25
27
  export function ChatMessage(handle) {
28
+ // As in the reference, a message glides to its new place when the
29
+ // conversation re-renders around it, such as earlier messages moving up as a
30
+ // new one is sent and the transcript scrolls to it. Its place is read before
31
+ // the update lands and again on the next frame, once the transcript has
32
+ // scrolled. A message still playing its entrance does not glide.
33
+ let element = null;
34
+ let entered = false;
35
+ let glide;
36
+ const track = ref((node, signal) => {
37
+ if (!(node instanceof HTMLElement))
38
+ return;
39
+ element = node;
40
+ entered = !node.hasAttribute('data-animate') || node.getAnimations().length === 0;
41
+ const settle = (event) => {
42
+ if (event.target === node)
43
+ entered = true;
44
+ };
45
+ node.addEventListener('animationend', settle, { signal });
46
+ node.addEventListener('animationcancel', settle, { signal });
47
+ signal.addEventListener('abort', () => {
48
+ glide?.cancel();
49
+ if (element === node)
50
+ element = null;
51
+ }, { once: true });
52
+ });
53
+ function followMove() {
54
+ const node = element;
55
+ if (!node ||
56
+ !entered ||
57
+ !node.isConnected ||
58
+ prefersReducedMotion() ||
59
+ document.documentElement.hasAttribute('data-rf-message-transition'))
60
+ return;
61
+ // Where the message shows now, a glide in flight included.
62
+ const before = node.getBoundingClientRect().top;
63
+ const glideFrom = (shown) => {
64
+ if (element !== node || !node.isConnected)
65
+ return;
66
+ glide?.cancel();
67
+ const offset = shown - node.getBoundingClientRect().top;
68
+ if (Math.abs(offset) < 0.5)
69
+ return;
70
+ glide = node.animate([{ transform: `translateY(${offset}px)` }, { transform: 'none' }], motion.moderate);
71
+ // A starting animation holds its first frame from now, not from its
72
+ // first tick, so the next paint already shows the old place.
73
+ glide.currentTime = 0;
74
+ };
75
+ // Glide as soon as the update lands, so no frame shows the jump. If the
76
+ // transcript scrolls before the next frame, the glide restarts from the
77
+ // same place against the scrolled layout.
78
+ handle.queueTask(() => {
79
+ glideFrom(before);
80
+ const started = glide;
81
+ const shown = node.getBoundingClientRect().top;
82
+ requestAnimationFrame(() => {
83
+ // The message has not been painted elsewhere yet, so a restart is seamless.
84
+ if (glide === started && Math.abs(node.getBoundingClientRect().top - shown) >= 0.5) {
85
+ glideFrom(before);
86
+ }
87
+ });
88
+ });
89
+ }
26
90
  return () => {
27
91
  const { role, label, attachments, children, size, time, actions, animate = true } = handle.props;
28
- return (_jsxs("article", { class: "rf-chat-message", "data-role": role, "data-size": size, "data-animate": animate ? '' : undefined, "aria-label": label ?? (role === 'user' ? 'You' : 'Assistant'), children: [attachments?.length ? (_jsx("ul", { class: "rf-chat-attachments", "aria-label": "Attachments", children: attachments.map((attachment, index) => (_jsxs("li", { class: "rf-chat-thumb", title: attachment.detail ? `${attachment.name} (${attachment.detail})` : undefined, children: [attachment.previewUrl ? (_jsx("img", { src: attachment.previewUrl, alt: attachment.name })) : (_jsx("span", { class: "rf-chat-thumb-glyph", role: "img", "aria-label": attachment.name, children: _jsxs("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: [_jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }), _jsx("path", { d: "M14 3v5h5" })] }) })), attachment.url && _jsx("a", { href: attachment.url, "aria-label": attachment.name })] }, index))) })) : null, children != null && children !== '' && (_jsx("div", { class: "rf-chat-body", style: handle.props.transitionId
92
+ followMove();
93
+ return (_jsxs("article", { class: "rf-chat-message", "data-role": role, "data-size": size, "data-animate": animate ? '' : undefined, "aria-label": label ?? (role === 'user' ? 'You' : 'Assistant'), mix: track, children: [attachments?.length ? (_jsx("ul", { class: "rf-chat-attachments", "aria-label": "Attachments", children: attachments.map((attachment, index) => (_jsxs("li", { class: "rf-chat-thumb", title: attachment.detail ? `${attachment.name} (${attachment.detail})` : undefined, children: [attachment.previewUrl ? (_jsx("img", { src: attachment.previewUrl, alt: attachment.name })) : (_jsx("span", { class: "rf-chat-thumb-glyph", role: "img", "aria-label": attachment.name, children: _jsxs("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: [_jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }), _jsx("path", { d: "M14 3v5h5" })] }) })), attachment.url && _jsx("a", { href: attachment.url, "aria-label": attachment.name })] }, index))) })) : null, children != null && children !== '' && (_jsx("div", { class: "rf-chat-body", style: handle.props.transitionId
29
94
  ? { viewTransitionName: messageTransitionName(handle.props.transitionId) }
30
95
  : undefined, children: children })), ((role === 'user' && time) || actions) && (_jsxs("div", { class: "rf-chat-meta", children: [role === 'user' && time && _jsx("span", { class: "rf-chat-time", children: time }), actions != null && _jsx("span", { class: "rf-chat-actions", children: actions })] }))] }));
31
96
  };
@@ -428,7 +493,7 @@ export function ThinkingSteps(handle) {
428
493
  const list = children ??
429
494
  (timeline ? (_jsx("ol", { class: "rf-thinking-timeline", children: steps
430
495
  .filter((step) => step.status !== 'pending')
431
- .map((step, index) => (_jsx("li", { "data-status": step.status ?? 'complete', style: { '--rf-step-delay': `${Math.min(index, 6) * 24}ms` }, children: _jsxs("div", { class: "rf-thinking-step-body", children: [_jsx("span", { class: "rf-thinking-timeline-icon", "aria-hidden": "true", children: step.icon ?? _jsx("span", { class: "rf-thinking-timeline-dot" }) }), _jsxs("div", { class: "rf-thinking-timeline-row", children: [_jsxs("span", { class: "rf-thinking-timeline-title", children: [step.title, step.status === 'active' && !step.title.endsWith('…') ? '…' : ''] }), _jsxs("span", { class: "rf-basic-sr-only", children: [" (", step.status ?? 'complete', ")"] }), step.description && (_jsx("span", { class: "rf-thinking-timeline-description", children: step.description })), step.content != null && (_jsx("div", { class: "rf-thinking-timeline-content", children: step.content }))] })] }) }, step.value))) })) : (_jsx(Accordion, { defaultValue: defaultValue, items: steps.map((step) => ({
496
+ .map((step) => (_jsx("li", { "data-status": step.status ?? 'complete', children: _jsxs("div", { class: "rf-thinking-step-body", children: [_jsx("span", { class: "rf-thinking-timeline-icon", "aria-hidden": "true", children: step.icon ?? _jsx("span", { class: "rf-thinking-timeline-dot" }) }), _jsxs("div", { class: "rf-thinking-timeline-row", children: [_jsxs("span", { class: "rf-thinking-timeline-title", children: [step.title, step.status === 'active' && !step.title.endsWith('…') ? '…' : ''] }), _jsxs("span", { class: "rf-basic-sr-only", children: [" (", step.status ?? 'complete', ")"] }), step.description && (_jsx("span", { class: "rf-thinking-timeline-description", children: step.description })), step.content != null && (_jsx("div", { class: "rf-thinking-timeline-content", children: step.content }))] })] }) }, step.value))) })) : (_jsx(Accordion, { defaultValue: defaultValue, items: steps.map((step) => ({
432
497
  ...step,
433
498
  content: step.content,
434
499
  title: (_jsxs("span", { class: "rf-step-title", "data-status": step.status ?? 'pending', children: [_jsx("span", { "aria-hidden": "true", children: step.status === 'complete' ? '✓' : '○' }), step.title, _jsxs("span", { class: "rf-basic-sr-only", children: [" (", step.status ?? 'pending', ")"] })] })),
package/dist/dates.css CHANGED
@@ -31,6 +31,42 @@
31
31
  'opsz' 18;
32
32
  }
33
33
 
34
+ /* With week numbers, the title belongs to the days: it starts past the
35
+ week column. */
36
+ .rf-calendar[data-week-numbers] .rf-calendar-header {
37
+ padding-inline-start: calc(var(--rf-date-cell) * 0.75 + 8px);
38
+ }
39
+
40
+ /* Months side by side, as in the reference: each title centered over its
41
+ days, previous at the far left, next at the far right. */
42
+ .rf-calendar-header[data-centered] {
43
+ position: relative;
44
+ justify-content: center;
45
+ padding-inline: 0;
46
+ }
47
+ .rf-calendar[data-week-numbers] .rf-calendar-header[data-centered] {
48
+ padding-inline-start: calc(var(--rf-date-cell) * 0.75);
49
+ }
50
+ .rf-calendar-header[data-centered] .rf-calendar-months-label {
51
+ margin-inline-end: 0;
52
+ text-align: center;
53
+ }
54
+ .rf-calendar-header[data-centered] .rf-calendar-nav {
55
+ position: absolute;
56
+ inset-block-start: 0;
57
+ }
58
+ .rf-calendar-header[data-centered] .rf-calendar-nav[data-direction='previous'] {
59
+ inset-inline-start: 0;
60
+ }
61
+ .rf-calendar[data-week-numbers]
62
+ .rf-calendar-header[data-centered]
63
+ .rf-calendar-nav[data-direction='previous'] {
64
+ inset-inline-start: calc(var(--rf-date-cell) * 0.75);
65
+ }
66
+ .rf-calendar-header[data-centered] .rf-calendar-nav[data-direction='next'] {
67
+ inset-inline-end: 0;
68
+ }
69
+
34
70
  .rf-calendar-nav,
35
71
  .rf-calendar-day {
36
72
  appearance: none;
@@ -102,6 +138,46 @@
102
138
  gap: 4px;
103
139
  }
104
140
 
141
+ /* Stacked months, as in the reference: one weekday row above them, kept in
142
+ view while a short popup scrolls, 16px between the months, and arrows that
143
+ page up and down. */
144
+ .rf-calendar[data-stacked] .rf-calendar-months {
145
+ gap: 16px;
146
+ }
147
+ .rf-calendar-weekdays {
148
+ position: sticky;
149
+ inset-block-start: 0;
150
+ z-index: 3;
151
+ display: flex;
152
+ margin-block-end: 16px;
153
+ background: inherit;
154
+ color: var(--rf-muted);
155
+ font-size: var(--rf-text-caption, 12px);
156
+ line-height: var(--rf-leading-caption, 16px);
157
+ }
158
+ .rf-calendar-weekdays > span {
159
+ display: grid;
160
+ flex: none;
161
+ place-items: center;
162
+ inline-size: var(--rf-date-cell);
163
+ block-size: calc(var(--rf-date-cell) * 0.8);
164
+ block-size: round(down, calc(var(--rf-date-cell) * 0.8), 1px);
165
+ }
166
+ .rf-calendar-weekdays > .rf-calendar-weekdays-week {
167
+ inline-size: calc(var(--rf-date-cell) * 0.75);
168
+ }
169
+ .rf-calendar[data-stacked] .rf-calendar-grid thead {
170
+ position: absolute;
171
+ inline-size: 1px;
172
+ block-size: 1px;
173
+ overflow: hidden;
174
+ clip-path: inset(50%);
175
+ white-space: nowrap;
176
+ }
177
+ .rf-calendar[data-stacked] .rf-calendar-nav svg {
178
+ rotate: 90deg;
179
+ }
180
+
105
181
  .rf-calendar-grid {
106
182
  border-collapse: separate;
107
183
  border-spacing: 0;
@@ -186,13 +262,42 @@
186
262
  color: var(--rf-bg);
187
263
  }
188
264
 
265
+ /* Picking a range's end, as in the reference: the proposed end is filled
266
+ like a selected day and glides to the pointer, with a faint ring 1px inside
267
+ it that goes solid while pressed. Its number draws above the fill. */
189
268
  .rf-calendar-preview-end {
190
269
  z-index: 1;
191
- box-shadow: inset 0 0 0 1px var(--rf-accent, #6b97ff);
270
+ background: var(--rf-fg);
271
+ }
272
+ .rf-calendar-preview-end::before {
273
+ content: '';
274
+ position: absolute;
275
+ inset: 1px;
276
+ border: 1px solid var(--rf-bg);
277
+ border-radius: max(0px, calc(var(--rf-radius, 8px) - 1px));
278
+ opacity: 0.4;
279
+ transition: opacity 80ms ease;
280
+ }
281
+ .rf-calendar-months:has(.rf-calendar-day[data-rf-selected]:active)
282
+ .rf-calendar-preview-end::before {
283
+ opacity: 1;
284
+ }
285
+ .rf-calendar-day {
286
+ z-index: 2;
287
+ }
288
+ .rf-calendar-day[data-rf-selected] {
289
+ color: var(--rf-bg);
192
290
  }
193
-
194
291
  .rf-calendar-months:not([data-rf-has-selection]) .rf-calendar-day[data-rf-selected] {
195
- box-shadow: inset 0 0 0 1px var(--rf-accent, #6b97ff);
292
+ background: var(--rf-fg);
293
+ }
294
+ /* The same faint ring marks a filled day under the pointer. */
295
+ .rf-calendar-day[data-selected][data-rf-hovered] {
296
+ outline: 1px solid color-mix(in oklab, var(--rf-bg) 40%, transparent);
297
+ outline-offset: -2px;
298
+ }
299
+ .rf-calendar-day[data-selected][data-rf-hovered]:active {
300
+ outline-color: var(--rf-bg);
196
301
  }
197
302
 
198
303
  @media (forced-colors: active) {
package/dist/dates.js CHANGED
@@ -255,7 +255,7 @@ export function Calendar(handle) {
255
255
  }
256
256
  function monthButton(direction, disabled) {
257
257
  const label = direction === 'left' ? 'Previous month' : 'Next month';
258
- return (_jsx(Tooltip, { content: label, side: "bottom", renderTrigger: (trigger) => (_jsx("button", { ...trigger, type: "button", class: "rf-calendar-nav", "data-rf-hover-item": true, "aria-label": label, disabled: disabled, mix: [trigger.mix, on('click', () => moveMonth(direction === 'left' ? -1 : 1))], children: _jsx(DateArrow, { direction: direction }) })) }));
258
+ return (_jsx(Tooltip, { content: label, side: "bottom", renderTrigger: (trigger) => (_jsx("button", { ...trigger, type: "button", class: "rf-calendar-nav", "data-direction": direction === 'left' ? 'previous' : 'next', "data-rf-hover-item": true, "aria-label": label, disabled: disabled, mix: [trigger.mix, on('click', () => moveMonth(direction === 'left' ? -1 : 1))], children: _jsx(DateArrow, { direction: direction }) })) }));
259
259
  }
260
260
  function onDayKey(event, day) {
261
261
  let next;
@@ -300,9 +300,19 @@ export function Calendar(handle) {
300
300
  event.preventDefault();
301
301
  focusDay(next);
302
302
  }
303
- return (_jsxs("section", { class: "rf-calendar", "aria-label": label, "data-orientation": orientation, style: cellSize && Number.isFinite(cellSize) && cellSize > 0
303
+ // As in the reference: side by side, each title is centered over its
304
+ // month, previous sits at the far left and next at the far right. One
305
+ // month, or months stacked, keep the title left and both arrows together
306
+ // on the first month's right.
307
+ const sideBySide = months.length > 1 && orientation !== 'vertical';
308
+ // Stacked months share one weekday row above them, as in the reference;
309
+ // each month's own header stays in its table for screen readers.
310
+ const stacked = months.length > 1 && orientation === 'vertical';
311
+ const previousDisabled = firstMonth <= monthStart(min);
312
+ const nextDisabled = firstMonth >= monthStart(max);
313
+ return (_jsxs("section", { class: "rf-calendar", "aria-label": label, "data-orientation": orientation, "data-week-numbers": showWeekNumbers ? '' : undefined, "data-stacked": stacked ? '' : undefined, style: cellSize && Number.isFinite(cellSize) && cellSize > 0
304
314
  ? { '--rf-date-cell-size': `${cellSize}px` }
305
- : undefined, children: [_jsxs("div", { class: "rf-calendar-months", mix: [
315
+ : undefined, children: [stacked && (_jsxs("div", { class: "rf-calendar-weekdays", "aria-hidden": "true", children: [showWeekNumbers && _jsx("span", { class: "rf-calendar-weekdays-week" }), weekdays.map((weekday) => (_jsx("span", { children: weekday.slice(0, 2) }, weekday)))] })), _jsxs("div", { class: "rf-calendar-months", mix: [
306
316
  gridRef,
307
317
  hover,
308
318
  on('pointerleave', () => {
@@ -318,8 +328,9 @@ export function Calendar(handle) {
318
328
  const weekCount = Math.ceil((firstDayOffset + daysInMonth) / 7);
319
329
  const weeks = Array.from({ length: weekCount }, (_, row) => Array.from({ length: 7 }, (_, column) => addDays(start, row * 7 + column)));
320
330
  const monthIso = iso(month).slice(0, 7);
321
- return (_jsxs("div", { class: "rf-calendar-month", children: [_jsxs("div", { class: "rf-calendar-header", children: [index === 0 && monthButton('left', firstMonth <= monthStart(min)), _jsx("div", { class: "rf-calendar-months-label", "aria-live": index === 0 ? 'polite' : undefined, "aria-atomic": index === 0 ? 'true' : undefined, children: monthLabel.format(month) }), index === months.length - 1 &&
322
- monthButton('right', firstMonth >= monthStart(max))] }), _jsxs("table", { class: "rf-calendar-grid", role: "grid", "aria-label": monthLabel.format(month), children: [_jsx("thead", { children: _jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "col", class: "rf-calendar-week", abbr: "Week number", children: _jsx("span", { class: "rf-basic-sr-only", children: "Week number" }) })), weekdays.map((weekday) => (_jsx("th", { scope: "col", abbr: weekday, children: weekday.slice(0, 2) }, weekday)))] }) }), _jsx("tbody", { children: weeks.map((week) => (_jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "row", class: "rf-calendar-week", children: getWeekNumber(week[0]) })), week.map((day) => {
331
+ return (_jsxs("div", { class: "rf-calendar-month", children: [_jsxs("div", { class: "rf-calendar-header", "data-centered": sideBySide ? '' : undefined, children: [sideBySide && index === 0 && monthButton('left', previousDisabled), _jsx("div", { class: "rf-calendar-months-label", "aria-live": index === 0 ? 'polite' : undefined, "aria-atomic": index === 0 ? 'true' : undefined, children: monthLabel.format(month) }), sideBySide
332
+ ? index === months.length - 1 && monthButton('right', nextDisabled)
333
+ : index === 0 && (_jsxs(_Fragment, { children: [monthButton('left', previousDisabled), monthButton('right', nextDisabled)] }))] }), _jsxs("table", { class: "rf-calendar-grid", role: "grid", "aria-label": monthLabel.format(month), children: [_jsx("thead", { children: _jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "col", class: "rf-calendar-week", abbr: "Week number", children: _jsx("span", { class: "rf-basic-sr-only", children: "Week number" }) })), weekdays.map((weekday) => (_jsx("th", { scope: "col", abbr: weekday, children: weekday.slice(0, 2) }, weekday)))] }) }), _jsx("tbody", { children: weeks.map((week) => (_jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "row", class: "rf-calendar-week", children: getWeekNumber(week[0]) })), week.map((day) => {
323
334
  const dayIso = iso(day);
324
335
  const outside = !dayIso.startsWith(monthIso);
325
336
  if (!dayIso || (months.length > 1 && outside))
package/dist/flows.css CHANGED
@@ -141,7 +141,10 @@
141
141
  rows continue below and goes at the end, which is why the footer draws no
142
142
  line of its own. Without a footer the list is the panel's last child and
143
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. */
144
+ scroll root because the viewport's fade mask would erase them.
145
+ This follows the reference's intent, not its pixels: there the stronger
146
+ line sits on the root's border, which the root's overflow clip hides, so
147
+ its header line never strengthens. Keep the strengthening. */
145
148
  .rf-command-scroll::before,
146
149
  .rf-command-scroll::after {
147
150
  content: '';
package/dist/groups.css CHANGED
@@ -243,6 +243,40 @@
243
243
  .rf-choice-group-list[data-orientation='horizontal'] .rf-choice-group-row {
244
244
  border-radius: var(--rf-radius);
245
245
  }
246
+ /* Once hydrated, a vertical group draws its checked fill as moving blocks, one
247
+ per run of checked rows, under the hover fill; the rows draw none. */
248
+ .rf-choice-runs {
249
+ position: absolute;
250
+ z-index: -1;
251
+ inset: 0;
252
+ pointer-events: none;
253
+ }
254
+ .rf-choice-run {
255
+ position: absolute;
256
+ background: var(--rf-active);
257
+ transition:
258
+ top var(--rf-spring-moderate),
259
+ left var(--rf-spring-moderate),
260
+ width var(--rf-spring-moderate),
261
+ height var(--rf-spring-moderate),
262
+ border-radius var(--rf-spring-moderate) var(--rf-corner-delay, 0ms),
263
+ opacity 80ms ease;
264
+ }
265
+ .rf-choice-run[data-instant] {
266
+ transition: none;
267
+ }
268
+ .rf-choice-run[data-leaving] {
269
+ opacity: 0;
270
+ transition: opacity var(--rf-exit-moderate);
271
+ }
272
+ .rf-choice-group-list[data-rf-selection-blocks] > .rf-choice-group-row:has(input:checked) {
273
+ background: transparent;
274
+ }
275
+ @media (prefers-reduced-motion: reduce) {
276
+ .rf-choice-run:not([data-leaving]) {
277
+ transition: opacity 80ms ease;
278
+ }
279
+ }
246
280
  .rf-choice-group-row[aria-disabled='true'] {
247
281
  opacity: 0.45;
248
282
  }
package/dist/groups.js CHANGED
@@ -4,6 +4,7 @@ import * as accordion from '@remix-run/ui/accordion';
4
4
  import { on } from 'remix/component';
5
5
  import { Checkbox } from './basic.js';
6
6
  import { fluidHover } from './interactive.js';
7
+ import { selectionRuns } from './selection-runs.js';
7
8
  export function Accordion(handle) {
8
9
  return () => {
9
10
  const { items, variant = 'grouped', highlight = 'item', headingLevel = 3, children: _children, ...props } = handle.props;
@@ -12,9 +13,16 @@ export function Accordion(handle) {
12
13
  };
13
14
  }
14
15
  export function CheckboxGroup(handle) {
16
+ // The checked fill moves with the selection, as in the reference. A
17
+ // controlled value changes without a change event, so each render resyncs.
18
+ const runs = selectionRuns();
15
19
  return () => {
16
20
  const { options, label, hideLabel, name, disabled, value, defaultValue, onValueChange, orientation = 'vertical', } = handle.props;
21
+ const vertical = orientation !== 'horizontal';
22
+ if (vertical)
23
+ handle.queueTask(runs.sync);
17
24
  return (_jsxs("fieldset", { class: "rf-choice-group", disabled: disabled, children: [_jsx("legend", { class: hideLabel ? 'rf-basic-sr-only' : undefined, children: label }), _jsxs("div", { class: "rf-choice-group-list", "data-orientation": orientation, mix: [
25
+ vertical && runs.mix,
18
26
  fluidHover({
19
27
  axis: orientation === 'horizontal' ? 'x' : 'y',
20
28
  focusSelector: ':has(input:focus-visible)',
@@ -43,7 +51,7 @@ export function CheckboxGroup(handle) {
43
51
  const selected = event.currentTarget.querySelectorAll('input[type="checkbox"]:checked');
44
52
  onValueChange(Array.from(selected, (input) => input.value));
45
53
  }),
46
- ], children: [_jsx("div", { class: "rf-highlight", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("div", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), options.map((option) => (_jsx("div", { class: "rf-choice-group-row", "data-rf-hover-item": true, "aria-disabled": disabled || option.disabled || undefined, children: _jsx(Checkbox, { name: name, value: option.value, disabled: option.disabled, checked: value?.includes(option.value), defaultChecked: value === undefined ? defaultValue?.includes(option.value) : undefined, label: _jsxs(_Fragment, { children: [option.label, option.description && _jsx("small", { children: option.description })] }) }) }, option.value)))] })] }));
54
+ ], children: [vertical && _jsx("div", { class: "rf-choice-runs", "aria-hidden": "true", "data-rf-selection-runs": true }), _jsx("div", { class: "rf-highlight", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("div", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), options.map((option) => (_jsx("div", { class: "rf-choice-group-row", "data-rf-hover-item": true, "aria-disabled": disabled || option.disabled || undefined, children: _jsx(Checkbox, { name: name, value: option.value, disabled: option.disabled, checked: value?.includes(option.value), defaultChecked: value === undefined ? defaultValue?.includes(option.value) : undefined, label: _jsxs(_Fragment, { children: [option.label, option.description && _jsx("small", { children: option.description })] }) }) }, option.value)))] })] }));
47
55
  };
48
56
  }
49
57
  export function RadioGroup(handle) {
@@ -281,6 +281,19 @@ export declare const defaultIcons: {
281
281
  }], readonly ['path', {
282
282
  readonly d: 'M12 5v14';
283
283
  }]];
284
+ readonly pause: readonly [readonly ['rect', {
285
+ readonly x: '14';
286
+ readonly y: '3';
287
+ readonly width: '5';
288
+ readonly height: '18';
289
+ readonly rx: '1';
290
+ }], readonly ['rect', {
291
+ readonly x: '5';
292
+ readonly y: '3';
293
+ readonly width: '5';
294
+ readonly height: '18';
295
+ readonly rx: '1';
296
+ }]];
284
297
  readonly 'rotate-ccw': readonly [readonly ['path', {
285
298
  readonly d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8';
286
299
  }], readonly ['path', {
package/dist/icon-data.js CHANGED
@@ -246,6 +246,10 @@ export const defaultIcons = {
246
246
  ['path', { d: 'M5 12h14' }],
247
247
  ['path', { d: 'M12 5v14' }],
248
248
  ],
249
+ pause: [
250
+ ['rect', { x: '14', y: '3', width: '5', height: '18', rx: '1' }],
251
+ ['rect', { x: '5', y: '3', width: '5', height: '18', rx: '1' }],
252
+ ],
249
253
  'rotate-ccw': [
250
254
  ['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
251
255
  ['path', { d: 'M3 3v5h5' }],
@@ -1,4 +1,4 @@
1
- import type { Handle, RemixNode } from 'remix/component';
1
+ import type { Handle, MixInput, RemixNode } from 'remix/component';
2
2
  export interface FluidHoverOptions {
3
3
  /** Track the pointer on one axis or across a two-dimensional grid. */
4
4
  axis?: 'x' | 'y' | 'both';
@@ -62,6 +62,8 @@ export interface DropdownProps {
62
62
  /** `bare` renders an unstyled button trigger that takes its look from `triggerClass`, for sidebar rows and actions. */
63
63
  variant?: 'outline' | 'ghost' | 'bare';
64
64
  triggerClass?: string;
65
+ /** Extra behavior on the trigger, such as a Tooltip's trigger props. */
66
+ triggerMix?: MixInput;
65
67
  /** Which side of the trigger the menu opens on. Default bottom. */
66
68
  side?: 'top' | 'bottom';
67
69
  searchable?: boolean;
@@ -376,12 +376,14 @@ export function Dropdown(handle) {
376
376
  offset: 6,
377
377
  }),
378
378
  hoverMix,
379
+ handle.props.triggerMix,
379
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: [
380
381
  menu.trigger({
381
382
  placement: `${handle.props.side ?? 'bottom'}-${align}`,
382
383
  offset: 6,
383
384
  }),
384
385
  hoverMix,
386
+ handle.props.triggerMix,
385
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
386
388
  ? { query, setQuery, placeholder: searchPlaceholder }
387
389
  : undefined })] }) }));
@@ -517,7 +519,7 @@ function DropdownSurface(handle) {
517
519
  ? search.query.slice(0, -1)
518
520
  : search.query + event.key);
519
521
  }, true),
520
- ], 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({
521
523
  value: item.value,
522
524
  label: item.label,
523
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtzll/reflow",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",