@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 +1 -1
- package/dist/breadcrumb.js +46 -15
- 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/groups.css +34 -0
- package/dist/groups.js +9 -1
- package/dist/icon-data.d.ts +13 -0
- package/dist/icon-data.js +4 -0
- package/dist/interactive.d.ts +3 -1
- package/dist/interactive.js +3 -1
- package/dist/selection-runs.d.ts +4 -0
- package/dist/selection-runs.js +221 -0
- package/dist/sidebar.css +8 -0
- 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/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
|
|
@@ -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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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',
|
|
130
|
-
on('focusin',
|
|
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
|
}
|
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', ")"] })] })),
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
322
|
-
|
|
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) {
|
package/dist/icon-data.d.ts
CHANGED
|
@@ -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' }],
|
package/dist/interactive.d.ts
CHANGED
|
@@ -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;
|
package/dist/interactive.js
CHANGED
|
@@ -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 }),
|
|
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,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;
|