@rtzll/reflow 0.2.0 → 0.4.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
 
package/dist/basic.js CHANGED
@@ -13,7 +13,7 @@ export function Button(handle) {
13
13
  const iconOnly = props.size === 'icon' || props.size === 'icon-compact';
14
14
  const leading = iconOnly ? undefined : props.leadingIcon;
15
15
  const trailing = iconOnly ? undefined : props.trailingIcon;
16
- const content = (_jsxs(_Fragment, { children: [_jsxs("span", { class: "rf-button-content", children: [leading && (_jsx("span", { class: "rf-button-icon", "aria-hidden": "true", children: leading })), _jsx("span", { class: "rf-button-label", children: props.children }), trailing && (_jsx("span", { class: "rf-button-icon", "aria-hidden": "true", children: trailing }))] }), props.loading && (_jsx("svg", { class: "rf-button-spinner", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M12 12C14 8.5 19 8.5 19 12C19 15.5 14 15.5 12 12C10 8.5 5 8.5 5 12C5 15.5 10 15.5 12 12Z", stroke: "currentColor", "stroke-width": "1.125", "stroke-linecap": "round", pathLength: "100" }) }))] }));
16
+ const content = (_jsxs(_Fragment, { children: [_jsxs("span", { class: "rf-button-content", children: [leading && (_jsx("span", { class: "rf-button-icon", "aria-hidden": "true", children: leading })), _jsx("span", { class: "rf-button-label", children: props.children }), trailing && (_jsx("span", { class: "rf-button-icon", "aria-hidden": "true", children: trailing }))] }), props.loading && (_jsx("svg", { class: "rf-button-spinner", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("g", { children: _jsx("circle", { cx: "8", cy: "8", r: "6.25", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", pathLength: "100", transform: "rotate(-90 8 8)" }) }) }))] }));
17
17
  const appearance = {
18
18
  class: classNames,
19
19
  'data-slot': 'button',
@@ -33,6 +33,16 @@
33
33
  font-size: var(--rf-text-body);
34
34
  line-height: var(--rf-leading-body);
35
35
  }
36
+ .rf-breadcrumb-measure {
37
+ position: absolute;
38
+ inline-size: 0;
39
+ block-size: 0;
40
+ overflow: clip;
41
+ visibility: hidden;
42
+ }
43
+ .rf-breadcrumb-measure > .rf-breadcrumb-list {
44
+ inline-size: max-content;
45
+ }
36
46
 
37
47
  /* Steps keep their width; only the current page gives way and truncates. */
38
48
  .rf-breadcrumb-item {
@@ -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
@@ -10,10 +11,12 @@ const fallbackEllipsisWidth = 44;
10
11
  */
11
12
  export function Breadcrumb(handle) {
12
13
  let list = null;
13
- let hidden = 0;
14
+ let hidden;
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 arrives.
19
+ let cut = new Set();
17
20
  const listRef = ref((node, signal) => {
18
21
  if (!(node instanceof HTMLOListElement))
19
22
  return;
@@ -39,34 +42,39 @@ export function Breadcrumb(handle) {
39
42
  }
40
43
  function measure() {
41
44
  const nav = list?.parentElement;
42
- if (!list || !nav)
45
+ if (!list || !nav || !handle.props.collapse)
43
46
  return;
44
47
  const count = handle.props.items.length;
48
+ const maxHidden = Math.max(0, count - 3);
45
49
  const items = Array.from(list.children);
46
50
  const steps = items.filter((item) => !item.hasAttribute('data-ellipsis'));
47
51
  const ellipsis = items.find((item) => item.hasAttribute('data-ellipsis'));
48
52
  if (ellipsis)
49
53
  ellipsisWidth = naturalWidth(ellipsis);
50
- // The rendered steps are 0, then hidden + 1 onward.
51
- steps.forEach((item, i) => {
52
- const index = hidden > 0 && i > 0 ? i + hidden : i;
53
- if (index < count)
54
- widths[index] = naturalWidth(item);
55
- });
54
+ const measuring = nav.querySelector('.rf-breadcrumb-measure > ol');
55
+ if (measuring) {
56
+ widths = Array.from(measuring.children, (item) => item.getBoundingClientRect().width);
57
+ }
58
+ else {
59
+ // The rendered steps are 0, then hidden + 1 onward.
60
+ const hiddenCount = Math.min(hidden ?? 0, maxHidden);
61
+ steps.forEach((item, i) => {
62
+ const index = i === 0 ? i : i + hiddenCount;
63
+ if (index < count)
64
+ widths[index] = naturalWidth(item);
65
+ });
66
+ }
56
67
  let next = 0;
57
- if (handle.props.collapse) {
58
- const available = nav.clientWidth;
59
- const width = (i) => widths[i] ?? 0;
60
- let total = 0;
61
- for (let i = 0; i < count; i++)
62
- total += width(i);
63
- const maxHidden = Math.max(0, count - 3);
64
- if (total > available + 0.5) {
65
- let shown = total + (ellipsisWidth || fallbackEllipsisWidth);
66
- while (next < maxHidden && shown > available + 0.5) {
67
- shown -= width(1 + next);
68
- next++;
69
- }
68
+ const available = nav.clientWidth;
69
+ const width = (i) => widths[i] ?? 0;
70
+ let total = 0;
71
+ for (let i = 0; i < count; i++)
72
+ total += width(i);
73
+ if (total > available + 0.5) {
74
+ let shown = total + (ellipsisWidth || fallbackEllipsisWidth);
75
+ while (next < maxHidden && shown > available + 0.5) {
76
+ shown -= width(1 + next);
77
+ next++;
70
78
  }
71
79
  }
72
80
  if (next !== hidden) {
@@ -74,37 +82,55 @@ export function Breadcrumb(handle) {
74
82
  void handle.update();
75
83
  }
76
84
  }
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;
85
+ // Whether a step's label is cut, read as the pointer or keyboard focus
86
+ // arrives: cheap, and current when its tooltip could show.
87
+ function checkCut(event) {
88
+ const crumb = event.target instanceof Element ? event.target.closest('[data-rf-step]') : null;
82
89
  const label = crumb?.querySelector('.rf-breadcrumb-label');
83
90
  if (!crumb || !label)
84
91
  return;
85
- if (label.scrollWidth > label.clientWidth + 0.5)
86
- crumb.title = label.textContent ?? '';
92
+ const index = Number(crumb.dataset.rfStep);
93
+ const isCut = label.scrollWidth > label.clientWidth + 0.5;
94
+ if (isCut === cut.has(index))
95
+ return;
96
+ cut = new Set(cut);
97
+ if (isCut)
98
+ cut.add(index);
87
99
  else
88
- crumb.removeAttribute('title');
100
+ cut.delete(index);
101
+ void handle.update();
89
102
  }
90
103
  return () => {
91
104
  const { items, label = 'Breadcrumb', size, separator, collapse, openOnHover } = handle.props;
92
105
  widths.length = items.length;
93
- const hide = collapse ? Math.min(hidden, Math.max(0, items.length - 3)) : 0;
106
+ const maxHidden = Math.max(0, items.length - 3);
107
+ if (!collapse)
108
+ hidden = undefined;
109
+ // Start with the shortest usable trail, then restore the steps that fit.
110
+ const hide = collapse ? Math.min(hidden ?? maxHidden, maxHidden) : 0;
94
111
  // Every render: a label change can change a step's width.
95
112
  if (collapse)
96
113
  handle.queueTask(() => measure());
97
114
  const trailing = separator === null ? null : (_jsx("span", { class: "rf-breadcrumb-separator", "aria-hidden": "true", children: separator ?? _jsx(Icon, { name: "chevron-right" }) }));
98
115
  const content = (item) => (_jsxs(_Fragment, { children: [item.icon, _jsx("span", { class: "rf-breadcrumb-label", children: item.label })] }));
116
+ // The full name of a cut step, in the library tooltip under it, as in
117
+ // the reference. The step is described by the tooltip only while its name is cut.
118
+ const withFullName = (item, index, render) => (_jsx(Tooltip, { content: item.label, side: "bottom", open: cut.has(index) ? undefined : false, renderTrigger: (props) => render(cut.has(index) ? props : { 'aria-describedby': '', mix: props.mix }) }));
119
+ const described = (trigger) => trigger['aria-describedby'] || undefined;
99
120
  const step = (item, index) => {
100
121
  const last = index === items.length - 1;
101
122
  if (last) {
102
- return (_jsx("span", { class: "rf-breadcrumb-crumb rf-breadcrumb-page", role: "link", "aria-disabled": "true", "aria-current": "page", children: content(item) }));
123
+ 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
124
  }
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] }) }));
125
+ const menu = item.menu;
126
+ if (menu) {
127
+ return withFullName(item, index, (trigger) => (_jsx(Dropdown, { label: item.label, variant: "bare", triggerClass: "rf-breadcrumb-crumb", triggerMix: [
128
+ trigger.mix,
129
+ attrs({ 'data-rf-step': String(index) }),
130
+ described(trigger) && attrs({ 'aria-describedby': described(trigger) }),
131
+ ], size: size, openOnHover: openOnHover, items: menu, onSelect: item.onMenuSelect, triggerContent: _jsxs(_Fragment, { children: [content(item), trailing] }) })));
106
132
  }
107
- return (_jsxs("a", { class: "rf-breadcrumb-crumb", href: item.href, children: [content(item), trailing] }));
133
+ 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
134
  };
109
135
  const hiddenItems = items.slice(1, 1 + hide);
110
136
  const visible = items
@@ -126,8 +152,11 @@ export function Breadcrumb(handle) {
126
152
  ]);
127
153
  return (_jsxs("nav", { class: "rf-breadcrumb", "aria-label": label, "data-density": size, "data-separator": separator === undefined ? 'chevron' : undefined, mix: [
128
154
  fluidHover({ axis: 'x', focusSelector: ':has(:focus-visible)' }),
129
- on('pointerover', showFullName),
130
- on('focusin', showFullName),
131
- ], children: [_jsx("span", { class: "rf-highlight rf-breadcrumb-hover", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("span", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), _jsx("ol", { class: "rf-breadcrumb-list", mix: listRef, children: rows })] }));
155
+ on('pointerover', checkCut),
156
+ on('focusin', checkCut),
157
+ ], 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 }), collapse && hidden === undefined && maxHidden > 0 && (_jsx("div", { class: "rf-breadcrumb-measure", "aria-hidden": "true", inert: true, children: _jsx("ol", { class: "rf-breadcrumb-list", children: items.map((item, index) => {
158
+ const last = index === items.length - 1;
159
+ return (_jsx("li", { class: "rf-breadcrumb-item", children: _jsxs("span", { class: last ? 'rf-breadcrumb-crumb rf-breadcrumb-page' : 'rf-breadcrumb-crumb', children: [content(item), !last && trailing] }) }, index));
160
+ }) }) }))] }));
132
161
  };
133
162
  }
@@ -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', ")"] })] })),