anentrypoint-design 0.0.385 → 0.0.387
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.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- package/src/page-html.js +14 -291
package/src/components/files.js
CHANGED
|
@@ -1,566 +1,17 @@
|
|
|
1
1
|
// File primitives — matches upstream signatures.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
audio: 'audio file',
|
|
18
|
-
code: 'code file',
|
|
19
|
-
text: 'text file',
|
|
20
|
-
archive: 'archive file',
|
|
21
|
-
document: 'document file',
|
|
22
|
-
symlink: 'symbolic link',
|
|
23
|
-
other: 'file'
|
|
2
|
+
//
|
|
3
|
+
// This module is a barrel: every component lives in a single-responsibility
|
|
4
|
+
// submodule under ./files/, and the public export surface here is unchanged
|
|
5
|
+
// — no consumer import needs to move.
|
|
6
|
+
|
|
7
|
+
import { fileGlyph, fmtFileSize, FileIcon, sortFiles } from './files/types.js';
|
|
8
|
+
import { FileRow, FileSkeleton } from './files/entries.js';
|
|
9
|
+
import { FileGrid } from './files/grid.js';
|
|
10
|
+
import { FileToolbar, RootsPicker, DropZone, UploadProgress, EmptyState, BreadcrumbPath, BulkBar } from './files/chrome.js';
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
fileGlyph, fmtFileSize, FileIcon, sortFiles,
|
|
14
|
+
FileRow, FileSkeleton,
|
|
15
|
+
FileGrid,
|
|
16
|
+
FileToolbar, RootsPicker, DropZone, UploadProgress, EmptyState, BreadcrumbPath, BulkBar,
|
|
24
17
|
};
|
|
25
|
-
|
|
26
|
-
export function fileGlyph(type) {
|
|
27
|
-
return TYPE_ICON[type] || TYPE_ICON.other;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
|
|
31
|
-
// format everywhere: '0 B' for zero; the em-dash means unknown/null/invalid
|
|
32
|
-
// (NaN, a negative count, or anything non-numeric never reaches the divide
|
|
33
|
-
// loop — previously NaN fell through to the loop unchanged and rendered the
|
|
34
|
-
// literal string "NaN B").
|
|
35
|
-
export function fmtFileSize(bytes) {
|
|
36
|
-
if (bytes == null) return '—';
|
|
37
|
-
if (typeof bytes !== 'number' || Number.isNaN(bytes) || bytes < 0) return '—';
|
|
38
|
-
if (bytes === 0) return '0 B';
|
|
39
|
-
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
40
|
-
let i = 0, n = bytes;
|
|
41
|
-
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
42
|
-
return n.toFixed(i === 0 ? 0 : 1) + ' ' + u[i];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function FileIcon({ type = 'other' } = {}) {
|
|
46
|
-
return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Default action set for FileRow. A host without mutation endpoints passes a
|
|
50
|
-
// narrower `actions` list (e.g. ['download']) so the row renders no dead controls.
|
|
51
|
-
const FILE_ROW_ACTIONS = ['download', 'rename', 'move', 'delete'];
|
|
52
|
-
|
|
53
|
-
export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
|
|
54
|
-
actions = FILE_ROW_ACTIONS, busy = false, selectable = false, selected = false, onToggleSelect } = {}) {
|
|
55
|
-
// permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
|
|
56
|
-
// be listed (the dir stat saw it) but not opened — show an ASCII tag and
|
|
57
|
-
// disable the open button so the row reads honestly instead of silently
|
|
58
|
-
// failing on click.
|
|
59
|
-
const noAccess = locked || permissions === 'EACCES' || (Array.isArray(permissions) && permissions.length === 0);
|
|
60
|
-
const readOnly = !noAccess && Array.isArray(permissions) && permissions.indexOf('write') === -1 && permissions.indexOf('read') !== -1;
|
|
61
|
-
const permTag = noAccess ? 'no access' : (readOnly ? 'read-only' : null);
|
|
62
|
-
// permTag is rendered as its own chip (a SHAPE channel, not folded into the
|
|
63
|
-
// muted meta text) - so drop it from the meta join, but keep it in the
|
|
64
|
-
// accessible label so AT still announces the restriction.
|
|
65
|
-
const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null].filter(Boolean).join(' · ');
|
|
66
|
-
const typeLabel = TYPE_LABELS[type] || 'file';
|
|
67
|
-
const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}${permTag ? ', ' + permTag : ''}`;
|
|
68
|
-
const canOpen = onOpen && !noAccess && !busy;
|
|
69
|
-
// Mutation actions on a read-only/no-access row render disabled (with a
|
|
70
|
-
// 'read-only' title) instead of vanishing, so the affordance reads honestly.
|
|
71
|
-
// `busy` (in-flight mutation) disables every control on the row.
|
|
72
|
-
const mutateDisabled = busy || readOnly || noAccess;
|
|
73
|
-
const actBtn = (act, title, ariaLabel, icon, warn) => h('button', {
|
|
74
|
-
key: 'act-' + act,
|
|
75
|
-
type: 'button',
|
|
76
|
-
class: 'ds-file-act' + (warn ? ' ds-file-act-warn' : ''),
|
|
77
|
-
title: mutateDisabled && act !== 'download' ? 'read-only' : title,
|
|
78
|
-
'aria-label': ariaLabel,
|
|
79
|
-
disabled: (act === 'download' ? busy : mutateDisabled) ? true : null,
|
|
80
|
-
'aria-disabled': (act === 'download' ? busy : mutateDisabled) ? 'true' : null,
|
|
81
|
-
onclick: () => onAction(act),
|
|
82
|
-
}, Icon(icon));
|
|
83
|
-
const actionBtns = onAction ? [
|
|
84
|
-
actions.indexOf('download') !== -1 && type !== 'dir'
|
|
85
|
-
? actBtn('download', 'download', `download ${name}`, 'arrow-down', false) : null,
|
|
86
|
-
actions.indexOf('rename') !== -1
|
|
87
|
-
? actBtn('rename', 'rename', `rename ${name}`, 'pencil', false) : null,
|
|
88
|
-
// Single-file move used to require checkbox-select + BulkBar - a
|
|
89
|
-
// per-row affordance matches fsbrowse and the kit rename/delete rows
|
|
90
|
-
// already on this row (no reason move alone needed a select detour).
|
|
91
|
-
actions.indexOf('move') !== -1
|
|
92
|
-
? actBtn('move', 'move', `move ${name}`, 'arrow-right', false) : null,
|
|
93
|
-
actions.indexOf('delete') !== -1
|
|
94
|
-
? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
|
|
95
|
-
].filter(Boolean) : [];
|
|
96
|
-
// Multi-select checkbox — a sibling control before the open button so the
|
|
97
|
-
// row stays valid HTML (no interactive nesting). A no-access entry cannot
|
|
98
|
-
// be marked (bulk mutations would fail on it anyway).
|
|
99
|
-
const checkCtl = selectable ? h('button', {
|
|
100
|
-
key: 'mark',
|
|
101
|
-
type: 'button',
|
|
102
|
-
class: 'ds-file-check' + (selected ? ' is-marked' : ''),
|
|
103
|
-
role: 'checkbox',
|
|
104
|
-
'aria-checked': selected ? 'true' : 'false',
|
|
105
|
-
'aria-label': (selected ? 'unselect ' : 'select ') + name,
|
|
106
|
-
disabled: (noAccess || busy) ? true : null,
|
|
107
|
-
onclick: onToggleSelect ? (e) => onToggleSelect({ range: !!e.shiftKey }) : null,
|
|
108
|
-
}, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null;
|
|
109
|
-
// A role=button row containing real <button> action controls is invalid
|
|
110
|
-
// HTML (interactive nesting). Instead the row is a plain container and the
|
|
111
|
-
// primary "open" affordance is itself a real <button> (native keyboard +
|
|
112
|
-
// semantics); the per-file action buttons sit alongside it as siblings.
|
|
113
|
-
const rowKids = [
|
|
114
|
-
checkCtl,
|
|
115
|
-
h('button', {
|
|
116
|
-
key: 'open',
|
|
117
|
-
type: 'button',
|
|
118
|
-
class: 'ds-file-open',
|
|
119
|
-
onclick: canOpen ? onOpen : null,
|
|
120
|
-
'aria-label': accessibleLabel + (noAccess ? ' (no access)' : ''),
|
|
121
|
-
'aria-pressed': active ? 'true' : 'false',
|
|
122
|
-
disabled: canOpen ? null : true,
|
|
123
|
-
},
|
|
124
|
-
...[
|
|
125
|
-
code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
|
|
126
|
-
FileIcon({ type }),
|
|
127
|
-
h('span', { class: 'title' }, name),
|
|
128
|
-
h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—'),
|
|
129
|
-
permTag ? h('span', { class: 'ds-file-perm-tag' + (noAccess ? ' is-noaccess' : ''), 'aria-hidden': 'true' }, permTag) : null,
|
|
130
|
-
].filter(Boolean)
|
|
131
|
-
),
|
|
132
|
-
actionBtns.length ? h('span', { key: 'acts', class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
|
|
133
|
-
...actionBtns
|
|
134
|
-
) : null,
|
|
135
|
-
].filter(Boolean);
|
|
136
|
-
return h('div', {
|
|
137
|
-
key,
|
|
138
|
-
class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : '')
|
|
139
|
-
+ (readOnly ? ' is-restricted' : '')
|
|
140
|
-
+ (selected ? ' is-marked' : '') + (selectable ? ' is-selectable' : ''),
|
|
141
|
-
'data-file-type': type,
|
|
142
|
-
'aria-busy': busy ? 'true' : null,
|
|
143
|
-
}, ...rowKids);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
|
|
147
|
-
// grid does not flash from a bare spinner to a full list (predictable perceived
|
|
148
|
-
// perf, the file-manager feel). `rows` controls how many ghost rows render.
|
|
149
|
-
export function FileSkeleton({ rows = 12 } = {}) {
|
|
150
|
-
return h('div', { class: 'ds-file-grid ds-file-skeleton', role: 'status', 'aria-busy': 'true', 'aria-label': 'loading files' },
|
|
151
|
-
...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-file-row ds-file-row-skeleton', 'aria-hidden': 'true' },
|
|
152
|
-
h('span', { class: 'ds-skel ds-skel-icon' }),
|
|
153
|
-
h('span', { class: 'ds-skel ds-skel-title' }),
|
|
154
|
-
h('span', { class: 'ds-skel ds-skel-meta' })))
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Sort a file list by a key (name/size/modified/type), dirs-first always so the
|
|
159
|
-
// hierarchy reads top-down regardless of sort. `dir` is 'asc'|'desc'.
|
|
160
|
-
// `modifiedTs` (epoch ms) is used for the modified sort when present, since the
|
|
161
|
-
// `modified` field is a pre-formatted relative string the host passes for display.
|
|
162
|
-
export function sortFiles(files = [], sort = 'name', dir = 'asc') {
|
|
163
|
-
const mul = dir === 'desc' ? -1 : 1;
|
|
164
|
-
const cmp = (a, b) => {
|
|
165
|
-
// Directories always cluster before files; within a cluster, apply the sort.
|
|
166
|
-
const ad = a.type === 'dir' ? 0 : 1, bd = b.type === 'dir' ? 0 : 1;
|
|
167
|
-
if (ad !== bd) return ad - bd;
|
|
168
|
-
let r = 0;
|
|
169
|
-
if (sort === 'size') r = (a.size || 0) - (b.size || 0);
|
|
170
|
-
else if (sort === 'modified') r = (a.modifiedTs || 0) - (b.modifiedTs || 0);
|
|
171
|
-
else if (sort === 'type') r = String(a.type || '').localeCompare(String(b.type || ''));
|
|
172
|
-
else r = String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' });
|
|
173
|
-
return r * mul || String(a.name || '').localeCompare(String(b.name || ''));
|
|
174
|
-
};
|
|
175
|
-
return files.slice().sort(cmp);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// FileGrid — the directory listing. Optional in-grid sort + filter make it a
|
|
179
|
-
// real file manager rather than a static dump:
|
|
180
|
-
// sort : { key, dir, onSort(key) } - clickable column headers (name/size/modified)
|
|
181
|
-
// filter : { value, onInput, placeholder } - a quick in-dir name filter
|
|
182
|
-
// onOpen(f) opens a row; onAction(act,f) wires the per-row download/rename/delete.
|
|
183
|
-
// Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
|
|
184
|
-
// row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
|
|
185
|
-
// focus state; the grid tracks it on the DOM via roving tabindex.
|
|
186
|
-
// How many rows to render before the "show more" cap kicks in. A node_modules-
|
|
187
|
-
// scale directory would otherwise flood the DOM with thousands of rows (and make
|
|
188
|
-
// the roving-tabindex querySelectorAll scan O(n) per keypress). Render the first
|
|
189
|
-
// CAP and a "show N more" row, mirroring the History tab's "load N older".
|
|
190
|
-
const FILE_GRID_CAP = 200;
|
|
191
|
-
|
|
192
|
-
export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet', emptyAction,
|
|
193
|
-
sort, filter, loading = false,
|
|
194
|
-
shown, onShowMore, actions, busy,
|
|
195
|
-
// Canonical multi-select contract (shared with
|
|
196
|
-
// SessionDashboard): selected/onToggleSelect.
|
|
197
|
-
// marked/onMark are accepted FileGrid aliases.
|
|
198
|
-
selectable = false, selected, onToggleSelect,
|
|
199
|
-
marked = selected, onMark = onToggleSelect,
|
|
200
|
-
onSelectAll, onClearSelection,
|
|
201
|
-
density = 'list', onDensity, thumbUrl } = {}) {
|
|
202
|
-
// Skeleton ONLY for a cold load. A refresh of a populated grid (rename /
|
|
203
|
-
// delete / upload round-trip) keeps the rows on screen and dims them -
|
|
204
|
-
// flashing the whole directory to shimmer rows on every mutation reads as
|
|
205
|
-
// data loss.
|
|
206
|
-
if (loading && !files.length) return FileSkeleton({ rows: 12 });
|
|
207
|
-
// A filtered miss is NOT an empty directory: when the in-grid filter narrows
|
|
208
|
-
// to zero matches, the host still passes an empty `files` array - but we must
|
|
209
|
-
// keep the controls toolbar (the filter input that caused the miss) mounted so
|
|
210
|
-
// the user can clear/edit it to recover. Only a genuinely-empty directory (no
|
|
211
|
-
// active filter) gets the bare cold EmptyState early-return.
|
|
212
|
-
const hasFilter = !!(filter && (filter.value || '').length > 0);
|
|
213
|
-
if (!files.length && !hasFilter) return EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }), action: emptyAction });
|
|
214
|
-
const refreshing = loading && files.length > 0;
|
|
215
|
-
// Cap the rendered rows. `shown` (host-controlled) overrides the default cap
|
|
216
|
-
// so "show more" can grow it; otherwise default to FILE_GRID_CAP.
|
|
217
|
-
const limit = shown != null ? shown : FILE_GRID_CAP;
|
|
218
|
-
const capped = files.length > limit;
|
|
219
|
-
const visible = capped ? files.slice(0, limit) : files;
|
|
220
|
-
const isThumb = density === 'thumb';
|
|
221
|
-
// NOTE: the old `columns`-driven data-columns card-mode was removed - it placed
|
|
222
|
-
// flex list-rows into a 2-4 col grid (squashed rows, mis-sized actions) and was
|
|
223
|
-
// a half-wired third layout never exposed by the density radiogroup (list/
|
|
224
|
-
// compact/thumb). Thumb density is the canonical multi-column grid.
|
|
225
|
-
const gridAttrs = {};
|
|
226
|
-
// Multi-select bookkeeping. Entries are keyed by path (fallback name); a
|
|
227
|
-
// locked/EACCES entry is never selectable — bulk mutations would fail on it.
|
|
228
|
-
const entryKeyOf = (f) => f.path || f.name;
|
|
229
|
-
const isLockedEntry = (f) => f.locked || f.permissions === 'EACCES'
|
|
230
|
-
|| (Array.isArray(f.permissions) && f.permissions.length === 0);
|
|
231
|
-
const selSet = marked instanceof Set ? marked : new Set(marked || []);
|
|
232
|
-
const selectableKeys = selectable ? visible.filter((f) => !isLockedEntry(f)).map(entryKeyOf) : [];
|
|
233
|
-
// Keyboard: roving focus over the open buttons inside the grid (rows and
|
|
234
|
-
// thumbnail cells share the pattern). Ctrl/Cmd+A selects all SHOWN rows.
|
|
235
|
-
const onKeyDown = (e) => {
|
|
236
|
-
const grid = e.currentTarget;
|
|
237
|
-
const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled]), .ds-file-cell-open:not([disabled])'));
|
|
238
|
-
const cur = opens.indexOf(document.activeElement);
|
|
239
|
-
if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
|
|
240
|
-
else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
|
|
241
|
-
else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
|
|
242
|
-
else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
|
|
243
|
-
else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
|
|
244
|
-
else if ((e.key === 'a' || e.key === 'A') && (e.ctrlKey || e.metaKey)
|
|
245
|
-
&& selectable && onSelectAll && selectableKeys.length) {
|
|
246
|
-
e.preventDefault(); onSelectAll(selectableKeys);
|
|
247
|
-
}
|
|
248
|
-
};
|
|
249
|
-
const head = sort ? FileSortHeader(sort) : null;
|
|
250
|
-
// Tri-state select-all over the selectable SHOWN rows (the cap label below
|
|
251
|
-
// already tells the user more rows exist beyond the window).
|
|
252
|
-
const selOfVisible = selectableKeys.filter((k) => selSet.has(k)).length;
|
|
253
|
-
const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableKeys.length ? 'true' : 'mixed');
|
|
254
|
-
const selectAllCtl = (selectable && onSelectAll && selectableKeys.length)
|
|
255
|
-
? h('button', { key: 'selall', type: 'button', class: 'ds-file-selectall', role: 'checkbox',
|
|
256
|
-
'aria-checked': allState,
|
|
257
|
-
'aria-label': allState === 'true' ? 'clear selection' : 'select all ' + selectableKeys.length + ' shown files',
|
|
258
|
-
onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableKeys) },
|
|
259
|
-
h('span', { class: 'ds-check-box', 'aria-hidden': 'true' }),
|
|
260
|
-
h('span', {}, 'all'))
|
|
261
|
-
: null;
|
|
262
|
-
// Density picker — list / compact / thumbnails. A radiogroup, not tabs:
|
|
263
|
-
// it switches presentation of the same content, not panels.
|
|
264
|
-
const densityCtl = onDensity
|
|
265
|
-
? h('div', { key: 'density', class: 'ds-density', role: 'radiogroup', 'aria-label': 'view density' },
|
|
266
|
-
...DENSITIES.map(([k, label], idx) => h('button', {
|
|
267
|
-
key: 'd-' + k, type: 'button', role: 'radio',
|
|
268
|
-
class: 'ds-density-btn' + (density === k ? ' active' : ''),
|
|
269
|
-
'aria-checked': density === k ? 'true' : 'false',
|
|
270
|
-
// Icon-led, but the density name stays the accessible name
|
|
271
|
-
// (aria-label) + the native tooltip (title) so the control reads
|
|
272
|
-
// dense without losing its label.
|
|
273
|
-
'aria-label': label, title: label,
|
|
274
|
-
// Single tab stop: the checked radio is tabbable, the rest are
|
|
275
|
-
// roved. Arrow/Home/End move + select (selection follows focus).
|
|
276
|
-
tabindex: density === k ? '0' : '-1',
|
|
277
|
-
onkeydown: (e) => rovingRadio(e, idx, DENSITIES, (tk) => { if (density !== tk) onDensity(tk); }),
|
|
278
|
-
onclick: () => { if (density !== k) onDensity(k); },
|
|
279
|
-
}, Icon(DENSITY_ICONS[k], { size: 15 }))))
|
|
280
|
-
: null;
|
|
281
|
-
// One toolbar baseline: filter + select-all + sort sit left, density is
|
|
282
|
-
// pushed right by the spread. The filter used to be a separate right-aligned
|
|
283
|
-
// strip ABOVE controls, giving two strips with conflicting alignment.
|
|
284
|
-
const filterCtl = filter ? h('span', { key: 'filterwrap', class: 'ds-file-filter-wrap' },
|
|
285
|
-
h('input', {
|
|
286
|
-
key: 'filter',
|
|
287
|
-
class: 'ds-file-filter-input', type: 'search',
|
|
288
|
-
value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
|
|
289
|
-
'aria-label': filter.placeholder || 'Filter files in this directory',
|
|
290
|
-
oninput: (e) => filter.onInput && filter.onInput(e.target.value),
|
|
291
|
-
onkeydown: (e) => {
|
|
292
|
-
if (e.key === 'Escape' && filter.value) { e.preventDefault(); e.stopPropagation(); filter.onInput && filter.onInput(''); }
|
|
293
|
-
},
|
|
294
|
-
}),
|
|
295
|
-
// Announces the filtered count as the filter narrows the list, so a
|
|
296
|
-
// screen-reader user gets the same feedback a sighted user reads off
|
|
297
|
-
// the grid without having to re-scan it after every keystroke. `files`
|
|
298
|
-
// here is already the host's filter-applied set (see hasFilter above) -
|
|
299
|
-
// there is no separate pre-filter total available inside this component.
|
|
300
|
-
h('span', { key: 'filtercount', class: 'sr-only', role: 'status', 'aria-live': 'polite' },
|
|
301
|
-
hasFilter ? files.length + (files.length === 1 ? ' file' : ' files') + ' shown' : '')
|
|
302
|
-
) : null;
|
|
303
|
-
const leftKids = [filterCtl, selectAllCtl, head].filter(Boolean);
|
|
304
|
-
const controlsKids = [
|
|
305
|
-
...leftKids,
|
|
306
|
-
(leftKids.length && densityCtl) ? h('span', { key: 'spread', class: 'spread' }) : null,
|
|
307
|
-
densityCtl].filter(Boolean);
|
|
308
|
-
const controls = controlsKids.length
|
|
309
|
-
? h('div', { class: 'ds-file-controls' }, ...controlsKids)
|
|
310
|
-
: null;
|
|
311
|
-
// A filtered miss (zero rows but an active filter) renders the EmptyState
|
|
312
|
-
// INSIDE the listing, below the controls toolbar, so the filter input stays
|
|
313
|
-
// mounted and editable - the user can clear/edit it to recover instead of
|
|
314
|
-
// being stranded with no toolbar (the early-return only fires for a genuinely
|
|
315
|
-
// empty directory). The host passes filter-aware copy via emptyText.
|
|
316
|
-
const filteredEmpty = !files.length && hasFilter;
|
|
317
|
-
// role=group not listbox: the rows contain real <button> action controls, so
|
|
318
|
-
// listbox/option semantics are invalid (an option can't host interactive
|
|
319
|
-
// children). Keyboard nav still works via roving focus over the open buttons.
|
|
320
|
-
const grid = filteredEmpty ? EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }) }) : h('div', {
|
|
321
|
-
class: 'ds-file-grid' + (isThumb ? ' ds-file-grid-thumb' : '') + (refreshing ? ' is-refreshing' : ''),
|
|
322
|
-
role: 'group', 'aria-label': 'files', tabindex: '0',
|
|
323
|
-
'aria-busy': refreshing ? 'true' : 'false',
|
|
324
|
-
// Always concrete (webjsx's attribute diff can leave a null-valued
|
|
325
|
-
// attribute unset when toggling away from the default).
|
|
326
|
-
'data-density': density || 'list',
|
|
327
|
-
onkeydown: onKeyDown, ...gridAttrs },
|
|
328
|
-
...visible.map((f, i) => isThumb
|
|
329
|
-
? FileCell({
|
|
330
|
-
key: f.path || f.name + i, f,
|
|
331
|
-
selectable, selected: selSet.has(entryKeyOf(f)),
|
|
332
|
-
onToggleSelect: onMark ? (opts) => onMark(f, opts) : null,
|
|
333
|
-
onOpen,
|
|
334
|
-
thumb: (thumbUrl && f.type === 'image') ? thumbUrl(f) : null,
|
|
335
|
-
})
|
|
336
|
-
: FileRow({
|
|
337
|
-
key: f.path || f.name + i,
|
|
338
|
-
name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
|
|
339
|
-
permissions: f.permissions, locked: f.locked,
|
|
340
|
-
actions: actions != null ? actions : undefined,
|
|
341
|
-
busy: busy != null ? !!busy : !!f.busy,
|
|
342
|
-
selectable, selected: selSet.has(entryKeyOf(f)),
|
|
343
|
-
onToggleSelect: onMark ? (opts) => onMark(f, opts) : null,
|
|
344
|
-
onOpen: onOpen ? () => onOpen(f) : null,
|
|
345
|
-
onAction: onAction ? (act) => onAction(act, f) : null
|
|
346
|
-
}))
|
|
347
|
-
);
|
|
348
|
-
// A count + "show more" affordance so a capped large dir reads as "more
|
|
349
|
-
// exist", not "this is everything". aria-live announces the shown/total.
|
|
350
|
-
const more = capped
|
|
351
|
-
? h('div', { class: 'ds-file-more' },
|
|
352
|
-
h('span', { class: 'ds-file-more-count', role: 'status', 'aria-live': 'polite' },
|
|
353
|
-
'showing ' + visible.length + ' of ' + files.length),
|
|
354
|
-
onShowMore ? h('button', { type: 'button', class: 'ds-file-more-btn',
|
|
355
|
-
onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
|
|
356
|
-
'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
|
|
357
|
-
: null;
|
|
358
|
-
return (controls || more)
|
|
359
|
-
? h('div', { class: 'ds-file-listing' }, controls, grid, more)
|
|
360
|
-
: grid;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const DENSITIES = [['list', 'list'], ['compact', 'compact'], ['thumb', 'thumbnails']];
|
|
364
|
-
const DENSITY_ICONS = { list: 'rows', compact: 'rows-tight', thumb: 'grid' };
|
|
365
|
-
|
|
366
|
-
// Roving-radiogroup keyboard helper (the WAI-ARIA radio pattern): a radiogroup
|
|
367
|
-
// is a SINGLE tab stop where Arrow/Home/End move AND select among options, with
|
|
368
|
-
// selection following focus. `items` is the ordered [[key, ...], ...] list;
|
|
369
|
-
// `onSelect(targetKey)` is the same handler the onclick fires. Mouse path is
|
|
370
|
-
// unchanged - this only adds keyboard navigation.
|
|
371
|
-
function rovingRadio(e, idx, items, onSelect) {
|
|
372
|
-
let target = -1;
|
|
373
|
-
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') target = (idx - 1 + items.length) % items.length;
|
|
374
|
-
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') target = (idx + 1) % items.length;
|
|
375
|
-
else if (e.key === 'Home') target = 0;
|
|
376
|
-
else if (e.key === 'End') target = items.length - 1;
|
|
377
|
-
else return;
|
|
378
|
-
e.preventDefault();
|
|
379
|
-
onSelect(items[target][0]);
|
|
380
|
-
const sib = e.currentTarget.parentNode && e.currentTarget.parentNode.children[target];
|
|
381
|
-
sib && sib.focus();
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// FileCell — the thumbnail-density tile. Image entries show a real (lazy)
|
|
385
|
-
// thumbnail through the host's confined thumbUrl; everything else keeps its
|
|
386
|
-
// type icon. Same open/mark semantics as FileRow, same no-nesting rule.
|
|
387
|
-
function FileCell({ key, f = {}, selectable = false, selected = false, onToggleSelect, onOpen, thumb } = {}) {
|
|
388
|
-
const noAccess = f.locked || f.permissions === 'EACCES'
|
|
389
|
-
|| (Array.isArray(f.permissions) && f.permissions.length === 0);
|
|
390
|
-
const canOpen = onOpen && !noAccess;
|
|
391
|
-
const typeLabel = TYPE_LABELS[f.type] || 'file';
|
|
392
|
-
const kids = [
|
|
393
|
-
selectable ? h('button', {
|
|
394
|
-
key: 'mark', type: 'button',
|
|
395
|
-
class: 'ds-file-check ds-file-cell-check' + (selected ? ' is-marked' : ''),
|
|
396
|
-
role: 'checkbox', 'aria-checked': selected ? 'true' : 'false',
|
|
397
|
-
'aria-label': (selected ? 'unselect ' : 'select ') + f.name,
|
|
398
|
-
disabled: noAccess ? true : null,
|
|
399
|
-
onclick: onToggleSelect ? (e) => onToggleSelect({ range: !!e.shiftKey }) : null,
|
|
400
|
-
}, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null,
|
|
401
|
-
h('button', {
|
|
402
|
-
key: 'open', type: 'button', class: 'ds-file-cell-open',
|
|
403
|
-
onclick: canOpen ? () => onOpen(f) : null,
|
|
404
|
-
disabled: canOpen ? null : true,
|
|
405
|
-
'aria-label': typeLabel + ': ' + f.name + (noAccess ? ' (no access)' : ''),
|
|
406
|
-
},
|
|
407
|
-
h('span', { class: 'ds-file-cell-media' },
|
|
408
|
-
thumb
|
|
409
|
-
? h('img', { class: 'ds-file-cell-thumb', src: thumb, alt: '', loading: 'lazy' })
|
|
410
|
-
: FileIcon({ type: f.type })),
|
|
411
|
-
h('span', { class: 'ds-file-cell-name', title: f.name }, f.name),
|
|
412
|
-
h('span', { class: 'ds-file-cell-meta' }, f.type === 'dir' ? 'folder' : fmtFileSize(f.size))),
|
|
413
|
-
].filter(Boolean);
|
|
414
|
-
return h('div', {
|
|
415
|
-
key,
|
|
416
|
-
class: 'ds-file-cell' + (selected ? ' is-marked' : '') + (f.active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
|
|
417
|
-
'data-file-type': f.type,
|
|
418
|
-
}, ...kids);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// BulkBar — the act-on-selection strip shown while a multi-select is active.
|
|
422
|
-
// Host renders it above the grid; `actions` are [{ label, onClick, danger,
|
|
423
|
-
// disabled }]; `busy` disables everything while a bulk operation is in flight.
|
|
424
|
-
export function BulkBar({ count = 0, noun = 'file', nounPlural, actions = [], onClear, busy = false } = {}) {
|
|
425
|
-
if (!count) return null;
|
|
426
|
-
// 'entry' pluralizes to 'entries', not 'entrys' - handle the -y noun class
|
|
427
|
-
// unless the host passes an explicit plural.
|
|
428
|
-
const plural = nounPlural || (/[^aeiou]y$/.test(noun) ? noun.slice(0, -1) + 'ies' : noun + 's');
|
|
429
|
-
const kids = [
|
|
430
|
-
h('span', { key: 'count', class: 'ds-bulkbar-count', role: 'status', 'aria-live': 'polite' },
|
|
431
|
-
count + ' ' + (count === 1 ? noun : plural) + ' selected'),
|
|
432
|
-
...actions.map((a, i) => Btn({
|
|
433
|
-
key: 'bba' + i, danger: !!a.danger, disabled: busy || a.disabled,
|
|
434
|
-
onClick: a.onClick, children: a.label,
|
|
435
|
-
})),
|
|
436
|
-
onClear ? Btn({ key: 'bbclear', disabled: busy, onClick: onClear, children: 'clear selection' }) : null,
|
|
437
|
-
].filter(Boolean);
|
|
438
|
-
return h('div', { class: 'ds-bulkbar', role: 'toolbar', 'aria-label': 'bulk file actions', 'aria-busy': busy ? 'true' : null }, ...kids);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Clickable column headers for FileGrid sort. Active column shows its direction
|
|
442
|
-
// as an ASCII caret word (asc/desc) - never a glyph arrow.
|
|
443
|
-
function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
|
|
444
|
-
const cols = [['name', 'name'], ['size', 'size'], ['modified', 'modified']];
|
|
445
|
-
return h('div', { class: 'ds-file-sort', role: 'group', 'aria-label': 'sort files' },
|
|
446
|
-
...cols.map(([k, label]) => h('button', {
|
|
447
|
-
key: k, type: 'button',
|
|
448
|
-
class: 'ds-file-sort-btn' + (active === k ? ' active' : ''),
|
|
449
|
-
'aria-pressed': active === k ? 'true' : 'false',
|
|
450
|
-
'aria-label': 'sort by ' + label + (active === k ? ' (' + (dir === 'asc' ? 'ascending' : 'descending') + ')' : ''),
|
|
451
|
-
onclick: () => onSort && onSort(k),
|
|
452
|
-
}, label + (active === k ? ' ' + (dir === 'asc' ? 'asc' : 'desc') : ''))));
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export function FileToolbar({ left = [], right = [] } = {}) {
|
|
456
|
-
return h('div', { class: 'ds-file-toolbar' },
|
|
457
|
-
h('div', { class: 'ds-file-toolbar-left' }, ...left),
|
|
458
|
-
h('div', { class: 'ds-file-toolbar-right' }, ...right)
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// RootsPicker — a segmented control for choosing among multiple allowed FS roots
|
|
463
|
-
// (so the app stops borrowing the history-tab .pill markup). Each root is
|
|
464
|
-
// { id, label }; `selected` is the active id. role=tablist for AT navigation.
|
|
465
|
-
export function RootsPicker({ roots = [], selected, onSelect, label = 'roots' } = {}) {
|
|
466
|
-
if (!roots.length) return null;
|
|
467
|
-
return h('div', { class: 'ds-roots-picker', role: 'tablist', 'aria-label': label },
|
|
468
|
-
...roots.map((r) => h('button', {
|
|
469
|
-
key: 'root-' + (r.id != null ? r.id : r.label),
|
|
470
|
-
type: 'button', role: 'tab',
|
|
471
|
-
class: 'ds-roots-tab' + ((r.id != null ? r.id : r.label) === selected ? ' active' : ''),
|
|
472
|
-
'aria-selected': (r.id != null ? r.id : r.label) === selected ? 'true' : 'false',
|
|
473
|
-
onclick: () => onSelect && onSelect(r.id != null ? r.id : r.label),
|
|
474
|
-
}, r.label || r.id)));
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave, label = 'drop files here', onPick } = {}) {
|
|
478
|
-
// With children the zone is a passive WRAPPER: content renders normally and
|
|
479
|
-
// the dashed affordance appears only while a drag is over it (real file
|
|
480
|
-
// managers never burn a permanent band on a maybe-drop). Without children
|
|
481
|
-
// it keeps the explicit picker-block look.
|
|
482
|
-
const kids = Array.isArray(children) ? children : children ? [children] : [];
|
|
483
|
-
return h('div', {
|
|
484
|
-
class: 'ds-dropzone' + (kids.length ? ' ds-dropzone--wrap' : '') + (dragover ? ' dragover' : ''),
|
|
485
|
-
ondragover: (e) => { e.preventDefault(); onDragOver && onDragOver(e); },
|
|
486
|
-
ondragleave: (e) => { if (!e.currentTarget.contains(e.relatedTarget)) { onDragLeave && onDragLeave(e); } },
|
|
487
|
-
ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
|
|
488
|
-
},
|
|
489
|
-
h('div', { class: 'ds-dropzone-inner' },
|
|
490
|
-
h('span', { class: 'ds-dropzone-glyph', role: 'img', 'aria-label': 'upload' }, Icon('arrow-up')),
|
|
491
|
-
h('span', { class: 'ds-dropzone-label' }, label),
|
|
492
|
-
onPick ? Btn({ onClick: onPick, children: 'pick files' }) : null
|
|
493
|
-
),
|
|
494
|
-
...kids
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// UploadProgress — per-file upload rows. Error rows are recoverable, not dead
|
|
499
|
-
// ends: each item may carry `actions` ([{ label, onClick }], e.g. 'replace' on
|
|
500
|
-
// a 409 collision) and the host may wire `onDismiss(item, index)` so error rows
|
|
501
|
-
// can be cleared without waiting for the next successful batch.
|
|
502
|
-
export function UploadProgress({ items = [], onDismiss } = {}) {
|
|
503
|
-
if (!items.length) return null;
|
|
504
|
-
return h('div', { class: 'ds-upload-progress' },
|
|
505
|
-
...items.map((it, i) => {
|
|
506
|
-
const indeterminate = !it.error && !it.done && !it.pct && it.indeterminate;
|
|
507
|
-
const status = it.error ? 'error' : (it.done ? 'complete' : (indeterminate ? 'uploading' : `uploading ${it.pct || 0}%`));
|
|
508
|
-
const rowActions = [
|
|
509
|
-
...((it.actions || []).map((a, ai) => h('button', {
|
|
510
|
-
key: 'ua' + ai, type: 'button', class: 'ds-upload-act',
|
|
511
|
-
'aria-label': `${a.label} ${it.name}`,
|
|
512
|
-
onclick: () => a.onClick && a.onClick(it, i),
|
|
513
|
-
}, a.label))),
|
|
514
|
-
(it.error && onDismiss) ? h('button', {
|
|
515
|
-
key: 'ud', type: 'button', class: 'ds-upload-act',
|
|
516
|
-
'aria-label': `dismiss ${it.name}`,
|
|
517
|
-
onclick: () => onDismiss(it, i),
|
|
518
|
-
}, 'dismiss') : null,
|
|
519
|
-
].filter(Boolean);
|
|
520
|
-
return h('div', {
|
|
521
|
-
key: it.name + i,
|
|
522
|
-
class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
|
|
523
|
-
role: 'status',
|
|
524
|
-
'aria-label': `${it.name}: ${status}`,
|
|
525
|
-
'aria-live': 'polite'
|
|
526
|
-
},
|
|
527
|
-
h('span', { class: 'ds-upload-name' }, it.name),
|
|
528
|
-
h('span', { class: 'ds-upload-bar' + (indeterminate ? ' indeterminate' : '') },
|
|
529
|
-
h('span', { class: 'ds-upload-fill', 'data-pct': String(Math.max(0, Math.min(100, it.pct || 0))), 'aria-hidden': 'true' })
|
|
530
|
-
),
|
|
531
|
-
h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (indeterminate ? '...' : (it.pct || 0) + '%')))),
|
|
532
|
-
rowActions.length ? h('span', { class: 'ds-upload-actions', role: 'group', 'aria-label': `actions for ${it.name}` }, ...rowActions) : null
|
|
533
|
-
);
|
|
534
|
-
})
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
export function EmptyState({ text = 'nothing here', glyph = Icon('circle'), action } = {}) {
|
|
539
|
-
// action: { onClick, label } - an optional CTA (e.g. 'go up' / 'upload a
|
|
540
|
-
// file'), mirroring the SessionDashboard emptyAction contract so an empty
|
|
541
|
-
// directory is not a dead end. Children are built as an array + filtered so
|
|
542
|
-
// the keyed Btn never sits beside an unkeyed span (webjsx applyDiff 'key'
|
|
543
|
-
// crash on mixed keyed/unkeyed siblings).
|
|
544
|
-
return h('div', { class: 'ds-file-empty', role: 'status' },
|
|
545
|
-
...[
|
|
546
|
-
h('span', { key: 'glyph', class: 'ds-file-empty-glyph', 'aria-hidden': 'true' }, glyph),
|
|
547
|
-
h('span', { key: 'text', class: 'ds-file-empty-text' }, text),
|
|
548
|
-
(action && action.onClick)
|
|
549
|
-
? Btn({ key: 'ea', onClick: action.onClick, children: action.label || 'go up' })
|
|
550
|
-
: null,
|
|
551
|
-
].filter(Boolean)
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
export function BreadcrumbPath({ segments = [], onNav, root = 'root' } = {}) {
|
|
556
|
-
const parts = [h('button', { key: 'root', class: 'ds-crumb-seg', onclick: () => onNav && onNav(0) }, root)];
|
|
557
|
-
segments.forEach((seg, i) => {
|
|
558
|
-
parts.push(h('span', { key: 'sep' + i, class: 'ds-crumb-sep', 'aria-hidden': 'true' }, Icon('chevron-right', { size: 13 })));
|
|
559
|
-
parts.push(h('button', {
|
|
560
|
-
key: 'seg' + i,
|
|
561
|
-
class: 'ds-crumb-seg' + (i === segments.length - 1 ? ' leaf' : ''),
|
|
562
|
-
onclick: () => onNav && onNav(i + 1)
|
|
563
|
-
}, seg));
|
|
564
|
-
});
|
|
565
|
-
return h('div', { class: 'ds-crumb-path' }, ...parts);
|
|
566
|
-
}
|