@surflane/ui 0.7.0 → 0.8.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/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, disabled: state === 'busy', "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'
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
- :is(.pui-date-picker, .pui-time-field)[data-invalid] .pui-date-group {
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-select-trigger[aria-invalid='true'] {
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-wrap: wrap;
23
- align-items: center;
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-4);
85
- border-radius: var(--radius-md);
86
- background: var(--surface);
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: var(--space-2) 0 0;
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
- padding: var(--space-4);
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
- padding: var(--ui-file-padding);
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-border);
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
- .pui-slider-thumb:focus-within {
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
  }
@@ -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
- 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 })] }), _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, {}), 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] })] }), 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 })), _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 })] })] }, itemId));
345
- }) })), _jsx("span", { className: 'pui-visually-hidden', role: 'status', children: announcement })] }));
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
  });
@@ -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("span", { ...attributes, "aria-disabled": 'true', children: children })) : (_jsx("a", { ...attributes, href: getPageHref(target), children: children }));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surflane/ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.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.7.0",
410
+ "@surflane/design-tokens": "^0.8.0",
411
411
  "@tanstack/react-virtual": "^3.14.21",
412
412
  "react": "^19.0.0",
413
413
  "react-aria-components": "^1.22.1",