@rtzll/reflow 0.4.0 → 0.6.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,119 @@
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
+ border-radius: 6px;
14
+ outline: none;
15
+ background: var(--rf-neutral-fill);
16
+ color: var(--rf-muted);
17
+ font: 500 11px / 1 var(--rf-font);
18
+ font-variant-numeric: tabular-nums;
19
+ text-decoration: none;
20
+ vertical-align: 0.1em;
21
+ white-space: nowrap;
22
+ transition:
23
+ color var(--rf-fast),
24
+ background-color var(--rf-fast);
25
+ }
26
+ .rf-citation[data-variant='site'] {
27
+ padding-inline: 7px;
28
+ }
29
+ .rf-citation[data-size='compact'] {
30
+ min-inline-size: 16px;
31
+ block-size: 16px;
32
+ padding-inline: 4px;
33
+ font-size: 10px;
34
+ }
35
+ @media (hover: hover) {
36
+ .rf-citation:hover {
37
+ background: color-mix(in oklab, var(--rf-neutral-fill), var(--rf-fg) 10%);
38
+ color: var(--rf-fg);
39
+ }
40
+ }
41
+ .rf-citation:focus-visible {
42
+ box-shadow: 0 0 0 2px var(--rf-accent);
43
+ color: var(--rf-fg);
44
+ }
45
+
46
+ .rf-citation-sources {
47
+ display: grid;
48
+ gap: 12px;
49
+ inline-size: min(300px, calc(100vw - 32px));
50
+ margin: 0;
51
+ padding: 0;
52
+ list-style: none;
53
+ }
54
+ .rf-citation-sources[data-density='compact'] {
55
+ gap: 10px;
56
+ }
57
+ .rf-citation-source {
58
+ display: grid;
59
+ gap: 2px;
60
+ min-inline-size: 0;
61
+ }
62
+ .rf-citation-source + .rf-citation-source {
63
+ padding-block-start: 12px;
64
+ border-block-start: 1px solid var(--rf-border);
65
+ }
66
+ .rf-citation-meta {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 6px;
70
+ color: var(--rf-muted);
71
+ font: var(--rf-text-caption) / var(--rf-leading-caption) var(--rf-font);
72
+ }
73
+ .rf-citation-icon {
74
+ display: inline-grid;
75
+ flex: none;
76
+ place-items: center;
77
+ inline-size: 14px;
78
+ block-size: 14px;
79
+ overflow: hidden;
80
+ border-radius: 3px;
81
+ }
82
+ .rf-citation-icon > :is(img, svg) {
83
+ inline-size: 100%;
84
+ block-size: 100%;
85
+ }
86
+ .rf-citation-site {
87
+ min-inline-size: 0;
88
+ overflow: hidden;
89
+ text-overflow: ellipsis;
90
+ white-space: nowrap;
91
+ }
92
+ .rf-citation-number {
93
+ margin-inline-start: auto;
94
+ font-variant-numeric: tabular-nums;
95
+ }
96
+ .rf-citation-title {
97
+ color: var(--rf-fg);
98
+ font-variation-settings: var(--rf-weight-strong);
99
+ text-decoration: none;
100
+ }
101
+ .rf-citation-title:hover,
102
+ .rf-citation-title:focus-visible {
103
+ text-decoration: underline;
104
+ text-underline-offset: 2px;
105
+ }
106
+ .rf-citation-description {
107
+ display: -webkit-box;
108
+ margin: 0;
109
+ overflow: hidden;
110
+ color: var(--rf-muted);
111
+ -webkit-box-orient: vertical;
112
+ -webkit-line-clamp: 3;
113
+ }
114
+
115
+ @media (forced-colors: active) {
116
+ .rf-citation {
117
+ border: 1px solid LinkText;
118
+ }
119
+ }
@@ -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;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
2
+ import { HoverCard } from './hover-card.js';
3
+ import { Icon } from './icon.js';
4
+ function siteOf(source) {
5
+ if (source.site)
6
+ return source.site;
7
+ try {
8
+ return new URL(source.href).host.replace(/^www\./, '');
9
+ }
10
+ catch {
11
+ return source.href;
12
+ }
13
+ }
14
+ /**
15
+ * An inline citation in generated text: a small chip with the source's
16
+ * number, or its site, that links to the source and previews it on hover or
17
+ * focus, as a HoverCard. One chip can cite several sources; its preview
18
+ * lists each with its site, title and an excerpt. Its accessible name says
19
+ * what it cites, not only its number.
20
+ */
21
+ export function Citation(handle) {
22
+ return () => {
23
+ const { variant = 'number', index = 1, target, size } = handle.props;
24
+ const list = Array.isArray(handle.props.sources)
25
+ ? handle.props.sources
26
+ : [handle.props.sources];
27
+ const first = list[0];
28
+ if (!first)
29
+ return null;
30
+ const last = index + list.length - 1;
31
+ const described = list.map((source) => `${source.title} (${siteOf(source)})`).join('; ');
32
+ const label = variant === 'site'
33
+ ? `${list.length > 1 ? 'Sources' : 'Source'}: ${described}`
34
+ : list.length > 1
35
+ ? `Sources ${index} to ${last}: ${described}`
36
+ : `Source ${index}: ${described}`;
37
+ const text = variant === 'site'
38
+ ? `${siteOf(first)}${list.length > 1 ? ` +${list.length - 1}` : ''}`
39
+ : list.length > 1
40
+ ? `${index}–${last}`
41
+ : String(index);
42
+ const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
43
+ return (_jsx(HoverCard, { placement: handle.props.placement ?? 'top', openDelay: handle.props.openDelay ?? 300, size: size, renderTrigger: (trigger) => (_jsx("a", { class: "rf-citation", href: first.href, target: target, rel: rel, "data-variant": variant, "data-size": size, "aria-label": label, ...trigger, children: text })), children: _jsx("span", { class: "rf-citation-sources", role: "list", "data-density": size, children: list.map((source, offset) => (_jsxs("span", { class: "rf-citation-source", role: "listitem", children: [_jsxs("span", { class: "rf-citation-meta", children: [_jsx("span", { class: "rf-citation-icon", "aria-hidden": "true", children: source.icon ?? _jsx(Icon, { name: "globe", size: 12 }) }), _jsx("span", { class: "rf-citation-site", children: siteOf(source) }), variant === 'number' && (_jsx("span", { class: "rf-citation-number", children: String(index + offset) }))] }), _jsx("a", { class: "rf-citation-title", href: source.href, target: target, rel: rel, children: source.title }), source.description != null && (_jsx("span", { class: "rf-citation-description", children: source.description }))] }, offset))) }) }));
44
+ };
45
+ }
@@ -0,0 +1,292 @@
1
+ /* CodeBlock: code in a quiet frame, with a header for its file or language
2
+ and a copy button. Highlighted code from Reflow's own theme takes every
3
+ colour from the --rf-code-token-* variables below, so it follows the light
4
+ and dark themes and the accent; other Shiki themes bring their colours
5
+ inline. Lines carry Shiki's marks: highlighted, diff add and remove,
6
+ focused, error and warning, and highlighted words. */
7
+ .rf-code-block {
8
+ --rf-code-font: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
9
+ --rf-code-size: 13px;
10
+ --rf-code-leading: 20px;
11
+ --rf-code-pad: 16px;
12
+ --rf-code-foreground: var(--rf-fg);
13
+ --rf-code-background: transparent;
14
+ --rf-code-token-keyword: color-mix(in oklab, var(--rf-accent) 78%, var(--rf-fg));
15
+ --rf-code-token-string: var(--rf-success);
16
+ --rf-code-token-string-expression: var(--rf-success);
17
+ --rf-code-token-function: var(--rf-info);
18
+ --rf-code-token-constant: var(--rf-warning);
19
+ --rf-code-token-parameter: light-dark(#c2410c, #fdba74);
20
+ --rf-code-token-comment: var(--rf-muted);
21
+ --rf-code-token-punctuation: color-mix(in oklab, var(--rf-fg) 62%, transparent);
22
+ --rf-code-token-link: var(--rf-info);
23
+ --rf-code-token-inserted: var(--rf-success);
24
+ --rf-code-token-deleted: var(--rf-danger);
25
+ --rf-code-token-changed: var(--rf-warning);
26
+ --rf-code-added: light-dark(#22c55e, var(--rf-success));
27
+ --rf-code-removed: light-dark(#ef4444, var(--rf-danger));
28
+ position: relative;
29
+ display: flex;
30
+ flex-direction: column;
31
+ box-sizing: border-box;
32
+ min-inline-size: 0;
33
+ margin: 0;
34
+ overflow: hidden;
35
+ border-radius: var(--rf-panel-radius);
36
+ background: var(--rf-surface-2);
37
+ box-shadow: inset 0 0 0 1px var(--rf-border);
38
+ color: var(--rf-fg);
39
+ font: var(--rf-text-body) / var(--rf-leading-body) var(--rf-font);
40
+ }
41
+ .rf-code-block[data-density='compact'],
42
+ [data-density='compact'] .rf-code-block:not([data-density]) {
43
+ --rf-code-size: 12px;
44
+ --rf-code-leading: 18px;
45
+ --rf-code-pad: 12px;
46
+ }
47
+
48
+ .rf-code-header {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 8px;
52
+ min-block-size: 40px;
53
+ box-sizing: border-box;
54
+ padding: 4px 6px 4px var(--rf-code-pad);
55
+ border-block-end: 1px solid var(--rf-border);
56
+ color: var(--rf-muted);
57
+ font-size: var(--rf-text-caption);
58
+ line-height: var(--rf-leading-caption, 16px);
59
+ }
60
+ /* With only the copy button, the header floats over the code's corner. */
61
+ .rf-code-header:not(:has(.rf-code-filename, .rf-code-language)) {
62
+ position: absolute;
63
+ z-index: 1;
64
+ inset: 6px 6px auto auto;
65
+ min-block-size: 0;
66
+ padding: 0;
67
+ border: 0;
68
+ }
69
+ .rf-code-filename {
70
+ min-inline-size: 0;
71
+ overflow: hidden;
72
+ color: var(--rf-fg);
73
+ font-family: var(--rf-code-font);
74
+ text-overflow: ellipsis;
75
+ white-space: nowrap;
76
+ }
77
+ .rf-code-language {
78
+ font-family: var(--rf-code-font);
79
+ letter-spacing: 0.02em;
80
+ }
81
+ .rf-code-copy {
82
+ display: grid;
83
+ flex: none;
84
+ place-items: center;
85
+ inline-size: 28px;
86
+ block-size: 28px;
87
+ margin-inline-start: auto;
88
+ padding: 0;
89
+ border: 0;
90
+ border-radius: var(--rf-radius);
91
+ outline: none;
92
+ background: transparent;
93
+ color: var(--rf-muted);
94
+ cursor: pointer;
95
+ transition:
96
+ color var(--rf-fast),
97
+ background-color var(--rf-fast);
98
+ }
99
+ .rf-code-copy .rf-copy-action {
100
+ padding: 0;
101
+ color: inherit;
102
+ }
103
+ .rf-code-copy:focus-visible {
104
+ box-shadow: 0 0 0 1px var(--rf-accent);
105
+ }
106
+ @media (hover: hover) {
107
+ .rf-code-copy:hover {
108
+ background: var(--rf-hover);
109
+ color: var(--rf-fg);
110
+ }
111
+ .rf-code-copy:hover .rf-copy-action svg {
112
+ stroke-width: 2;
113
+ }
114
+ }
115
+ .rf-code-block[data-state='copied'] .rf-code-copy {
116
+ color: var(--rf-fg);
117
+ }
118
+
119
+ .rf-code-scroll {
120
+ min-inline-size: 0;
121
+ }
122
+ .rf-code-viewport:focus-visible {
123
+ outline: 2px solid var(--rf-accent);
124
+ outline-offset: -2px;
125
+ }
126
+ .rf-code {
127
+ margin: 0;
128
+ padding: 12px 0;
129
+ background: var(--rf-code-background);
130
+ color: var(--rf-code-foreground);
131
+ font: var(--rf-code-size) / var(--rf-code-leading) var(--rf-code-font);
132
+ font-variant-ligatures: none;
133
+ tab-size: 2;
134
+ }
135
+ .rf-code > code {
136
+ display: block;
137
+ inline-size: max-content;
138
+ min-inline-size: 100%;
139
+ counter-reset: rf-code-line var(--rf-code-start, 0);
140
+ font: inherit;
141
+ }
142
+ .rf-code-line {
143
+ display: inline-flex;
144
+ box-sizing: border-box;
145
+ min-inline-size: 100%;
146
+ padding-inline: var(--rf-code-pad);
147
+ transition:
148
+ opacity var(--rf-spring-moderate),
149
+ filter var(--rf-spring-moderate);
150
+ }
151
+ .rf-code-text {
152
+ flex: 1;
153
+ white-space: pre;
154
+ }
155
+ /* Wrapped, everything takes the available width instead of the longest
156
+ line's, so long lines break inside it. */
157
+ .rf-code-block[data-wrap] .rf-scroll-root .rf-scroll-content {
158
+ width: 100%;
159
+ }
160
+ .rf-code-block[data-wrap] .rf-code {
161
+ flex: 1;
162
+ min-inline-size: 0;
163
+ }
164
+ .rf-code-block[data-wrap] .rf-code > code {
165
+ inline-size: auto;
166
+ }
167
+ .rf-code-block[data-wrap] .rf-code-line {
168
+ inline-size: 100%;
169
+ }
170
+ .rf-code-block[data-wrap] .rf-code-text {
171
+ min-inline-size: 0;
172
+ white-space: pre-wrap;
173
+ overflow-wrap: anywhere;
174
+ }
175
+
176
+ /* Line numbers and diff marks sit in a gutter that selection skips. */
177
+ .rf-code-block[data-line-numbers] .rf-code-line::before {
178
+ content: counter(rf-code-line);
179
+ counter-increment: rf-code-line;
180
+ flex: none;
181
+ min-inline-size: calc(var(--rf-code-digits, 2) * 1ch);
182
+ margin-inline-end: 16px;
183
+ color: var(--rf-muted);
184
+ opacity: 0.7;
185
+ text-align: end;
186
+ user-select: none;
187
+ }
188
+ .rf-code-mark {
189
+ flex: none;
190
+ inline-size: 2ch;
191
+ color: var(--rf-muted);
192
+ user-select: none;
193
+ }
194
+
195
+ .rf-code-line.highlighted {
196
+ background: color-mix(in oklab, var(--rf-accent) 12%, transparent);
197
+ box-shadow: inset 2px 0 var(--rf-accent);
198
+ }
199
+ .rf-code-line.highlighted.error {
200
+ background: color-mix(in oklab, var(--rf-code-removed) 14%, transparent);
201
+ box-shadow: inset 2px 0 var(--rf-code-removed);
202
+ }
203
+ .rf-code-line.highlighted.warning {
204
+ background: color-mix(in oklab, light-dark(#f59e0b, var(--rf-warning)) 16%, transparent);
205
+ box-shadow: inset 2px 0 light-dark(#f59e0b, var(--rf-warning));
206
+ }
207
+ .rf-code-line.diff.add {
208
+ background: color-mix(in oklab, var(--rf-code-added) 13%, transparent);
209
+ }
210
+ .rf-code-line.diff.add .rf-code-mark {
211
+ color: var(--rf-code-added);
212
+ }
213
+ .rf-code-line.diff.remove {
214
+ background: color-mix(in oklab, var(--rf-code-removed) 12%, transparent);
215
+ }
216
+ .rf-code-line.diff.remove .rf-code-mark {
217
+ color: var(--rf-code-removed);
218
+ }
219
+ .rf-code-line.diff.remove .rf-code-text {
220
+ opacity: 0.75;
221
+ }
222
+ .rf-code .highlighted-word {
223
+ border-radius: 4px;
224
+ background: color-mix(in oklab, var(--rf-accent) 16%, transparent);
225
+ box-shadow: 0 0 0 1px color-mix(in oklab, var(--rf-accent) 30%, transparent);
226
+ }
227
+ /* Focused lines keep the eye; the rest blur until the reader points at or
228
+ focuses the block. */
229
+ .rf-code-block[data-has-focused] .rf-code-line:not(.focused) {
230
+ opacity: 0.5;
231
+ filter: blur(0.06em);
232
+ }
233
+ .rf-code-block[data-has-focused]:is(:hover, :focus-within) .rf-code-line:not(.focused) {
234
+ opacity: 1;
235
+ filter: none;
236
+ }
237
+
238
+ /* A theme with a light and a dark side follows the page between them. */
239
+ .rf-code-block[data-themed] .rf-code[style*='--shiki-light'] {
240
+ background: light-dark(var(--shiki-light-bg), var(--shiki-dark-bg));
241
+ color: light-dark(var(--shiki-light), var(--shiki-dark));
242
+ }
243
+ .rf-code-block[data-themed] .rf-code[style*='--shiki-light'] span[style] {
244
+ color: light-dark(var(--shiki-light), var(--shiki-dark));
245
+ }
246
+
247
+ /* Folded, the code shows its first lines and fades into the Show all button. */
248
+ .rf-code-block[data-collapsed='true'] .rf-code-viewport {
249
+ max-block-size: calc(var(--rf-code-max-lines) * var(--rf-code-leading) + 12px);
250
+ overflow-y: hidden;
251
+ mask-image: linear-gradient(to bottom, black 55%, transparent);
252
+ }
253
+ .rf-code-toggle {
254
+ display: block;
255
+ inline-size: 100%;
256
+ padding: 8px;
257
+ border: 0;
258
+ border-block-start: 1px solid var(--rf-border);
259
+ outline: none;
260
+ background: transparent;
261
+ color: var(--rf-muted);
262
+ font: inherit;
263
+ font-size: var(--rf-text-caption);
264
+ cursor: pointer;
265
+ transition:
266
+ color var(--rf-fast),
267
+ background-color var(--rf-fast);
268
+ }
269
+ .rf-code-toggle:focus-visible {
270
+ box-shadow: inset 0 0 0 1px var(--rf-accent);
271
+ }
272
+ @media (hover: hover) {
273
+ .rf-code-toggle:hover {
274
+ background: var(--rf-hover);
275
+ color: var(--rf-fg);
276
+ }
277
+ }
278
+
279
+ @media (prefers-reduced-motion: reduce) {
280
+ .rf-code-line {
281
+ transition: none;
282
+ }
283
+ }
284
+ @media (forced-colors: active) {
285
+ .rf-code-block {
286
+ border: 1px solid CanvasText;
287
+ }
288
+ .rf-code-line:is(.highlighted, .diff) {
289
+ outline: 1px solid Highlight;
290
+ outline-offset: -1px;
291
+ }
292
+ }
@@ -0,0 +1,59 @@
1
+ import { type Handle } from 'remix/component';
2
+ /** One run of text in a highlighted line, with the inline style and classes the highlighter gave it. */
3
+ export interface CodeToken {
4
+ content: string;
5
+ style?: string;
6
+ class?: string;
7
+ }
8
+ /** A highlighted line: its tokens, and marks such as `highlighted`, `diff add`, `diff remove` or `focused`. */
9
+ export interface CodeLine {
10
+ class?: string;
11
+ tokens: CodeToken[];
12
+ }
13
+ /**
14
+ * Code ready to render, as `highlight()` from `@rtzll/reflow/highlight`
15
+ * returns it. It is plain data, so a route handler can highlight on the
16
+ * server and hand it to a client entry.
17
+ */
18
+ export interface HighlightedCode {
19
+ lang: string;
20
+ /** The code as shown, with any notation removed. Copy copies this. */
21
+ code: string;
22
+ lines: CodeLine[];
23
+ /** Marks for the whole block: `has-diff`, `has-highlighted`, `has-focused`. */
24
+ flags: string[];
25
+ /** The theme's inline style for the block, such as the colours of a light and dark pair. */
26
+ style?: string;
27
+ }
28
+ export interface CodeBlockProps {
29
+ /** Plain code, or code from `highlight()`. */
30
+ code: string | HighlightedCode;
31
+ /** Shown in the header when there is no filename. Defaults to the highlighted language. */
32
+ language?: string;
33
+ filename?: string;
34
+ /** Names the code region. Defaults to the filename, or the language followed by "code". */
35
+ label?: string;
36
+ /** Numbers the lines. Default false. */
37
+ lineNumbers?: boolean;
38
+ /** The first line's number. Default 1. */
39
+ startLine?: number;
40
+ /** Wraps long lines instead of scrolling them. Default false. */
41
+ wrap?: boolean;
42
+ /** Folds longer code to this many lines behind a Show all button. */
43
+ maxLines?: number;
44
+ /** Shows the copy button. Default true. */
45
+ copyable?: boolean;
46
+ /** Called after the code is copied. */
47
+ onCopy?: () => void;
48
+ size?: 'default' | 'compact';
49
+ class?: string;
50
+ }
51
+ /**
52
+ * Code in a frame with its filename or language, a copy button, and optional
53
+ * line numbers, wrapping and folding. It renders plain code as it is, and
54
+ * code from `highlight()` in `@rtzll/reflow/highlight` with its colours and
55
+ * line marks: highlighted lines, added and removed lines, focused lines and
56
+ * highlighted words. It has no dependencies of its own; highlighting runs
57
+ * wherever you call `highlight()`, ideally on the server.
58
+ */
59
+ export declare function CodeBlock(handle: Handle<CodeBlockProps>): () => import("remix/component").RemixElement;
@@ -0,0 +1,117 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
2
+ import { on } from 'remix/component';
3
+ import { Icon } from './icon.js';
4
+ import { ScrollArea } from './scroll-area.js';
5
+ // Highlighters hand over inline styles as text, such as "color:var(--x)".
6
+ function styleObject(style) {
7
+ if (!style)
8
+ return undefined;
9
+ const result = {};
10
+ for (const declaration of style.split(';')) {
11
+ const colon = declaration.indexOf(':');
12
+ if (colon < 1)
13
+ continue;
14
+ result[declaration.slice(0, colon).trim()] = declaration.slice(colon + 1).trim();
15
+ }
16
+ return result;
17
+ }
18
+ function plain(code) {
19
+ return {
20
+ lang: 'text',
21
+ code,
22
+ flags: [],
23
+ lines: code.split('\n').map((line) => ({ tokens: [{ content: line }] })),
24
+ };
25
+ }
26
+ /**
27
+ * Code in a frame with its filename or language, a copy button, and optional
28
+ * line numbers, wrapping and folding. It renders plain code as it is, and
29
+ * code from `highlight()` in `@rtzll/reflow/highlight` with its colours and
30
+ * line marks: highlighted lines, added and removed lines, focused lines and
31
+ * highlighted words. It has no dependencies of its own; highlighting runs
32
+ * wherever you call `highlight()`, ideally on the server.
33
+ */
34
+ export function CodeBlock(handle) {
35
+ let expanded = false;
36
+ let state = 'idle';
37
+ // The glyph lags the state by one exit, as InputCopy's does.
38
+ let glyph = 'idle';
39
+ let glyphKey = 0;
40
+ let phase;
41
+ let request = 0;
42
+ let reset;
43
+ let swapTimer;
44
+ handle.signal.addEventListener('abort', () => {
45
+ clearTimeout(reset);
46
+ clearTimeout(swapTimer);
47
+ }, { once: true });
48
+ function swap() {
49
+ clearTimeout(swapTimer);
50
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
51
+ glyph = state;
52
+ glyphKey++;
53
+ phase = undefined;
54
+ void handle.update();
55
+ return;
56
+ }
57
+ phase = 'exit';
58
+ void handle.update();
59
+ swapTimer = setTimeout(() => {
60
+ glyph = state;
61
+ glyphKey++;
62
+ phase = 'enter';
63
+ void handle.update();
64
+ }, 115);
65
+ }
66
+ async function copy(text) {
67
+ const operation = ++request;
68
+ clearTimeout(reset);
69
+ let copied = false;
70
+ try {
71
+ await navigator.clipboard.writeText(text);
72
+ copied = true;
73
+ }
74
+ catch {
75
+ // The code stays selectable when clipboard access is unavailable.
76
+ }
77
+ if (handle.signal.aborted || operation !== request)
78
+ return;
79
+ state = copied ? 'copied' : 'error';
80
+ swap();
81
+ reset = setTimeout(() => {
82
+ state = 'idle';
83
+ swap();
84
+ }, 2000);
85
+ if (copied)
86
+ handle.props.onCopy?.();
87
+ }
88
+ return () => {
89
+ const props = handle.props;
90
+ const source = typeof props.code === 'string' ? plain(props.code) : props.code;
91
+ const language = props.language ?? (source.lang === 'text' ? undefined : source.lang);
92
+ const label = props.label ?? props.filename ?? (language ? `${language} code` : 'Code');
93
+ const copyable = props.copyable ?? true;
94
+ const folds = props.maxLines !== undefined && source.lines.length > props.maxLines;
95
+ const collapsed = folds && !expanded;
96
+ const hasDiff = source.flags.includes('has-diff');
97
+ const style = {};
98
+ if (props.startLine !== undefined)
99
+ style['--rf-code-start'] = String(props.startLine - 1);
100
+ if (props.lineNumbers)
101
+ style['--rf-code-digits'] = String(String((props.startLine ?? 1) + source.lines.length - 1).length);
102
+ if (folds)
103
+ style['--rf-code-max-lines'] = String(props.maxLines);
104
+ const feedback = state === 'copied' ? 'Copied' : state === 'error' ? 'Copy failed' : '';
105
+ return (_jsxs("figure", { class: props.class ? `rf-code-block ${props.class}` : 'rf-code-block', "data-slot": "code-block", "data-state": state, "data-density": props.size, "data-line-numbers": props.lineNumbers || undefined, "data-wrap": props.wrap || undefined, "data-collapsed": folds ? String(collapsed) : undefined, "data-has-diff": hasDiff || undefined, "data-has-highlighted": source.flags.includes('has-highlighted') || undefined, "data-has-focused": source.flags.includes('has-focused') || undefined, "data-themed": source.style ? '' : undefined, style: style, children: [(props.filename || language || copyable) && (_jsxs("figcaption", { class: "rf-code-header", children: [props.filename ? (_jsx("span", { class: "rf-code-filename", children: props.filename })) : (language && _jsx("span", { class: "rf-code-language", children: language })), copyable && (_jsx("button", { type: "button", class: "rf-code-copy", "aria-label": feedback || 'Copy code', mix: on('click', () => void copy(source.code)), children: _jsx("span", { class: "rf-copy-action", "aria-hidden": "true", children: _jsx("span", { class: "rf-copy-glyph", "data-glyph": glyph, "data-phase": phase, children: glyph === 'idle' ? (_jsx(Icon, { name: "copy", size: 14 })) : (_jsx("svg", { viewBox: "2 4 20 16", width: "14", height: "14", fill: "none", "data-result": true, children: _jsx("path", { d: glyph === 'copied' ? 'M6 12L10 16L18 8' : 'M9 9L15 15M15 9L9 15', pathLength: "1" }) })) }, glyphKey) }) })), _jsx("span", { class: "rf-basic-sr-only", "aria-live": "polite", children: feedback })] })), _jsx(ScrollArea, { label: label, orientation: "horizontal", tabIndex: 0, className: "rf-code-scroll", viewportClassName: "rf-code-viewport", fade: false, children: _jsx("pre", { class: "rf-code", style: styleObject(source.style), children: _jsx("code", { children: source.lines.flatMap((line, index) => {
106
+ const node = (_jsxs("span", { class: line.class ? `rf-code-line ${line.class}` : 'rf-code-line', children: [hasDiff && (_jsx("span", { class: "rf-code-mark", "aria-hidden": "true", children: /\badd\b/.test(line.class ?? '')
107
+ ? '+'
108
+ : /\bremove\b/.test(line.class ?? '')
109
+ ? '-'
110
+ : ' ' })), _jsx("span", { class: "rf-code-text", children: line.tokens.map((token, tokenIndex) => token.style || token.class ? (_jsx("span", { style: styleObject(token.style), class: token.class, children: token.content }, tokenIndex)) : (token.content)) })] }, index));
111
+ return index < source.lines.length - 1 ? [node, '\n'] : [node];
112
+ }) }) }) }), folds && (_jsx("button", { type: "button", class: "rf-code-toggle", "aria-expanded": !collapsed, mix: on('click', () => {
113
+ expanded = !expanded;
114
+ void handle.update();
115
+ }), children: collapsed ? `Show all ${source.lines.length} lines` : 'Show less' }))] }));
116
+ };
117
+ }
@@ -0,0 +1,21 @@
1
+ /** The colours Badge and Avatar share. Internal: not exported from the package. */
2
+ export declare const badgeColors: {
3
+ readonly accent: 'var(--rf-accent)';
4
+ readonly gray: '#a3a3a3';
5
+ readonly red: '#ef4444';
6
+ readonly orange: '#f97316';
7
+ readonly amber: '#f59e0b';
8
+ readonly yellow: '#eab308';
9
+ readonly lime: '#84cc16';
10
+ readonly green: '#22c55e';
11
+ readonly emerald: '#10b981';
12
+ readonly teal: '#14b8a6';
13
+ readonly cyan: '#06b6d4';
14
+ readonly blue: '#3b82f6';
15
+ readonly indigo: '#6366f1';
16
+ readonly violet: '#8b5cf6';
17
+ readonly purple: '#a855f7';
18
+ readonly fuchsia: '#d946ef';
19
+ readonly pink: '#ec4899';
20
+ readonly rose: '#f43f5e';
21
+ };