anentrypoint-design 0.0.206 → 0.0.208
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/app-shell.css +124 -12
- package/chat.css +139 -2
- package/dist/247420.css +263 -14
- package/dist/247420.js +15 -13
- package/package.json +1 -1
- package/src/components/agent-chat.js +5 -1
- package/src/components/chat.js +26 -3
- package/src/components/files-modals.js +45 -3
- package/src/components/files.js +164 -29
- package/src/components/sessions.js +131 -50
- package/src/components/shell.js +14 -6
- package/src/components.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.208",
|
|
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",
|
|
@@ -252,7 +252,11 @@ export function AgentChat(props = {}) {
|
|
|
252
252
|
return ChatMessage({
|
|
253
253
|
key: m.id || String(i),
|
|
254
254
|
who: isAssistant ? 'them' : 'you',
|
|
255
|
-
|
|
255
|
+
// Claude-Code-web layout: flat full-width turns (no avatar disc, no colored
|
|
256
|
+
// bubble), distinguished by a role label + a faint assistant background.
|
|
257
|
+
// aicat is left OFF so the mascot tint never reaches the agent surface.
|
|
258
|
+
flat: true,
|
|
259
|
+
aicat: false,
|
|
256
260
|
// A stable per-agent product mark (host passes a small line-SVG via
|
|
257
261
|
// `avatar`) instead of a per-agent letter initial that shifts identity.
|
|
258
262
|
avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
|
package/src/components/chat.js
CHANGED
|
@@ -128,6 +128,18 @@ export function injectCodeCopy(container) {
|
|
|
128
128
|
shell.className = 'chat-code-block';
|
|
129
129
|
pre.parentNode.insertBefore(shell, pre);
|
|
130
130
|
shell.appendChild(pre);
|
|
131
|
+
// Surface the fenced language as a small header tab (claude.ai/code
|
|
132
|
+
// shows the language on every block, not just the structured CodeNode).
|
|
133
|
+
// The highlighter sets language-xx / lang-xx on the inner <code>.
|
|
134
|
+
const codeEl = pre.querySelector('code');
|
|
135
|
+
const langCls = codeEl && (codeEl.className || '').match(/(?:language|lang)-([a-z0-9+#]+)/i);
|
|
136
|
+
if (langCls && langCls[1]) {
|
|
137
|
+
const lang = document.createElement('span');
|
|
138
|
+
lang.className = 'chat-code-lang';
|
|
139
|
+
lang.setAttribute('aria-hidden', 'true');
|
|
140
|
+
lang.textContent = langCls[1];
|
|
141
|
+
shell.appendChild(lang);
|
|
142
|
+
}
|
|
131
143
|
const btn = document.createElement('button');
|
|
132
144
|
btn.type = 'button';
|
|
133
145
|
btn.className = 'chat-code-copy';
|
|
@@ -289,7 +301,7 @@ function renderPart(p, key) {
|
|
|
289
301
|
return node;
|
|
290
302
|
}
|
|
291
303
|
|
|
292
|
-
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped }) {
|
|
304
|
+
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped, flat }) {
|
|
293
305
|
_stats.messages += 1;
|
|
294
306
|
// Support legacy 'who' prop, prefer 'role' with mapping:
|
|
295
307
|
// 'user' -> 'you' (right-aligned, accent bubble)
|
|
@@ -304,7 +316,11 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
304
316
|
: role)
|
|
305
317
|
: who;
|
|
306
318
|
const isCentered = resolvedWho === 'system' || resolvedWho === 'tool' || resolvedWho === 'thinking';
|
|
307
|
-
|
|
319
|
+
// Flat layout (Claude-Code-web): full-width, avatar-less turns with a role
|
|
320
|
+
// label above the content and a faint assistant background, instead of the
|
|
321
|
+
// messenger avatar-disc + colored-bubble layout (kept for the chat demo).
|
|
322
|
+
const isFlat = flat && !isCentered;
|
|
323
|
+
const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '') + (isFlat ? ' chat-msg-flat' : '');
|
|
308
324
|
const fallbackAvatar = avatar != null
|
|
309
325
|
? avatar
|
|
310
326
|
: (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '?' : '?'));
|
|
@@ -355,11 +371,18 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
355
371
|
}, a.icon ? Icon(a.icon, { size: 14 }) : null,
|
|
356
372
|
a.label ? h('span', { class: 'chat-msg-action-label' }, a.label) : null)))
|
|
357
373
|
: null;
|
|
358
|
-
|
|
374
|
+
// Flat layout leads the turn with a small role label (You / agent name)
|
|
375
|
+
// above the content, the way claude.ai/code titles each turn.
|
|
376
|
+
const roleLabel = isFlat
|
|
377
|
+
? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? 'You' : (name || 'Assistant'))
|
|
378
|
+
: null;
|
|
379
|
+
const stack = h('div', { class: 'chat-stack' }, roleLabel, ...bodyNodes, reactionRow, actionRow, meta);
|
|
359
380
|
// Centered roles (system/tool/thinking) skip the avatar column entirely so
|
|
360
381
|
// the bubble owns the full row — the chrome reads as out-of-band signal,
|
|
361
382
|
// not a participant turn.
|
|
362
383
|
if (isCentered) return h('div', { key, class: cls }, stack);
|
|
384
|
+
// Flat turns drop the avatar column entirely (full-width content).
|
|
385
|
+
if (isFlat) return h('div', { key, class: cls }, stack);
|
|
363
386
|
return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
|
|
364
387
|
}
|
|
365
388
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
4
4
|
import { Btn, Icon } from './shell.js';
|
|
5
5
|
import { fileGlyph, fmtFileSize } from './files.js';
|
|
6
|
+
import { highlightAllUnder } from '../highlight.js';
|
|
6
7
|
const h = webjsx.createElement;
|
|
7
8
|
|
|
8
9
|
// Monotonic id source for aria-labelledby wiring between a modal and its head.
|
|
@@ -205,7 +206,29 @@ export function PromptDialog({ title = 'name', value = '', placeholder = '', con
|
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
export function FilePreviewMedia({ src, type = 'other', name } = {}) {
|
|
208
|
-
if (type === 'image')
|
|
209
|
+
if (type === 'image') {
|
|
210
|
+
// Fit-to-pane (default) vs actual-size (1:1) toggle + a checkerboard so
|
|
211
|
+
// transparency reads. The toggle flips a class on the img in-place and
|
|
212
|
+
// reports the natural pixel dimensions into its own caption on load.
|
|
213
|
+
const onToggle = (e) => {
|
|
214
|
+
const wrap = e.currentTarget.closest('.ds-preview-media-wrap');
|
|
215
|
+
const img = wrap && wrap.querySelector('.ds-preview-media');
|
|
216
|
+
if (!img) return;
|
|
217
|
+
const actual = img.classList.toggle('is-actual');
|
|
218
|
+
e.currentTarget.textContent = actual ? 'fit to pane' : 'actual size';
|
|
219
|
+
};
|
|
220
|
+
const onLoad = (e) => {
|
|
221
|
+
const img = e.currentTarget;
|
|
222
|
+
const cap = img.closest('.ds-preview-media-wrap');
|
|
223
|
+
const dim = cap && cap.querySelector('.ds-preview-media-dim');
|
|
224
|
+
if (dim && img.naturalWidth) dim.textContent = img.naturalWidth + ' x ' + img.naturalHeight + ' px';
|
|
225
|
+
};
|
|
226
|
+
return h('div', { class: 'ds-preview-media-wrap' },
|
|
227
|
+
h('img', { class: 'ds-preview-media ds-preview-media-alpha', src, alt: name || '', onload: onLoad }),
|
|
228
|
+
h('div', { class: 'ds-preview-media-controls' },
|
|
229
|
+
h('span', { class: 'ds-preview-media-dim', 'aria-live': 'polite' }, ''),
|
|
230
|
+
h('button', { type: 'button', class: 'chat-code-copy', onclick: onToggle }, 'actual size')));
|
|
231
|
+
}
|
|
209
232
|
if (type === 'video') return h('video', { class: 'ds-preview-media', src, controls: true });
|
|
210
233
|
if (type === 'audio') return h('audio', { class: 'ds-preview-audio', src, controls: true });
|
|
211
234
|
return h('div', { class: 'ds-preview-fallback' },
|
|
@@ -230,11 +253,30 @@ export function FilePreviewCode({ content = '', lang, filename } = {}) {
|
|
|
230
253
|
filename ? h('span', { class: 'name' }, filename) : null,
|
|
231
254
|
h('span', { class: 'spread' }),
|
|
232
255
|
h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')),
|
|
233
|
-
|
|
234
|
-
h('code', { class: lang ? 'language-' + lang : '' }, content))
|
|
256
|
+
codeBody({ content, lang })
|
|
235
257
|
);
|
|
236
258
|
}
|
|
237
259
|
|
|
260
|
+
// The code body: a non-selectable line-number gutter + the highlighted code.
|
|
261
|
+
// A ref triggers Prism over the <code> after mount (the bundle only auto-runs
|
|
262
|
+
// Prism in the chat path), so the file preview is token-colored like Claude
|
|
263
|
+
// Code's file pane. lineNumbers defaults on for code, off for plaintext.
|
|
264
|
+
function codeBody({ content = '', lang } = {}) {
|
|
265
|
+
const wantGutter = !!lang;
|
|
266
|
+
const lineCount = content ? content.split('\n').length : 1;
|
|
267
|
+
const gutter = wantGutter
|
|
268
|
+
? h('div', { class: 'ds-preview-gutter', 'aria-hidden': 'true' },
|
|
269
|
+
Array.from({ length: lineCount }, (_, i) => String(i + 1)).join('\n'))
|
|
270
|
+
: null;
|
|
271
|
+
const highlightRef = (el) => {
|
|
272
|
+
if (!el) return;
|
|
273
|
+
try { highlightAllUnder(el); } catch {}
|
|
274
|
+
};
|
|
275
|
+
return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : ''), ref: highlightRef },
|
|
276
|
+
gutter,
|
|
277
|
+
h('code', { class: lang ? 'language-' + lang : '' }, content));
|
|
278
|
+
}
|
|
279
|
+
|
|
238
280
|
export function FilePreviewText({ content = '', truncated } = {}) {
|
|
239
281
|
return h('pre', { class: 'ds-preview-text' },
|
|
240
282
|
h('code', {}, content),
|
package/src/components/files.js
CHANGED
|
@@ -10,8 +10,8 @@ const FILE_GRID_MIN_COL = '240px';
|
|
|
10
10
|
|
|
11
11
|
const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
|
|
12
12
|
const TYPE_ICON = {
|
|
13
|
-
dir: '
|
|
14
|
-
text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: '
|
|
13
|
+
dir: 'folder', image: 'file-image', video: 'file-video', audio: 'file-audio', code: 'file-code',
|
|
14
|
+
text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'link', other: 'file'
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
const TYPE_LABELS = {
|
|
@@ -51,7 +51,7 @@ export function FileIcon({ type = 'other' } = {}) {
|
|
|
51
51
|
const FILE_ROW_ACTIONS = ['download', 'rename', 'delete'];
|
|
52
52
|
|
|
53
53
|
export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
|
|
54
|
-
actions = FILE_ROW_ACTIONS, busy = false } = {}) {
|
|
54
|
+
actions = FILE_ROW_ACTIONS, busy = false, selectable = false, marked = false, onMark } = {}) {
|
|
55
55
|
// permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
|
|
56
56
|
// be listed (the dir stat saw it) but not opened — show an ASCII tag and
|
|
57
57
|
// disable the open button so the row reads honestly instead of silently
|
|
@@ -85,17 +85,27 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
85
85
|
actions.indexOf('delete') !== -1
|
|
86
86
|
? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
|
|
87
87
|
].filter(Boolean) : [];
|
|
88
|
+
// Multi-select checkbox — a sibling control before the open button so the
|
|
89
|
+
// row stays valid HTML (no interactive nesting). A no-access entry cannot
|
|
90
|
+
// be marked (bulk mutations would fail on it anyway).
|
|
91
|
+
const checkCtl = selectable ? h('button', {
|
|
92
|
+
key: 'mark',
|
|
93
|
+
type: 'button',
|
|
94
|
+
class: 'ds-file-check' + (marked ? ' is-marked' : ''),
|
|
95
|
+
role: 'checkbox',
|
|
96
|
+
'aria-checked': marked ? 'true' : 'false',
|
|
97
|
+
'aria-label': (marked ? 'unselect ' : 'select ') + name,
|
|
98
|
+
disabled: (noAccess || busy) ? true : null,
|
|
99
|
+
onclick: onMark ? (e) => onMark({ range: !!e.shiftKey }) : null,
|
|
100
|
+
}, h('span', { 'aria-hidden': 'true' }, marked ? '[x]' : '[ ]')) : null;
|
|
88
101
|
// A role=button row containing real <button> action controls is invalid
|
|
89
102
|
// HTML (interactive nesting). Instead the row is a plain container and the
|
|
90
103
|
// primary "open" affordance is itself a real <button> (native keyboard +
|
|
91
104
|
// semantics); the per-file action buttons sit alongside it as siblings.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
|
|
95
|
-
'data-file-type': type,
|
|
96
|
-
'aria-busy': busy ? 'true' : null,
|
|
97
|
-
},
|
|
105
|
+
const rowKids = [
|
|
106
|
+
checkCtl,
|
|
98
107
|
h('button', {
|
|
108
|
+
key: 'open',
|
|
99
109
|
type: 'button',
|
|
100
110
|
class: 'ds-file-open',
|
|
101
111
|
onclick: canOpen ? onOpen : null,
|
|
@@ -108,10 +118,17 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
108
118
|
h('span', { class: 'title' }, name),
|
|
109
119
|
h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
|
|
110
120
|
),
|
|
111
|
-
actionBtns.length ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
|
|
121
|
+
actionBtns.length ? h('span', { key: 'acts', class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
|
|
112
122
|
...actionBtns
|
|
113
|
-
) : null
|
|
114
|
-
);
|
|
123
|
+
) : null,
|
|
124
|
+
].filter(Boolean);
|
|
125
|
+
return h('div', {
|
|
126
|
+
key,
|
|
127
|
+
class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : '')
|
|
128
|
+
+ (marked ? ' is-marked' : '') + (selectable ? ' is-selectable' : ''),
|
|
129
|
+
'data-file-type': type,
|
|
130
|
+
'aria-busy': busy ? 'true' : null,
|
|
131
|
+
}, ...rowKids);
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
// FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
|
|
@@ -162,7 +179,9 @@ const FILE_GRID_CAP = 200;
|
|
|
162
179
|
|
|
163
180
|
export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
|
|
164
181
|
columns = 'auto', sort, filter, loading = false,
|
|
165
|
-
shown, onShowMore, actions, busy
|
|
182
|
+
shown, onShowMore, actions, busy,
|
|
183
|
+
selectable = false, marked, onMark, onSelectAll, onClearSelection,
|
|
184
|
+
density = 'list', onDensity, thumbUrl } = {}) {
|
|
166
185
|
if (loading) return FileSkeleton({});
|
|
167
186
|
if (!files.length) return EmptyState({ text: emptyText });
|
|
168
187
|
// Cap the rendered rows. `shown` (host-controlled) overrides the default cap
|
|
@@ -170,8 +189,9 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
|
|
|
170
189
|
const limit = shown != null ? shown : FILE_GRID_CAP;
|
|
171
190
|
const capped = files.length > limit;
|
|
172
191
|
const visible = capped ? files.slice(0, limit) : files;
|
|
192
|
+
const isThumb = density === 'thumb';
|
|
173
193
|
const gridAttrs = {};
|
|
174
|
-
if (columns !== 'auto' && columns > 0) {
|
|
194
|
+
if (!isThumb && columns !== 'auto' && columns > 0) {
|
|
175
195
|
const col = Math.max(1, Math.min(4, Math.floor(columns)));
|
|
176
196
|
gridAttrs['data-columns'] = String(col);
|
|
177
197
|
gridAttrs.style = {
|
|
@@ -180,19 +200,59 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
|
|
|
180
200
|
gap: 'var(--space-3)'
|
|
181
201
|
};
|
|
182
202
|
}
|
|
183
|
-
//
|
|
203
|
+
// Multi-select bookkeeping. Entries are keyed by path (fallback name); a
|
|
204
|
+
// locked/EACCES entry is never selectable — bulk mutations would fail on it.
|
|
205
|
+
const entryKeyOf = (f) => f.path || f.name;
|
|
206
|
+
const isLockedEntry = (f) => f.locked || f.permissions === 'EACCES'
|
|
207
|
+
|| (Array.isArray(f.permissions) && f.permissions.length === 0);
|
|
208
|
+
const selSet = marked instanceof Set ? marked : new Set(marked || []);
|
|
209
|
+
const selectableKeys = selectable ? visible.filter((f) => !isLockedEntry(f)).map(entryKeyOf) : [];
|
|
210
|
+
// Keyboard: roving focus over the open buttons inside the grid (rows and
|
|
211
|
+
// thumbnail cells share the pattern). Ctrl/Cmd+A selects all SHOWN rows.
|
|
184
212
|
const onKeyDown = (e) => {
|
|
185
213
|
const grid = e.currentTarget;
|
|
186
|
-
const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled])'));
|
|
187
|
-
if (!opens.length) return;
|
|
214
|
+
const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled]), .ds-file-cell-open:not([disabled])'));
|
|
188
215
|
const cur = opens.indexOf(document.activeElement);
|
|
189
216
|
if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
|
|
190
217
|
else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
|
|
191
218
|
else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
|
|
192
219
|
else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
|
|
193
220
|
else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
|
|
221
|
+
else if ((e.key === 'a' || e.key === 'A') && (e.ctrlKey || e.metaKey)
|
|
222
|
+
&& selectable && onSelectAll && selectableKeys.length) {
|
|
223
|
+
e.preventDefault(); onSelectAll(selectableKeys);
|
|
224
|
+
}
|
|
194
225
|
};
|
|
195
226
|
const head = sort ? FileSortHeader(sort) : null;
|
|
227
|
+
// Tri-state select-all over the selectable SHOWN rows (the cap label below
|
|
228
|
+
// already tells the user more rows exist beyond the window).
|
|
229
|
+
const selOfVisible = selectableKeys.filter((k) => selSet.has(k)).length;
|
|
230
|
+
const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableKeys.length ? 'true' : 'mixed');
|
|
231
|
+
const selectAllCtl = (selectable && onSelectAll && selectableKeys.length)
|
|
232
|
+
? h('button', { key: 'selall', type: 'button', class: 'ds-file-selectall', role: 'checkbox',
|
|
233
|
+
'aria-checked': allState,
|
|
234
|
+
'aria-label': allState === 'true' ? 'clear selection' : 'select all ' + selectableKeys.length + ' shown files',
|
|
235
|
+
onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableKeys) },
|
|
236
|
+
h('span', { 'aria-hidden': 'true' }, allState === 'true' ? '[x]' : allState === 'mixed' ? '[-]' : '[ ]'),
|
|
237
|
+
h('span', {}, 'all'))
|
|
238
|
+
: null;
|
|
239
|
+
// Density picker — list / compact / thumbnails. A radiogroup, not tabs:
|
|
240
|
+
// it switches presentation of the same content, not panels.
|
|
241
|
+
const densityCtl = onDensity
|
|
242
|
+
? h('div', { key: 'density', class: 'ds-density', role: 'radiogroup', 'aria-label': 'view density' },
|
|
243
|
+
...DENSITIES.map(([k, label]) => h('button', {
|
|
244
|
+
key: 'd-' + k, type: 'button', role: 'radio',
|
|
245
|
+
class: 'ds-density-btn' + (density === k ? ' active' : ''),
|
|
246
|
+
'aria-checked': density === k ? 'true' : 'false',
|
|
247
|
+
onclick: () => { if (density !== k) onDensity(k); },
|
|
248
|
+
}, label)))
|
|
249
|
+
: null;
|
|
250
|
+
const controlsKids = [selectAllCtl, head,
|
|
251
|
+
(selectAllCtl || head) && densityCtl ? h('span', { key: 'spread', class: 'spread' }) : null,
|
|
252
|
+
densityCtl].filter(Boolean);
|
|
253
|
+
const controls = controlsKids.length
|
|
254
|
+
? h('div', { class: 'ds-file-controls' }, ...controlsKids)
|
|
255
|
+
: null;
|
|
196
256
|
const filterBar = filter ? h('div', { class: 'ds-file-filter' },
|
|
197
257
|
h('input', {
|
|
198
258
|
class: 'ds-file-filter-input', type: 'search',
|
|
@@ -203,16 +263,32 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
|
|
|
203
263
|
// role=group not listbox: the rows contain real <button> action controls, so
|
|
204
264
|
// listbox/option semantics are invalid (an option can't host interactive
|
|
205
265
|
// children). Keyboard nav still works via roving focus over the open buttons.
|
|
206
|
-
const grid = h('div', {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
266
|
+
const grid = h('div', {
|
|
267
|
+
class: 'ds-file-grid' + (isThumb ? ' ds-file-grid-thumb' : ''),
|
|
268
|
+
role: 'group', 'aria-label': 'files', tabindex: '0',
|
|
269
|
+
// Always concrete (webjsx's attribute diff can leave a null-valued
|
|
270
|
+
// attribute unset when toggling away from the default).
|
|
271
|
+
'data-density': density || 'list',
|
|
272
|
+
onkeydown: onKeyDown, ...gridAttrs },
|
|
273
|
+
...visible.map((f, i) => isThumb
|
|
274
|
+
? FileCell({
|
|
275
|
+
key: f.path || f.name + i, f,
|
|
276
|
+
selectable, marked: selSet.has(entryKeyOf(f)),
|
|
277
|
+
onMark: onMark ? (opts) => onMark(f, opts) : null,
|
|
278
|
+
onOpen,
|
|
279
|
+
thumb: (thumbUrl && f.type === 'image') ? thumbUrl(f) : null,
|
|
280
|
+
})
|
|
281
|
+
: FileRow({
|
|
282
|
+
key: f.path || f.name + i,
|
|
283
|
+
name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
|
|
284
|
+
permissions: f.permissions, locked: f.locked,
|
|
285
|
+
actions: actions != null ? actions : undefined,
|
|
286
|
+
busy: busy != null ? !!busy : !!f.busy,
|
|
287
|
+
selectable, marked: selSet.has(entryKeyOf(f)),
|
|
288
|
+
onMark: onMark ? (opts) => onMark(f, opts) : null,
|
|
289
|
+
onOpen: onOpen ? () => onOpen(f) : null,
|
|
290
|
+
onAction: onAction ? (act) => onAction(act, f) : null
|
|
291
|
+
}))
|
|
216
292
|
);
|
|
217
293
|
// A count + "show more" affordance so a capped large dir reads as "more
|
|
218
294
|
// exist", not "this is everything". aria-live announces the shown/total.
|
|
@@ -224,11 +300,70 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
|
|
|
224
300
|
onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
|
|
225
301
|
'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
|
|
226
302
|
: null;
|
|
227
|
-
return (
|
|
228
|
-
? h('div', { class: 'ds-file-listing' }, filterBar,
|
|
303
|
+
return (controls || filterBar || more)
|
|
304
|
+
? h('div', { class: 'ds-file-listing' }, filterBar, controls, grid, more)
|
|
229
305
|
: grid;
|
|
230
306
|
}
|
|
231
307
|
|
|
308
|
+
const DENSITIES = [['list', 'list'], ['compact', 'compact'], ['thumb', 'thumbnails']];
|
|
309
|
+
|
|
310
|
+
// FileCell — the thumbnail-density tile. Image entries show a real (lazy)
|
|
311
|
+
// thumbnail through the host's confined thumbUrl; everything else keeps its
|
|
312
|
+
// type icon. Same open/mark semantics as FileRow, same no-nesting rule.
|
|
313
|
+
function FileCell({ key, f = {}, selectable = false, marked = false, onMark, onOpen, thumb } = {}) {
|
|
314
|
+
const noAccess = f.locked || f.permissions === 'EACCES'
|
|
315
|
+
|| (Array.isArray(f.permissions) && f.permissions.length === 0);
|
|
316
|
+
const canOpen = onOpen && !noAccess;
|
|
317
|
+
const typeLabel = TYPE_LABELS[f.type] || 'file';
|
|
318
|
+
const kids = [
|
|
319
|
+
selectable ? h('button', {
|
|
320
|
+
key: 'mark', type: 'button',
|
|
321
|
+
class: 'ds-file-check ds-file-cell-check' + (marked ? ' is-marked' : ''),
|
|
322
|
+
role: 'checkbox', 'aria-checked': marked ? 'true' : 'false',
|
|
323
|
+
'aria-label': (marked ? 'unselect ' : 'select ') + f.name,
|
|
324
|
+
disabled: noAccess ? true : null,
|
|
325
|
+
onclick: onMark ? (e) => onMark({ range: !!e.shiftKey }) : null,
|
|
326
|
+
}, h('span', { 'aria-hidden': 'true' }, marked ? '[x]' : '[ ]')) : null,
|
|
327
|
+
h('button', {
|
|
328
|
+
key: 'open', type: 'button', class: 'ds-file-cell-open',
|
|
329
|
+
onclick: canOpen ? () => onOpen(f) : null,
|
|
330
|
+
disabled: canOpen ? null : true,
|
|
331
|
+
'aria-label': typeLabel + ': ' + f.name + (noAccess ? ' (no access)' : ''),
|
|
332
|
+
},
|
|
333
|
+
h('span', { class: 'ds-file-cell-media' },
|
|
334
|
+
thumb
|
|
335
|
+
? h('img', { class: 'ds-file-cell-thumb', src: thumb, alt: '', loading: 'lazy' })
|
|
336
|
+
: FileIcon({ type: f.type })),
|
|
337
|
+
h('span', { class: 'ds-file-cell-name', title: f.name }, f.name),
|
|
338
|
+
h('span', { class: 'ds-file-cell-meta' }, f.type === 'dir' ? 'folder' : fmtFileSize(f.size))),
|
|
339
|
+
].filter(Boolean);
|
|
340
|
+
return h('div', {
|
|
341
|
+
key,
|
|
342
|
+
class: 'ds-file-cell' + (marked ? ' is-marked' : '') + (f.active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
|
|
343
|
+
'data-file-type': f.type,
|
|
344
|
+
}, ...kids);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// BulkBar — the act-on-selection strip shown while a multi-select is active.
|
|
348
|
+
// Host renders it above the grid; `actions` are [{ label, onClick, danger,
|
|
349
|
+
// disabled }]; `busy` disables everything while a bulk operation is in flight.
|
|
350
|
+
export function BulkBar({ count = 0, noun = 'file', nounPlural, actions = [], onClear, busy = false } = {}) {
|
|
351
|
+
if (!count) return null;
|
|
352
|
+
// 'entry' pluralizes to 'entries', not 'entrys' - handle the -y noun class
|
|
353
|
+
// unless the host passes an explicit plural.
|
|
354
|
+
const plural = nounPlural || (/[^aeiou]y$/.test(noun) ? noun.slice(0, -1) + 'ies' : noun + 's');
|
|
355
|
+
const kids = [
|
|
356
|
+
h('span', { key: 'count', class: 'ds-bulkbar-count', role: 'status', 'aria-live': 'polite' },
|
|
357
|
+
count + ' ' + (count === 1 ? noun : plural) + ' selected'),
|
|
358
|
+
...actions.map((a, i) => Btn({
|
|
359
|
+
key: 'bba' + i, danger: !!a.danger, disabled: busy || a.disabled,
|
|
360
|
+
onClick: a.onClick, children: a.label,
|
|
361
|
+
})),
|
|
362
|
+
onClear ? Btn({ key: 'bbclear', disabled: busy, onClick: onClear, children: 'clear selection' }) : null,
|
|
363
|
+
].filter(Boolean);
|
|
364
|
+
return h('div', { class: 'ds-bulkbar', role: 'toolbar', 'aria-label': 'bulk file actions', 'aria-busy': busy ? 'true' : null }, ...kids);
|
|
365
|
+
}
|
|
366
|
+
|
|
232
367
|
// Clickable column headers for FileGrid sort. Active column shows its direction
|
|
233
368
|
// as an ASCII caret word (asc/desc) - never a glyph arrow.
|
|
234
369
|
function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
|