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.
- package/dist/247420.css +228 -162
- package/dist/247420.js +24 -24
- package/package.json +1 -1
- package/src/components/content/avatar.js +31 -0
- package/src/components/content/charts.js +50 -0
- package/src/components/content/cli.js +46 -0
- package/src/components/content/feedback.js +77 -0
- package/src/components/content/fields.js +136 -0
- package/src/components/content/hero.js +146 -0
- package/src/components/content/lists.js +85 -0
- package/src/components/content/panel.js +79 -0
- package/src/components/content/row.js +115 -0
- package/src/components/content/table.js +97 -0
- package/src/components/content/views.js +81 -0
- package/src/components/content.js +29 -861
- package/src/components/editor-primitives/batch.js +60 -0
- package/src/components/editor-primitives/chrome.js +146 -0
- package/src/components/editor-primitives/collapse.js +46 -0
- package/src/components/editor-primitives/context-menu.js +127 -0
- package/src/components/editor-primitives/diagnostics.js +44 -0
- package/src/components/editor-primitives/focus-trap.js +26 -0
- package/src/components/editor-primitives/json-viewer.js +123 -0
- package/src/components/editor-primitives/layout.js +89 -0
- package/src/components/editor-primitives/modals.js +89 -0
- package/src/components/editor-primitives/pager.js +74 -0
- package/src/components/editor-primitives/property-grid.js +57 -0
- package/src/components/editor-primitives/shared.js +18 -0
- package/src/components/editor-primitives/split-panel.js +101 -0
- package/src/components/editor-primitives/toast.js +59 -0
- package/src/components/editor-primitives/tree.js +75 -0
- package/src/components/editor-primitives.js +35 -1048
- package/src/components/overlay-primitives/approval-prompt.js +38 -0
- package/src/components/overlay-primitives/auth-modal.js +89 -0
- package/src/components/overlay-primitives/command-palette.js +101 -0
- package/src/components/overlay-primitives/emoji-picker.js +103 -0
- package/src/components/overlay-primitives/floating.js +146 -0
- package/src/components/overlay-primitives/full-screen.js +45 -0
- package/src/components/overlay-primitives/menus.js +131 -0
- package/src/components/overlay-primitives/popover.js +51 -0
- package/src/components/overlay-primitives/roving-menu.js +73 -0
- package/src/components/overlay-primitives/settings-popover.js +85 -0
- package/src/components/overlay-primitives/tooltip.js +55 -0
- package/src/components/overlay-primitives.js +33 -855
- package/src/css/app-shell/chat-polish.css +39 -32
- package/src/css/app-shell/data-density.css +25 -21
- package/src/css/app-shell/files.css +37 -29
- package/src/css/app-shell/kits-appended.css +73 -50
- package/src/css/app-shell/responsive.css +47 -29
- package/src/kits/os/freddie-dashboard.css +2 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// BatchProgressLabel — sequential batch-operation progress readout: "label
|
|
3
|
+
// (i/n)" while in flight. Ported from docstudio's sequential upload badge
|
|
4
|
+
// and bulk-action progress button (both show a live count against a total
|
|
5
|
+
// while processing one item at a time). Pure display — the host owns the
|
|
6
|
+
// actual queue/loop; this only renders its current {done, total} state.
|
|
7
|
+
// done === 0 renders the bare label with no count suffix (nothing has
|
|
8
|
+
// happened yet); done === total renders as complete with no live-region
|
|
9
|
+
// churn once settled.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
13
|
+
const h = webjsx.createElement;
|
|
14
|
+
|
|
15
|
+
export function BatchProgressLabel({ label = 'Processing', done = 0, total = 0, key } = {}) {
|
|
16
|
+
const inFlight = total > 0 && done < total;
|
|
17
|
+
const suffix = total > 0 ? ` (${done}/${total})` : '';
|
|
18
|
+
return h('span', { key, class: 'ds-ep-batchprogress', role: 'status', 'aria-live': inFlight ? 'polite' : 'off' },
|
|
19
|
+
label + suffix);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// formatBatchOutcome — pure string helper for the aggregate toast shown once
|
|
23
|
+
// a batch settles: "N/total succeeded" plus a truncated failed-name list
|
|
24
|
+
// when any items failed, matching docstudio's attach-bar/bulk-action
|
|
25
|
+
// aggregation copy. Handles all three outcome shapes (all-succeed, all-fail,
|
|
26
|
+
// partial) and truncates a long failed-name list ("...and N more") instead
|
|
27
|
+
// of growing the toast unboundedly for a big batch.
|
|
28
|
+
export function formatBatchOutcome({ succeeded = 0, total = 0, failedNames = [], maxNames = 3 } = {}) {
|
|
29
|
+
if (total === 0) return '';
|
|
30
|
+
if (failedNames.length === 0) return `${succeeded}/${total} succeeded`;
|
|
31
|
+
const shown = failedNames.slice(0, maxNames).join(', ');
|
|
32
|
+
const more = failedNames.length > maxNames ? ` and ${failedNames.length - maxNames} more` : '';
|
|
33
|
+
return `${succeeded}/${total} succeeded; failed: ${shown}${more}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// runBatchSequential — pure async orchestration companion to
|
|
37
|
+
// BatchProgressLabel/formatBatchOutcome: runs `items` through `fn` one at a
|
|
38
|
+
// time (never in parallel, matching docstudio's rate-limited bulk-action
|
|
39
|
+
// runner), never aborting on a single item's failure. `onProgress({done,
|
|
40
|
+
// total})` fires after each item settles so a host can drive
|
|
41
|
+
// BatchProgressLabel live; the final `{succeeded, total, failedNames}`
|
|
42
|
+
// return shape feeds formatBatchOutcome directly. `fn` receives (item, index)
|
|
43
|
+
// and may reject/throw — a rejection is recorded as a failure keyed by
|
|
44
|
+
// `item.name != null ? item.name : String(item)`, never re-thrown.
|
|
45
|
+
export async function runBatchSequential(items = [], fn, onProgress) {
|
|
46
|
+
const total = items.length;
|
|
47
|
+
let succeeded = 0;
|
|
48
|
+
const failedNames = [];
|
|
49
|
+
for (let i = 0; i < total; i += 1) {
|
|
50
|
+
const item = items[i];
|
|
51
|
+
try {
|
|
52
|
+
await fn(item, i);
|
|
53
|
+
succeeded += 1;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
failedNames.push(item && item.name != null ? item.name : String(item));
|
|
56
|
+
}
|
|
57
|
+
if (onProgress) onProgress({ done: i + 1, total });
|
|
58
|
+
}
|
|
59
|
+
return { succeeded, total, failedNames };
|
|
60
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Editor chrome — the action/navigation bars that frame an editor surface:
|
|
2
|
+
// Toolbar (three-slot), ToolbarRow (flat wrapping row), Tabs (WAI-ARIA tabs
|
|
3
|
+
// with a sliding underline), IconButtonGroup (segmented toggle row). Pure
|
|
4
|
+
// factories, h-based, theme-token driven. All visuals route through CSS
|
|
5
|
+
// classes defined in editor-primitives.css; no hex/rgba literals appear here.
|
|
6
|
+
|
|
7
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
8
|
+
import { Icon } from '../shell.js';
|
|
9
|
+
import { kids } from './shared.js';
|
|
10
|
+
const h = webjsx.createElement;
|
|
11
|
+
|
|
12
|
+
export function Toolbar({ leading = [], trailing = [], dense = false, children } = {}) {
|
|
13
|
+
const cls = 'ds-ep-toolbar' + (dense ? ' dense' : '');
|
|
14
|
+
return h('div', { class: cls, role: 'toolbar' },
|
|
15
|
+
h('div', { class: 'ds-ep-toolbar-leading' }, ...kids(leading)),
|
|
16
|
+
children != null ? h('div', { class: 'ds-ep-toolbar-center' }, ...kids(children)) : null,
|
|
17
|
+
h('div', { class: 'ds-ep-toolbar-trailing' }, ...kids(trailing))
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// ToolbarRow — a flat, wrapping row of arbitrary action nodes (buttons,
|
|
23
|
+
// inputs, chips) with no leading/center/trailing slot structure. Toolbar's
|
|
24
|
+
// three-slot split is the wrong shape when a caller just wants "this row of
|
|
25
|
+
// controls, left to right, wrapping on narrow viewports" — the exact shape
|
|
26
|
+
// gmsniff's panels.js hand-rolled as a bare '.gm-toolbar' div because Toolbar
|
|
27
|
+
// didn't cover it. Accepts children as varargs or a single array.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
export function ToolbarRow(...actions) {
|
|
30
|
+
const flat = actions.length === 1 && Array.isArray(actions[0]) ? actions[0] : actions;
|
|
31
|
+
return h('div', { class: 'ds-ep-toolbar-row', role: 'toolbar' }, ...kids(flat));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Tabs({ items = [], active, onChange, children, 'aria-label': ariaLabel, onClose, scroll = false } = {}) {
|
|
35
|
+
// Roving tabindex + arrow nav per WAI-ARIA tabs pattern.
|
|
36
|
+
// Only the active tab is in the tab order; arrows move focus + activate.
|
|
37
|
+
const activeIdx = Math.max(0, items.findIndex(it => it.id === active));
|
|
38
|
+
const onTabKeyDown = (e, idx) => {
|
|
39
|
+
let next = null;
|
|
40
|
+
if (e.key === 'ArrowRight') next = (idx + 1) % items.length;
|
|
41
|
+
else if (e.key === 'ArrowLeft') next = (idx - 1 + items.length) % items.length;
|
|
42
|
+
else if (e.key === 'Home') next = 0;
|
|
43
|
+
else if (e.key === 'End') next = items.length - 1;
|
|
44
|
+
if (next == null) return;
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
const nextId = items[next]?.id;
|
|
47
|
+
if (nextId && onChange) onChange(nextId);
|
|
48
|
+
// Move focus on next paint (so the newly rendered active button gets focus)
|
|
49
|
+
queueMicrotask(() => {
|
|
50
|
+
const head = e.currentTarget?.parentElement;
|
|
51
|
+
const btn = head?.querySelectorAll('[role="tab"]')[next];
|
|
52
|
+
if (btn) btn.focus();
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
// Position the sliding underline from the active tab's geometry. The ref is
|
|
56
|
+
// on the OUTER .ds-ep-tabs (position:relative) — the indicator is its child,
|
|
57
|
+
// NOT the flex head's (an abspos child of a horizontal flex row mis-sizes to
|
|
58
|
+
// 0 in Chromium). Runs on every render (ref fires after applyDiff) so a tab
|
|
59
|
+
// change re-measures; the CSS transition animates the move. left/width come
|
|
60
|
+
// from the active tab; top sits the bar on the head's bottom edge.
|
|
61
|
+
const positionSlider = (root) => {
|
|
62
|
+
if (!root) return;
|
|
63
|
+
const head = root.querySelector('.ds-ep-tabs-head');
|
|
64
|
+
const active = root.querySelector('.ds-ep-tab.active');
|
|
65
|
+
const ind = root.querySelector('.ds-ep-tab-indicator');
|
|
66
|
+
if (!head || !active || !ind) return;
|
|
67
|
+
// Size via left+right insets, NOT width: an abspos element's inline
|
|
68
|
+
// `width` computes to 0 in some flex-sibling layouts (Chromium), but
|
|
69
|
+
// left+right insets size reliably. left/right animate via the CSS
|
|
70
|
+
// transition on .ds-ep-tab-indicator.
|
|
71
|
+
const rootW = root.offsetWidth;
|
|
72
|
+
const l = active.offsetLeft, w = active.offsetWidth;
|
|
73
|
+
ind.style.left = l + 'px';
|
|
74
|
+
ind.style.right = Math.max(0, rootW - l - w) + 'px';
|
|
75
|
+
ind.style.top = (head.offsetTop + head.offsetHeight - 2) + 'px';
|
|
76
|
+
head.classList.add('has-slider');
|
|
77
|
+
};
|
|
78
|
+
// scroll=true: tabs size to content (min/max-width) instead of stretching
|
|
79
|
+
// equally (flex:1) — the shape pi-web's TabBar uses for an open-file strip
|
|
80
|
+
// where tab count is unbounded and overflow-x scroll (already on
|
|
81
|
+
// .ds-ep-tabs-head) needs real per-tab widths to have something to scroll.
|
|
82
|
+
// onClose: per-item close affordance — a close button plus middle-click
|
|
83
|
+
// (auxclick button 1) to close, matching pi-web's TabBar. Opt-in: passing
|
|
84
|
+
// onClose without scroll still renders close buttons on the flex:1 tabs.
|
|
85
|
+
const closable = typeof onClose === 'function';
|
|
86
|
+
return h('div', { class: 'ds-ep-tabs', ref: positionSlider },
|
|
87
|
+
h('div', { class: 'ds-ep-tabs-head' + (scroll ? ' scroll' : ''), role: 'tablist', 'aria-label': ariaLabel || 'tabs' },
|
|
88
|
+
...items.map((it, idx) => h('span', {
|
|
89
|
+
key: it.id,
|
|
90
|
+
class: 'ds-ep-tab-wrap' + (it.id === active ? ' active' : ''),
|
|
91
|
+
onmousedown: closable ? (e) => { if (e.button === 1) e.preventDefault(); } : null,
|
|
92
|
+
onauxclick: closable ? (e) => {
|
|
93
|
+
if (e.button !== 1) return;
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
onClose(it.id);
|
|
97
|
+
} : null
|
|
98
|
+
},
|
|
99
|
+
h('button', {
|
|
100
|
+
type: 'button',
|
|
101
|
+
class: 'ds-ep-tab' + (it.id === active ? ' active' : ''),
|
|
102
|
+
role: 'tab',
|
|
103
|
+
id: 'tab-' + it.id,
|
|
104
|
+
title: typeof it.label === 'string' ? it.label : undefined,
|
|
105
|
+
'aria-selected': it.id === active ? 'true' : 'false',
|
|
106
|
+
'aria-controls': 'tabpanel-' + it.id,
|
|
107
|
+
'aria-label': typeof it.label === 'string' ? it.label : ('tab ' + (idx + 1)),
|
|
108
|
+
tabindex: idx === activeIdx ? '0' : '-1',
|
|
109
|
+
onclick: () => onChange && onChange(it.id),
|
|
110
|
+
onkeydown: (e) => onTabKeyDown(e, idx)
|
|
111
|
+
}, it.label),
|
|
112
|
+
closable ? h('button', {
|
|
113
|
+
type: 'button',
|
|
114
|
+
class: 'ds-ep-tab-close',
|
|
115
|
+
title: 'Close',
|
|
116
|
+
'aria-label': 'Close ' + (typeof it.label === 'string' ? it.label : 'tab'),
|
|
117
|
+
onclick: (e) => { e.stopPropagation(); onClose(it.id); }
|
|
118
|
+
}, Icon('x', { size: 14 })) : null
|
|
119
|
+
))
|
|
120
|
+
),
|
|
121
|
+
// The sliding underline — child of the outer column (see positionSlider).
|
|
122
|
+
// Keyed + decorative. Renders at 0-width until positioned (no-JS: hidden).
|
|
123
|
+
h('span', { key: '__ind', class: 'ds-ep-tab-indicator', 'aria-hidden': 'true' }),
|
|
124
|
+
h('div', {
|
|
125
|
+
class: 'ds-ep-tabs-body',
|
|
126
|
+
role: 'tabpanel',
|
|
127
|
+
id: active ? 'tabpanel-' + active : undefined,
|
|
128
|
+
'aria-labelledby': active ? 'tab-' + active : undefined,
|
|
129
|
+
tabindex: '0'
|
|
130
|
+
}, ...kids(children))
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function IconButtonGroup({ items = [], value, onChange, dense = false } = {}) {
|
|
135
|
+
return h('div', { class: 'ds-ep-btngrp' + (dense ? ' dense' : ''), role: 'group' },
|
|
136
|
+
...items.map((it) => h('button', {
|
|
137
|
+
key: it.id,
|
|
138
|
+
type: 'button',
|
|
139
|
+
class: 'ds-ep-btngrp-btn' + (it.id === value ? ' active' : ''),
|
|
140
|
+
title: it.title || it.label || it.id,
|
|
141
|
+
'aria-pressed': it.id === value ? 'true' : 'false',
|
|
142
|
+
disabled: it.disabled ? 'disabled' : null,
|
|
143
|
+
onclick: () => { if (!it.disabled && onChange) onChange(it.id); }
|
|
144
|
+
}, it.glyph != null ? it.glyph : it.label))
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Progressive disclosure — Collapse (a single controlled toggle panel) and
|
|
2
|
+
// CollapseGroup (the accordion wrapper enforcing single-open-at-a-time).
|
|
3
|
+
// Both are controlled: the caller owns `expanded`/`openId` state, same
|
|
4
|
+
// pattern as Drawer/Dialog's `open`/`onClose` — no internal state.
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Collapse — progressive-disclosure toggle panel (screen-real-estate
|
|
11
|
+
// density: property inspectors, nested settings, FAQ-style panels).
|
|
12
|
+
// Controlled component: caller owns `expanded` state and passes `onToggle`,
|
|
13
|
+
// same pattern as Drawer/Dialog `open`/`onClose` above — no internal state.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export function Collapse({ title, expanded = false, onToggle, children, key } = {}) {
|
|
16
|
+
return h('div', { key, class: 'ds-ep-collapse' + (expanded ? ' is-expanded' : '') },
|
|
17
|
+
h('button', {
|
|
18
|
+
type: 'button', class: 'ds-ep-collapse-head',
|
|
19
|
+
'aria-expanded': expanded ? 'true' : 'false',
|
|
20
|
+
onclick: () => { if (onToggle) onToggle(!expanded); },
|
|
21
|
+
},
|
|
22
|
+
h('span', { class: 'ds-ep-collapse-chevron' }, expanded ? 'v' : '>'),
|
|
23
|
+
h('span', { class: 'ds-ep-collapse-title' }, title)),
|
|
24
|
+
expanded ? h('div', { class: 'ds-ep-collapse-body' }, children) : null);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// CollapseGroup — accordion wrapper enforcing single-open-at-a-time when
|
|
29
|
+
// `accordion=true` (default false — group just lays out children, each
|
|
30
|
+
// Collapse still individually controlled). `openId`/`onOpenChange` drive the
|
|
31
|
+
// accordion; `items` is [{id, title, children}].
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
export function CollapseGroup({ items = [], openId, onOpenChange, accordion = false, key } = {}) {
|
|
34
|
+
return h('div', { key, class: 'ds-ep-collapse-group' },
|
|
35
|
+
...items.map((it) => Collapse({
|
|
36
|
+
key: it.id,
|
|
37
|
+
title: it.title,
|
|
38
|
+
expanded: accordion ? it.id === openId : Boolean(it.expanded),
|
|
39
|
+
onToggle: (next) => {
|
|
40
|
+
if (!onOpenChange) return;
|
|
41
|
+
if (accordion) onOpenChange(next ? it.id : null);
|
|
42
|
+
else onOpenChange(it.id, next);
|
|
43
|
+
},
|
|
44
|
+
children: it.children,
|
|
45
|
+
})));
|
|
46
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Context menu — the right-click/long-press menu surface: ContextMenu (the
|
|
2
|
+
// viewport-clamped, keyboard-navigable menu itself) plus useContextMenu, the
|
|
3
|
+
// helper that wires right-click + long-press on a target element and hands
|
|
4
|
+
// the caller an {x, y, items} payload to render it from.
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// ContextMenu — items, anchor {x,y}, onClose. Viewport-clamped. Keyboard nav.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export function ContextMenu({ items = [], anchor = { x: 0, y: 0 }, onClose } = {}) {
|
|
13
|
+
let rootEl = null;
|
|
14
|
+
const close = () => { if (onClose) onClose(); };
|
|
15
|
+
const select = (it) => {
|
|
16
|
+
if (it.disabled || it.separator) return;
|
|
17
|
+
if (it.onSelect) it.onSelect();
|
|
18
|
+
close();
|
|
19
|
+
};
|
|
20
|
+
const onKey = (e) => {
|
|
21
|
+
const btns = rootEl ? [...rootEl.querySelectorAll('button[data-ix]')] : [];
|
|
22
|
+
const active = document.activeElement;
|
|
23
|
+
const idx = btns.indexOf(active);
|
|
24
|
+
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
25
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); (btns[(idx + 1) % btns.length] || btns[0])?.focus(); }
|
|
26
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); (btns[(idx - 1 + btns.length) % btns.length] || btns[0])?.focus(); }
|
|
27
|
+
else if (e.key === 'Enter' && idx >= 0) { e.preventDefault(); btns[idx].click(); }
|
|
28
|
+
};
|
|
29
|
+
return h('div', {
|
|
30
|
+
class: 'ds-ep-ctxmenu-backdrop',
|
|
31
|
+
onmousedown: (e) => { if (e.target === e.currentTarget) close(); },
|
|
32
|
+
oncontextmenu: (e) => { e.preventDefault(); close(); },
|
|
33
|
+
},
|
|
34
|
+
h('div', {
|
|
35
|
+
class: 'ds-ep-ctxmenu',
|
|
36
|
+
role: 'menu',
|
|
37
|
+
tabindex: '-1',
|
|
38
|
+
onkeydown: onKey,
|
|
39
|
+
ref: (el) => {
|
|
40
|
+
if (!el) {
|
|
41
|
+
// Unmount: unhook the resize re-clamp bound on mount.
|
|
42
|
+
if (rootEl && rootEl._dsCtxClampOff) { rootEl._dsCtxClampOff(); }
|
|
43
|
+
rootEl = null;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
rootEl = el;
|
|
47
|
+
// Position at the anchor immediately, then clamp once layout has
|
|
48
|
+
// settled — measuring synchronously in ref reads a zero-size box
|
|
49
|
+
// (children not yet painted), so the clamp must run post-layout.
|
|
50
|
+
const ax = anchor.x || 0, ay = anchor.y || 0;
|
|
51
|
+
el.style.left = ax + 'px';
|
|
52
|
+
el.style.top = ay + 'px';
|
|
53
|
+
const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
|
|
54
|
+
const clamp = () => {
|
|
55
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
56
|
+
const r = el.getBoundingClientRect();
|
|
57
|
+
let x = ax, y = ay;
|
|
58
|
+
// Touch: keep the menu clear of the lifting finger — nudge
|
|
59
|
+
// below the touch point, or open above when it fits and the
|
|
60
|
+
// anchor sits in the lower half (lift-off would otherwise
|
|
61
|
+
// activate the first item).
|
|
62
|
+
if (coarse) {
|
|
63
|
+
y = ay + 10;
|
|
64
|
+
if (ay > vh / 2 && ay - r.height >= 4) y = ay - r.height;
|
|
65
|
+
}
|
|
66
|
+
if (x + r.width > vw) x = Math.max(4, vw - r.width - 4);
|
|
67
|
+
if (y + r.height > vh) y = Math.max(4, vh - r.height - 4);
|
|
68
|
+
el.style.left = x + 'px';
|
|
69
|
+
el.style.top = y + 'px';
|
|
70
|
+
};
|
|
71
|
+
requestAnimationFrame(clamp);
|
|
72
|
+
// Re-clamp on resize/orientation change for the menu's lifetime.
|
|
73
|
+
window.addEventListener('resize', clamp);
|
|
74
|
+
el._dsCtxClampOff = () => { window.removeEventListener('resize', clamp); el._dsCtxClampOff = null; };
|
|
75
|
+
// setTimeout(0), not queueMicrotask: the triggering contextmenu/click
|
|
76
|
+
// event's own default focus can otherwise win the race and leave focus
|
|
77
|
+
// outside the menu, breaking keyboard arrow-nav/Escape.
|
|
78
|
+
setTimeout(() => { el.querySelector('button[data-ix]')?.focus(); }, 0);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
...items.map((it, i) => it.separator
|
|
82
|
+
? h('div', { key: 'sep' + i, class: 'ds-ep-ctxmenu-sep', role: 'separator' })
|
|
83
|
+
: h('button', {
|
|
84
|
+
key: i, type: 'button', role: 'menuitem',
|
|
85
|
+
'data-ix': String(i),
|
|
86
|
+
class: 'ds-ep-ctxmenu-item' + (it.danger ? ' danger' : '') + (it.disabled ? ' disabled' : ''),
|
|
87
|
+
disabled: it.disabled ? 'disabled' : null,
|
|
88
|
+
onclick: () => select(it),
|
|
89
|
+
},
|
|
90
|
+
it.icon != null ? h('span', { class: 'ds-ep-ctxmenu-icon' }, it.icon) : null,
|
|
91
|
+
h('span', { class: 'ds-ep-ctxmenu-label' }, it.label)
|
|
92
|
+
))
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Helper: wires right-click + long-press to a target ref. Caller manages state.
|
|
98
|
+
export function useContextMenu(targetEl, items, openCb) {
|
|
99
|
+
if (!targetEl) return () => {};
|
|
100
|
+
let touchTimer = null, lastOpen = 0;
|
|
101
|
+
// Android fires the native contextmenu event on long-press AND our 500ms
|
|
102
|
+
// touch timer — dedupe so the menu opens once, not twice (open/flicker).
|
|
103
|
+
const open = (x, y) => {
|
|
104
|
+
if (Date.now() - lastOpen < 700) return;
|
|
105
|
+
lastOpen = Date.now();
|
|
106
|
+
if (openCb) openCb({ x, y, items });
|
|
107
|
+
};
|
|
108
|
+
const onCtx = (e) => { e.preventDefault(); open(e.clientX, e.clientY); };
|
|
109
|
+
const onTouchStart = (e) => {
|
|
110
|
+
const t = e.touches && e.touches[0]; if (!t) return;
|
|
111
|
+
touchTimer = setTimeout(() => { touchTimer = null; open(t.clientX, t.clientY); }, 500);
|
|
112
|
+
};
|
|
113
|
+
const cancel = () => { if (touchTimer) { clearTimeout(touchTimer); touchTimer = null; } };
|
|
114
|
+
targetEl.addEventListener('contextmenu', onCtx);
|
|
115
|
+
targetEl.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
116
|
+
targetEl.addEventListener('touchmove', cancel, { passive: true });
|
|
117
|
+
targetEl.addEventListener('touchend', cancel);
|
|
118
|
+
targetEl.addEventListener('touchcancel', cancel);
|
|
119
|
+
return () => {
|
|
120
|
+
targetEl.removeEventListener('contextmenu', onCtx);
|
|
121
|
+
targetEl.removeEventListener('touchstart', onTouchStart);
|
|
122
|
+
targetEl.removeEventListener('touchmove', cancel);
|
|
123
|
+
targetEl.removeEventListener('touchend', cancel);
|
|
124
|
+
targetEl.removeEventListener('touchcancel', cancel);
|
|
125
|
+
cancel();
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// InfoRow / InfoSection / DiagnosticsPanel — static debug/system-info
|
|
3
|
+
// readouts: a bordered section of label + monospace-value rows. Ported from
|
|
4
|
+
// docstudio's diagnostics page (auth/streaming/service-worker state, recent
|
|
5
|
+
// client errors, environment facts). Distinct from PropertyGrid, which is
|
|
6
|
+
// for EDITABLE properties — these rows are read-only display, never inputs.
|
|
7
|
+
// `data == null` (not yet loaded) renders a loading placeholder row instead
|
|
8
|
+
// of an empty section, so a panel never flashes an empty bordered box before
|
|
9
|
+
// its first data arrives; `onRefresh` renders a trailing refresh button that
|
|
10
|
+
// reuses the section's own header row rather than shifting layout beneath it.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
14
|
+
const h = webjsx.createElement;
|
|
15
|
+
|
|
16
|
+
export function InfoRow({ label, value, key } = {}) {
|
|
17
|
+
return h('div', { key, class: 'ds-ep-inforow' },
|
|
18
|
+
h('span', { class: 'ds-ep-inforow-label' }, label),
|
|
19
|
+
h('span', { class: 'ds-ep-inforow-value' }, value == null || value === '' ? '—' : String(value)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function InfoSection({ title, rows, key } = {}) {
|
|
23
|
+
const children = [
|
|
24
|
+
title ? h('h3', { key: 'title', class: 'ds-ep-infosection-title' }, title) : null,
|
|
25
|
+
rows == null
|
|
26
|
+
? h('div', { key: 'body-loading', class: 'ds-ep-infosection-loading', role: 'status' }, 'Loading…')
|
|
27
|
+
: h('div', { key: 'body-rows', class: 'ds-ep-infosection-rows' }, ...rows.map((r, i) => InfoRow({ ...r, key: r.key != null ? r.key : i })))
|
|
28
|
+
].filter(Boolean);
|
|
29
|
+
return h('section', { key, class: 'ds-ep-infosection' }, ...children);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function DiagnosticsPanel({ title = 'Diagnostics', sections = [], onRefresh, refreshing = false, key } = {}) {
|
|
33
|
+
const headChildren = [
|
|
34
|
+
h('h2', { key: 'title', class: 'ds-ep-diagnostics-title' }, title),
|
|
35
|
+
onRefresh ? h('button', {
|
|
36
|
+
key: 'refresh', type: 'button', class: 'ds-ep-diagnostics-refresh', disabled: refreshing, 'aria-busy': refreshing ? 'true' : 'false',
|
|
37
|
+
onclick: () => onRefresh()
|
|
38
|
+
}, refreshing ? 'Refreshing…' : 'Refresh') : null
|
|
39
|
+
].filter(Boolean);
|
|
40
|
+
return h('div', { key, class: 'ds-ep-diagnostics' },
|
|
41
|
+
h('div', { key: 'head', class: 'ds-ep-diagnostics-head' }, ...headChildren),
|
|
42
|
+
...sections.map((s, i) => InfoSection({ ...s, key: s.key || i }))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// FocusTrap — wraps subtree, traps Tab/Shift+Tab. Mount/unmount lifecycle is
|
|
3
|
+
// managed via DOM-level keydown listener attached when first focused.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
7
|
+
import { kids, FOCUSABLE_SEL, trapTabKey } from './shared.js';
|
|
8
|
+
const h = webjsx.createElement;
|
|
9
|
+
|
|
10
|
+
export function FocusTrap({ children } = {}) {
|
|
11
|
+
return h('div', {
|
|
12
|
+
class: 'ds-ep-focustrap',
|
|
13
|
+
tabindex: '-1',
|
|
14
|
+
ref: (el) => {
|
|
15
|
+
if (!el || el._dsTrap) return;
|
|
16
|
+
el._dsTrap = true;
|
|
17
|
+
el.addEventListener('keydown', (e) => trapTabKey(el, e));
|
|
18
|
+
// Auto-focus first focusable
|
|
19
|
+
queueMicrotask(() => {
|
|
20
|
+
const first = el.querySelector(FOCUSABLE_SEL);
|
|
21
|
+
if (first) first.focus();
|
|
22
|
+
else el.focus();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}, ...kids(children));
|
|
26
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// JsonViewer — monospace data preview (max-height + scroll), generalizing
|
|
3
|
+
// gmsniff's gm-json. Accepts a pre-stringified string OR any value
|
|
4
|
+
// (objects/arrays get JSON.stringify(v, null, 2); null/undefined render the
|
|
5
|
+
// empty-state text rather than the literal string "undefined"/"null").
|
|
6
|
+
//
|
|
7
|
+
// mode selects rendering; 'plain' is the historical contract (children[0] is
|
|
8
|
+
// the raw text string, verbatim for string input) and stays the default so
|
|
9
|
+
// every existing consumer is untouched:
|
|
10
|
+
// 'plain' — flat <pre>, raw text.
|
|
11
|
+
// 'highlight' — flat <pre>, text tokenized into ds-ep-json-* spans
|
|
12
|
+
// (key/string/number/boolean/null). A string that does not
|
|
13
|
+
// parse as JSON falls back to plain text — arbitrary prose is
|
|
14
|
+
// never falsely tokenized.
|
|
15
|
+
// 'tree' — collapsible <details> tree per nested object/array, open
|
|
16
|
+
// above treeDepth (default 2), each summary carrying a
|
|
17
|
+
// child-count tag. Scalars/unparseable input fall back to
|
|
18
|
+
// 'highlight'/plain respectively.
|
|
19
|
+
// copyable=true wraps the viewer with a copy-to-clipboard button (transient
|
|
20
|
+
// copied/failed feedback, no dependencies).
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
24
|
+
const h = webjsx.createElement;
|
|
25
|
+
|
|
26
|
+
const JSON_NUM_CHARS = '0123456789eE+.-';
|
|
27
|
+
|
|
28
|
+
// Linear single-pass scan — no regex, no backtracking, safe on truncated
|
|
29
|
+
// input (an unterminated string just consumes to end-of-text).
|
|
30
|
+
function tokenizeJson(text) {
|
|
31
|
+
const toks = [];
|
|
32
|
+
let i = 0, plain = '';
|
|
33
|
+
const flush = () => { if (plain) { toks.push(['', plain]); plain = ''; } };
|
|
34
|
+
while (i < text.length) {
|
|
35
|
+
const c = text[i];
|
|
36
|
+
if (c === '"') {
|
|
37
|
+
const start = i;
|
|
38
|
+
i++;
|
|
39
|
+
while (i < text.length && text[i] !== '"') { if (text[i] === '\\') i++; i++; }
|
|
40
|
+
i = Math.min(i + 1, text.length);
|
|
41
|
+
let j = i;
|
|
42
|
+
while (j < text.length && (text[j] === ' ' || text[j] === '\t' || text[j] === '\n' || text[j] === '\r')) j++;
|
|
43
|
+
flush();
|
|
44
|
+
toks.push([text[j] === ':' ? 'k' : 's', text.slice(start, i)]);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (c === '-' || (c >= '0' && c <= '9')) {
|
|
48
|
+
const start = i;
|
|
49
|
+
i++;
|
|
50
|
+
while (i < text.length && JSON_NUM_CHARS.includes(text[i])) i++;
|
|
51
|
+
flush();
|
|
52
|
+
toks.push(['n', text.slice(start, i)]);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (text.startsWith('true', i)) { flush(); toks.push(['b', 'true']); i += 4; continue; }
|
|
56
|
+
if (text.startsWith('false', i)) { flush(); toks.push(['b', 'false']); i += 5; continue; }
|
|
57
|
+
if (text.startsWith('null', i)) { flush(); toks.push(['z', 'null']); i += 4; continue; }
|
|
58
|
+
plain += c; i++;
|
|
59
|
+
}
|
|
60
|
+
flush();
|
|
61
|
+
return toks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function highlightJsonSpans(text) {
|
|
65
|
+
return tokenizeJson(text).map(([t, s]) => t ? h('span', { class: 'ds-ep-json-' + t }, s) : s);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function jsonTreeNode(key, val, depth, treeDepth) {
|
|
69
|
+
const keyParts = key != null ? [h('span', { class: 'ds-ep-json-k' }, JSON.stringify(key)), ': '] : [];
|
|
70
|
+
if (val !== null && typeof val === 'object') {
|
|
71
|
+
const isArr = Array.isArray(val);
|
|
72
|
+
const entries = isArr ? val.map((v) => [null, v]) : Object.entries(val);
|
|
73
|
+
if (!entries.length) return h('div', { class: 'ds-ep-json-leaf' }, ...keyParts, isArr ? '[]' : '{}');
|
|
74
|
+
const tag = isArr ? '[' + entries.length + ']' : '{' + entries.length + '}';
|
|
75
|
+
return h('details', { class: 'ds-ep-json-node', open: depth < treeDepth ? true : null },
|
|
76
|
+
h('summary', { class: 'ds-ep-json-sum' }, ...keyParts, h('span', { class: 'ds-ep-json-tag' }, tag)),
|
|
77
|
+
h('div', { class: 'ds-ep-json-kids' }, ...entries.map(([k, v]) => jsonTreeNode(k, v, depth + 1, treeDepth))));
|
|
78
|
+
}
|
|
79
|
+
const t = typeof val === 'string' ? 's' : typeof val === 'number' ? 'n' : typeof val === 'boolean' ? 'b' : 'z';
|
|
80
|
+
return h('div', { class: 'ds-ep-json-leaf' }, ...keyParts, h('span', { class: 'ds-ep-json-' + t }, JSON.stringify(val) ?? String(val)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function jsonCopyButton(text) {
|
|
84
|
+
return h('button', {
|
|
85
|
+
type: 'button', class: 'ds-ep-json-copy', title: 'copy JSON', 'aria-label': 'copy JSON',
|
|
86
|
+
onclick: (e) => {
|
|
87
|
+
const btn = e.currentTarget;
|
|
88
|
+
const show = (label, ok) => {
|
|
89
|
+
btn.textContent = label;
|
|
90
|
+
btn.classList.toggle('copied', ok);
|
|
91
|
+
setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 1200);
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
navigator.clipboard.writeText(text).then(() => show('copied', true), () => show('failed', false));
|
|
95
|
+
} catch {
|
|
96
|
+
show('failed', false);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}, 'copy');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function JsonViewer({ value, emptyText = 'no data', maxHeight, mode = 'plain', copyable = false, treeDepth = 2 } = {}) {
|
|
103
|
+
let text, parsed;
|
|
104
|
+
let knownJson = false;
|
|
105
|
+
if (value == null) text = null;
|
|
106
|
+
else if (typeof value === 'string') text = value;
|
|
107
|
+
else { try { text = JSON.stringify(value, null, 2); knownJson = text != null; parsed = value; } catch { text = String(value); } }
|
|
108
|
+
if (!text) return h('div', { class: 'ds-ep-json ds-ep-json-empty' }, emptyText);
|
|
109
|
+
const style = maxHeight ? ('max-height:' + maxHeight) : null;
|
|
110
|
+
if (!knownJson && (mode === 'highlight' || mode === 'tree')) {
|
|
111
|
+
try { parsed = JSON.parse(text); knownJson = true; } catch { /* swallow: not JSON — render plain */ }
|
|
112
|
+
}
|
|
113
|
+
let body;
|
|
114
|
+
if (mode === 'tree' && knownJson && parsed !== null && typeof parsed === 'object') {
|
|
115
|
+
body = h('div', { class: 'ds-ep-json ds-ep-json-tree', style }, jsonTreeNode(null, parsed, 0, treeDepth));
|
|
116
|
+
} else if ((mode === 'highlight' || mode === 'tree') && knownJson) {
|
|
117
|
+
body = h('pre', { class: 'ds-ep-json ds-ep-json-hl', style }, ...highlightJsonSpans(text));
|
|
118
|
+
} else {
|
|
119
|
+
body = h('pre', { class: 'ds-ep-json', style }, text);
|
|
120
|
+
}
|
|
121
|
+
if (!copyable) return body;
|
|
122
|
+
return h('div', { class: 'ds-ep-json-wrap' }, jsonCopyButton(text), body);
|
|
123
|
+
}
|