@rtzll/reflow 0.6.0 → 0.7.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/dist/avatar.css CHANGED
@@ -22,6 +22,9 @@
22
22
  .rf-avatar[data-shape='square'] {
23
23
  border-radius: max(4px, calc(var(--rf-avatar-size) * 0.25));
24
24
  }
25
+ [data-shape='sharp'] .rf-avatar[data-shape='square'] {
26
+ border-radius: var(--rf-radius);
27
+ }
25
28
  .rf-avatar > * {
26
29
  grid-area: 1 / 1;
27
30
  }
@@ -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 images. */
52
+ .rf-carousel-slide 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;
@@ -0,0 +1,277 @@
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
+ node.addEventListener('pointerup', endDrag, options);
214
+ node.addEventListener('pointercancel', endDrag, options);
215
+ // A drag that ends over a link does not follow it.
216
+ node.addEventListener('click', (event) => {
217
+ if (!suppressClick)
218
+ return;
219
+ suppressClick = false;
220
+ event.preventDefault();
221
+ event.stopPropagation();
222
+ }, { capture: true, signal });
223
+ node.addEventListener('pointerdown', () => (suppressClick = false), { signal, capture: true });
224
+ signal.addEventListener('abort', () => {
225
+ if (viewport === node)
226
+ viewport = null;
227
+ }, { once: true });
228
+ });
229
+ return () => {
230
+ const { label, children, slideLabels, size, autoplay, align = 'start' } = handle.props;
231
+ const slides = slidesOf(children);
232
+ const total = slides.length;
233
+ const positions = stops();
234
+ const index = current();
235
+ // A controlled index set from outside brings its slide into view.
236
+ if (shownIndex !== -1 && shownIndex !== index && steering !== index && !drag?.started)
237
+ handle.queueTask(() => scrollToSlide(index));
238
+ if (shownIndex === -1 && index > 0)
239
+ handle.queueTask(() => scrollToSlide(index, false));
240
+ shownIndex = index;
241
+ const paused = userPaused || hovered || focused;
242
+ const rotating = Boolean(autoplay) && !paused;
243
+ const showDots = handle.props.dots ?? true;
244
+ const showControls = handle.props.controls ?? true;
245
+ const names = slideLabels ?? [];
246
+ 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: [
247
+ on('pointerenter', (event) => {
248
+ if (event.pointerType === 'touch')
249
+ return;
250
+ hovered = true;
251
+ void handle.update();
252
+ }),
253
+ on('pointerleave', () => {
254
+ hovered = false;
255
+ void handle.update();
256
+ }),
257
+ on('focusin', () => {
258
+ if (focused)
259
+ return;
260
+ focused = true;
261
+ void handle.update();
262
+ }),
263
+ on('focusout', (event) => {
264
+ const next = event.relatedTarget;
265
+ if (next instanceof Node && event.currentTarget.contains(next))
266
+ return;
267
+ focused = false;
268
+ void handle.update();
269
+ }),
270
+ ], 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]
271
+ ? `${names[position]}, ${position + 1} of ${total}`
272
+ : `${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', () => {
273
+ userPaused = !userPaused;
274
+ void handle.update();
275
+ }), children: _jsx(Icon, { name: userPaused ? 'play' : 'pause' }) }))] }))] }));
276
+ };
277
+ }
@@ -109,6 +109,9 @@
109
109
  background: color-mix(in oklab, var(--rf-surface-3, white), var(--rf-fg) 5%);
110
110
  }
111
111
  }
112
+ [data-shape='sharp'] .rf-chat-jump {
113
+ border-radius: var(--rf-radius);
114
+ }
112
115
  .rf-chat-jump > svg {
113
116
  flex: none;
114
117
  }
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
- border-radius: 6px;
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
- inline-size: min(300px, calc(100vw - 32px));
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;
@@ -220,7 +220,7 @@
220
220
  opacity: 0.75;
221
221
  }
222
222
  .rf-code .highlighted-word {
223
- border-radius: 4px;
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
  }
@@ -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)
@@ -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 on Escape. It shares Popover's surface and motion.
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
@@ -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 on Escape. It shares Popover's surface and motion.
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';
@@ -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`, mix: on('click', () => move(message.id, index - 1)), children: "\u2191" }), _jsx(Button, { size: "icon-compact", variant: "ghost", disabled: index === messages.length - 1, "aria-label": `Move message ${index + 1} down`, mix: on('click', () => move(message.id, index + 1)), children: "\u2193" }), onRemove && (_jsx(Button, { size: "icon-compact", variant: "ghost", "aria-label": `Remove message ${index + 1}`, mix: on('click', () => onRemove(message.id)), children: "\u00D7" })), _jsx(Button, { size: "compact", variant: "secondary", mix: on('click', () => send(message.id)), children: "Send" })] })] }, message.id))) })] }));
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
@@ -18,4 +18,5 @@
18
18
  @import './citation.css';
19
19
  @import './avatar.css';
20
20
  @import './chat-layout.css';
21
+ @import './carousel.css';
21
22
  @import './systems.css';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtzll/reflow",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",