anentrypoint-design 0.0.207 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.207",
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",
@@ -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
- return h('div', {
93
- key,
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
- // Keyboard: roving focus over the .ds-file-open buttons inside the grid.
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', { class: 'ds-file-grid', role: 'group', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
207
- ...visible.map((f, i) => FileRow({
208
- key: f.path || f.name + i,
209
- name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
210
- permissions: f.permissions, locked: f.locked,
211
- actions: actions != null ? actions : undefined,
212
- busy: busy != null ? !!busy : !!f.busy,
213
- onOpen: onOpen ? () => onOpen(f) : null,
214
- onAction: onAction ? (act) => onAction(act, f) : null
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 (head || filterBar || more)
228
- ? h('div', { class: 'ds-file-listing' }, filterBar, head, grid, more)
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 } = {}) {
package/src/components.js CHANGED
@@ -36,7 +36,7 @@ export { ContextPane } from './components/context-pane.js';
36
36
  export {
37
37
  fileGlyph, fmtFileSize,
38
38
  FileIcon, FileRow, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker,
39
- DropZone, UploadProgress, EmptyState, BreadcrumbPath
39
+ DropZone, UploadProgress, EmptyState, BreadcrumbPath, BulkBar
40
40
  } from './components/files.js';
41
41
 
42
42
  export {