anentrypoint-design 0.0.381 → 0.0.382

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.
Files changed (49) hide show
  1. package/dist/247420.css +228 -162
  2. package/dist/247420.js +24 -24
  3. package/package.json +1 -1
  4. package/src/components/content/avatar.js +31 -0
  5. package/src/components/content/charts.js +50 -0
  6. package/src/components/content/cli.js +46 -0
  7. package/src/components/content/feedback.js +77 -0
  8. package/src/components/content/fields.js +136 -0
  9. package/src/components/content/hero.js +146 -0
  10. package/src/components/content/lists.js +85 -0
  11. package/src/components/content/panel.js +79 -0
  12. package/src/components/content/row.js +115 -0
  13. package/src/components/content/table.js +97 -0
  14. package/src/components/content/views.js +81 -0
  15. package/src/components/content.js +29 -861
  16. package/src/components/editor-primitives/batch.js +60 -0
  17. package/src/components/editor-primitives/chrome.js +146 -0
  18. package/src/components/editor-primitives/collapse.js +46 -0
  19. package/src/components/editor-primitives/context-menu.js +127 -0
  20. package/src/components/editor-primitives/diagnostics.js +44 -0
  21. package/src/components/editor-primitives/focus-trap.js +26 -0
  22. package/src/components/editor-primitives/json-viewer.js +123 -0
  23. package/src/components/editor-primitives/layout.js +89 -0
  24. package/src/components/editor-primitives/modals.js +89 -0
  25. package/src/components/editor-primitives/pager.js +74 -0
  26. package/src/components/editor-primitives/property-grid.js +57 -0
  27. package/src/components/editor-primitives/shared.js +18 -0
  28. package/src/components/editor-primitives/split-panel.js +101 -0
  29. package/src/components/editor-primitives/toast.js +59 -0
  30. package/src/components/editor-primitives/tree.js +75 -0
  31. package/src/components/editor-primitives.js +35 -1048
  32. package/src/components/overlay-primitives/approval-prompt.js +38 -0
  33. package/src/components/overlay-primitives/auth-modal.js +89 -0
  34. package/src/components/overlay-primitives/command-palette.js +101 -0
  35. package/src/components/overlay-primitives/emoji-picker.js +103 -0
  36. package/src/components/overlay-primitives/floating.js +146 -0
  37. package/src/components/overlay-primitives/full-screen.js +45 -0
  38. package/src/components/overlay-primitives/menus.js +131 -0
  39. package/src/components/overlay-primitives/popover.js +51 -0
  40. package/src/components/overlay-primitives/roving-menu.js +73 -0
  41. package/src/components/overlay-primitives/settings-popover.js +85 -0
  42. package/src/components/overlay-primitives/tooltip.js +55 -0
  43. package/src/components/overlay-primitives.js +33 -855
  44. package/src/css/app-shell/chat-polish.css +39 -32
  45. package/src/css/app-shell/data-density.css +25 -21
  46. package/src/css/app-shell/files.css +37 -29
  47. package/src/css/app-shell/kits-appended.css +73 -50
  48. package/src/css/app-shell/responsive.css +47 -29
  49. package/src/kits/os/freddie-dashboard.css +2 -2
@@ -3,1052 +3,39 @@
3
3
  // visuals route through CSS classes defined in editor-primitives.css;
4
4
  // no hex/rgba literals appear in this file. Theme switching happens
5
5
  // via the kit's data-theme attribute on the .ds-247420 scope root.
