@rtzll/reflow 0.6.0 → 0.7.1
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/dist/avatar.css +3 -0
- package/dist/carousel.css +67 -0
- package/dist/carousel.d.ts +36 -0
- package/dist/carousel.js +280 -0
- package/dist/chat-layout.css +3 -0
- package/dist/choices.css +11 -0
- package/dist/citation.css +4 -2
- package/dist/code-block.css +1 -1
- package/dist/compositions.css +6 -0
- package/dist/compositions.js +6 -0
- package/dist/dates.css +2 -0
- package/dist/hover-card.d.ts +2 -1
- package/dist/hover-card.js +14 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/queued-stack.js +50 -1
- package/dist/styles.css +1 -0
- package/package.json +1 -1
package/dist/avatar.css
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* Carousel: slides in a row on a native scroll container that snaps each
|
|
2
|
+
one into place. Set --rf-carousel-slide for the slides' width (100% shows
|
|
3
|
+
one at a time) and --rf-carousel-gap for the space between them. While a
|
|
4
|
+
mouse drags it, snapping waits until the slide it lands on is chosen. */
|
|
5
|
+
.rf-carousel {
|
|
6
|
+
--rf-carousel-slide: 100%;
|
|
7
|
+
--rf-carousel-gap: 12px;
|
|
8
|
+
display: grid;
|
|
9
|
+
gap: 12px;
|
|
10
|
+
min-inline-size: 0;
|
|
11
|
+
}
|
|
12
|
+
.rf-carousel-viewport {
|
|
13
|
+
position: relative;
|
|
14
|
+
display: grid;
|
|
15
|
+
grid-auto-columns: var(--rf-carousel-slide);
|
|
16
|
+
grid-auto-flow: column;
|
|
17
|
+
gap: var(--rf-carousel-gap);
|
|
18
|
+
overflow-x: auto;
|
|
19
|
+
overflow-y: hidden;
|
|
20
|
+
overscroll-behavior-x: contain;
|
|
21
|
+
scroll-snap-type: x mandatory;
|
|
22
|
+
scrollbar-width: none;
|
|
23
|
+
border-radius: var(--rf-panel-radius);
|
|
24
|
+
outline: none;
|
|
25
|
+
}
|
|
26
|
+
.rf-carousel-viewport::-webkit-scrollbar {
|
|
27
|
+
display: none;
|
|
28
|
+
}
|
|
29
|
+
.rf-carousel-viewport:focus-visible {
|
|
30
|
+
outline: 2px solid var(--rf-accent);
|
|
31
|
+
outline-offset: 2px;
|
|
32
|
+
}
|
|
33
|
+
@media (hover: hover) and (pointer: fine) {
|
|
34
|
+
.rf-carousel-viewport {
|
|
35
|
+
cursor: grab;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
.rf-carousel-viewport[data-dragging] {
|
|
39
|
+
cursor: grabbing;
|
|
40
|
+
scroll-snap-type: none;
|
|
41
|
+
user-select: none;
|
|
42
|
+
}
|
|
43
|
+
.rf-carousel-slide {
|
|
44
|
+
min-inline-size: 0;
|
|
45
|
+
scroll-snap-align: start;
|
|
46
|
+
scroll-snap-stop: normal;
|
|
47
|
+
}
|
|
48
|
+
.rf-carousel[data-align='center'] .rf-carousel-slide {
|
|
49
|
+
scroll-snap-align: center;
|
|
50
|
+
}
|
|
51
|
+
/* Dragging moves the slides, not their links or images. */
|
|
52
|
+
.rf-carousel-slide :is(a, img) {
|
|
53
|
+
-webkit-user-drag: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.rf-carousel-controls {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
justify-content: center;
|
|
60
|
+
gap: 4px;
|
|
61
|
+
}
|
|
62
|
+
.rf-carousel-controls .rf-carousel-dots {
|
|
63
|
+
margin-inline: 4px;
|
|
64
|
+
}
|
|
65
|
+
.rf-carousel-play {
|
|
66
|
+
margin-inline-start: 4px;
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Handle, type RemixNode } from 'remix/component';
|
|
2
|
+
export interface CarouselProps {
|
|
3
|
+
/** Names the carousel, such as "Featured projects". */
|
|
4
|
+
label: string;
|
|
5
|
+
/** The slides, one per child. */
|
|
6
|
+
children?: RemixNode;
|
|
7
|
+
/** Names each slide, for its announcement and its dot. */
|
|
8
|
+
slideLabels?: readonly string[];
|
|
9
|
+
/** The current slide, when controlled by the parent. */
|
|
10
|
+
index?: number;
|
|
11
|
+
defaultIndex?: number;
|
|
12
|
+
/** Called when a button, a dot, a drag, a scroll or autoplay changes the slide. */
|
|
13
|
+
onIndexChange?: (index: number) => void;
|
|
14
|
+
/** Shows CarouselDots under the slides. Default true. */
|
|
15
|
+
dots?: boolean;
|
|
16
|
+
/** Shows previous and next buttons. Default true. */
|
|
17
|
+
controls?: boolean;
|
|
18
|
+
/** Moves on after this many milliseconds, timed and shown by the dots, with a button to pause. */
|
|
19
|
+
autoplay?: {
|
|
20
|
+
duration: number;
|
|
21
|
+
};
|
|
22
|
+
/** Where a slide comes to rest. Default start. */
|
|
23
|
+
align?: 'start' | 'center';
|
|
24
|
+
size?: 'default' | 'compact';
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Slides in a row on a real scroll container: touch, trackpads, the
|
|
29
|
+
* keyboard and screen readers scroll it natively, and CSS scroll snap
|
|
30
|
+
* brings each slide to rest. Mice can drag it, with momentum. Previous and
|
|
31
|
+
* next buttons and CarouselDots follow the slide in view and move it;
|
|
32
|
+
* autoplay runs through the dots and pauses while the carousel is pointed
|
|
33
|
+
* at or focused, or with its pause button. It is a labelled carousel whose
|
|
34
|
+
* slides read as "3 of 7", and it touches nothing outside its own element.
|
|
35
|
+
*/
|
|
36
|
+
export declare function Carousel(handle: Handle<CarouselProps>): () => import("remix/component").RemixElement;
|
package/dist/carousel.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
|
|
2
|
+
import { on, ref } from 'remix/component';
|
|
3
|
+
import { prefersReducedMotion } from '@remix-run/ui/animation';
|
|
4
|
+
import { Button } from './basic.js';
|
|
5
|
+
import { CarouselDots } from './compositions.js';
|
|
6
|
+
import { Icon } from './icon.js';
|
|
7
|
+
// A mouse drag starts past this distance, so a click stays a click.
|
|
8
|
+
const dragThreshold = 6;
|
|
9
|
+
// How far a release keeps travelling, in milliseconds of its speed.
|
|
10
|
+
const throwTime = 220;
|
|
11
|
+
function slidesOf(children) {
|
|
12
|
+
const list = Array.isArray(children) ? children.flat(Infinity) : [children];
|
|
13
|
+
return list.filter((child) => child != null && child !== false && child !== '');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Slides in a row on a real scroll container: touch, trackpads, the
|
|
17
|
+
* keyboard and screen readers scroll it natively, and CSS scroll snap
|
|
18
|
+
* brings each slide to rest. Mice can drag it, with momentum. Previous and
|
|
19
|
+
* next buttons and CarouselDots follow the slide in view and move it;
|
|
20
|
+
* autoplay runs through the dots and pauses while the carousel is pointed
|
|
21
|
+
* at or focused, or with its pause button. It is a labelled carousel whose
|
|
22
|
+
* slides read as "3 of 7", and it touches nothing outside its own element.
|
|
23
|
+
*/
|
|
24
|
+
export function Carousel(handle) {
|
|
25
|
+
let viewport = null;
|
|
26
|
+
let localIndex = handle.props.defaultIndex ?? 0;
|
|
27
|
+
let shownIndex = -1;
|
|
28
|
+
let hovered = false;
|
|
29
|
+
let focused = false;
|
|
30
|
+
let userPaused = false;
|
|
31
|
+
let drag;
|
|
32
|
+
let suppressClick = false;
|
|
33
|
+
// While a scroll we started is in flight, its scroll events do not move the index.
|
|
34
|
+
let steering;
|
|
35
|
+
let steerTimer;
|
|
36
|
+
let frame = 0;
|
|
37
|
+
// How many slides can come to rest; with several in view, the last few
|
|
38
|
+
// share the end. Zero until measured, which counts every slide.
|
|
39
|
+
let reachable = 0;
|
|
40
|
+
handle.signal.addEventListener('abort', () => {
|
|
41
|
+
clearTimeout(steerTimer);
|
|
42
|
+
cancelAnimationFrame(frame);
|
|
43
|
+
}, { once: true });
|
|
44
|
+
const count = () => slidesOf(handle.props.children).length;
|
|
45
|
+
const stops = () => Math.min(count(), reachable || count());
|
|
46
|
+
const current = () => Math.min(Math.max(0, handle.props.index ?? localIndex), Math.max(0, stops() - 1));
|
|
47
|
+
function slideAt(index) {
|
|
48
|
+
return viewport?.querySelectorAll(':scope > .rf-carousel-slide')[index];
|
|
49
|
+
}
|
|
50
|
+
// Where a slide comes to rest, within the range the viewport can scroll.
|
|
51
|
+
// Measured from the slide's box against the viewport's, so it holds in
|
|
52
|
+
// either direction: right to left, scrollLeft runs from 0 down to minus
|
|
53
|
+
// the scroll width.
|
|
54
|
+
function restingLeft(slide) {
|
|
55
|
+
if (!viewport)
|
|
56
|
+
return 0;
|
|
57
|
+
const style = getComputedStyle(viewport);
|
|
58
|
+
const rtl = style.direction === 'rtl';
|
|
59
|
+
const padding = parseFloat(style.scrollPaddingInlineStart) || 0;
|
|
60
|
+
const frame = viewport.getBoundingClientRect();
|
|
61
|
+
const box = slide.getBoundingClientRect();
|
|
62
|
+
const left = handle.props.align === 'center'
|
|
63
|
+
? viewport.scrollLeft + (box.left + box.width / 2) - (frame.left + frame.width / 2)
|
|
64
|
+
: rtl
|
|
65
|
+
? viewport.scrollLeft + (box.right - frame.right) + padding
|
|
66
|
+
: viewport.scrollLeft + (box.left - frame.left) - padding;
|
|
67
|
+
const max = viewport.scrollWidth - viewport.clientWidth;
|
|
68
|
+
return rtl ? Math.min(0, Math.max(-max, left)) : Math.min(max, Math.max(0, left));
|
|
69
|
+
}
|
|
70
|
+
function measure() {
|
|
71
|
+
if (!viewport)
|
|
72
|
+
return;
|
|
73
|
+
const slides = Array.from(viewport.querySelectorAll(':scope > .rf-carousel-slide'));
|
|
74
|
+
// Not laid out in a row, such as without the stylesheet: every slide counts.
|
|
75
|
+
if (slides.some((slide) => slide.offsetTop !== slides[0].offsetTop)) {
|
|
76
|
+
if (reachable !== 0) {
|
|
77
|
+
reachable = 0;
|
|
78
|
+
void handle.update();
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const end = viewport.scrollWidth - viewport.clientWidth;
|
|
83
|
+
const last = slides.findIndex((slide) => Math.abs(restingLeft(slide)) >= end - 1);
|
|
84
|
+
const next = last === -1 ? slides.length : last + 1;
|
|
85
|
+
if (next === reachable)
|
|
86
|
+
return;
|
|
87
|
+
reachable = next;
|
|
88
|
+
void handle.update();
|
|
89
|
+
}
|
|
90
|
+
function scrollToSlide(index, smooth = true) {
|
|
91
|
+
const slide = slideAt(index);
|
|
92
|
+
if (!viewport || !slide)
|
|
93
|
+
return;
|
|
94
|
+
steering = index;
|
|
95
|
+
clearTimeout(steerTimer);
|
|
96
|
+
steerTimer = setTimeout(() => (steering = undefined), 900);
|
|
97
|
+
viewport.scrollTo({
|
|
98
|
+
left: restingLeft(slide),
|
|
99
|
+
behavior: smooth && !prefersReducedMotion() ? 'smooth' : 'instant',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function select(index, scroll = true) {
|
|
103
|
+
const next = Math.min(Math.max(0, index), Math.max(0, stops() - 1));
|
|
104
|
+
if (scroll)
|
|
105
|
+
scrollToSlide(next);
|
|
106
|
+
if (next === current())
|
|
107
|
+
return;
|
|
108
|
+
localIndex = next;
|
|
109
|
+
handle.props.onIndexChange?.(next);
|
|
110
|
+
void handle.update();
|
|
111
|
+
}
|
|
112
|
+
// The slide whose resting place is nearest a scroll position.
|
|
113
|
+
function nearest(left) {
|
|
114
|
+
if (!viewport)
|
|
115
|
+
return 0;
|
|
116
|
+
let best = 0;
|
|
117
|
+
let distance = Infinity;
|
|
118
|
+
const slides = viewport.querySelectorAll(':scope > .rf-carousel-slide');
|
|
119
|
+
slides.forEach((slide, index) => {
|
|
120
|
+
const gap = Math.abs(restingLeft(slide) - left);
|
|
121
|
+
if (gap < distance) {
|
|
122
|
+
distance = gap;
|
|
123
|
+
best = index;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return best;
|
|
127
|
+
}
|
|
128
|
+
function onScroll() {
|
|
129
|
+
if (frame)
|
|
130
|
+
return;
|
|
131
|
+
frame = requestAnimationFrame(() => {
|
|
132
|
+
frame = 0;
|
|
133
|
+
if (!viewport || drag?.started)
|
|
134
|
+
return;
|
|
135
|
+
if (steering !== undefined)
|
|
136
|
+
return;
|
|
137
|
+
const index = nearest(viewport.scrollLeft);
|
|
138
|
+
if (index !== current())
|
|
139
|
+
select(index, false);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function endDrag(event) {
|
|
143
|
+
if (!drag || drag.pointer !== event.pointerId || !viewport)
|
|
144
|
+
return;
|
|
145
|
+
const finished = drag;
|
|
146
|
+
drag = undefined;
|
|
147
|
+
if (!finished.started)
|
|
148
|
+
return;
|
|
149
|
+
viewport.removeAttribute('data-dragging');
|
|
150
|
+
suppressClick = true;
|
|
151
|
+
const recent = finished.samples.filter((sample) => sample.time > performance.now() - 100);
|
|
152
|
+
const first = recent[0];
|
|
153
|
+
const last = recent[recent.length - 1];
|
|
154
|
+
const velocity = first && last && last.time > first.time ? (last.x - first.x) / (last.time - first.time) : 0;
|
|
155
|
+
// A throw travels on, but at most a view's worth of slides from where the
|
|
156
|
+
// drag began: one at a time when a slide fills the view.
|
|
157
|
+
const sample = slideAt(0);
|
|
158
|
+
const perView = sample
|
|
159
|
+
? Math.max(1, Math.round(viewport.clientWidth / Math.max(1, sample.offsetWidth)))
|
|
160
|
+
: 1;
|
|
161
|
+
const landed = nearest(viewport.scrollLeft - velocity * throwTime);
|
|
162
|
+
select(Math.min(finished.from + perView, Math.max(finished.from - perView, landed)));
|
|
163
|
+
}
|
|
164
|
+
const viewportMix = ref((node, signal) => {
|
|
165
|
+
if (!(node instanceof HTMLElement))
|
|
166
|
+
return;
|
|
167
|
+
viewport = node;
|
|
168
|
+
const options = { signal };
|
|
169
|
+
node.addEventListener('scroll', onScroll, { signal, passive: true });
|
|
170
|
+
const resize = new ResizeObserver(() => measure());
|
|
171
|
+
resize.observe(node);
|
|
172
|
+
signal.addEventListener('abort', () => resize.disconnect(), { once: true });
|
|
173
|
+
measure();
|
|
174
|
+
node.addEventListener('scrollend', () => {
|
|
175
|
+
steering = undefined;
|
|
176
|
+
clearTimeout(steerTimer);
|
|
177
|
+
onScroll();
|
|
178
|
+
}, options);
|
|
179
|
+
node.addEventListener('pointerdown', (event) => {
|
|
180
|
+
if (event.pointerType !== 'mouse' || event.button !== 0)
|
|
181
|
+
return;
|
|
182
|
+
drag = {
|
|
183
|
+
pointer: event.pointerId,
|
|
184
|
+
x: event.clientX,
|
|
185
|
+
left: node.scrollLeft,
|
|
186
|
+
from: nearest(node.scrollLeft),
|
|
187
|
+
started: false,
|
|
188
|
+
samples: [{ time: performance.now(), x: event.clientX }],
|
|
189
|
+
};
|
|
190
|
+
}, options);
|
|
191
|
+
node.addEventListener('pointermove', (event) => {
|
|
192
|
+
if (!drag || drag.pointer !== event.pointerId)
|
|
193
|
+
return;
|
|
194
|
+
const dx = event.clientX - drag.x;
|
|
195
|
+
if (!drag.started) {
|
|
196
|
+
if (Math.abs(dx) < dragThreshold)
|
|
197
|
+
return;
|
|
198
|
+
drag.started = true;
|
|
199
|
+
node.setAttribute('data-dragging', '');
|
|
200
|
+
window.getSelection()?.removeAllRanges();
|
|
201
|
+
try {
|
|
202
|
+
node.setPointerCapture(event.pointerId);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Synthetic pointers have nothing to capture.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
node.scrollLeft = drag.left - dx;
|
|
209
|
+
drag.samples.push({ time: performance.now(), x: event.clientX });
|
|
210
|
+
if (drag.samples.length > 8)
|
|
211
|
+
drag.samples.shift();
|
|
212
|
+
}, options);
|
|
213
|
+
// Links and images are draggable by default; a native drag would cancel
|
|
214
|
+
// the pointer and take over the slides' drag, so the carousel keeps it.
|
|
215
|
+
node.addEventListener('dragstart', (event) => event.preventDefault(), options);
|
|
216
|
+
node.addEventListener('pointerup', endDrag, options);
|
|
217
|
+
node.addEventListener('pointercancel', endDrag, options);
|
|
218
|
+
// A drag that ends over a link does not follow it.
|
|
219
|
+
node.addEventListener('click', (event) => {
|
|
220
|
+
if (!suppressClick)
|
|
221
|
+
return;
|
|
222
|
+
suppressClick = false;
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
event.stopPropagation();
|
|
225
|
+
}, { capture: true, signal });
|
|
226
|
+
node.addEventListener('pointerdown', () => (suppressClick = false), { signal, capture: true });
|
|
227
|
+
signal.addEventListener('abort', () => {
|
|
228
|
+
if (viewport === node)
|
|
229
|
+
viewport = null;
|
|
230
|
+
}, { once: true });
|
|
231
|
+
});
|
|
232
|
+
return () => {
|
|
233
|
+
const { label, children, slideLabels, size, autoplay, align = 'start' } = handle.props;
|
|
234
|
+
const slides = slidesOf(children);
|
|
235
|
+
const total = slides.length;
|
|
236
|
+
const positions = stops();
|
|
237
|
+
const index = current();
|
|
238
|
+
// A controlled index set from outside brings its slide into view.
|
|
239
|
+
if (shownIndex !== -1 && shownIndex !== index && steering !== index && !drag?.started)
|
|
240
|
+
handle.queueTask(() => scrollToSlide(index));
|
|
241
|
+
if (shownIndex === -1 && index > 0)
|
|
242
|
+
handle.queueTask(() => scrollToSlide(index, false));
|
|
243
|
+
shownIndex = index;
|
|
244
|
+
const paused = userPaused || hovered || focused;
|
|
245
|
+
const rotating = Boolean(autoplay) && !paused;
|
|
246
|
+
const showDots = handle.props.dots ?? true;
|
|
247
|
+
const showControls = handle.props.controls ?? true;
|
|
248
|
+
const names = slideLabels ?? [];
|
|
249
|
+
return (_jsxs("section", { class: handle.props.class ? `rf-carousel ${handle.props.class}` : 'rf-carousel', "aria-roledescription": "carousel", "aria-label": label, "data-density": size, "data-align": align, mix: [
|
|
250
|
+
on('pointerenter', (event) => {
|
|
251
|
+
if (event.pointerType === 'touch')
|
|
252
|
+
return;
|
|
253
|
+
hovered = true;
|
|
254
|
+
void handle.update();
|
|
255
|
+
}),
|
|
256
|
+
on('pointerleave', () => {
|
|
257
|
+
hovered = false;
|
|
258
|
+
void handle.update();
|
|
259
|
+
}),
|
|
260
|
+
on('focusin', () => {
|
|
261
|
+
if (focused)
|
|
262
|
+
return;
|
|
263
|
+
focused = true;
|
|
264
|
+
void handle.update();
|
|
265
|
+
}),
|
|
266
|
+
on('focusout', (event) => {
|
|
267
|
+
const next = event.relatedTarget;
|
|
268
|
+
if (next instanceof Node && event.currentTarget.contains(next))
|
|
269
|
+
return;
|
|
270
|
+
focused = false;
|
|
271
|
+
void handle.update();
|
|
272
|
+
}),
|
|
273
|
+
], children: [_jsx("div", { class: "rf-carousel-viewport", tabIndex: 0, "aria-live": rotating ? 'off' : 'polite', mix: viewportMix, children: slides.map((slide, position) => (_jsx("div", { class: "rf-carousel-slide", role: "group", "aria-roledescription": "slide", "aria-label": names[position]
|
|
274
|
+
? `${names[position]}, ${position + 1} of ${total}`
|
|
275
|
+
: `${position + 1} of ${total}`, "data-current": position === index || undefined, children: slide }, position))) }), (showControls || showDots || autoplay) && positions > 1 && (_jsxs("div", { class: "rf-carousel-controls", children: [showControls && (_jsx(Button, { variant: "ghost", size: "icon-compact", "aria-label": "Previous slide", disabled: index === 0, mix: on('click', () => select(index - 1)), children: _jsx(Icon, { name: "chevron-left" }) })), showDots && (_jsx(CarouselDots, { count: positions, current: index, label: `Choose a slide in ${label}`, labels: Array.from({ length: positions }, (_, position) => names[position] ? `Show ${names[position]}` : `Show slide ${position + 1}`), autoplay: autoplay ? { duration: autoplay.duration, paused } : undefined, onChange: (next) => select(next) })), showControls && (_jsx(Button, { variant: "ghost", size: "icon-compact", "aria-label": "Next slide", disabled: index === positions - 1, mix: on('click', () => select(index + 1)), children: _jsx(Icon, { name: "chevron-right" }) })), autoplay && (_jsx(Button, { variant: "ghost", size: "icon-compact", class: "rf-carousel-play", "aria-label": userPaused ? 'Play slides' : 'Pause slides', mix: on('click', () => {
|
|
276
|
+
userPaused = !userPaused;
|
|
277
|
+
void handle.update();
|
|
278
|
+
}), children: _jsx(Icon, { name: userPaused ? 'play' : 'pause' }) }))] }))] }));
|
|
279
|
+
};
|
|
280
|
+
}
|
package/dist/chat-layout.css
CHANGED
package/dist/choices.css
CHANGED
|
@@ -414,6 +414,17 @@
|
|
|
414
414
|
color: var(--rf-muted);
|
|
415
415
|
cursor: pointer;
|
|
416
416
|
}
|
|
417
|
+
/* On touch, the remove button answers a little beyond its drawn square. */
|
|
418
|
+
@media (pointer: coarse) {
|
|
419
|
+
.rf-picker-chip > button {
|
|
420
|
+
position: relative;
|
|
421
|
+
}
|
|
422
|
+
.rf-picker-chip > button::before {
|
|
423
|
+
content: '';
|
|
424
|
+
position: absolute;
|
|
425
|
+
inset: -4px;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
417
428
|
.rf-picker-chip > button:hover {
|
|
418
429
|
background: var(--rf-active);
|
|
419
430
|
color: var(--rf-fg);
|
package/dist/citation.css
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
block-size: 18px;
|
|
11
11
|
margin-inline: 2px;
|
|
12
12
|
padding-inline: 5px;
|
|
13
|
-
|
|
13
|
+
/* Three quarters of the item radius: 6px rounded, a pill at pill, square at sharp. */
|
|
14
|
+
border-radius: calc(var(--rf-item-radius) * 0.75);
|
|
14
15
|
outline: none;
|
|
15
16
|
background: var(--rf-neutral-fill);
|
|
16
17
|
color: var(--rf-muted);
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
.rf-citation-sources {
|
|
47
48
|
display: grid;
|
|
48
49
|
gap: 12px;
|
|
49
|
-
|
|
50
|
+
/* The preview's widest content box: 320px less its 12px padding. */
|
|
51
|
+
inline-size: min(296px, calc(100vw - 40px));
|
|
50
52
|
margin: 0;
|
|
51
53
|
padding: 0;
|
|
52
54
|
list-style: none;
|
package/dist/code-block.css
CHANGED
|
@@ -220,7 +220,7 @@
|
|
|
220
220
|
opacity: 0.75;
|
|
221
221
|
}
|
|
222
222
|
.rf-code .highlighted-word {
|
|
223
|
-
border-radius:
|
|
223
|
+
border-radius: calc(var(--rf-radius) / 2);
|
|
224
224
|
background: color-mix(in oklab, var(--rf-accent) 16%, transparent);
|
|
225
225
|
box-shadow: 0 0 0 1px color-mix(in oklab, var(--rf-accent) 30%, transparent);
|
|
226
226
|
}
|
package/dist/compositions.css
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
background: transparent;
|
|
19
19
|
cursor: pointer;
|
|
20
20
|
}
|
|
21
|
+
/* On touch, each dot's button reaches the 24px minimum target; the dots stay small. */
|
|
22
|
+
@media (pointer: coarse) {
|
|
23
|
+
.rf-carousel-dots button {
|
|
24
|
+
padding: 9px;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
21
27
|
.rf-carousel-dots button:focus-visible {
|
|
22
28
|
box-shadow: 0 0 0 1px var(--rf-accent);
|
|
23
29
|
}
|
package/dist/compositions.js
CHANGED
|
@@ -183,10 +183,16 @@ export function InputMessage(handle) {
|
|
|
183
183
|
changeFiles([...files, ...additions.slice(0, Math.max(0, remaining))]);
|
|
184
184
|
}
|
|
185
185
|
function changeQueue(next) {
|
|
186
|
+
// When the last queued message leaves with focus inside the queue, the
|
|
187
|
+
// message box takes focus, so the keyboard does not fall back to the page.
|
|
188
|
+
const active = document.activeElement;
|
|
189
|
+
const composer = next.length === 0 ? active?.closest('.rf-queue-wrap')?.closest('form') : null;
|
|
186
190
|
if (handle.props.queue === undefined)
|
|
187
191
|
queued = next;
|
|
188
192
|
handle.props.onQueueChange?.(next);
|
|
189
193
|
handle.update();
|
|
194
|
+
if (composer)
|
|
195
|
+
handle.queueTask(() => composer.querySelector('textarea')?.focus({ preventScroll: true }));
|
|
190
196
|
}
|
|
191
197
|
async function sendQueued(id) {
|
|
192
198
|
if (sending || handle.props.disabled)
|
package/dist/dates.css
CHANGED
package/dist/hover-card.d.ts
CHANGED
|
@@ -27,7 +27,8 @@ export interface HoverCardProps {
|
|
|
27
27
|
* supplementary: the trigger still works on its own, a tap on a touch screen
|
|
28
28
|
* follows it, and the card never takes focus or locks scrolling. The pointer
|
|
29
29
|
* can cross into the card and use its links; it closes once the pointer has
|
|
30
|
-
* left both, or
|
|
30
|
+
* left both, on Escape, or when the page scrolls. It shares Popover's
|
|
31
|
+
* surface and motion.
|
|
31
32
|
*
|
|
32
33
|
* The card is a span, so a trigger can sit inside a paragraph. Keep its
|
|
33
34
|
* content to phrasing elements there, such as span and a, since a div or a
|
package/dist/hover-card.js
CHANGED
|
@@ -7,7 +7,8 @@ import { anchor } from '@remix-run/ui/anchor';
|
|
|
7
7
|
* supplementary: the trigger still works on its own, a tap on a touch screen
|
|
8
8
|
* follows it, and the card never takes focus or locks scrolling. The pointer
|
|
9
9
|
* can cross into the card and use its links; it closes once the pointer has
|
|
10
|
-
* left both, or
|
|
10
|
+
* left both, on Escape, or when the page scrolls. It shares Popover's
|
|
11
|
+
* surface and motion.
|
|
11
12
|
*
|
|
12
13
|
* The card is a span, so a trigger can sit inside a paragraph. Keep its
|
|
13
14
|
* content to phrasing elements there, such as span and a, since a div or a
|
|
@@ -88,6 +89,18 @@ export function HoverCard(handle) {
|
|
|
88
89
|
return;
|
|
89
90
|
setOpen(false);
|
|
90
91
|
}, options);
|
|
92
|
+
// The card is placed by script, so while the trigger scrolls it trails a
|
|
93
|
+
// frame behind. A preview belongs to where the pointer rests, so when the
|
|
94
|
+
// page, or anything holding the trigger, scrolls, it closes at once. Other
|
|
95
|
+
// scrollers, such as a carousel elsewhere, leave it be.
|
|
96
|
+
element.ownerDocument.addEventListener('scroll', (event) => {
|
|
97
|
+
if (!isOpen())
|
|
98
|
+
return;
|
|
99
|
+
const source = event.target;
|
|
100
|
+
const movesTrigger = source === element.ownerDocument || (source instanceof Node && source.contains(element));
|
|
101
|
+
if (movesTrigger)
|
|
102
|
+
setOpen(false);
|
|
103
|
+
}, { capture: true, passive: true, signal });
|
|
91
104
|
signal.addEventListener('abort', () => {
|
|
92
105
|
clearTimeout(openTimer);
|
|
93
106
|
clearTimeout(closeTimer);
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export * from './code-block.tsx';
|
|
|
19
19
|
export * from './citation.tsx';
|
|
20
20
|
export * from './avatar.tsx';
|
|
21
21
|
export * from './chat-layout.tsx';
|
|
22
|
+
export * from './carousel.tsx';
|
|
22
23
|
export * from './systems.ts';
|
|
23
24
|
export * from './queued-stack.tsx';
|
|
24
25
|
export * from './scroll-area.tsx';
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export * from './code-block.js';
|
|
|
19
19
|
export * from './citation.js';
|
|
20
20
|
export * from './avatar.js';
|
|
21
21
|
export * from './chat-layout.js';
|
|
22
|
+
export * from './carousel.js';
|
|
22
23
|
export * from './systems.js';
|
|
23
24
|
export * from './queued-stack.js';
|
|
24
25
|
export * from './scroll-area.js';
|
package/dist/queued-stack.js
CHANGED
|
@@ -12,6 +12,41 @@ export function QueuedStack(handle) {
|
|
|
12
12
|
list = node;
|
|
13
13
|
});
|
|
14
14
|
const sending = new Set();
|
|
15
|
+
// The action that had focus when its message moved or left, to hand focus
|
|
16
|
+
// on to once the queue has re-rendered.
|
|
17
|
+
let refocus;
|
|
18
|
+
function remember(action, id, index) {
|
|
19
|
+
if (list?.contains(document.activeElement))
|
|
20
|
+
refocus = { action, id, index };
|
|
21
|
+
}
|
|
22
|
+
function restoreFocus() {
|
|
23
|
+
const target = refocus;
|
|
24
|
+
if (!target || !list)
|
|
25
|
+
return;
|
|
26
|
+
const rows = Array.from(list.children);
|
|
27
|
+
const row = rows.find((item) => item.dataset.queueId === target.id);
|
|
28
|
+
let button;
|
|
29
|
+
if (target.action === 'up' || target.action === 'down') {
|
|
30
|
+
// The moved message keeps focus, on the other arrow if this one ran out.
|
|
31
|
+
if (!row)
|
|
32
|
+
return;
|
|
33
|
+
if (rows.indexOf(row) === target.index)
|
|
34
|
+
return;
|
|
35
|
+
button = row.querySelector(`[data-queue-action="${target.action}"]`);
|
|
36
|
+
if (button?.disabled)
|
|
37
|
+
button = row.querySelector(`[data-queue-action="${target.action === 'up' ? 'down' : 'up'}"]`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// The message that takes the sent or removed one's place, or the one before it.
|
|
41
|
+
if (row)
|
|
42
|
+
return;
|
|
43
|
+
const next = rows[Math.min(target.index, rows.length - 1)];
|
|
44
|
+
button = next?.querySelector(`[data-queue-action="${target.action}"]`);
|
|
45
|
+
}
|
|
46
|
+
refocus = undefined;
|
|
47
|
+
// It takes the place of the button that had focus, so nothing needs scrolling.
|
|
48
|
+
button?.focus({ preventScroll: true });
|
|
49
|
+
}
|
|
15
50
|
async function send(id) {
|
|
16
51
|
if (sending.has(id))
|
|
17
52
|
return;
|
|
@@ -51,6 +86,8 @@ export function QueuedStack(handle) {
|
|
|
51
86
|
const { messages, onRemove, onEdit, label = 'Queued messages' } = handle.props;
|
|
52
87
|
if (!messages.length)
|
|
53
88
|
expanded = false;
|
|
89
|
+
if (refocus)
|
|
90
|
+
handle.queueTask(restoreFocus);
|
|
54
91
|
return (_jsxs("div", { class: "rf-queue-wrap", hidden: !messages.length, "data-expanded": expanded || undefined, children: [messages.length > 1 && (_jsx("button", { type: "button", class: "rf-queue-toggle", "aria-expanded": expanded, "aria-controls": handle.id, mix: on('click', () => {
|
|
55
92
|
expanded = !expanded;
|
|
56
93
|
handle.update();
|
|
@@ -133,6 +170,18 @@ export function QueuedStack(handle) {
|
|
|
133
170
|
expanded = true;
|
|
134
171
|
handle.update();
|
|
135
172
|
}),
|
|
136
|
-
], children: [_jsx("span", { class: "rf-queue-content", children: message.content }), _jsxs("div", { class: "rf-queue-actions", children: [_jsx(Button, { size: "icon-compact", variant: "ghost", disabled: index === 0, "aria-label": `Move message ${index + 1} up`,
|
|
173
|
+
], children: [_jsx("span", { class: "rf-queue-content", children: message.content }), _jsxs("div", { class: "rf-queue-actions", children: [_jsx(Button, { size: "icon-compact", variant: "ghost", disabled: index === 0, "aria-label": `Move message ${index + 1} up`, "data-queue-action": "up", mix: on('click', () => {
|
|
174
|
+
remember('up', message.id, index);
|
|
175
|
+
move(message.id, index - 1);
|
|
176
|
+
}), children: "\u2191" }), _jsx(Button, { size: "icon-compact", variant: "ghost", disabled: index === messages.length - 1, "aria-label": `Move message ${index + 1} down`, "data-queue-action": "down", mix: on('click', () => {
|
|
177
|
+
remember('down', message.id, index);
|
|
178
|
+
move(message.id, index + 1);
|
|
179
|
+
}), children: "\u2193" }), onRemove && (_jsx(Button, { size: "icon-compact", variant: "ghost", "aria-label": `Remove message ${index + 1}`, "data-queue-action": "remove", mix: on('click', () => {
|
|
180
|
+
remember('remove', message.id, index);
|
|
181
|
+
onRemove(message.id);
|
|
182
|
+
}), children: "\u00D7" })), _jsx(Button, { size: "compact", variant: "secondary", "data-queue-action": "send", mix: on('click', () => {
|
|
183
|
+
remember('send', message.id, index);
|
|
184
|
+
void send(message.id);
|
|
185
|
+
}), children: "Send" })] })] }, message.id))) })] }));
|
|
137
186
|
};
|
|
138
187
|
}
|
package/dist/styles.css
CHANGED