@surflane/ui 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/code.js +3 -1
- package/dist/css/rules/choice.css +13 -1
- package/dist/css/rules/combobox.css +4 -0
- package/dist/css/rules/date-picker.css +7 -1
- package/dist/css/rules/field-control.css +12 -1
- package/dist/css/rules/file-upload.css +31 -20
- package/dist/css/rules/input-group.css +8 -1
- package/dist/css/rules/scroll-area.css +7 -0
- package/dist/css/rules/select.css +6 -0
- package/dist/css/rules/slider.css +2 -1
- package/dist/file-upload.js +47 -47
- package/dist/pagination.js +3 -1
- package/dist/syntax-context.d.ts +11 -2
- package/dist/syntax-context.js +34 -1
- package/dist/syntax-highlighter.js +34 -23
- package/dist/syntax-highlighting.d.ts +14 -1
- package/dist/syntax-highlighting.js +54 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ Chart, DatePicker, Markdown, Tree and syntax highlighting have their own entry p
|
|
|
55
55
|
| `Tree` from `@surflane/ui/tree` | `@headless-tree/core @headless-tree/react @tanstack/react-virtual` |
|
|
56
56
|
| `SyntaxHighlighting` from `@surflane/ui/syntax-highlighting` | `shiki` |
|
|
57
57
|
|
|
58
|
-
`CodeBlock` and `SyntaxHighlighter` render plain text until a `<SyntaxHighlighting>` provider wraps them.
|
|
58
|
+
`CodeBlock` and `SyntaxHighlighter` render plain text until a `<SyntaxHighlighting>` provider wraps them. With `<SyntaxHighlighting suspense>`, a server render waits for the tokens and writes highlighted HTML.
|
|
59
59
|
|
|
60
60
|
## Frameworks
|
|
61
61
|
|
package/dist/code.js
CHANGED
|
@@ -74,7 +74,9 @@ export const CodeBlock = /* @__PURE__ */ React.forwardRef(function CodeBlock({ c
|
|
|
74
74
|
}, copyFeedbackDuration);
|
|
75
75
|
}
|
|
76
76
|
const icon = state === 'copied' || state === 'failed' ? state : 'idle';
|
|
77
|
-
return (_jsxs("div", { ...props, ref: ref, "data-wrap": wrap || undefined, className: cx('pui-code-block', className), children: [_jsxs("div", { className: 'pui-code-toolbar', children: [_jsxs("span", { className: 'pui-code-label', children: [label, language && _jsx("span", { className: 'pui-code-language', children: language })] }), _jsxs(Button, { ref: button, type: 'button', variant: 'outline', size: 'sm', className: 'pui-code-copy', onClick: copy,
|
|
77
|
+
return (_jsxs("div", { ...props, ref: ref, "data-wrap": wrap || undefined, className: cx('pui-code-block', className), children: [_jsxs("div", { className: 'pui-code-toolbar', children: [_jsxs("span", { className: 'pui-code-label', children: [label, language && _jsx("span", { className: 'pui-code-language', children: language })] }), _jsxs(Button, { ref: button, type: 'button', variant: 'outline', size: 'sm', className: 'pui-code-copy', onClick: copy,
|
|
78
|
+
// A pending copy blocks a second start but keeps focus on the button.
|
|
79
|
+
disabled: state === 'busy', focusableWhenDisabled: true, "aria-busy": state === 'busy' || undefined, "data-copy-state": icon === 'idle' ? undefined : icon, "data-copy-used": used || undefined, children: [_jsx(Icon, { name: copyIcons[icon], className: 'pui-code-copy-icon' }, icon), _jsx("span", { "aria-live": 'polite', children: _jsx("span", { className: 'pui-code-copy-label', children: state === 'copied'
|
|
78
80
|
? copiedLabel
|
|
79
81
|
: state === 'failed'
|
|
80
82
|
? copyErrorLabel
|
|
@@ -81,9 +81,21 @@
|
|
|
81
81
|
.pui-switch[data-readonly] {
|
|
82
82
|
cursor: default;
|
|
83
83
|
}
|
|
84
|
+
/* An invalid control keeps its edge through every hover, checked or not; hover
|
|
85
|
+
still tints the fill. Each selector matches the hover rule it must outrank. */
|
|
84
86
|
:is(.pui-checkbox, .pui-radio, .pui-switch)[data-invalid],
|
|
87
|
+
:is(.pui-checkbox, .pui-radio, .pui-switch)[data-invalid]:hover,
|
|
88
|
+
:is(.pui-checkbox, .pui-radio, .pui-switch)[data-invalid]:is(
|
|
89
|
+
[data-checked],
|
|
90
|
+
[data-indeterminate]
|
|
91
|
+
):hover,
|
|
92
|
+
.pui-choice-label:hover
|
|
93
|
+
:is(.pui-checkbox, .pui-radio, .pui-switch)[data-invalid],
|
|
85
94
|
.pui-choice-label:hover
|
|
86
|
-
:is(.pui-checkbox, .pui-radio, .pui-switch)[data-invalid]
|
|
95
|
+
:is(.pui-checkbox, .pui-radio, .pui-switch)[data-invalid]:is(
|
|
96
|
+
[data-checked],
|
|
97
|
+
[data-indeterminate]
|
|
98
|
+
) {
|
|
87
99
|
border-color: var(--status-danger-fg);
|
|
88
100
|
}
|
|
89
101
|
@media (forced-colors: active) {
|
|
@@ -143,6 +143,10 @@
|
|
|
143
143
|
outline: 1px solid CanvasText;
|
|
144
144
|
outline-offset: -1px;
|
|
145
145
|
}
|
|
146
|
+
/* The group draws the field's focus, so its input draws none of its own. */
|
|
147
|
+
.pui-combobox-group .pui-combobox-input:focus {
|
|
148
|
+
outline: none;
|
|
149
|
+
}
|
|
146
150
|
}
|
|
147
151
|
.pui-combobox-group > .pui-combobox-input,
|
|
148
152
|
.pui-combobox-action {
|
|
@@ -40,7 +40,13 @@
|
|
|
40
40
|
border-color: var(--control-focus);
|
|
41
41
|
box-shadow: 0 0 0 var(--control-focus-ring-size) var(--control-focus-ring);
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
/* An invalid field keeps its edge while hovered; hover still tints the fill. */
|
|
44
|
+
:is(.pui-date-picker, .pui-time-field)[data-invalid] .pui-date-group,
|
|
45
|
+
:is(.pui-date-picker, .pui-time-field)[data-invalid]:not(
|
|
46
|
+
[data-disabled],
|
|
47
|
+
[data-readonly]
|
|
48
|
+
)
|
|
49
|
+
.pui-date-group:hover {
|
|
44
50
|
border-color: var(--status-danger-fg);
|
|
45
51
|
}
|
|
46
52
|
:is(.pui-date-picker, .pui-time-field)[data-disabled] {
|
|
@@ -53,10 +53,21 @@
|
|
|
53
53
|
cursor: not-allowed;
|
|
54
54
|
background: var(--control-disabled-background);
|
|
55
55
|
}
|
|
56
|
+
/* An invalid field keeps its edge while hovered; hover still tints the fill. */
|
|
56
57
|
.pui-input[aria-invalid='true'],
|
|
57
|
-
.pui-
|
|
58
|
+
.pui-input[aria-invalid='true']:hover,
|
|
59
|
+
.pui-select-trigger[aria-invalid='true'],
|
|
60
|
+
.pui-select-trigger[aria-invalid='true']:hover {
|
|
58
61
|
border-color: var(--status-danger-fg);
|
|
59
62
|
}
|
|
63
|
+
/* Forced colors drop the border color and the halo, so focus becomes an outline. */
|
|
64
|
+
@media (forced-colors: active) {
|
|
65
|
+
.pui-input:focus,
|
|
66
|
+
.pui-select-trigger:focus-visible {
|
|
67
|
+
outline: var(--focus-ring-width) solid Highlight;
|
|
68
|
+
outline-offset: var(--focus-ring-offset);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
60
71
|
@media (prefers-reduced-motion: reduce) {
|
|
61
72
|
.pui-input,
|
|
62
73
|
.pui-select-trigger {
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
clip-path: inset(50%);
|
|
18
18
|
white-space: nowrap;
|
|
19
19
|
}
|
|
20
|
+
/* One plane: the picker row, then a row per file. Rows meet at a line, and the
|
|
21
|
+
plane keeps one inset on every edge. */
|
|
20
22
|
.pui-file-dropzone {
|
|
23
|
+
--file-inset: var(--space-5);
|
|
21
24
|
display: flex;
|
|
22
|
-
flex-
|
|
23
|
-
|
|
24
|
-
gap: var(--space-3);
|
|
25
|
-
padding: var(--space-5);
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
padding-inline: var(--file-inset);
|
|
26
27
|
border: 1px solid transparent;
|
|
27
28
|
border-radius: var(--radius-lg);
|
|
28
29
|
background: var(--surface);
|
|
@@ -31,6 +32,13 @@
|
|
|
31
32
|
.pui-file-upload[data-dragging] .pui-file-dropzone {
|
|
32
33
|
background: var(--selected);
|
|
33
34
|
}
|
|
35
|
+
.pui-file-picker {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: var(--space-3);
|
|
40
|
+
padding-block: var(--file-inset);
|
|
41
|
+
}
|
|
34
42
|
.pui-file-trigger {
|
|
35
43
|
flex-direction: row;
|
|
36
44
|
width: fit-content;
|
|
@@ -74,18 +82,30 @@
|
|
|
74
82
|
}
|
|
75
83
|
.pui-file-list {
|
|
76
84
|
display: grid;
|
|
77
|
-
gap: var(--space-2);
|
|
78
85
|
list-style: none;
|
|
79
86
|
padding: 0;
|
|
80
87
|
margin: 0;
|
|
81
88
|
}
|
|
89
|
+
/* The file's details lead its row and its actions end it, wrapping below when the
|
|
90
|
+
row is narrow. Progress and an upload error run under the whole row. */
|
|
82
91
|
.pui-file-item {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-wrap: wrap;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: var(--space-3) var(--space-4);
|
|
83
96
|
min-width: 0;
|
|
84
|
-
padding: var(--space-
|
|
85
|
-
border-
|
|
86
|
-
|
|
97
|
+
padding-block: var(--space-3);
|
|
98
|
+
border-top: 1px solid var(--line);
|
|
99
|
+
}
|
|
100
|
+
.pui-file-item:last-child {
|
|
101
|
+
padding-block-end: var(--file-inset);
|
|
102
|
+
}
|
|
103
|
+
.pui-file-item > :not(.pui-file-info, .pui-file-actions) {
|
|
104
|
+
flex-basis: 100%;
|
|
87
105
|
}
|
|
88
106
|
.pui-file-info {
|
|
107
|
+
flex: 1 1 auto;
|
|
108
|
+
min-width: 0;
|
|
89
109
|
display: flex;
|
|
90
110
|
flex-wrap: wrap;
|
|
91
111
|
align-items: center;
|
|
@@ -107,13 +127,9 @@
|
|
|
107
127
|
display: flex;
|
|
108
128
|
flex-wrap: wrap;
|
|
109
129
|
gap: var(--space-2);
|
|
110
|
-
margin-top: var(--space-3);
|
|
111
|
-
}
|
|
112
|
-
.pui-file-item .pui-gauge {
|
|
113
|
-
margin-top: var(--space-3);
|
|
114
130
|
}
|
|
115
131
|
.pui-file-upload-error {
|
|
116
|
-
margin:
|
|
132
|
+
margin: 0;
|
|
117
133
|
color: var(--status-danger-fg);
|
|
118
134
|
font-size: var(--type-compact-size);
|
|
119
135
|
line-height: var(--type-compact-leading);
|
|
@@ -126,7 +142,7 @@
|
|
|
126
142
|
}
|
|
127
143
|
@media (max-width: 760px) {
|
|
128
144
|
.pui-file-dropzone {
|
|
129
|
-
|
|
145
|
+
--file-inset: var(--space-4);
|
|
130
146
|
}
|
|
131
147
|
}
|
|
132
148
|
@media (forced-colors: active) {
|
|
@@ -137,11 +153,6 @@
|
|
|
137
153
|
.pui-file-upload[data-dragging] .pui-file-dropzone {
|
|
138
154
|
border-color: Highlight;
|
|
139
155
|
}
|
|
140
|
-
.pui-file-item {
|
|
141
|
-
border: 1px solid CanvasText;
|
|
142
|
-
background: Canvas;
|
|
143
|
-
color: CanvasText;
|
|
144
|
-
}
|
|
145
156
|
}
|
|
146
157
|
@media (prefers-reduced-motion: reduce) {
|
|
147
158
|
.pui-file-dropzone {
|
|
@@ -151,6 +162,6 @@
|
|
|
151
162
|
}
|
|
152
163
|
@layer surflane.sizes {
|
|
153
164
|
:where([data-ui-size], [data-ui-size] *).pui-file-dropzone {
|
|
154
|
-
|
|
165
|
+
--file-inset: var(--ui-file-padding);
|
|
155
166
|
}
|
|
156
167
|
}
|
|
@@ -64,6 +64,13 @@
|
|
|
64
64
|
}
|
|
65
65
|
.pui-combobox-group[data-invalid],
|
|
66
66
|
.pui-number-group[data-invalid] {
|
|
67
|
-
border-color: var(--status-danger-
|
|
67
|
+
border-color: var(--status-danger-fg);
|
|
68
|
+
}
|
|
69
|
+
@media (forced-colors: active) {
|
|
70
|
+
.pui-combobox-group:focus-within,
|
|
71
|
+
.pui-number-group:focus-within {
|
|
72
|
+
outline: var(--focus-ring-width) solid Highlight;
|
|
73
|
+
outline-offset: var(--focus-ring-offset);
|
|
74
|
+
}
|
|
68
75
|
}
|
|
69
76
|
}
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
max-width: 100%;
|
|
13
13
|
overscroll-behavior: auto;
|
|
14
14
|
scrollbar-width: none !important;
|
|
15
|
+
outline: none;
|
|
16
|
+
}
|
|
17
|
+
/* A labeled scroller takes keyboard focus. Its root draws the shared ring, since
|
|
18
|
+
the viewport's edge fade would clip a ring drawn on the viewport itself. */
|
|
19
|
+
.pui-scroll-root:has(> .pui-scroll-viewport:focus-visible) {
|
|
20
|
+
outline: var(--focus-ring-width) solid var(--interaction-focus);
|
|
21
|
+
outline-offset: var(--focus-ring-offset);
|
|
15
22
|
}
|
|
16
23
|
.pui-scroll-viewport::-webkit-scrollbar {
|
|
17
24
|
display: none;
|
|
@@ -55,6 +55,12 @@
|
|
|
55
55
|
.pui-select-item[data-disabled] {
|
|
56
56
|
opacity: var(--control-disabled-opacity);
|
|
57
57
|
}
|
|
58
|
+
@media (forced-colors: active) {
|
|
59
|
+
.pui-select-item[data-highlighted] {
|
|
60
|
+
outline: 1px solid CanvasText;
|
|
61
|
+
outline-offset: -1px;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
58
64
|
.pui-select-check {
|
|
59
65
|
position: absolute;
|
|
60
66
|
inset-inline-start: var(--space-3);
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
border-radius: var(--radius-full);
|
|
55
55
|
background: var(--canvas);
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
/* The thumb's input is focused on pointer use too; only keyboard focus rings. */
|
|
58
|
+
.pui-slider-thumb:has(:focus-visible) {
|
|
58
59
|
outline: var(--focus-ring-width) solid var(--interaction-focus);
|
|
59
60
|
outline-offset: var(--focus-ring-offset);
|
|
60
61
|
}
|
package/dist/file-upload.js
CHANGED
|
@@ -296,51 +296,51 @@ export const FileUpload = /* @__PURE__ */ React.forwardRef(function FileUpload({
|
|
|
296
296
|
event.preventDefault();
|
|
297
297
|
select(Array.from(event.dataTransfer.files), 'drop');
|
|
298
298
|
}
|
|
299
|
-
}, children: [_jsx(Field.Control, { ref: input, id: inputId, name: name, form: form, type: 'file', accept: accept, capture: capture, multiple: multiple, required: required, disabled: disabled, className: 'pui-file-native', "aria-describedby": errorId, onKeyDown: (event) => {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
299
|
+
}, children: [_jsxs("div", { className: 'pui-file-picker', children: [_jsx(Field.Control, { ref: input, id: inputId, name: name, form: form, type: 'file', accept: accept, capture: capture, multiple: multiple, required: required, disabled: disabled, className: 'pui-file-native', "aria-describedby": errorId, onKeyDown: (event) => {
|
|
300
|
+
if (event.key === 'Enter')
|
|
301
|
+
event.preventBaseUIHandler();
|
|
302
|
+
}, onChange: (event) => {
|
|
303
|
+
if (!notifying.current) {
|
|
304
|
+
event.preventBaseUIHandler();
|
|
305
|
+
select(Array.from(event.currentTarget.files ?? []), 'select');
|
|
306
|
+
}
|
|
307
|
+
} }), _jsxs("label", { htmlFor: inputId, className: 'pui-button pui-file-trigger', "data-variant": 'outline', "data-disabled": disabled || undefined, children: [_jsx(Icon, { name: 'upload' }), labels.choose] }), _jsx("p", { className: 'pui-file-drop-hint', children: labels.drop })] }), files.length > 0 && (_jsx("ul", { className: 'pui-file-list', role: 'list', children: files.map((file) => {
|
|
308
|
+
const state = metadata.get(file) ?? {
|
|
309
|
+
status: 'ready',
|
|
310
|
+
progress: null,
|
|
311
|
+
};
|
|
312
|
+
if (!identities.current.has(file))
|
|
313
|
+
identities.current.set(file, `${uid}-${++nextId.current}`);
|
|
314
|
+
const itemId = identities.current.get(file);
|
|
315
|
+
return (_jsxs("li", { className: 'pui-file-item', children: [_jsxs("div", { className: 'pui-file-info', children: [_jsx("strong", { children: file.name }), _jsx("span", { children: sizeLabel(file.size) }), _jsx(StatusBadge, { tone: state.status === 'success'
|
|
316
|
+
? 'success'
|
|
317
|
+
: state.status === 'error'
|
|
318
|
+
? 'danger'
|
|
319
|
+
: state.status === 'uploading'
|
|
320
|
+
? 'progress'
|
|
321
|
+
: 'neutral', children: labels.status[state.status] })] }), _jsxs("div", { className: 'pui-file-actions', children: [transport && state.status !== 'success' && (_jsx(Button, { ref: (node) => {
|
|
322
|
+
if (node)
|
|
323
|
+
primaryButtons.current.set(file, node);
|
|
324
|
+
else
|
|
325
|
+
primaryButtons.current.delete(file);
|
|
326
|
+
}, type: 'button', size: 'sm', variant: 'outline', disabled: disabled, "aria-describedby": state.error ? `${itemId}-error` : undefined, "aria-label": `${state.status === 'uploading' ? labels.cancel : state.status === 'ready' ? labels.upload : labels.retry} ${file.name}`, onClick: () => {
|
|
327
|
+
if (state.status === 'uploading')
|
|
328
|
+
cancel(file);
|
|
329
|
+
else
|
|
330
|
+
void upload(file);
|
|
331
|
+
}, children: state.status === 'uploading'
|
|
332
|
+
? labels.cancel
|
|
333
|
+
: state.status === 'ready'
|
|
334
|
+
? labels.upload
|
|
335
|
+
: labels.retry })), _jsx(Button, { type: 'button', size: 'sm', variant: 'ghost', ref: (node) => {
|
|
336
|
+
if (node)
|
|
337
|
+
removeButtons.current.set(file, node);
|
|
338
|
+
else
|
|
339
|
+
removeButtons.current.delete(file);
|
|
340
|
+
}, disabled: disabled, "aria-label": `${labels.remove} ${file.name}`, onClick: () => {
|
|
341
|
+
setRejections([]);
|
|
342
|
+
if (commit(files.filter((current) => current !== file), 'remove'))
|
|
343
|
+
focusPicker.current = file;
|
|
344
|
+
}, children: labels.remove })] }), state.status === 'uploading' && (_jsx(Progress.Root, { value: state.progress, "aria-label": labels.progress(file), children: _jsx(Progress.Track, { children: _jsx(Progress.Indicator, {}) }) })), state.error && (_jsx("p", { id: `${itemId}-error`, className: 'pui-file-upload-error', children: state.error }))] }, itemId));
|
|
345
|
+
}) }))] }), _jsx("div", { id: errorId, role: 'alert', className: 'pui-file-errors', children: rejections.length > 0 && (_jsx("ul", { children: rejections.map((rejection, index) => (_jsx("li", { children: labels.rejection(rejection) }, index))) })) }), _jsx(Field.Error, {}), _jsx("span", { className: 'pui-feedback-sr', role: 'status', children: announcement })] }));
|
|
346
346
|
});
|
package/dist/pagination.js
CHANGED
|
@@ -32,8 +32,10 @@ export const Pagination = /* @__PURE__ */ React.forwardRef(function Pagination({
|
|
|
32
32
|
'aria-label': name,
|
|
33
33
|
'aria-current': currentPage ? 'page' : undefined,
|
|
34
34
|
};
|
|
35
|
+
// An inactive page is a link with no destination: it keeps its name and reads as
|
|
36
|
+
// unavailable, as a disabled Button does. A plain span could carry neither.
|
|
35
37
|
if (getPageHref)
|
|
36
|
-
return inactive ? (_jsx("
|
|
38
|
+
return inactive ? (_jsx("a", { ...attributes, role: 'link', "aria-disabled": 'true', children: children })) : (_jsx("a", { ...attributes, href: getPageHref(target), children: children }));
|
|
37
39
|
return (_jsx(Button, { ...attributes, variant: 'ghost', type: 'button', disabled: inactive, onClick: () => {
|
|
38
40
|
if (target !== current)
|
|
39
41
|
onPageChange?.(target);
|
package/dist/syntax-context.d.ts
CHANGED
|
@@ -3,5 +3,14 @@ import type { SyntaxToken } from './syntax-engine.js';
|
|
|
3
3
|
export type SyntaxEngine = {
|
|
4
4
|
highlightCode(code: string, language: string): Promise<SyntaxToken[][] | null>;
|
|
5
5
|
};
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Loads the highlighting engine, and says whether code waits for its tokens inside
|
|
8
|
+
* Suspense. Without a provider, code renders as plain text.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SyntaxEngineContext: React.Context<{
|
|
11
|
+
load: () => Promise<SyntaxEngine>;
|
|
12
|
+
suspense: boolean;
|
|
13
|
+
} | null>;
|
|
14
|
+
export declare function requestTokens(load: () => Promise<SyntaxEngine>, code: string, language: string): Promise<SyntaxToken[][] | null>;
|
|
15
|
+
/** Records tokens already known, such as those a server render wrote. */
|
|
16
|
+
export declare function provideTokens(code: string, language: string, tokens: SyntaxToken[][]): void;
|
package/dist/syntax-context.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import * as React from 'react';
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Loads the highlighting engine, and says whether code waits for its tokens inside
|
|
5
|
+
* Suspense. Without a provider, code renders as plain text.
|
|
6
|
+
*/
|
|
4
7
|
export const SyntaxEngineContext = /* @__PURE__ */ React.createContext(null);
|
|
8
|
+
// One request per language and code, so a render that suspended finds its tokens again.
|
|
9
|
+
// A settled request carries React's status and value, so use() reads it at once.
|
|
10
|
+
const requests = new Map();
|
|
11
|
+
export function requestTokens(load, code, language) {
|
|
12
|
+
const key = `${language}\n${code}`;
|
|
13
|
+
let pending = requests.get(key);
|
|
14
|
+
if (!pending) {
|
|
15
|
+
const request = load()
|
|
16
|
+
.then((engine) => engine.highlightCode(code, language))
|
|
17
|
+
.catch(() => null);
|
|
18
|
+
void request.then((tokens) => Object.assign(request, { status: 'fulfilled', value: tokens }));
|
|
19
|
+
pending = request;
|
|
20
|
+
remember(key, pending);
|
|
21
|
+
}
|
|
22
|
+
return pending;
|
|
23
|
+
}
|
|
24
|
+
/** Records tokens already known, such as those a server render wrote. */
|
|
25
|
+
export function provideTokens(code, language, tokens) {
|
|
26
|
+
const key = `${language}\n${code}`;
|
|
27
|
+
if (!requests.has(key))
|
|
28
|
+
remember(key, Object.assign(Promise.resolve(tokens), {
|
|
29
|
+
status: 'fulfilled',
|
|
30
|
+
value: tokens,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
function remember(key, request) {
|
|
34
|
+
requests.set(key, request);
|
|
35
|
+
if (requests.size > 500)
|
|
36
|
+
requests.delete(requests.keys().next().value);
|
|
37
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { ScrollArea } from './scroll-area.js';
|
|
5
|
-
import { SyntaxEngineContext } from './syntax-context.js';
|
|
5
|
+
import { SyntaxEngineContext, requestTokens } from './syntax-context.js';
|
|
6
6
|
import { cx } from './parts.js';
|
|
7
7
|
export const syntaxLanguages = [
|
|
8
8
|
'tsx',
|
|
@@ -31,41 +31,52 @@ const aliases = {
|
|
|
31
31
|
plaintext: 'text',
|
|
32
32
|
txt: 'text',
|
|
33
33
|
};
|
|
34
|
+
function Highlighted({ tokens, render, }) {
|
|
35
|
+
return render(React.use(tokens));
|
|
36
|
+
}
|
|
34
37
|
/**
|
|
35
38
|
* Rendering remains selectable React text; it never inserts executable HTML. Tokens are
|
|
36
39
|
* colored inside a SyntaxHighlighting provider; without one, the code renders plain.
|
|
37
40
|
*/
|
|
38
41
|
export const SyntaxHighlighter = /* @__PURE__ */ React.forwardRef(function SyntaxHighlighter({ code, language = 'text', inline = false, label = 'Highlighted source', wrap = false, className, ...props }, ref) {
|
|
39
|
-
const
|
|
42
|
+
const source = React.useContext(SyntaxEngineContext);
|
|
40
43
|
const name = language.trim().toLowerCase();
|
|
41
44
|
const lang = aliases[name] ?? name;
|
|
45
|
+
const highlightable = source !== null &&
|
|
46
|
+
lang !== 'text' &&
|
|
47
|
+
syntaxLanguages.includes(lang) &&
|
|
48
|
+
code.length <= 50000;
|
|
49
|
+
// Without suspense, code shows plain until an effect brings its tokens.
|
|
42
50
|
const [result, setResult] = React.useState();
|
|
51
|
+
// A block waits only for the code it mounted with. Code that changes later is
|
|
52
|
+
// highlighted in place, so a boundary never hides shown code behind its fallback.
|
|
53
|
+
const [first] = React.useState(() => ({ code, lang }));
|
|
54
|
+
const suspense = highlightable &&
|
|
55
|
+
source.suspense &&
|
|
56
|
+
first.code === code &&
|
|
57
|
+
first.lang === lang;
|
|
43
58
|
React.useEffect(() => {
|
|
44
|
-
|
|
45
|
-
if (!load ||
|
|
46
|
-
lang === 'text' ||
|
|
47
|
-
!syntaxLanguages.includes(lang) ||
|
|
48
|
-
code.length > 50000)
|
|
59
|
+
if (!highlightable || suspense)
|
|
49
60
|
return;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (current)
|
|
61
|
+
let live = true;
|
|
62
|
+
void requestTokens(source.load, code, lang).then((tokens) => {
|
|
63
|
+
if (live)
|
|
54
64
|
setResult({ code, language: lang, tokens });
|
|
55
|
-
})
|
|
56
|
-
.catch(() => {
|
|
57
|
-
if (current)
|
|
58
|
-
setResult({ code, language: lang, tokens: null });
|
|
59
65
|
});
|
|
60
66
|
return () => {
|
|
61
|
-
|
|
67
|
+
live = false;
|
|
62
68
|
};
|
|
63
|
-
}, [code, lang,
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
}, [code, lang, source, highlightable, suspense]);
|
|
70
|
+
const render = (tokens) => {
|
|
71
|
+
const eols = code.match(/\r\n|\n|\r/g) ?? [];
|
|
72
|
+
const valid = tokens && tokens.length === eols.length + 1;
|
|
73
|
+
return (_jsx("code", { ...props, ref: ref, className: cx('pui-syntax', className), "data-language": lang, "data-highlighted": valid ? 'true' : 'false', children: valid
|
|
74
|
+
? tokens.map((line, i) => (_jsxs(React.Fragment, { children: [line.map((token, j) => (_jsx("span", { "data-syntax": token.role, children: token.content }, j))), eols[i] ?? ''] }, i)))
|
|
75
|
+
: code }));
|
|
76
|
+
};
|
|
77
|
+
// With suspense, the block waits for its tokens behind its own boundary, plain
|
|
78
|
+
// meanwhile: a server render writes it highlighted and hydration keeps that HTML,
|
|
79
|
+
// while a block that appears later never suspends anything around it.
|
|
80
|
+
const element = suspense ? (_jsx(React.Suspense, { fallback: render(null), children: _jsx(Highlighted, { tokens: requestTokens(source.load, code, lang), render: render }) })) : (render(result?.code === code && result.language === lang ? result.tokens : null));
|
|
70
81
|
return inline ? (element) : (_jsx(ScrollArea, { label: label, orientation: 'horizontal', rootClassName: 'pui-syntax-scroll', children: _jsx("pre", { className: 'pui-syntax-pre', "data-wrap": wrap || undefined, children: element }) }));
|
|
71
82
|
});
|
|
@@ -2,7 +2,20 @@ import * as React from 'react';
|
|
|
2
2
|
/**
|
|
3
3
|
* Turns on syntax highlighting for CodeBlock, SyntaxHighlighter and Markdown inside it.
|
|
4
4
|
* Requires the optional `shiki` package; without this provider, code renders as plain text.
|
|
5
|
+
* Code shows as plain text until its tokens arrive. With `suspense`, each block waits
|
|
6
|
+
* for them behind its own Suspense boundary instead, so a server render writes it
|
|
7
|
+
* highlighted and hydration keeps that HTML. Nothing around a block suspends.
|
|
5
8
|
*/
|
|
6
|
-
export declare function SyntaxHighlighting({ children, }: {
|
|
9
|
+
export declare function SyntaxHighlighting({ children, suspense, }: {
|
|
7
10
|
children?: React.ReactNode;
|
|
11
|
+
suspense?: boolean;
|
|
8
12
|
}): React.JSX.Element;
|
|
13
|
+
/**
|
|
14
|
+
* Reads the tokens of the server-rendered code under `root` from its HTML, so
|
|
15
|
+
* hydration needs neither the engine nor a grammar. With `suspense`, await it before
|
|
16
|
+
* `hydrateRoot`: every block then hydrates at once, so no boundary is left waiting for
|
|
17
|
+
* its tokens, where a context change above it would make React drop the highlighted
|
|
18
|
+
* HTML for the plain fallback. A block whose HTML doesn't read back as its code is
|
|
19
|
+
* highlighted again.
|
|
20
|
+
*/
|
|
21
|
+
export declare function preloadSyntaxHighlighting(root?: ParentNode): Promise<undefined>;
|
|
@@ -1,12 +1,63 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { SyntaxEngineContext } from './syntax-context.js';
|
|
3
|
+
import { SyntaxEngineContext, provideTokens, requestTokens, } from './syntax-context.js';
|
|
4
4
|
// The engine and each grammar load on first use, in their own chunks.
|
|
5
5
|
const load = () => import('./syntax-engine.js');
|
|
6
|
+
const sources = {
|
|
7
|
+
plain: { load, suspense: false },
|
|
8
|
+
suspense: { load, suspense: true },
|
|
9
|
+
};
|
|
6
10
|
/**
|
|
7
11
|
* Turns on syntax highlighting for CodeBlock, SyntaxHighlighter and Markdown inside it.
|
|
8
12
|
* Requires the optional `shiki` package; without this provider, code renders as plain text.
|
|
13
|
+
* Code shows as plain text until its tokens arrive. With `suspense`, each block waits
|
|
14
|
+
* for them behind its own Suspense boundary instead, so a server render writes it
|
|
15
|
+
* highlighted and hydration keeps that HTML. Nothing around a block suspends.
|
|
9
16
|
*/
|
|
10
|
-
export function SyntaxHighlighting({ children, }) {
|
|
11
|
-
return (_jsx(SyntaxEngineContext.Provider, { value:
|
|
17
|
+
export function SyntaxHighlighting({ children, suspense = false, }) {
|
|
18
|
+
return (_jsx(SyntaxEngineContext.Provider, { value: suspense ? sources.suspense : sources.plain, children: children }));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reads the tokens of the server-rendered code under `root` from its HTML, so
|
|
22
|
+
* hydration needs neither the engine nor a grammar. With `suspense`, await it before
|
|
23
|
+
* `hydrateRoot`: every block then hydrates at once, so no boundary is left waiting for
|
|
24
|
+
* its tokens, where a context change above it would make React drop the highlighted
|
|
25
|
+
* HTML for the plain fallback. A block whose HTML doesn't read back as its code is
|
|
26
|
+
* highlighted again.
|
|
27
|
+
*/
|
|
28
|
+
export function preloadSyntaxHighlighting(root = document) {
|
|
29
|
+
return Promise.all(Array.from(root.querySelectorAll('code.pui-syntax[data-highlighted="true"]'), (code) => {
|
|
30
|
+
const text = code.textContent ?? '';
|
|
31
|
+
const language = code.dataset.language;
|
|
32
|
+
const tokens = readTokens(code, text);
|
|
33
|
+
if (tokens)
|
|
34
|
+
provideTokens(text, language, tokens);
|
|
35
|
+
else
|
|
36
|
+
return requestTokens(load, text, language);
|
|
37
|
+
})).then(() => undefined);
|
|
38
|
+
}
|
|
39
|
+
// SyntaxHighlighter writes a line's tokens as spans and ends it with its line break.
|
|
40
|
+
function readTokens(code, text) {
|
|
41
|
+
const lines = [[]];
|
|
42
|
+
let read = '';
|
|
43
|
+
for (const node of code.childNodes) {
|
|
44
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
45
|
+
const content = node.textContent ?? '';
|
|
46
|
+
lines[lines.length - 1].push({
|
|
47
|
+
content,
|
|
48
|
+
role: node.dataset.syntax ?? '',
|
|
49
|
+
});
|
|
50
|
+
read += content;
|
|
51
|
+
}
|
|
52
|
+
else if (node.nodeType === Node.TEXT_NODE) {
|
|
53
|
+
const breaks = node.textContent ?? '';
|
|
54
|
+
if (!/^(\r\n|\n|\r)*$/.test(breaks))
|
|
55
|
+
return null;
|
|
56
|
+
const count = breaks.match(/\r\n|\n|\r/g)?.length ?? 0;
|
|
57
|
+
for (let line = 0; line < count; line++)
|
|
58
|
+
lines.push([]);
|
|
59
|
+
read += breaks;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return read === text ? lines : null;
|
|
12
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@surflane/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Surflane React components built on Base UI, with data, chart, code and Markdown components.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"design-system",
|
|
@@ -407,7 +407,7 @@
|
|
|
407
407
|
"peerDependencies": {
|
|
408
408
|
"@headless-tree/core": "^1.8.0",
|
|
409
409
|
"@headless-tree/react": "^1.8.0",
|
|
410
|
-
"@surflane/design-tokens": "^0.
|
|
410
|
+
"@surflane/design-tokens": "^0.9.0",
|
|
411
411
|
"@tanstack/react-virtual": "^3.14.21",
|
|
412
412
|
"react": "^19.0.0",
|
|
413
413
|
"react-aria-components": "^1.22.1",
|