6
-
7
- import * as webjsx from '../../vendor/webjsx/index.js';
8
- import { Icon } from './shell.js';
9
- const h = webjsx.createElement;
10
-
11
- function kids(c) { return c == null ? [] : (Array.isArray(c) ? c : [c]); }
12
-
13
- export function Toolbar({ leading = [], trailing = [], dense = false, children } = {}) {
14
- const cls = 'ds-ep-toolbar' + (dense ? ' dense' : '');
15
- return h('div', { class: cls, role: 'toolbar' },
16
- h('div', { class: 'ds-ep-toolbar-leading' }, ...kids(leading)),
17
- children != null ? h('div', { class: 'ds-ep-toolbar-center' }, ...kids(children)) : null,
18
- h('div', { class: 'ds-ep-toolbar-trailing' }, ...kids(trailing))
19
- );
20
- }
21
-
22
- // ---------------------------------------------------------------------------
23
- // ToolbarRow — a flat, wrapping row of arbitrary action nodes (buttons,
24
- // inputs, chips) with no leading/center/trailing slot structure. Toolbar's
25
- // three-slot split is the wrong shape when a caller just wants "this row of
26
- // controls, left to right, wrapping on narrow viewports" — the exact shape
27
- // gmsniff's panels.js hand-rolled as a bare '.gm-toolbar' div because Toolbar
28
- // didn't cover it. Accepts children as varargs or a single array.
29
- // ---------------------------------------------------------------------------
30
- export function ToolbarRow(...actions) {
31
- const flat = actions.length === 1 && Array.isArray(actions[0]) ? actions[0] : actions;
32
- return h('div', { class: 'ds-ep-toolbar-row', role: 'toolbar' }, ...kids(flat));
33
- }
34
-
35
- export function Tabs({ items = [], active, onChange, children, 'aria-label': ariaLabel, onClose, scroll = false } = {}) {
36
- // Roving tabindex + arrow nav per WAI-ARIA tabs pattern.
37
- // Only the active tab is in the tab order; arrows move focus + activate.
38
- const activeIdx = Math.max(0, items.findIndex(it => it.id === active));
39
- const onTabKeyDown = (e, idx) => {
40
- let next = null;
41
- if (e.key === 'ArrowRight') next = (idx + 1) % items.length;
42
- else if (e.key === 'ArrowLeft') next = (idx - 1 + items.length) % items.length;
43
- else if (e.key === 'Home') next = 0;
44
- else if (e.key === 'End') next = items.length - 1;
45
- if (next == null) return;
46
- e.preventDefault();
47
- const nextId = items[next]?.id;
48
- if (nextId && onChange) onChange(nextId);
49
- // Move focus on next paint (so the newly rendered active button gets focus)
50
- queueMicrotask(() => {
51
- const head = e.currentTarget?.parentElement;
52
- const btn = head?.querySelectorAll('[role="tab"]')[next];
53
- if (btn) btn.focus();
54
- });
55
- };
56
- // Position the sliding underline from the active tab's geometry. The ref is
57
- // on the OUTER .ds-ep-tabs (position:relative) — the indicator is its child,
58
- // NOT the flex head's (an abspos child of a horizontal flex row mis-sizes to
59
- // 0 in Chromium). Runs on every render (ref fires after applyDiff) so a tab
60
- // change re-measures; the CSS transition animates the move. left/width come
61
- // from the active tab; top sits the bar on the head's bottom edge.
62
- const positionSlider = (root) => {
63
- if (!root) return;
64
- const head = root.querySelector('.ds-ep-tabs-head');
65
- const active = root.querySelector('.ds-ep-tab.active');
66
- const ind = root.querySelector('.ds-ep-tab-indicator');
67
- if (!head || !active || !ind) return;
68
- // Size via left+right insets, NOT width: an abspos element's inline
69
- // `width` computes to 0 in some flex-sibling layouts (Chromium), but
70
- // left+right insets size reliably. left/right animate via the CSS
71
- // transition on .ds-ep-tab-indicator.
72
- const rootW = root.offsetWidth;
73
- const l = active.offsetLeft, w = active.offsetWidth;
74
- ind.style.left = l + 'px';
75
- ind.style.right = Math.max(0, rootW - l - w) + 'px';
76
- ind.style.top = (head.offsetTop + head.offsetHeight - 2) + 'px';
77
- head.classList.add('has-slider');
78
- };
79
- // scroll=true: tabs size to content (min/max-width) instead of stretching
80
- // equally (flex:1) — the shape pi-web's TabBar uses for an open-file strip
81
- // where tab count is unbounded and overflow-x scroll (already on
82
- // .ds-ep-tabs-head) needs real per-tab widths to have something to scroll.
83
- // onClose: per-item close affordance — a close button plus middle-click
84
- // (auxclick button 1) to close, matching pi-web's TabBar. Opt-in: passing
85
- // onClose without scroll still renders close buttons on the flex:1 tabs.
86
- const closable = typeof onClose === 'function';
87
- return h('div', { class: 'ds-ep-tabs', ref: positionSlider },
88
- h('div', { class: 'ds-ep-tabs-head' + (scroll ? ' scroll' : ''), role: 'tablist', 'aria-label': ariaLabel || 'tabs' },
89
- ...items.map((it, idx) => h('span', {
90
- key: it.id,
91
- class: 'ds-ep-tab-wrap' + (it.id === active ? ' active' : ''),
92
- onmousedown: closable ? (e) => { if (e.button === 1) e.preventDefault(); } : null,
93
- onauxclick: closable ? (e) => {
94
- if (e.button !== 1) return;
95
- e.preventDefault();
96
- e.stopPropagation();
97
- onClose(it.id);
98
- } : null
99
- },
100
- h('button', {
101
- type: 'button',
102
- class: 'ds-ep-tab' + (it.id === active ? ' active' : ''),
103
- role: 'tab',
104
- id: 'tab-' + it.id,
105
- title: typeof it.label === 'string' ? it.label : undefined,
106
- 'aria-selected': it.id === active ? 'true' : 'false',
107
- 'aria-controls': 'tabpanel-' + it.id,
108
- 'aria-label': typeof it.label === 'string' ? it.label : ('tab ' + (idx + 1)),
109
- tabindex: idx === activeIdx ? '0' : '-1',
110
- onclick: () => onChange && onChange(it.id),
111
- onkeydown: (e) => onTabKeyDown(e, idx)
112
- }, it.label),
113
- closable ? h('button', {
114
- type: 'button',
115
- class: 'ds-ep-tab-close',
116
- title: 'Close',
117
- 'aria-label': 'Close ' + (typeof it.label === 'string' ? it.label : 'tab'),
118
- onclick: (e) => { e.stopPropagation(); onClose(it.id); }
119
- }, Icon('x', { size: 14 })) : null
120
- ))
121
- ),
122
- // The sliding underline — child of the outer column (see positionSlider).
123
- // Keyed + decorative. Renders at 0-width until positioned (no-JS: hidden).
124
- h('span', { key: '__ind', class: 'ds-ep-tab-indicator', 'aria-hidden': 'true' }),
125
- h('div', {
126
- class: 'ds-ep-tabs-body',
127
- role: 'tabpanel',
128
- id: active ? 'tabpanel-' + active : undefined,
129
- 'aria-labelledby': active ? 'tab-' + active : undefined,
130
- tabindex: '0'
131
- }, ...kids(children))
132
- );
133
- }
134
-
135
- export function TreeView({ children } = {}) {
136
- return h('div', { class: 'ds-ep-tree', role: 'tree' }, ...kids(children));
137
- }
138
-
139
- export function TreeItem({ label, glyph, tag, depth = 0, selected = false, expanded = false, onSelect, onToggle, children, hasChildren } = {}) {
140
- // Support legacy 'hasChildren' prop for future; infer from children param
141
- const hasKids = hasChildren != null ? hasChildren : (children != null);
142
- // Tree keyboard model (WAI-ARIA): Up/Down move between visible rows, Right expands/enters,
143
- // Left collapses/moves to parent, Enter/Space activate, Home/End jump to first/last visible.
144
- const onRowKeyDown = (e) => {
145
- const row = e.currentTarget;
146
- const tree = row.closest('[role="tree"]');
147
- if (!tree) return;
148
- const rows = Array.from(tree.querySelectorAll('.ds-ep-tree-row'));
149
- const idx = rows.indexOf(row);
150
- if (idx < 0) return;
151
- const move = (i) => {
152
- const r = rows[Math.max(0, Math.min(rows.length - 1, i))];
153
- if (r) r.focus();
154
- };
155
- switch (e.key) {
156
- case 'ArrowDown': e.preventDefault(); move(idx + 1); break;
157
- case 'ArrowUp': e.preventDefault(); move(idx - 1); break;
158
- case 'Home': e.preventDefault(); move(0); break;
159
- case 'End': e.preventDefault(); move(rows.length - 1); break;
160
- case 'ArrowRight':
161
- if (hasKids && !expanded && onToggle) { e.preventDefault(); onToggle(); }
162
- else if (hasKids && expanded) { e.preventDefault(); move(idx + 1); }
163
- break;
164
- case 'ArrowLeft':
165
- if (hasKids && expanded && onToggle) { e.preventDefault(); onToggle(); }
166
- break;
167
- case 'Enter':
168
- case ' ':
169
- e.preventDefault();
170
- if (onSelect) onSelect();
171
- break;
172
- }
173
- };
174
- return h('div', {
175
- class: 'ds-ep-tree-item' + (selected ? ' selected' : ''),
176
- role: 'treeitem',
177
- 'aria-selected': selected ? 'true' : 'false',
178
- 'aria-expanded': hasKids ? String(!!expanded) : null,
179
- 'aria-level': depth + 1
180
- },
181
- h('div', {
182
- class: 'ds-ep-tree-row',
183
- style: 'padding-left:calc(' + depth + ' * var(--tree-indent,12px) + var(--tree-base-indent,6px))',
184
- tabindex: selected ? '0' : '-1',
185
- onclick: () => onSelect && onSelect(),
186
- onkeydown: onRowKeyDown
187
- },
188
- h('span', {
189
- class: 'ds-ep-tree-twist' + (expanded ? ' open' : ''),
190
- 'aria-hidden': 'true',
191
- onclick: (e) => { e.stopPropagation(); if (hasKids && onToggle) onToggle(); }
192
- }, hasKids ? Icon('chevron-right') : ''),
193
- glyph != null ? h('span', { class: 'ds-ep-tree-glyph', 'aria-hidden': 'true' }, glyph) : null,
194
- h('span', { class: 'ds-ep-tree-label' }, label),
195
- tag != null ? h('span', { class: 'ds-ep-tree-tag' }, tag) : null
196
- ),
197
- hasKids && expanded ? h('div', { class: 'ds-ep-tree-children', role: 'group' }, ...kids(children)) : null
198
- );
199
- }
200
-
201
- export function PropertyGrid({ children } = {}) {
202
- return h('div', { class: 'ds-ep-propgrid', role: 'group' }, ...kids(children));
203
- }
204
-
205
- export function PropertyField({ label, hint, inline = false, children } = {}) {
206
- return h('label', { class: 'ds-ep-propfield' + (inline ? ' inline' : '') },
207
- h('span', { class: 'ds-ep-propfield-label' }, label),
208
- h('span', { class: 'ds-ep-propfield-value' }, ...kids(children)),
209
- hint != null ? h('span', { class: 'ds-ep-propfield-hint' }, hint) : null
210
- );
211
- }
212
-
213
- // ---------------------------------------------------------------------------
214
- // PropertyGridRow — a PropertyGrid row wrapper with a bottom-border divider
215
- // (last-child border suppressed), for editors that need a stronger per-row
216
- // visual separation than the default PropertyGrid gap gives (e.g. a list of
217
- // independently-editable records like PRD/mutable rows). Generalizes
218
- // gmsniff's gm-propgrid-row.
219
- // ---------------------------------------------------------------------------
220
- export function PropertyGridRow({ children, key } = {}) {
221
- return h('div', { key, class: 'ds-ep-propgrid-row' }, ...kids(children));
222
- }
223
-
224
- // ---------------------------------------------------------------------------
225
- // InlineEditableField — a borderless-until-focus text input that inherits
226
- // surrounding font (no boxed input chrome), with an explicit error state
227
- // (aria-invalid + danger-token border) for live per-field validation.
228
- // Generalizes gmsniff's gm-inline-input / gm-field-error pair. Renders a
229
- // <textarea> when multiline is set (for longer free-text edits), else a
230
- // single-line <input>.
231
- // ---------------------------------------------------------------------------
232
- export function InlineEditableField({ value = '', placeholder, onInput, onChange, error, multiline = false, rows = 3, ariaLabel, disabled = false } = {}) {
233
- const cls = 'ds-ep-inline-input' + (error ? ' has-error' : '');
234
- const common = {
235
- class: cls,
236
- value,
237
- placeholder,
238
- disabled: disabled ? 'disabled' : null,
239
- 'aria-label': ariaLabel,
240
- 'aria-invalid': error ? 'true' : null,
241
- oninput: onInput ? (e) => onInput(e.target.value, e) : null,
242
- onchange: onChange ? (e) => onChange(e.target.value, e) : null,
243
- };
244
- return multiline
245
- ? h('textarea', { ...common, rows })
246
- : h('input', { ...common, type: 'text' });
247
- }
248
-
249
- export function Dock({ top, left, right, bottom, center } = {}) {
250
- return h('div', { class: 'ds-ep-dock' },
251
- top != null ? h('div', { class: 'ds-ep-dock-top' }, ...kids(top)) : null,
252
- left != null ? h('div', { class: 'ds-ep-dock-left' }, ...kids(left)) : null,
253
- h('div', { class: 'ds-ep-dock-center' }, ...kids(center)),
254
- right != null ? h('div', { class: 'ds-ep-dock-right' }, ...kids(right)) : null,
255
- bottom != null ? h('div', { class: 'ds-ep-dock-bottom' }, ...kids(bottom)) : null
256
- );
257
- }
258
-
259
- // ---------------------------------------------------------------------------
260
- // Breakpoints + useMediaQuery
261
- // ---------------------------------------------------------------------------
262
- export const BP_SM = 480;
263
- export const BP_MD = 768;
264
- export const BP_LG = 1024;
265
- export const BP_XL = 1440;
266
-
267
- export function useMediaQuery(query) {
268
- if (typeof window === 'undefined' || !window.matchMedia) {
269
- return { matches: false, addListener: () => {}, removeListener: () => {} };
270
- }
271
- const mql = window.matchMedia(query);
272
- return {
273
- get matches() { return mql.matches; },
274
- addListener(fn) { mql.addEventListener ? mql.addEventListener('change', fn) : mql.addListener(fn); },
275
- removeListener(fn) { mql.removeEventListener ? mql.removeEventListener('change', fn) : mql.removeListener(fn); },
276
- };
277
- }
278
-
279
- // ---------------------------------------------------------------------------
280
- // Grid / GridItem — 24-column responsive layout primitive (screen-real-estate
281
- // density: dense multi-column panels without a hand-rolled grid-template-
282
- // columns per consumer). Column-span props are integers 1-24 (or `true` for
283
- // full-width/auto-grow, or `0` to hide at that breakpoint) evaluated at four
284
- // tiers mirroring BP_SM/MD/LG/XL (480/768/1024/1440) via media queries in
285
- // editor-primitives.css — no JS-side matchMedia needed, CSS custom
286
- // properties + @media do the layout work so it degrades gracefully with
287
- // SSR/no-hydration. Grid itself is a flex row wrapper; GridItem computes
288
- // flex-basis/max-width from its span at each tier.
289
- // ---------------------------------------------------------------------------
290
- export function Grid({ gap, justify, align, children, key } = {}) {
291
- const style = [
292
- gap != null ? `gap:${typeof gap === 'number' ? gap + 'px' : gap}` : '',
293
- justify ? `justify-content:${justify}` : '',
294
- align ? `align-items:${align}` : '',
295
- ].filter(Boolean).join(';');
296
- return h('div', { key, class: 'ds-ep-grid', style: style || null }, children);
297
- }
298
-
299
- function gridSpanStyle(prefix, val) {
300
- if (val === undefined) return '';
301
- if (val === true) return `--${prefix}-basis:100%;--${prefix}-grow:1;--${prefix}-display:inherit;`;
302
- if (val === 0) return `--${prefix}-display:none;`;
303
- const pct = Math.max(0, Math.min(100, (100 / 24) * val));
304
- return `--${prefix}-basis:${pct}%;--${prefix}-grow:0;--${prefix}-display:inherit;`;
305
- }
306
-
307
- export function GridItem({ xs, sm, md, lg, xl, children, key } = {}) {
308
- const style = [
309
- gridSpanStyle('xs', xs),
310
- gridSpanStyle('sm', sm),
311
- gridSpanStyle('md', md),
312
- gridSpanStyle('lg', lg),
313
- gridSpanStyle('xl', xl),
314
- ].join('');
315
- return h('div', { key, class: 'ds-ep-grid-item', style: style || null }, children);
316
- }
317
-
318
- // ---------------------------------------------------------------------------
319
- // FocusTrap — wraps subtree, traps Tab/Shift+Tab. Mount/unmount lifecycle is
320
- // managed via DOM-level keydown listener attached when first focused.
321
- // ---------------------------------------------------------------------------
322
- const FOCUSABLE_SEL = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
323
-
324
- function trapTabKey(rootEl, e) {
325
- if (e.key !== 'Tab') return;
326
- const nodes = rootEl.querySelectorAll(FOCUSABLE_SEL);
327
- if (!nodes.length) { e.preventDefault(); return; }
328
- const first = nodes[0], last = nodes[nodes.length - 1];
329
- const active = (rootEl.getRootNode && rootEl.getRootNode().activeElement) || document.activeElement;
330
- if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
331
- else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
332
- }
333
-
334
- export function FocusTrap({ children } = {}) {
335
- return h('div', {
336
- class: 'ds-ep-focustrap',
337
- tabindex: '-1',
338
- ref: (el) => {
339
- if (!el || el._dsTrap) return;
340
- el._dsTrap = true;
341
- el.addEventListener('keydown', (e) => trapTabKey(el, e));
342
- // Auto-focus first focusable
343
- queueMicrotask(() => {
344
- const first = el.querySelector(FOCUSABLE_SEL);
345
- if (first) first.focus();
346
- else el.focus();
347
- });
348
- }
349
- }, ...kids(children));
350
- }
351
-
352
- // ---------------------------------------------------------------------------
353
- // ResizeHandle — splitter, axis = 'horizontal' (vertical bar, horiz drag)
354
- // or 'vertical' (horizontal bar, vertical drag). onResize(delta:px).
355
- // ---------------------------------------------------------------------------
356
- export function ResizeHandle({ axis = 'horizontal', onResize, ariaLabel } = {}) {
357
- const isH = axis === 'horizontal';
358
- let dragOrigin = null;
359
- const step = 8;
360
- const emit = (dx, dy) => { if (onResize) onResize(isH ? dx : dy); };
361
- const onPointerDown = (e) => {
362
- e.preventDefault();
363
- dragOrigin = { x: e.clientX, y: e.clientY };
364
- e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId);
365
- };
366
- const onPointerMove = (e) => {
367
- if (!dragOrigin) return;
368
- const dx = e.clientX - dragOrigin.x;
369
- const dy = e.clientY - dragOrigin.y;
370
- dragOrigin = { x: e.clientX, y: e.clientY };
371
- emit(dx, dy);
372
- };
373
- const onPointerUp = (e) => {
374
- dragOrigin = null;
375
- try { e.currentTarget.releasePointerCapture && e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
376
- };
377
- const onKeyDown = (e) => {
378
- const k = e.key;
379
- if (isH) {
380
- if (k === 'ArrowLeft') { e.preventDefault(); emit(-step, 0); }
381
- else if (k === 'ArrowRight') { e.preventDefault(); emit(step, 0); }
382
- else if (k === 'Home') { e.preventDefault(); emit(-1e6, 0); }
383
- else if (k === 'End') { e.preventDefault(); emit(1e6, 0); }
384
- } else {
385
- if (k === 'ArrowUp') { e.preventDefault(); emit(0, -step); }
386
- else if (k === 'ArrowDown') { e.preventDefault(); emit(0, step); }
387
- else if (k === 'Home') { e.preventDefault(); emit(0, -1e6); }
388
- else if (k === 'End') { e.preventDefault(); emit(0, 1e6); }
389
- }
390
- };
391
- return h('div', {
392
- class: 'ds-ep-resize ' + (isH ? 'axis-h' : 'axis-v'),
393
- role: 'separator',
394
- tabindex: '0',
395
- 'aria-orientation': isH ? 'vertical' : 'horizontal',
396
- 'aria-label': ariaLabel || 'Resize',
397
- onpointerdown: onPointerDown,
398
- onpointermove: onPointerMove,
399
- onpointerup: onPointerUp,
400
- onpointercancel: onPointerUp,
401
- onkeydown: onKeyDown,
402
- });
403
- }
404
-
405
- // ---------------------------------------------------------------------------
406
- // SplitPanel — two children separated by a ResizeHandle. Stateful via DOM.
407
- // ---------------------------------------------------------------------------
408
- export function SplitPanel({ orientation = 'horizontal', initial = '50%', min = 80, max = Infinity, children } = {}) {
409
- const isH = orientation === 'horizontal';
410
- const ks = kids(children);
411
- const first = ks[0] || null;
412
- const second = ks[1] || null;
413
- const sizeProp = isH ? 'width' : 'height';
414
- const initStyle = typeof initial === 'number' ? initial + 'px' : initial;
415
- let rootEl = null;
416
- // The dragged size is persisted here so a re-render (applyDiff reconciling
417
- // the pane's style back to the initial value) does NOT reset the user's
418
- // resize. onResize records it; the pane's ref re-applies it after each diff.
419
- let draggedSize = null;
420
- const applySize = (a) => {
421
- if (!a) return;
422
- if (draggedSize != null) { a.style[sizeProp] = draggedSize + 'px'; a.style.flex = '0 0 auto'; }
423
- };
424
- const onResize = (delta) => {
425
- if (!rootEl) return;
426
- const a = rootEl.firstChild;
427
- if (!a) return;
428
- const rect = a.getBoundingClientRect();
429
- const curr = isH ? rect.width : rect.height;
430
- const total = isH ? rootEl.getBoundingClientRect().width : rootEl.getBoundingClientRect().height;
431
- const next = Math.max(min, Math.min(max === Infinity ? total - min : max, curr + delta));
432
- draggedSize = next;
433
- a.style[sizeProp] = next + 'px';
434
- a.style.flex = '0 0 auto';
435
- };
436
- return h('div', {
437
- class: 'ds-ep-split ' + (isH ? 'horiz' : 'vert'),
438
- ref: (el) => { rootEl = el; }
439
- },
440
- h('div', { class: 'ds-ep-split-pane', style: '--split-size:' + initStyle + ';flex:0 0 auto', ref: applySize }, first),
441
- ResizeHandle({ axis: isH ? 'horizontal' : 'vertical', onResize }),
442
- h('div', { class: 'ds-ep-split-pane grow', style: 'flex:1 1 0;min-' + sizeProp + ':0' }, second)
443
- );
444
- }
445
-
446
- // ---------------------------------------------------------------------------
447
- // ContextMenu — items, anchor {x,y}, onClose. Viewport-clamped. Keyboard nav.
448
- // ---------------------------------------------------------------------------
449
- export function ContextMenu({ items = [], anchor = { x: 0, y: 0 }, onClose } = {}) {
450
- let rootEl = null;
451
- const close = () => { if (onClose) onClose(); };
452
- const select = (it) => {
453
- if (it.disabled || it.separator) return;
454
- if (it.onSelect) it.onSelect();
455
- close();
456
- };
457
- const onKey = (e) => {
458
- const btns = rootEl ? [...rootEl.querySelectorAll('button[data-ix]')] : [];
459
- const active = document.activeElement;
460
- const idx = btns.indexOf(active);
461
- if (e.key === 'Escape') { e.preventDefault(); close(); }
462
- else if (e.key === 'ArrowDown') { e.preventDefault(); (btns[(idx + 1) % btns.length] || btns[0])?.focus(); }
463
- else if (e.key === 'ArrowUp') { e.preventDefault(); (btns[(idx - 1 + btns.length) % btns.length] || btns[0])?.focus(); }
464
- else if (e.key === 'Enter' && idx >= 0) { e.preventDefault(); btns[idx].click(); }
465
- };
466
- return h('div', {
467
- class: 'ds-ep-ctxmenu-backdrop',
468
- onmousedown: (e) => { if (e.target === e.currentTarget) close(); },
469
- oncontextmenu: (e) => { e.preventDefault(); close(); },
470
- },
471
- h('div', {
472
- class: 'ds-ep-ctxmenu',
473
- role: 'menu',
474
- tabindex: '-1',
475
- onkeydown: onKey,
476
- ref: (el) => {
477
- if (!el) {
478
- // Unmount: unhook the resize re-clamp bound on mount.
479
- if (rootEl && rootEl._dsCtxClampOff) { rootEl._dsCtxClampOff(); }
480
- rootEl = null;
481
- return;
482
- }
483
- rootEl = el;
484
- // Position at the anchor immediately, then clamp once layout has
485
- // settled — measuring synchronously in ref reads a zero-size box
486
- // (children not yet painted), so the clamp must run post-layout.
487
- const ax = anchor.x || 0, ay = anchor.y || 0;
488
- el.style.left = ax + 'px';
489
- el.style.top = ay + 'px';
490
- const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
491
- const clamp = () => {
492
- const vw = window.innerWidth, vh = window.innerHeight;
493
- const r = el.getBoundingClientRect();
494
- let x = ax, y = ay;
495
- // Touch: keep the menu clear of the lifting finger — nudge
496
- // below the touch point, or open above when it fits and the
497
- // anchor sits in the lower half (lift-off would otherwise
498
- // activate the first item).
499
- if (coarse) {
500
- y = ay + 10;
501
- if (ay > vh / 2 && ay - r.height >= 4) y = ay - r.height;
502
- }
503
- if (x + r.width > vw) x = Math.max(4, vw - r.width - 4);
504
- if (y + r.height > vh) y = Math.max(4, vh - r.height - 4);
505
- el.style.left = x + 'px';
506
- el.style.top = y + 'px';
507
- };
508
- requestAnimationFrame(clamp);
509
- // Re-clamp on resize/orientation change for the menu's lifetime.
510
- window.addEventListener('resize', clamp);
511
- el._dsCtxClampOff = () => { window.removeEventListener('resize', clamp); el._dsCtxClampOff = null; };
512
- // setTimeout(0), not queueMicrotask: the triggering contextmenu/click
513
- // event's own default focus can otherwise win the race and leave focus
514
- // outside the menu, breaking keyboard arrow-nav/Escape.
515
- setTimeout(() => { el.querySelector('button[data-ix]')?.focus(); }, 0);
516
- }
517
- },
518
- ...items.map((it, i) => it.separator
519
- ? h('div', { key: 'sep' + i, class: 'ds-ep-ctxmenu-sep', role: 'separator' })
520
- : h('button', {
521
- key: i, type: 'button', role: 'menuitem',
522
- 'data-ix': String(i),
523
- class: 'ds-ep-ctxmenu-item' + (it.danger ? ' danger' : '') + (it.disabled ? ' disabled' : ''),
524
- disabled: it.disabled ? 'disabled' : null,
525
- onclick: () => select(it),
526
- },
527
- it.icon != null ? h('span', { class: 'ds-ep-ctxmenu-icon' }, it.icon) : null,
528
- h('span', { class: 'ds-ep-ctxmenu-label' }, it.label)
529
- ))
530
- )
531
- );
532
- }
533
-
534
- // Helper: wires right-click + long-press to a target ref. Caller manages state.
535
- export function useContextMenu(targetEl, items, openCb) {
536
- if (!targetEl) return () => {};
537
- let touchTimer = null, lastOpen = 0;
538
- // Android fires the native contextmenu event on long-press AND our 500ms
539
- // touch timer — dedupe so the menu opens once, not twice (open/flicker).
540
- const open = (x, y) => {
541
- if (Date.now() - lastOpen < 700) return;
542
- lastOpen = Date.now();
543
- if (openCb) openCb({ x, y, items });
544
- };
545
- const onCtx = (e) => { e.preventDefault(); open(e.clientX, e.clientY); };
546
- const onTouchStart = (e) => {
547
- const t = e.touches && e.touches[0]; if (!t) return;
548
- touchTimer = setTimeout(() => { touchTimer = null; open(t.clientX, t.clientY); }, 500);
549
- };
550
- const cancel = () => { if (touchTimer) { clearTimeout(touchTimer); touchTimer = null; } };
551
- targetEl.addEventListener('contextmenu', onCtx);
552
- targetEl.addEventListener('touchstart', onTouchStart, { passive: true });
553
- targetEl.addEventListener('touchmove', cancel, { passive: true });
554
- targetEl.addEventListener('touchend', cancel);
555
- targetEl.addEventListener('touchcancel', cancel);
556
- return () => {
557
- targetEl.removeEventListener('contextmenu', onCtx);
558
- targetEl.removeEventListener('touchstart', onTouchStart);
559
- targetEl.removeEventListener('touchmove', cancel);
560
- targetEl.removeEventListener('touchend', cancel);
561
- targetEl.removeEventListener('touchcancel', cancel);
562
- cancel();
563
- };
564
- }
565
-
566
- // ---------------------------------------------------------------------------
567
- // Drawer — slide-in from side. side='left'|'right'|'bottom'.
568
- // ---------------------------------------------------------------------------
569
- export function Drawer({ side = 'left', open = false, onClose, children, ariaLabel } = {}) {
570
- if (!open) return null;
571
- const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); onClose && onClose(); } };
572
- return h('div', {
573
- class: 'ds-ep-drawer-backdrop',
574
- onmousedown: (e) => { if (e.target === e.currentTarget) onClose && onClose(); },
575
- },
576
- h('div', {
577
- class: 'ds-ep-drawer side-' + side,
578
- role: 'dialog',
579
- 'aria-modal': 'true',
580
- 'aria-label': ariaLabel || 'Drawer',
581
- tabindex: '-1',
582
- onkeydown: onKey,
583
- ref: (el) => {
584
- if (!el || el._dsTrap) return;
585
- el._dsTrap = true;
586
- el.addEventListener('keydown', (e) => trapTabKey(el, e));
587
- // setTimeout(0), not queueMicrotask — see Dialog's identical comment
588
- // below: the opening click's own default focus can win a same-tick race.
589
- setTimeout(() => {
590
- const f = el.querySelector(FOCUSABLE_SEL);
591
- (f || el).focus();
592
- }, 0);
593
- },
594
- }, ...kids(children))
595
- );
596
- }
597
-
598
- // ---------------------------------------------------------------------------
599
- // Dialog — modal. actions = [{label, onClick, kind?}], dismissible (backdrop).
600
- // ---------------------------------------------------------------------------
601
- export function Dialog({ title, open = false, onClose, children, actions = [], dismissible = false, ariaLabel } = {}) {
602
- if (!open) return null;
603
- const opener = (typeof document !== 'undefined') ? document.activeElement : null;
604
- const close = () => {
605
- if (onClose) onClose();
606
- if (opener && opener.focus) queueMicrotask(() => opener.focus());
607
- };
608
- const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
609
- return h('div', {
610
- class: 'ds-ep-dialog-backdrop',
611
- onmousedown: (e) => { if (dismissible && e.target === e.currentTarget) close(); },
612
- },
613
- h('div', {
614
- class: 'ds-ep-dialog',
615
- role: 'dialog',
616
- 'aria-modal': 'true',
617
- 'aria-label': ariaLabel || title || 'Dialog',
618
- tabindex: '-1',
619
- onkeydown: onKey,
620
- ref: (el) => {
621
- if (!el || el._dsTrap) return;
622
- el._dsTrap = true;
623
- el.addEventListener('keydown', (e) => trapTabKey(el, e));
624
- // setTimeout(0), not queueMicrotask: the triggering click's own default
625
- // focus-on-click can win a same-tick microtask race and leave focus on
626
- // the trigger button instead of the dialog, breaking Escape/Tab-trap
627
- // for keyboard users (keydown only bubbles from the focused element).
628
- setTimeout(() => {
629
- const f = el.querySelector(FOCUSABLE_SEL);
630
- (f || el).focus();
631
- }, 0);
632
- },
633
- },
634
- title != null ? h('div', { class: 'ds-ep-dialog-head' }, h('h2', { class: 'ds-ep-dialog-title' }, title)) : null,
635
- h('div', { class: 'ds-ep-dialog-body' }, ...kids(children)),
636
- actions && actions.length ? h('div', { class: 'ds-ep-dialog-actions' },
637
- ...actions.map((a, i) => h('button', {
638
- key: i, type: 'button',
639
- class: 'ds-ep-dialog-btn' + (a.kind ? (' kind-' + a.kind) : ''),
640
- onclick: (e) => { if (a.onClick) a.onClick(e); if (a.close !== false) close(); }
641
- }, a.label))
642
- ) : null
643
- )
644
- );
645
- }
646
-
647
- // ---------------------------------------------------------------------------
648
- // Toast — Toast({message,kind,duration}) component + imperative toast(opts).
649
- // ---------------------------------------------------------------------------
650
- export function Toast({ message, kind = 'info', duration = 3000, onClose } = {}) {
651
- return h('div', {
652
- class: 'ds-ep-toast kind-' + kind,
653
- role: 'status',
654
- 'aria-live': 'polite',
655
- ref: (el) => {
656
- if (!el || el._dsToast) return;
657
- el._dsToast = true;
658
- if (duration > 0) setTimeout(() => { onClose && onClose(); el.classList.add('leaving'); }, duration);
659
- }
660
- }, message);
661
- }
662
-
663
- let _toastHostEl = null;
664
- function ensureToastHost() {
665
- if (typeof document === 'undefined') return null;
666
- if (_toastHostEl && document.body.contains(_toastHostEl)) return _toastHostEl;
667
- _toastHostEl = document.createElement('div');
668
- _toastHostEl.className = 'ds-ep-toast-host';
669
- document.body.appendChild(_toastHostEl);
670
- return _toastHostEl;
671
- }
672
-
673
- export function toast({ message, kind = 'info', duration = 3000, actionLabel, onAction } = {}) {
674
- const host = ensureToastHost();
675
- if (!host) return () => {};
676
- const el = document.createElement('div');
677
- el.className = 'ds-ep-toast kind-' + kind;
678
- el.setAttribute('role', 'status');
679
- el.setAttribute('aria-live', 'polite');
680
- const text = document.createElement('span');
681
- text.className = 'ds-ep-toast-msg';
682
- text.textContent = message;
683
- el.appendChild(text);
684
- const dismiss = () => {
685
- if (!el.parentNode) return;
686
- el.classList.add('leaving');
687
- setTimeout(() => { el.parentNode && el.parentNode.removeChild(el); }, 200);
688
- };
689
- if (actionLabel && onAction) {
690
- el.classList.add('has-action');
691
- const btn = document.createElement('button');
692
- btn.type = 'button';
693
- btn.className = 'ds-ep-toast-action';
694
- btn.textContent = actionLabel;
695
- btn.onclick = () => onAction(dismiss);
696
- el.appendChild(btn);
697
- }
698
- host.appendChild(el);
699
- if (duration > 0) setTimeout(dismiss, duration);
700
- return dismiss;
701
- }
702
-
703
- // ---------------------------------------------------------------------------
704
- // Pager — prev/next paginator with a page label. Generalizes gmsniff's
705
- // gm-pager. page is 1-indexed; pageCount<=1 disables both buttons (no
706
- // divide-by-zero, no dead-end enabled control). total (optional) renders an
707
- // item-count suffix ("42 items") alongside the page label.
708
6
  //
