@rtzll/reflow 0.5.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.
@@ -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
+ }
@@ -0,0 +1,124 @@
1
+ /* ChatLayout: header, transcript and composer stacked in a column of
2
+ readable width. The transcript takes the remaining height and scrolls;
3
+ its messages rest at the bottom while they are few, as in a chat. */
4
+ .rf-chat-layout {
5
+ --rf-chat-width: 720px;
6
+ --rf-chat-gutter: 16px;
7
+ display: grid;
8
+ grid-template-rows: auto minmax(0, 1fr) auto;
9
+ box-sizing: border-box;
10
+ inline-size: 100%;
11
+ block-size: 100%;
12
+ min-block-size: 0;
13
+ }
14
+ .rf-chat-layout-header,
15
+ .rf-chat-layout-composer {
16
+ box-sizing: border-box;
17
+ inline-size: min(100%, var(--rf-chat-width));
18
+ margin-inline: auto;
19
+ padding-inline: var(--rf-chat-gutter);
20
+ }
21
+ .rf-chat-layout-header {
22
+ grid-row: 1;
23
+ padding-block: 12px;
24
+ }
25
+ .rf-chat-layout-body {
26
+ position: relative;
27
+ grid-row: 2;
28
+ min-block-size: 0;
29
+ }
30
+ .rf-chat-layout-scroll {
31
+ block-size: 100%;
32
+ }
33
+ .rf-chat-layout-viewport > .rf-scroll-content {
34
+ display: flex;
35
+ flex-direction: column;
36
+ min-block-size: 100%;
37
+ }
38
+ .rf-chat-transcript {
39
+ display: grid;
40
+ align-content: end;
41
+ gap: 8px;
42
+ box-sizing: border-box;
43
+ flex: 1;
44
+ inline-size: min(100%, var(--rf-chat-width));
45
+ margin-inline: auto;
46
+ padding: 16px var(--rf-chat-gutter);
47
+ }
48
+ .rf-chat-layout[data-empty] .rf-chat-transcript {
49
+ flex: 0;
50
+ padding-block: 0;
51
+ }
52
+ .rf-chat-layout-empty {
53
+ display: grid;
54
+ flex: 1;
55
+ place-items: center;
56
+ padding: 24px var(--rf-chat-gutter);
57
+ color: var(--rf-muted);
58
+ text-align: center;
59
+ }
60
+ .rf-chat-layout-composer {
61
+ grid-row: 3;
62
+ padding-block: 8px 16px;
63
+ }
64
+
65
+ /* Back to the newest message: a quiet pill resting on the transcript's edge. */
66
+ .rf-chat-jump {
67
+ position: absolute;
68
+ inset-block-end: 12px;
69
+ inset-inline-start: 50%;
70
+ display: inline-flex;
71
+ align-items: center;
72
+ gap: 6px;
73
+ block-size: 28px;
74
+ padding-inline: 10px 12px;
75
+ border: 0;
76
+ border-radius: 9999px;
77
+ outline: none;
78
+ background: var(--rf-surface-3, white);
79
+ box-shadow:
80
+ 0 0 0 1px light-dark(rgb(0 0 0 / 6%), rgb(255 255 255 / 8%)),
81
+ 0 4px 12px light-dark(rgb(0 0 0 / 10%), rgb(0 0 0 / 30%));
82
+ color: var(--rf-fg);
83
+ font: var(--rf-text-caption) / 1 var(--rf-font);
84
+ cursor: pointer;
85
+ translate: -50% 0;
86
+ transition:
87
+ opacity var(--rf-spring-moderate),
88
+ translate var(--rf-spring-moderate),
89
+ display var(--rf-spring-moderate) allow-discrete;
90
+ }
91
+ .rf-chat-jump[hidden] {
92
+ display: none;
93
+ opacity: 0;
94
+ translate: -50% 6px;
95
+ }
96
+ @starting-style {
97
+ .rf-chat-jump:not([hidden]) {
98
+ opacity: 0;
99
+ translate: -50% 6px;
100
+ }
101
+ }
102
+ .rf-chat-jump:focus-visible {
103
+ box-shadow:
104
+ 0 0 0 2px var(--rf-accent),
105
+ 0 4px 12px light-dark(rgb(0 0 0 / 10%), rgb(0 0 0 / 30%));
106
+ }
107
+ @media (hover: hover) {
108
+ .rf-chat-jump:hover {
109
+ background: color-mix(in oklab, var(--rf-surface-3, white), var(--rf-fg) 5%);
110
+ }
111
+ }
112
+ [data-shape='sharp'] .rf-chat-jump {
113
+ border-radius: var(--rf-radius);
114
+ }
115
+ .rf-chat-jump > svg {
116
+ flex: none;
117
+ }
118
+
119
+ @media (prefers-reduced-motion: reduce) {
120
+ .rf-chat-jump {
121
+ transition: opacity var(--rf-spring-moderate);
122
+ translate: -50% 0;
123
+ }
124
+ }
@@ -0,0 +1,24 @@
1
+ import { type Handle, type RemixNode } from 'remix/component';
2
+ export interface ChatLayoutProps {
3
+ /** The messages, usually ChatMessage elements, oldest first. */
4
+ children?: RemixNode;
5
+ /** Docked below the transcript, usually an InputMessage. */
6
+ composer?: RemixNode;
7
+ /** Above the transcript, such as a title or the people in the chat. */
8
+ header?: RemixNode;
9
+ /** Shown in place of the transcript until the first message. */
10
+ empty?: RemixNode;
11
+ /** Names the transcript. Default "Conversation". */
12
+ label?: string;
13
+ class?: string;
14
+ }
15
+ /**
16
+ * The conversation screen: a transcript that fills the space, with the
17
+ * composer docked below it. The transcript opens at the newest message and
18
+ * stays there as messages arrive or a reply streams in, as long as the
19
+ * reader is there too; once they scroll back, it leaves them where they
20
+ * are and offers a button back to the newest message. Sending a message
21
+ * of their own, a ChatMessage with the user role, brings them back. The transcript is a
22
+ * labelled log in a ScrollArea, and the column keeps a readable width.
23
+ */
24
+ export declare function ChatLayout(handle: Handle<ChatLayoutProps>): () => import("remix/component").RemixElement;
@@ -0,0 +1,108 @@
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 { Icon } from './icon.js';
5
+ import { ScrollArea } from './scroll-area.js';
6
+ // Within this many pixels of the end, the transcript counts as at the newest message.
7
+ const pinSlack = 24;
8
+ function isEmpty(node) {
9
+ if (node == null || node === false || node === '')
10
+ return true;
11
+ return Array.isArray(node) && node.every(isEmpty);
12
+ }
13
+ /**
14
+ * The conversation screen: a transcript that fills the space, with the
15
+ * composer docked below it. The transcript opens at the newest message and
16
+ * stays there as messages arrive or a reply streams in, as long as the
17
+ * reader is there too; once they scroll back, it leaves them where they
18
+ * are and offers a button back to the newest message. Sending a message
19
+ * of their own, a ChatMessage with the user role, brings them back. The transcript is a
20
+ * labelled log in a ScrollArea, and the column keeps a readable width.
21
+ */
22
+ export function ChatLayout(handle) {
23
+ let viewport = null;
24
+ let pinned = true;
25
+ let unseen = false;
26
+ // While a jump scrolls smoothly, its own scroll events must not unpin it.
27
+ let jumping = false;
28
+ function setState(nextPinned, nextUnseen) {
29
+ if (nextPinned === pinned && nextUnseen === unseen)
30
+ return;
31
+ pinned = nextPinned;
32
+ unseen = nextUnseen;
33
+ void handle.update();
34
+ }
35
+ // The end, from layout alone. Messages gliding to new places are moved by
36
+ // transforms, which grow scrollHeight while they play; measured that way,
37
+ // the transcript would scroll past the end and snap back as glides finish.
38
+ function endTop() {
39
+ if (!viewport)
40
+ return 0;
41
+ const content = viewport.firstElementChild;
42
+ return Math.max(0, (content?.offsetHeight ?? viewport.scrollHeight) - viewport.clientHeight);
43
+ }
44
+ function atEnd() {
45
+ if (!viewport)
46
+ return true;
47
+ return endTop() - viewport.scrollTop <= pinSlack;
48
+ }
49
+ function scrollToEnd(smooth = false) {
50
+ viewport?.scrollTo({
51
+ top: endTop(),
52
+ behavior: smooth && !prefersReducedMotion() ? 'smooth' : 'instant',
53
+ });
54
+ }
55
+ const logMix = ref((node, signal) => {
56
+ if (!(node instanceof HTMLElement))
57
+ return;
58
+ viewport = node.closest('.rf-scroll-viewport');
59
+ if (!viewport)
60
+ return;
61
+ const scroller = viewport;
62
+ scrollToEnd();
63
+ let lastHeight = node.offsetHeight;
64
+ const observer = new ResizeObserver(() => {
65
+ const grew = node.offsetHeight > lastHeight;
66
+ lastHeight = node.offsetHeight;
67
+ if (pinned)
68
+ scrollToEnd();
69
+ else if (grew)
70
+ setState(false, true);
71
+ });
72
+ observer.observe(node);
73
+ observer.observe(scroller);
74
+ // The reader's own message always brings them to the newest one.
75
+ const added = new MutationObserver((records) => {
76
+ const sent = records.some((record) => Array.from(record.addedNodes).some((child) => child instanceof Element && child.matches('[data-role="user"]')));
77
+ if (!sent)
78
+ return;
79
+ setState(true, false);
80
+ scrollToEnd();
81
+ });
82
+ added.observe(node, { childList: true });
83
+ scroller.addEventListener('scroll', () => {
84
+ if (jumping) {
85
+ if (atEnd())
86
+ jumping = false;
87
+ return;
88
+ }
89
+ const end = atEnd();
90
+ setState(end, end ? false : unseen);
91
+ }, { signal, passive: true });
92
+ signal.addEventListener('abort', () => {
93
+ observer.disconnect();
94
+ added.disconnect();
95
+ if (viewport === scroller)
96
+ viewport = null;
97
+ }, { once: true });
98
+ });
99
+ return () => {
100
+ const { children, composer, header, empty, label = 'Conversation' } = handle.props;
101
+ const blank = isEmpty(children);
102
+ return (_jsxs("div", { class: handle.props.class ? `rf-chat-layout ${handle.props.class}` : 'rf-chat-layout', "data-empty": blank || undefined, children: [header != null && _jsx("div", { class: "rf-chat-layout-header", children: header }), _jsxs("div", { class: "rf-chat-layout-body", children: [_jsxs(ScrollArea, { label: label, className: "rf-chat-layout-scroll", viewportClassName: "rf-chat-layout-viewport", children: [_jsx("div", { class: "rf-chat-transcript", role: "log", "aria-label": label, mix: logMix, children: children }), blank && empty != null && _jsx("div", { class: "rf-chat-layout-empty", children: empty })] }), _jsxs("button", { type: "button", class: "rf-chat-jump", hidden: pinned, mix: on('click', () => {
103
+ jumping = true;
104
+ setState(true, false);
105
+ scrollToEnd(true);
106
+ }), children: [_jsx(Icon, { name: "chevron-down", size: 14 }), unseen ? 'New messages' : 'Jump to latest'] })] }), composer != null && _jsx("div", { class: "rf-chat-layout-composer", children: composer })] }));
107
+ };
108
+ }
@@ -0,0 +1,121 @@
1
+ /* Citation: a small chip inline with the text it supports, on the gray
2
+ Badge's fill, sized to sit within the line without moving it. Its preview
3
+ is a HoverCard listing each source. */
4
+ .rf-citation {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ box-sizing: border-box;
9
+ min-inline-size: 18px;
10
+ block-size: 18px;
11
+ margin-inline: 2px;
12
+ padding-inline: 5px;
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);
15
+ outline: none;
16
+ background: var(--rf-neutral-fill);
17
+ color: var(--rf-muted);
18
+ font: 500 11px / 1 var(--rf-font);
19
+ font-variant-numeric: tabular-nums;
20
+ text-decoration: none;
21
+ vertical-align: 0.1em;
22
+ white-space: nowrap;
23
+ transition:
24
+ color var(--rf-fast),
25
+ background-color var(--rf-fast);
26
+ }
27
+ .rf-citation[data-variant='site'] {
28
+ padding-inline: 7px;
29
+ }
30
+ .rf-citation[data-size='compact'] {
31
+ min-inline-size: 16px;
32
+ block-size: 16px;
33
+ padding-inline: 4px;
34
+ font-size: 10px;
35
+ }
36
+ @media (hover: hover) {
37
+ .rf-citation:hover {
38
+ background: color-mix(in oklab, var(--rf-neutral-fill), var(--rf-fg) 10%);
39
+ color: var(--rf-fg);
40
+ }
41
+ }
42
+ .rf-citation:focus-visible {
43
+ box-shadow: 0 0 0 2px var(--rf-accent);
44
+ color: var(--rf-fg);
45
+ }
46
+
47
+ .rf-citation-sources {
48
+ display: grid;
49
+ gap: 12px;
50
+ /* The preview's widest content box: 320px less its 12px padding. */
51
+ inline-size: min(296px, calc(100vw - 40px));
52
+ margin: 0;
53
+ padding: 0;
54
+ list-style: none;
55
+ }
56
+ .rf-citation-sources[data-density='compact'] {
57
+ gap: 10px;
58
+ }
59
+ .rf-citation-source {
60
+ display: grid;
61
+ gap: 2px;
62
+ min-inline-size: 0;
63
+ }
64
+ .rf-citation-source + .rf-citation-source {
65
+ padding-block-start: 12px;
66
+ border-block-start: 1px solid var(--rf-border);
67
+ }
68
+ .rf-citation-meta {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 6px;
72
+ color: var(--rf-muted);
73
+ font: var(--rf-text-caption) / var(--rf-leading-caption) var(--rf-font);
74
+ }
75
+ .rf-citation-icon {
76
+ display: inline-grid;
77
+ flex: none;
78
+ place-items: center;
79
+ inline-size: 14px;
80
+ block-size: 14px;
81
+ overflow: hidden;
82
+ border-radius: 3px;
83
+ }
84
+ .rf-citation-icon > :is(img, svg) {
85
+ inline-size: 100%;
86
+ block-size: 100%;
87
+ }
88
+ .rf-citation-site {
89
+ min-inline-size: 0;
90
+ overflow: hidden;
91
+ text-overflow: ellipsis;
92
+ white-space: nowrap;
93
+ }
94
+ .rf-citation-number {
95
+ margin-inline-start: auto;
96
+ font-variant-numeric: tabular-nums;
97
+ }
98
+ .rf-citation-title {
99
+ color: var(--rf-fg);
100
+ font-variation-settings: var(--rf-weight-strong);
101
+ text-decoration: none;
102
+ }
103
+ .rf-citation-title:hover,
104
+ .rf-citation-title:focus-visible {
105
+ text-decoration: underline;
106
+ text-underline-offset: 2px;
107
+ }
108
+ .rf-citation-description {
109
+ display: -webkit-box;
110
+ margin: 0;
111
+ overflow: hidden;
112
+ color: var(--rf-muted);
113
+ -webkit-box-orient: vertical;
114
+ -webkit-line-clamp: 3;
115
+ }
116
+
117
+ @media (forced-colors: active) {
118
+ .rf-citation {
119
+ border: 1px solid LinkText;
120
+ }
121
+ }
@@ -0,0 +1,35 @@
1
+ import type { Handle, RemixNode } from 'remix/component';
2
+ import type { PopoverPlacement } from './popover.tsx';
3
+ export interface CitationSource {
4
+ title: string;
5
+ href: string;
6
+ /** The site's name. Defaults to the link's host, without www. */
7
+ site?: string;
8
+ /** A short excerpt shown in the preview. */
9
+ description?: RemixNode;
10
+ /** The site's icon, such as a favicon. Defaults to a globe. */
11
+ icon?: RemixNode;
12
+ }
13
+ export interface CitationProps {
14
+ /** The source, or several sources cited together. */
15
+ sources: CitationSource | readonly CitationSource[];
16
+ /** The first source's number. Several sources take the numbers that follow. Default 1. */
17
+ index?: number;
18
+ /** `number` shows the source numbers; `site` shows the first site's name and how many more. Default number. */
19
+ variant?: 'number' | 'site';
20
+ /** Where links open, such as `_blank`, which also sets a safe `rel`. */
21
+ target?: string;
22
+ /** Which side of the chip the preview opens on. Default top. */
23
+ placement?: PopoverPlacement;
24
+ /** Milliseconds the pointer rests on the chip before the preview opens. Default 300. */
25
+ openDelay?: number;
26
+ size?: 'default' | 'compact';
27
+ }
28
+ /**
29
+ * An inline citation in generated text: a small chip with the source's
30
+ * number, or its site, that links to the source and previews it on hover or
31
+ * focus, as a HoverCard. One chip can cite several sources; its preview
32
+ * lists each with its site, title and an excerpt. Its accessible name says
33
+ * what it cites, not only its number.
34
+ */
35
+ export declare function Citation(handle: Handle<CitationProps>): () => import("remix/component").RemixElement | null;