@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 +1 -1
- package/dist/basic.js +1 -1
- package/dist/breadcrumb.css +10 -0
- package/dist/breadcrumb.js +67 -38
- package/dist/color-picker.css +16 -0
- package/dist/color-picker.js +83 -9
- package/dist/compositions.css +27 -7
- package/dist/compositions.js +67 -2
- package/dist/dates.css +108 -3
- package/dist/dates.js +16 -5
- package/dist/flows.css +4 -1
- package/dist/foundations.css +21 -14
- package/dist/groups.css +34 -0
- package/dist/groups.js +9 -1
- package/dist/hover-card.d.ts +32 -0
- package/dist/hover-card.js +119 -0
- package/dist/icon-data.d.ts +13 -0
- package/dist/icon-data.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/interactive.d.ts +3 -1
- package/dist/interactive.js +3 -1
- package/dist/overlays.js +14 -2
- package/dist/popover.css +69 -0
- package/dist/popover.d.ts +38 -0
- package/dist/popover.js +57 -0
- package/dist/segmented-control.css +172 -0
- package/dist/segmented-control.d.ts +34 -0
- package/dist/segmented-control.js +38 -0
- package/dist/selection-runs.d.ts +4 -0
- package/dist/selection-runs.js +221 -0
- package/dist/sidebar.css +8 -0
- package/dist/styles.css +2 -0
- package/dist/systems.js +5 -1
- package/package.json +1 -1
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
|
-
- [
|
|
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
|
|
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',
|
package/dist/breadcrumb.css
CHANGED
|
@@ -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 {
|
package/dist/breadcrumb.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
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',
|
|
130
|
-
on('focusin',
|
|
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
|
}
|
package/dist/color-picker.css
CHANGED
|
@@ -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
|
}
|
package/dist/color-picker.js
CHANGED
|
@@ -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
|
-
|
|
504
|
+
const scrub = scrubbing;
|
|
505
|
+
if (!scrub || scrub.id !== event.pointerId)
|
|
447
506
|
return;
|
|
448
|
-
const
|
|
449
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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(
|
|
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: [
|
package/dist/compositions.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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-
|
|
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-
|
|
1085
|
+
.rf-thinking-step-body,
|
|
1066
1086
|
.rf-thinking-step-source,
|
|
1067
1087
|
.rf-thinking-step-image {
|
|
1068
1088
|
animation: none;
|
package/dist/compositions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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', ")"] })] })),
|