709
- // numbered=true switches to a compact numbered-button row (screen-real-estate
710
- // dense mode) instead of the prev/next label: always shows first, last, the
711
- // current page, and up to `siblingCount` neighbors either side, collapsing
712
- // gaps into an ellipsis span. Falls back to prev/next automatically when
713
- // pageCount<=1. The prev/next contract (default) is untouched.
714
- // ---------------------------------------------------------------------------
715
- function buildPageRange(count, page, siblingCount) {
716
- const limit = siblingCount * 2 + 3; // first + last + current + siblings
717
- if (count <= limit) return Array.from({ length: count }, (_, i) => i + 1);
718
- const pages = new Set([1, count, page]);
719
- for (let i = 1; i <= siblingCount; i++) {
720
- pages.add(page - i);
721
- pages.add(page + i);
722
- }
723
- const sorted = [...pages].filter((p) => p >= 1 && p <= count).sort((a, b) => a - b);
724
- const result = [];
725
- let prev = null;
726
- for (const p of sorted) {
727
- if (prev !== null && p - prev > 1) result.push('...');
728
- result.push(p);
729
- prev = p;
730
- }
731
- return result;
732
- }
733
-
734
- export function Pager({ page = 1, pageCount = 1, onPage, total, itemLabel = 'items', numbered = false, siblingCount = 1 } = {}) {
735
- const safeCount = Math.max(1, pageCount || 1);
736
- const safePage = Math.min(Math.max(1, page || 1), safeCount);
737
- const atStart = safePage <= 1;
738
- const atEnd = safePage >= safeCount;
739
- const prevBtn = h('button', {
740
- type: 'button', class: 'ds-ep-pager-btn', disabled: atStart ? 'disabled' : null,
741
- 'aria-label': 'previous page',
742
- onclick: () => { if (!atStart && onPage) onPage(safePage - 1); },
743
- }, '<-');
744
- const nextBtn = h('button', {
745
- type: 'button', class: 'ds-ep-pager-btn', disabled: atEnd ? 'disabled' : null,
746
- 'aria-label': 'next page',
747
- onclick: () => { if (!atEnd && onPage) onPage(safePage + 1); },
748
- }, '->');
749
- if (numbered) {
750
- const range = buildPageRange(safeCount, safePage, Math.max(1, siblingCount || 1));
751
- return h('div', { class: 'ds-ep-pager ds-ep-pager-numbered', role: 'group', 'aria-label': 'pagination' },
752
- prevBtn,
753
- ...range.map((p, i) => p === '...'
754
- ? h('span', { key: 'ellipsis-' + i, class: 'ds-ep-pager-ellipsis' }, '...')
755
- : h('button', {
756
- key: 'p' + p, type: 'button',
757
- class: 'ds-ep-pager-num' + (p === safePage ? ' is-current' : ''),
758
- 'aria-current': p === safePage ? 'page' : null,
759
- 'aria-label': 'page ' + p,
760
- onclick: () => { if (p !== safePage && onPage) onPage(p); },
761
- }, String(p))),
762
- nextBtn,
763
- total != null ? h('span', { class: 'ds-ep-pager-total' }, total + ' ' + itemLabel) : null
764
- );
765
- }
766
- return h('div', { class: 'ds-ep-pager', role: 'group', 'aria-label': 'pagination' },
767
- prevBtn,
768
- h('span', { class: 'ds-ep-pager-label' },
769
- 'page ' + safePage + ' / ' + safeCount + (total != null ? ' (' + total + ' ' + itemLabel + ')' : '')),
770
- nextBtn
771
- );
772
- }
773
-
774
- // ---------------------------------------------------------------------------
775
- // JsonViewer — monospace data preview (max-height + scroll), generalizing
776
- // gmsniff's gm-json. Accepts a pre-stringified string OR any value
777
- // (objects/arrays get JSON.stringify(v, null, 2); null/undefined render the
778
- // empty-state text rather than the literal string "undefined"/"null").
779
- //
780
- // mode selects rendering; 'plain' is the historical contract (children[0] is
781
- // the raw text string, verbatim for string input) and stays the default so
782
- // every existing consumer is untouched:
783
- // 'plain' — flat <pre>, raw text.
784
- // 'highlight' — flat <pre>, text tokenized into ds-ep-json-* spans
785
- // (key/string/number/boolean/null). A string that does not
786
- // parse as JSON falls back to plain text — arbitrary prose is
787
- // never falsely tokenized.
788
- // 'tree' — collapsible <details> tree per nested object/array, open
789
- // above treeDepth (default 2), each summary carrying a
790
- // child-count tag. Scalars/unparseable input fall back to
791
- // 'highlight'/plain respectively.
792
- // copyable=true wraps the viewer with a copy-to-clipboard button (transient
793
- // copied/failed feedback, no dependencies).
794
- // ---------------------------------------------------------------------------
795
- const JSON_NUM_CHARS = '0123456789eE+.-';
796
-
797
- // Linear single-pass scan — no regex, no backtracking, safe on truncated
798
- // input (an unterminated string just consumes to end-of-text).
799
- function tokenizeJson(text) {
800
- const toks = [];
801
- let i = 0, plain = '';
802
- const flush = () => { if (plain) { toks.push(['', plain]); plain = ''; } };
803
- while (i < text.length) {
804
- const c = text[i];
805
- if (c === '"') {
806
- const start = i;
807
- i++;
808
- while (i < text.length && text[i] !== '"') { if (text[i] === '\\') i++; i++; }
809
- i = Math.min(i + 1, text.length);
810
- let j = i;
811
- while (j < text.length && (text[j] === ' ' || text[j] === '\t' || text[j] === '\n' || text[j] === '\r')) j++;
812
- flush();
813
- toks.push([text[j] === ':' ? 'k' : 's', text.slice(start, i)]);
814
- continue;
815
- }
816
- if (c === '-' || (c >= '0' && c <= '9')) {
817
- const start = i;
818
- i++;
819
- while (i < text.length && JSON_NUM_CHARS.includes(text[i])) i++;
820
- flush();
821
- toks.push(['n', text.slice(start, i)]);
822
- continue;
823
- }
824
- if (text.startsWith('true', i)) { flush(); toks.push(['b', 'true']); i += 4; continue; }
825
- if (text.startsWith('false', i)) { flush(); toks.push(['b', 'false']); i += 5; continue; }
826
- if (text.startsWith('null', i)) { flush(); toks.push(['z', 'null']); i += 4; continue; }
827
- plain += c; i++;
828
- }
829
- flush();
830
- return toks;
831
- }
832
-
833
- function highlightJsonSpans(text) {
834
- return tokenizeJson(text).map(([t, s]) => t ? h('span', { class: 'ds-ep-json-' + t }, s) : s);
835
- }
836
-
837
- function jsonTreeNode(key, val, depth, treeDepth) {
838
- const keyParts = key != null ? [h('span', { class: 'ds-ep-json-k' }, JSON.stringify(key)), ': '] : [];
839
- if (val !== null && typeof val === 'object') {
840
- const isArr = Array.isArray(val);
841
- const entries = isArr ? val.map((v) => [null, v]) : Object.entries(val);
842
- if (!entries.length) return h('div', { class: 'ds-ep-json-leaf' }, ...keyParts, isArr ? '[]' : '{}');
843
- const tag = isArr ? '[' + entries.length + ']' : '{' + entries.length + '}';
844
- return h('details', { class: 'ds-ep-json-node', open: depth < treeDepth ? true : null },
845
- h('summary', { class: 'ds-ep-json-sum' }, ...keyParts, h('span', { class: 'ds-ep-json-tag' }, tag)),
846
- h('div', { class: 'ds-ep-json-kids' }, ...entries.map(([k, v]) => jsonTreeNode(k, v, depth + 1, treeDepth))));
847
- }
848
- const t = typeof val === 'string' ? 's' : typeof val === 'number' ? 'n' : typeof val === 'boolean' ? 'b' : 'z';
849
- return h('div', { class: 'ds-ep-json-leaf' }, ...keyParts, h('span', { class: 'ds-ep-json-' + t }, JSON.stringify(val) ?? String(val)));
850
- }
851
-
852
- function jsonCopyButton(text) {
853
- return h('button', {
854
- type: 'button', class: 'ds-ep-json-copy', title: 'copy JSON', 'aria-label': 'copy JSON',
855
- onclick: (e) => {
856
- const btn = e.currentTarget;
857
- const show = (label, ok) => {
858
- btn.textContent = label;
859
- btn.classList.toggle('copied', ok);
860
- setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 1200);
861
- };
862
- try {
863
- navigator.clipboard.writeText(text).then(() => show('copied', true), () => show('failed', false));
864
- } catch {
865
- show('failed', false);
866
- }
867
- },
868
- }, 'copy');
869
- }
870
-
871
- export function JsonViewer({ value, emptyText = 'no data', maxHeight, mode = 'plain', copyable = false, treeDepth = 2 } = {}) {
872
- let text, parsed;
873
- let knownJson = false;
874
- if (value == null) text = null;
875
- else if (typeof value === 'string') text = value;
876
- else { try { text = JSON.stringify(value, null, 2); knownJson = text != null; parsed = value; } catch { text = String(value); } }
877
- if (!text) return h('div', { class: 'ds-ep-json ds-ep-json-empty' }, emptyText);
878
- const style = maxHeight ? ('max-height:' + maxHeight) : null;
879
- if (!knownJson && (mode === 'highlight' || mode === 'tree')) {
880
- try { parsed = JSON.parse(text); knownJson = true; } catch { /* swallow: not JSON — render plain */ }
881
- }
882
- let body;
883
- if (mode === 'tree' && knownJson && parsed !== null && typeof parsed === 'object') {
884
- body = h('div', { class: 'ds-ep-json ds-ep-json-tree', style }, jsonTreeNode(null, parsed, 0, treeDepth));
885
- } else if ((mode === 'highlight' || mode === 'tree') && knownJson) {
886
- body = h('pre', { class: 'ds-ep-json ds-ep-json-hl', style }, ...highlightJsonSpans(text));
887
- } else {
888
- body = h('pre', { class: 'ds-ep-json', style }, text);
889
- }
890
- if (!copyable) return body;
891
- return h('div', { class: 'ds-ep-json-wrap' }, jsonCopyButton(text), body);
892
- }
893
-
894
- export function IconButtonGroup({ items = [], value, onChange, dense = false } = {}) {
895
- return h('div', { class: 'ds-ep-btngrp' + (dense ? ' dense' : ''), role: 'group' },
896
- ...items.map((it) => h('button', {
897
- key: it.id,
898
- type: 'button',
899
- class: 'ds-ep-btngrp-btn' + (it.id === value ? ' active' : ''),
900
- title: it.title || it.label || it.id,
901
- 'aria-pressed': it.id === value ? 'true' : 'false',
902
- disabled: it.disabled ? 'disabled' : null,
903
- onclick: () => { if (!it.disabled && onChange) onChange(it.id); }
904
- }, it.glyph != null ? it.glyph : it.label))
905
- );
906
- }
907
-
908
- // ---------------------------------------------------------------------------
909
- // Collapse — progressive-disclosure toggle panel (screen-real-estate
910
- // density: property inspectors, nested settings, FAQ-style panels).
911
- // Controlled component: caller owns `expanded` state and passes `onToggle`,
912
- // same pattern as Drawer/Dialog `open`/`onClose` above — no internal state.
913
- // ---------------------------------------------------------------------------
914
- export function Collapse({ title, expanded = false, onToggle, children, key } = {}) {
915
- return h('div', { key, class: 'ds-ep-collapse' + (expanded ? ' is-expanded' : '') },
916
- h('button', {
917
- type: 'button', class: 'ds-ep-collapse-head',
918
- 'aria-expanded': expanded ? 'true' : 'false',
919
- onclick: () => { if (onToggle) onToggle(!expanded); },
920
- },
921
- h('span', { class: 'ds-ep-collapse-chevron' }, expanded ? 'v' : '>'),
922
- h('span', { class: 'ds-ep-collapse-title' }, title)),
923
- expanded ? h('div', { class: 'ds-ep-collapse-body' }, children) : null);
924
- }
925
-
926
- // ---------------------------------------------------------------------------
927
- // CollapseGroup — accordion wrapper enforcing single-open-at-a-time when
928
- // `accordion=true` (default false — group just lays out children, each
929
- // Collapse still individually controlled). `openId`/`onOpenChange` drive the
930
- // accordion; `items` is [{id, title, children}].
931
- // ---------------------------------------------------------------------------
932
- export function CollapseGroup({ items = [], openId, onOpenChange, accordion = false, key } = {}) {
933
- return h('div', { key, class: 'ds-ep-collapse-group' },
934
- ...items.map((it) => Collapse({
935
- key: it.id,
936
- title: it.title,
937
- expanded: accordion ? it.id === openId : Boolean(it.expanded),
938
- onToggle: (next) => {
939
- if (!onOpenChange) return;
940
- if (accordion) onOpenChange(next ? it.id : null);
941
- else onOpenChange(it.id, next);
942
- },
943
- children: it.children,
944
- })));
945
- }
946
-
947
- // ---------------------------------------------------------------------------
948
- // Divider — plain rule, optional centered text label, optional vertical
949
- // orientation (for segmenting dense panels without a full Section wrapper).
950
- // ---------------------------------------------------------------------------
951
- export function Divider({ label, vertical = false, key } = {}) {
952
- if (vertical) return h('span', { key, class: 'ds-ep-divider ds-ep-divider-vertical', role: 'separator', 'aria-orientation': 'vertical' });
953
- if (!label) return h('hr', { key, class: 'ds-ep-divider' });
954
- return h('div', { key, class: 'ds-ep-divider ds-ep-divider-labeled', role: 'separator' },
955
- h('span', { class: 'ds-ep-divider-label' }, label));
956
- }
957
-
958
- // ---------------------------------------------------------------------------
959
- // InfoRow / InfoSection / DiagnosticsPanel — static debug/system-info
960
- // readouts: a bordered section of label + monospace-value rows. Ported from
961
- // docstudio's diagnostics page (auth/streaming/service-worker state, recent
962
- // client errors, environment facts). Distinct from PropertyGrid, which is
963
- // for EDITABLE properties — these rows are read-only display, never inputs.
964
- // `data == null` (not yet loaded) renders a loading placeholder row instead
965
- // of an empty section, so a panel never flashes an empty bordered box before
966
- // its first data arrives; `onRefresh` renders a trailing refresh button that
967
- // reuses the section's own header row rather than shifting layout beneath it.
968
- // ---------------------------------------------------------------------------
969
- export function InfoRow({ label, value, key } = {}) {
970
- return h('div', { key, class: 'ds-ep-inforow' },
971
- h('span', { class: 'ds-ep-inforow-label' }, label),
972
- h('span', { class: 'ds-ep-inforow-value' }, value == null || value === '' ? '—' : String(value)));
973
- }
974
-
975
- export function InfoSection({ title, rows, key } = {}) {
976
- const children = [
977
- title ? h('h3', { key: 'title', class: 'ds-ep-infosection-title' }, title) : null,
978
- rows == null
979
- ? h('div', { key: 'body-loading', class: 'ds-ep-infosection-loading', role: 'status' }, 'Loading…')
980
- : h('div', { key: 'body-rows', class: 'ds-ep-infosection-rows' }, ...rows.map((r, i) => InfoRow({ ...r, key: r.key != null ? r.key : i })))
981
- ].filter(Boolean);
982
- return h('section', { key, class: 'ds-ep-infosection' }, ...children);
983
- }
984
-
985
- export function DiagnosticsPanel({ title = 'Diagnostics', sections = [], onRefresh, refreshing = false, key } = {}) {
986
- const headChildren = [
987
- h('h2', { key: 'title', class: 'ds-ep-diagnostics-title' }, title),
988
- onRefresh ? h('button', {
989
- key: 'refresh', type: 'button', class: 'ds-ep-diagnostics-refresh', disabled: refreshing, 'aria-busy': refreshing ? 'true' : 'false',
990
- onclick: () => onRefresh()
991
- }, refreshing ? 'Refreshing…' : 'Refresh') : null
992
- ].filter(Boolean);
993
- return h('div', { key, class: 'ds-ep-diagnostics' },
994
- h('div', { key: 'head', class: 'ds-ep-diagnostics-head' }, ...headChildren),
995
- ...sections.map((s, i) => InfoSection({ ...s, key: s.key || i }))
996
- );
997
- }
998
-
999
- // ---------------------------------------------------------------------------
1000
- // BatchProgressLabel — sequential batch-operation progress readout: "label
1001
- // (i/n)" while in flight. Ported from docstudio's sequential upload badge
1002
- // and bulk-action progress button (both show a live count against a total
1003
- // while processing one item at a time). Pure display — the host owns the
1004
- // actual queue/loop; this only renders its current {done, total} state.
1005
- // done === 0 renders the bare label with no count suffix (nothing has
1006
- // happened yet); done === total renders as complete with no live-region
1007
- // churn once settled.
1008
- // ---------------------------------------------------------------------------
1009
- export function BatchProgressLabel({ label = 'Processing', done = 0, total = 0, key } = {}) {
1010
- const inFlight = total > 0 && done < total;
1011
- const suffix = total > 0 ? ` (${done}/${total})` : '';
1012
- return h('span', { key, class: 'ds-ep-batchprogress', role: 'status', 'aria-live': inFlight ? 'polite' : 'off' },
1013
- label + suffix);
1014
- }
1015
-
1016
- // formatBatchOutcome — pure string helper for the aggregate toast shown once
1017
- // a batch settles: "N/total succeeded" plus a truncated failed-name list
1018
- // when any items failed, matching docstudio's attach-bar/bulk-action
1019
- // aggregation copy. Handles all three outcome shapes (all-succeed, all-fail,
1020
- // partial) and truncates a long failed-name list ("...and N more") instead
1021
- // of growing the toast unboundedly for a big batch.
1022
- export function formatBatchOutcome({ succeeded = 0, total = 0, failedNames = [], maxNames = 3 } = {}) {
1023
- if (total === 0) return '';
1024
- if (failedNames.length === 0) return `${succeeded}/${total} succeeded`;
1025
- const shown = failedNames.slice(0, maxNames).join(', ');
1026
- const more = failedNames.length > maxNames ? ` and ${failedNames.length - maxNames} more` : '';
1027
- return `${succeeded}/${total} succeeded; failed: ${shown}${more}`;
1028
- }
1029
-
1030
- // runBatchSequential — pure async orchestration companion to
1031
- // BatchProgressLabel/formatBatchOutcome: runs `items` through `fn` one at a
1032
- // time (never in parallel, matching docstudio's rate-limited bulk-action
1033
- // runner), never aborting on a single item's failure. `onProgress({done,
1034
- // total})` fires after each item settles so a host can drive
1035
- // BatchProgressLabel live; the final `{succeeded, total, failedNames}`
1036
- // return shape feeds formatBatchOutcome directly. `fn` receives (item, index)
1037
- // and may reject/throw — a rejection is recorded as a failure keyed by
1038
- // `item.name != null ? item.name : String(item)`, never re-thrown.
1039
- export async function runBatchSequential(items = [], fn, onProgress) {
1040
- const total = items.length;
1041
- let succeeded = 0;
1042
- const failedNames = [];
1043
- for (let i = 0; i < total; i += 1) {
1044
- const item = items[i];
1045
- try {
1046
- await fn(item, i);
1047
- succeeded += 1;
1048
- } catch (err) {
1049
- failedNames.push(item && item.name != null ? item.name : String(item));
1050
- }
1051
- if (onProgress) onProgress({ done: i + 1, total });
1052
- }
1053
- return { succeeded, total, failedNames };
1054
- }
7
+ // This module is a barrel: every component lives in a single-responsibility
8
+ // submodule under ./editor-primitives/, and the public export surface here is
9
+ // unchanged no consumer import needs to move.
10
+
11
+ import { Toolbar, ToolbarRow, Tabs, IconButtonGroup } from './editor-primitives/chrome.js';
12
+ import { TreeView, TreeItem } from './editor-primitives/tree.js';
13
+ import { PropertyGrid, PropertyField, PropertyGridRow, InlineEditableField } from './editor-primitives/property-grid.js';
14
+ import { Dock, BP_SM, BP_MD, BP_LG, BP_XL, useMediaQuery, Grid, GridItem, Divider } from './editor-primitives/layout.js';
15
+ import { ResizeHandle, SplitPanel } from './editor-primitives/split-panel.js';
16
+ import { Collapse, CollapseGroup } from './editor-primitives/collapse.js';
17
+ import { FocusTrap } from './editor-primitives/focus-trap.js';
18
+ import { ContextMenu, useContextMenu } from './editor-primitives/context-menu.js';
19
+ import { Drawer, Dialog } from './editor-primitives/modals.js';
20
+ import { Toast, toast } from './editor-primitives/toast.js';
21
+ import { Pager } from './editor-primitives/pager.js';
22
+ import { JsonViewer } from './editor-primitives/json-viewer.js';
23
+ import { InfoRow, InfoSection, DiagnosticsPanel } from './editor-primitives/diagnostics.js';
24
+ import { BatchProgressLabel, formatBatchOutcome, runBatchSequential } from './editor-primitives/batch.js';
25
+
26
+ export {
27
+ Toolbar, ToolbarRow, Tabs, IconButtonGroup,
28
+ TreeView, TreeItem,
29
+ PropertyGrid, PropertyField, PropertyGridRow, InlineEditableField,
30
+ Dock, BP_SM, BP_MD, BP_LG, BP_XL, useMediaQuery, Grid, GridItem, Divider,
31
+ ResizeHandle, SplitPanel,
32
+ Collapse, CollapseGroup,
33
+ FocusTrap,
34
+ ContextMenu, useContextMenu,
35
+ Drawer, Dialog,
36
+ Toast, toast,
37
+ Pager,
38
+ JsonViewer,
39
+ InfoRow, InfoSection, DiagnosticsPanel,
40
+ BatchProgressLabel, formatBatchOutcome, runBatchSequential,
41
+ };