anentrypoint-design 0.0.172 → 0.0.174
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/247420.js +22 -17
- package/package.json +1 -1
- package/src/components/content.js +52 -11
- package/src/components/files-modals.js +80 -65
- package/src/components/files.js +26 -20
- package/src/components/form-primitives.js +5 -2
- package/src/components/freddie/runtime.js +1 -1
- package/src/components/freddie.js +65 -41
- package/src/components/interaction-primitives.js +25 -2
- package/src/components/overlay-primitives.js +75 -31
- package/src/components/shell.js +11 -7
- package/src/page-html.js +17 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.174",
|
|
4
4
|
"description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/247420.js",
|
|
@@ -109,7 +109,12 @@ export function WorksList({ works = [], openedIndex = -1, onToggle }) {
|
|
|
109
109
|
Row({
|
|
110
110
|
code: w.code,
|
|
111
111
|
title: w.title, sub: w.sub,
|
|
112
|
-
|
|
112
|
+
// Expand affordance: a chevron icon (down when open, right when
|
|
113
|
+
// collapsed) separated from the meta text by a CSS gap, not a
|
|
114
|
+
// literal +/- with a double-space.
|
|
115
|
+
meta: h('span', { class: 'ds-works-meta', style: 'display:inline-flex;align-items:center;gap:.4em' },
|
|
116
|
+
w.meta != null ? h('span', {}, w.meta) : null,
|
|
117
|
+
Icon(isOpen ? 'chevron-down' : 'chevron-right')),
|
|
113
118
|
active: isOpen,
|
|
114
119
|
onClick: () => onToggle && onToggle(isOpen ? -1 : i)
|
|
115
120
|
}),
|
|
@@ -163,15 +168,19 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
|
|
|
163
168
|
const c = row[0];
|
|
164
169
|
return c == null ? 'row' : (typeof c === 'object' ? 'row' : String(c));
|
|
165
170
|
};
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
// Native <table>/<tr>/<th>/<td> already carry the correct implicit ARIA
|
|
172
|
+
// roles — explicit role="table"/row/columnheader/cell is redundant and only
|
|
173
|
+
// risks overriding native semantics, so it is omitted.
|
|
174
|
+
return h('table', {},
|
|
175
|
+
h('thead', {}, h('tr', {}, ...headers.map((hd, i) => h('th', { key: i, scope: 'col' }, hd)))),
|
|
168
176
|
h('tbody', {}, ...rows.map((row, i) => h('tr', {
|
|
169
177
|
key: i,
|
|
170
178
|
class: onRowClick ? 'clickable' : '',
|
|
171
|
-
role: 'row',
|
|
172
179
|
onclick: onRowClick ? () => onRowClick(i) : null,
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
// Space scrolls by default — preventDefault on Space (and Enter) so
|
|
181
|
+
// keyboard activation matches click without page jump.
|
|
182
|
+
...(onRowClick ? { tabindex: '0', role: 'button', 'aria-label': 'open ' + labelFor(row, i), onkeydown: (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onRowClick(i); } } } : {})
|
|
183
|
+
}, ...row.map((c, j) => h('td', { key: j }, c == null ? '' : (typeof c === 'object' ? c : String(c))))))));
|
|
175
184
|
}
|
|
176
185
|
|
|
177
186
|
export function HomeView({ state = {}, onNav, onToggleWork, works = [], posts = [], manifesto = [], currentlyShipping } = {}) {
|
|
@@ -256,17 +265,21 @@ export function SearchInput({ value = '', placeholder = 'search…', onInput, on
|
|
|
256
265
|
});
|
|
257
266
|
}
|
|
258
267
|
|
|
259
|
-
export function TextField({ label, value = '', type = 'text', placeholder = '', onInput, onChange, name, key, hint, multiline, rows = 4, maxLength }) {
|
|
268
|
+
export function TextField({ label, value = '', type = 'text', placeholder = '', onInput, onChange, name, key, hint, multiline, rows = 4, maxLength, min, max, 'aria-label': ariaLabel }) {
|
|
260
269
|
const input = multiline
|
|
261
270
|
? h('textarea', {
|
|
262
271
|
key: 'i', name, rows, placeholder, value,
|
|
263
272
|
maxlength: maxLength != null ? maxLength : null,
|
|
273
|
+
'aria-label': ariaLabel || null,
|
|
264
274
|
oninput: onInput ? (e) => onInput(e.target.value, e) : null,
|
|
265
275
|
onchange: onChange ? (e) => onChange(e.target.value, e) : null
|
|
266
276
|
})
|
|
267
277
|
: h('input', {
|
|
268
278
|
key: 'i', type, name, placeholder, value,
|
|
269
279
|
maxlength: maxLength != null ? maxLength : null,
|
|
280
|
+
min: min != null ? String(min) : null,
|
|
281
|
+
max: max != null ? String(max) : null,
|
|
282
|
+
'aria-label': ariaLabel || null,
|
|
270
283
|
oninput: onInput ? (e) => onInput(e.target.value, e) : null,
|
|
271
284
|
onchange: onChange ? (e) => onChange(e.target.value, e) : null
|
|
272
285
|
});
|
|
@@ -320,9 +333,19 @@ export function EventList({ items, events, emptyText = 'no events', rankPad = 3
|
|
|
320
333
|
export function Form({ fields = [], submit = 'submit', onSubmit, columns = 1 }) {
|
|
321
334
|
const cols = columns > 1 ? String(columns) : null;
|
|
322
335
|
return h('form', { class: 'row-form', 'data-columns': cols, onsubmit: (ev) => { ev.preventDefault(); onSubmit && onSubmit(ev); } },
|
|
323
|
-
...fields.map((f, i) =>
|
|
324
|
-
|
|
325
|
-
|
|
336
|
+
...fields.map((f, i) => {
|
|
337
|
+
// Each control gets a stable id and an associated <label> so the
|
|
338
|
+
// placeholder is no longer the only (inaccessible) name. The label
|
|
339
|
+
// text falls back to label -> placeholder -> name.
|
|
340
|
+
const fieldId = 'ds-form-' + (f.name || 'field') + '-' + i;
|
|
341
|
+
const labelText = f.label != null ? f.label : (f.placeholder || f.name || '');
|
|
342
|
+
const control = f.kind === 'textarea'
|
|
343
|
+
? h('textarea', { key: 'i', id: fieldId, name: f.name, placeholder: f.placeholder || '', rows: f.rows || 4, required: f.required ? true : null })
|
|
344
|
+
: h('input', { key: 'i', id: fieldId, name: f.name, type: f.type || 'text', placeholder: f.placeholder || '', value: f.value || '', required: f.required ? true : null });
|
|
345
|
+
return h('label', { key: i, class: 'ds-field', for: fieldId },
|
|
346
|
+
labelText !== '' ? h('span', { key: 'l', class: 'ds-field-label' }, labelText) : null,
|
|
347
|
+
control);
|
|
348
|
+
}),
|
|
326
349
|
h('button', { type: 'submit', class: 'btn-primary' }, submit));
|
|
327
350
|
}
|
|
328
351
|
|
|
@@ -339,13 +362,31 @@ export function Spinner({ size = 'base', tone = 'accent', label = 'loading', key
|
|
|
339
362
|
);
|
|
340
363
|
}
|
|
341
364
|
|
|
365
|
+
// Clamp a caller-supplied CSS length to a sane range so a raw prop like
|
|
366
|
+
// height="9999px" can't blow out the layout. Accepts a CSS length string
|
|
367
|
+
// (px/em/rem/%/vh/vw) or a bare number (treated as px); rejects anything else
|
|
368
|
+
// back to the default. Numeric values are clamped to [2, 600] (px-equivalent).
|
|
369
|
+
function clampLen(v, fallback) {
|
|
370
|
+
if (v == null) return fallback;
|
|
371
|
+
const s = String(v).trim();
|
|
372
|
+
const m = /^(\d+(?:\.\d+)?)(px|em|rem|%|vh|vw)?$/.exec(s);
|
|
373
|
+
if (!m) return fallback;
|
|
374
|
+
const unit = m[2] || 'px';
|
|
375
|
+
let n = parseFloat(m[1]);
|
|
376
|
+
if (unit === '%' || unit === 'vh' || unit === 'vw') n = Math.min(100, Math.max(0, n));
|
|
377
|
+
else n = Math.min(600, Math.max(2, n));
|
|
378
|
+
return n + unit;
|
|
379
|
+
}
|
|
380
|
+
|
|
342
381
|
export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'loading content', key } = {}) {
|
|
382
|
+
const h_ = clampLen(height, '1em');
|
|
383
|
+
const w_ = clampLen(width, '100%');
|
|
343
384
|
return h('div', {
|
|
344
385
|
key, class: 'ds-skeleton-group',
|
|
345
386
|
role: 'status', 'aria-busy': 'true', 'aria-label': label
|
|
346
387
|
},
|
|
347
388
|
...Array(count).fill(0).map((_, i) =>
|
|
348
|
-
h('div', { key: String(i), class: 'ds-skeleton', style: `height:${
|
|
389
|
+
h('div', { key: String(i), class: 'ds-skeleton', style: `height:${h_};width:${w_};`, 'aria-hidden': 'true' })
|
|
349
390
|
)
|
|
350
391
|
);
|
|
351
392
|
}
|
|
@@ -6,14 +6,18 @@ import { fileGlyph, fmtFileSize } from './files.js';
|
|
|
6
6
|
const h = webjsx.createElement;
|
|
7
7
|
|
|
8
8
|
function Backdrop({ onClose, children, kind = '' } = {}) {
|
|
9
|
+
// webjsx invokes a ref callback with the element on mount and with null on
|
|
10
|
+
// unmount. We stash the per-element keydown teardown on the node itself so
|
|
11
|
+
// the null branch can run it — otherwise the document/element listener leaks
|
|
12
|
+
// once the modal is removed.
|
|
9
13
|
const backdropRef = (el) => {
|
|
10
|
-
if (!el) return;
|
|
14
|
+
if (!el) return; // unmount (ref(null)) handled by wrapper below
|
|
11
15
|
const modal = el.querySelector('.ds-modal');
|
|
12
16
|
if (!modal) return;
|
|
13
17
|
|
|
14
18
|
// Focus trap: handle Tab key to cycle focus within modal
|
|
15
19
|
const focusables = modal.querySelectorAll(
|
|
16
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
20
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
17
21
|
);
|
|
18
22
|
const firstFocusable = focusables[0];
|
|
19
23
|
const lastFocusable = focusables[focusables.length - 1];
|
|
@@ -46,75 +50,86 @@ function Backdrop({ onClose, children, kind = '' } = {}) {
|
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
el.addEventListener('keydown', handleKeydown);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return () => el.removeEventListener('keydown', handleKeydown);
|
|
53
|
+
el._dsModalTeardown = () => el.removeEventListener('keydown', handleKeydown);
|
|
54
|
+
// Auto-focus on open — always, not only when focus sits on <body>.
|
|
55
|
+
// Prefer an element explicitly marked [autofocus].
|
|
56
|
+
const preferred = modal.querySelector('[autofocus]') || firstFocusable;
|
|
57
|
+
if (preferred) preferred.focus();
|
|
55
58
|
};
|
|
56
59
|
|
|
57
60
|
return h('div', {
|
|
58
61
|
class: 'ds-modal-backdrop',
|
|
59
|
-
ref:
|
|
62
|
+
ref: (el) => {
|
|
63
|
+
if (el) backdropRef(el);
|
|
64
|
+
else if (Backdrop._last && Backdrop._last._dsModalTeardown) { Backdrop._last._dsModalTeardown(); Backdrop._last = null; }
|
|
65
|
+
if (el) Backdrop._last = el;
|
|
66
|
+
},
|
|
60
67
|
onclick: (e) => { if (e.target === e.currentTarget && onClose) onClose(); }
|
|
61
68
|
},
|
|
62
69
|
h('div', { class: 'ds-modal' + (kind ? ' ds-modal-' + kind : '') }, ...(Array.isArray(children) ? children : [children]))
|
|
63
70
|
);
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
// Shared modal shell: head + body + actions row. ConfirmDialog/PromptDialog/
|
|
74
|
+
// FileViewer all funnel through this so the ds-modal markup is authored once.
|
|
75
|
+
// `actions` is an array of vnodes (already using the Btn primitive). Any of the
|
|
76
|
+
// slots may be omitted.
|
|
77
|
+
function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions } = {}) {
|
|
67
78
|
return Backdrop({
|
|
79
|
+
onClose,
|
|
80
|
+
kind,
|
|
81
|
+
children: [
|
|
82
|
+
head != null ? h('div', { class: ('ds-modal-head' + (headClass ? ' ' + headClass : '')), ...headAttrs }, ...(Array.isArray(head) ? head : [head])) : null,
|
|
83
|
+
body != null ? h('div', { class: bodyClass, ...bodyAttrs }, ...(Array.isArray(body) ? body : [body])) : null,
|
|
84
|
+
actions != null ? h('div', { class: 'ds-modal-actions' }, ...(Array.isArray(actions) ? actions : [actions])) : null,
|
|
85
|
+
].filter(Boolean)
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel } = {}) {
|
|
90
|
+
return Modal({
|
|
68
91
|
onClose: onCancel,
|
|
69
92
|
kind: 'small',
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
h('button', {
|
|
76
|
-
class: destructive ? 'btn-primary danger' : 'btn-primary',
|
|
77
|
-
onclick: onConfirm
|
|
78
|
-
}, confirmLabel)
|
|
79
|
-
)
|
|
93
|
+
head: title,
|
|
94
|
+
body: message || '',
|
|
95
|
+
actions: [
|
|
96
|
+
Btn({ onClick: onCancel, children: cancelLabel }),
|
|
97
|
+
Btn({ primary: true, danger: !!destructive, onClick: onConfirm, children: confirmLabel })
|
|
80
98
|
]
|
|
81
99
|
});
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput } = {}) {
|
|
85
|
-
return
|
|
103
|
+
return Modal({
|
|
86
104
|
onClose: onCancel,
|
|
87
105
|
kind: 'small',
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
}, confirmLabel)
|
|
117
|
-
)
|
|
106
|
+
head: title,
|
|
107
|
+
body: h('input', {
|
|
108
|
+
class: 'input ds-modal-input',
|
|
109
|
+
type: 'text',
|
|
110
|
+
value,
|
|
111
|
+
placeholder,
|
|
112
|
+
autofocus: true,
|
|
113
|
+
oninput: (e) => onInput && onInput(e.target.value),
|
|
114
|
+
onkeydown: (e) => {
|
|
115
|
+
if (e.key === 'Enter') { e.preventDefault(); onConfirm && onConfirm(e.target.value); }
|
|
116
|
+
if (e.key === 'Escape') { e.preventDefault(); onCancel && onCancel(); }
|
|
117
|
+
}
|
|
118
|
+
}),
|
|
119
|
+
actions: [
|
|
120
|
+
Btn({ onClick: onCancel, children: cancelLabel }),
|
|
121
|
+
Btn({
|
|
122
|
+
primary: true,
|
|
123
|
+
// Read the live input value, not the closed-over `value` prop:
|
|
124
|
+
// consumers update their state in oninput without re-rendering
|
|
125
|
+
// (to avoid caret jump), so the prop is stale at click time.
|
|
126
|
+
onClick: (e) => {
|
|
127
|
+
if (!onConfirm) return;
|
|
128
|
+
const inp = e.currentTarget.closest('.ds-modal')?.querySelector('.ds-modal-input');
|
|
129
|
+
onConfirm(inp ? inp.value : value);
|
|
130
|
+
},
|
|
131
|
+
children: confirmLabel
|
|
132
|
+
})
|
|
118
133
|
]
|
|
119
134
|
});
|
|
120
135
|
}
|
|
@@ -124,7 +139,7 @@ export function FilePreviewMedia({ src, type = 'other', name } = {}) {
|
|
|
124
139
|
if (type === 'video') return h('video', { class: 'ds-preview-media', src, controls: true });
|
|
125
140
|
if (type === 'audio') return h('audio', { class: 'ds-preview-audio', src, controls: true });
|
|
126
141
|
return h('div', { class: 'ds-preview-fallback' },
|
|
127
|
-
h('span', { class: 'ds-preview-glyph' }, fileGlyph(type)),
|
|
142
|
+
h('span', { class: 'ds-preview-glyph', 'aria-hidden': 'true' }, Icon(fileGlyph(type))),
|
|
128
143
|
h('span', {}, 'no inline preview for ' + (type || 'this file'))
|
|
129
144
|
);
|
|
130
145
|
}
|
|
@@ -146,21 +161,21 @@ export function FileViewer({ file, body, onClose, onAction } = {}) {
|
|
|
146
161
|
if (!file) return null;
|
|
147
162
|
const meta = [file.type, file.size != null ? fmtFileSize(file.size) : null, file.modified || null]
|
|
148
163
|
.filter(Boolean).join(' · ');
|
|
149
|
-
return
|
|
164
|
+
return Modal({
|
|
150
165
|
onClose,
|
|
151
166
|
kind: 'preview',
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
)
|
|
160
|
-
),
|
|
161
|
-
h('div', { class: 'ds-preview-body', 'data-file-type': file.type || 'other' },
|
|
162
|
-
...(Array.isArray(body) ? body : [body])
|
|
167
|
+
headClass: 'ds-preview-head',
|
|
168
|
+
headAttrs: { 'data-file-type': file.type || 'other' },
|
|
169
|
+
head: [
|
|
170
|
+
h('span', { class: 'ds-preview-name' }, file.name || ''),
|
|
171
|
+
h('span', { class: 'ds-preview-meta' }, meta),
|
|
172
|
+
h('span', { class: 'ds-preview-actions' },
|
|
173
|
+
onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
|
|
174
|
+
h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x'))
|
|
163
175
|
)
|
|
164
|
-
]
|
|
176
|
+
],
|
|
177
|
+
bodyClass: 'ds-preview-body',
|
|
178
|
+
bodyAttrs: { 'data-file-type': file.type || 'other' },
|
|
179
|
+
body: Array.isArray(body) ? body : [body],
|
|
165
180
|
});
|
|
166
181
|
}
|
package/src/components/files.js
CHANGED
|
@@ -4,6 +4,10 @@ import * as webjsx from '../../vendor/webjsx/index.js';
|
|
|
4
4
|
import { Btn, Icon } from './shell.js';
|
|
5
5
|
const h = webjsx.createElement;
|
|
6
6
|
|
|
7
|
+
// Minimum column width for the responsive file grid (minmax floor). Named so
|
|
8
|
+
// the magic 240px isn't buried in the gridTemplateColumns string.
|
|
9
|
+
const FILE_GRID_MIN_COL = '240px';
|
|
10
|
+
|
|
7
11
|
const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
|
|
8
12
|
const TYPE_ICON = {
|
|
9
13
|
dir: 'file', image: 'file', video: 'file-video', audio: 'file-audio', code: 'file-code',
|
|
@@ -43,27 +47,29 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
43
47
|
const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null].filter(Boolean).join(' · ');
|
|
44
48
|
const typeLabel = TYPE_LABELS[type] || 'file';
|
|
45
49
|
const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
|
|
50
|
+
// A role=button row containing real <button> action controls is invalid
|
|
51
|
+
// HTML (interactive nesting). Instead the row is a plain container and the
|
|
52
|
+
// primary "open" affordance is itself a real <button> (native keyboard +
|
|
53
|
+
// semantics); the per-file action buttons sit alongside it as siblings.
|
|
46
54
|
return h('div', {
|
|
47
55
|
key,
|
|
48
56
|
class: 'ds-file-row row' + (active ? ' active' : ''),
|
|
49
57
|
'data-file-type': type,
|
|
50
|
-
onclick: onOpen,
|
|
51
|
-
role: 'button',
|
|
52
|
-
tabindex: '0',
|
|
53
|
-
'aria-label': accessibleLabel,
|
|
54
|
-
'aria-pressed': active ? 'true' : 'false',
|
|
55
|
-
onkeydown: (e) => {
|
|
56
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
57
|
-
e.preventDefault();
|
|
58
|
-
onOpen && onOpen();
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
58
|
},
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
h('button', {
|
|
60
|
+
type: 'button',
|
|
61
|
+
class: 'ds-file-open',
|
|
62
|
+
onclick: onOpen || null,
|
|
63
|
+
'aria-label': accessibleLabel,
|
|
64
|
+
'aria-pressed': active ? 'true' : 'false',
|
|
65
|
+
disabled: onOpen ? null : true,
|
|
66
|
+
},
|
|
67
|
+
code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
|
|
68
|
+
FileIcon({ type }),
|
|
69
|
+
h('span', { class: 'title' }, name),
|
|
70
|
+
h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
|
|
71
|
+
),
|
|
72
|
+
onAction ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
|
|
67
73
|
h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, Icon('arrow-down')),
|
|
68
74
|
h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, Icon('pencil')),
|
|
69
75
|
h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, Icon('x'))
|
|
@@ -79,7 +85,7 @@ export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files h
|
|
|
79
85
|
gridAttrs['data-columns'] = String(col);
|
|
80
86
|
gridAttrs.style = {
|
|
81
87
|
display: 'grid',
|
|
82
|
-
gridTemplateColumns: `repeat(${col}, minmax(
|
|
88
|
+
gridTemplateColumns: `repeat(${col}, minmax(${FILE_GRID_MIN_COL}, 1fr))`,
|
|
83
89
|
gap: 'var(--space-3)'
|
|
84
90
|
};
|
|
85
91
|
}
|
|
@@ -108,7 +114,7 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
|
|
|
108
114
|
ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
|
|
109
115
|
},
|
|
110
116
|
h('div', { class: 'ds-dropzone-inner' },
|
|
111
|
-
h('span', { class: 'ds-dropzone-glyph' }, '
|
|
117
|
+
h('span', { class: 'ds-dropzone-glyph', role: 'img', 'aria-label': 'upload' }, Icon('arrow-up')),
|
|
112
118
|
h('span', { class: 'ds-dropzone-label' }, label),
|
|
113
119
|
onPick ? Btn({ onClick: onPick, children: 'pick files' }) : null
|
|
114
120
|
),
|
|
@@ -142,8 +148,8 @@ export function UploadProgress({ items = [] } = {}) {
|
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
export function EmptyState({ text = 'nothing here', glyph = Icon('circle') } = {}) {
|
|
145
|
-
return h('div', { class: 'ds-file-empty' },
|
|
146
|
-
h('span', { class: 'ds-file-empty-glyph' }, glyph),
|
|
151
|
+
return h('div', { class: 'ds-file-empty', role: 'status' },
|
|
152
|
+
h('span', { class: 'ds-file-empty-glyph', 'aria-hidden': 'true' }, glyph),
|
|
147
153
|
h('span', { class: 'ds-file-empty-text' }, text)
|
|
148
154
|
);
|
|
149
155
|
}
|
|
@@ -116,13 +116,16 @@ export function Field({ label, hint, error, required, requiredMarker = '*', html
|
|
|
116
116
|
const errorId = error != null ? autoId + '-err' : null;
|
|
117
117
|
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || null;
|
|
118
118
|
const list = Array.isArray(children) ? children : [children];
|
|
119
|
+
// Apply the generated id to the FIRST control that lacks one so the label's
|
|
120
|
+
// `for=autoId` and the hint/error aria-describedby actually reference it.
|
|
121
|
+
// Controls that already carry an id keep theirs (and still get describedby).
|
|
122
|
+
let idApplied = false;
|
|
119
123
|
const decorated = list.map((c) => {
|
|
120
124
|
if (!c || typeof c !== 'object') return c;
|
|
121
125
|
const props = c.props || {};
|
|
122
126
|
const extra = { 'aria-describedby': describedBy };
|
|
123
127
|
if (error != null) extra['aria-invalid'] = 'true';
|
|
124
|
-
if (!props.id && !
|
|
125
|
-
else if (!props.id) extra.id = autoId;
|
|
128
|
+
if (!props.id && !idApplied) { extra.id = autoId; idApplied = true; }
|
|
126
129
|
return cloneWithProps(c, extra);
|
|
127
130
|
});
|
|
128
131
|
return h('div', { key, class: 'ds-field-wrap' },
|
|
@@ -99,7 +99,7 @@ export function errorState(err, onRetry) {
|
|
|
99
99
|
h('div', { class: 'ds-alert-content' },
|
|
100
100
|
h('div', { class: 'ds-alert-title' }, 'failed to load'),
|
|
101
101
|
h('div', { class: 'ds-alert-message' }, msg),
|
|
102
|
-
onRetry ? h('button', { class: 'btn ds-alert-retry', onclick: onRetry }, 'retry') : null));
|
|
102
|
+
onRetry ? h('button', { type: 'button', class: 'btn ds-alert-retry', onclick: onRetry }, 'retry') : null));
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export function emptyState(text = 'nothing here yet', glyph = Icon('circle')) {
|