anentrypoint-design 0.0.130 → 0.0.134

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.130",
3
+ "version": "0.0.134",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -42,6 +42,7 @@
42
42
  },
43
43
  "./src/page-html.js": "./src/page-html.js",
44
44
  "./web-components/ds-chat.js": "./src/web-components/ds-chat.js",
45
+ "./web-components/freddie-chat.js": "./src/web-components/freddie-chat.js",
45
46
  "./package.json": "./package.json"
46
47
  },
47
48
  "files": [
@@ -0,0 +1,168 @@
1
+ // Form primitives — Checkbox, Radio, RadioGroup, Toggle, Field,
2
+ // useFormValidation. Native inputs styled via CSS classes. No inline
3
+ // styles. All visuals route through form-primitives rules appended to
4
+ // editor-primitives.css. Theme-token driven; respects prefers-reduced-motion.
5
+
6
+ import * as webjsx from '../../vendor/webjsx/index.js';
7
+ const h = webjsx.createElement;
8
+
9
+ let _uid = 0;
10
+ function uid(prefix) { _uid += 1; return prefix + '-' + _uid; }
11
+
12
+ function setIndeterminate(node, flag) {
13
+ if (node && typeof flag === 'boolean') node.indeterminate = flag;
14
+ }
15
+
16
+ export function Checkbox({ checked, indeterminate, disabled, label, hint, onChange, ariaLabel, key, name, id } = {}) {
17
+ const inputId = id || uid('ds-check');
18
+ const hintId = hint ? inputId + '-hint' : null;
19
+ const input = h('input', {
20
+ key: 'i', type: 'checkbox', id: inputId, name,
21
+ class: 'ds-check',
22
+ checked: checked ? true : null,
23
+ disabled: disabled ? true : null,
24
+ 'aria-label': ariaLabel || null,
25
+ 'aria-describedby': hintId,
26
+ ref: (node) => setIndeterminate(node, indeterminate),
27
+ onchange: onChange ? (e) => onChange(e.target.checked, e) : null
28
+ });
29
+ return h('label', { key, class: 'ds-check-row', for: inputId },
30
+ input,
31
+ label != null ? h('span', { key: 'l', class: 'ds-check-label' }, label) : null,
32
+ hint != null ? h('span', { key: 'h', id: hintId, class: 'ds-field-hint' }, hint) : null
33
+ );
34
+ }
35
+
36
+ export function Radio({ name, value, checked, disabled, label, hint, onChange, ariaLabel, key, id } = {}) {
37
+ const inputId = id || uid('ds-radio');
38
+ const hintId = hint ? inputId + '-hint' : null;
39
+ const input = h('input', {
40
+ key: 'i', type: 'radio', id: inputId, name, value,
41
+ class: 'ds-radio',
42
+ checked: checked ? true : null,
43
+ disabled: disabled ? true : null,
44
+ 'aria-label': ariaLabel || null,
45
+ 'aria-describedby': hintId,
46
+ onchange: onChange ? (e) => onChange(value, e) : null
47
+ });
48
+ return h('label', { key, class: 'ds-radio-row', for: inputId },
49
+ input,
50
+ label != null ? h('span', { key: 'l', class: 'ds-radio-label' }, label) : null,
51
+ hint != null ? h('span', { key: 'h', id: hintId, class: 'ds-field-hint' }, hint) : null
52
+ );
53
+ }
54
+
55
+ export function RadioGroup({ legend, name, value, options = [], onChange, orientation = 'vertical', key } = {}) {
56
+ const groupName = name || uid('ds-rg');
57
+ const isHoriz = orientation === 'horizontal';
58
+ const onKeyDown = (e) => {
59
+ const nextKey = isHoriz ? 'ArrowRight' : 'ArrowDown';
60
+ const prevKey = isHoriz ? 'ArrowLeft' : 'ArrowUp';
61
+ if (e.key !== nextKey && e.key !== prevKey && e.key !== 'Home' && e.key !== 'End') return;
62
+ e.preventDefault();
63
+ const idx = options.findIndex(o => (typeof o === 'string' ? o : o.value) === value);
64
+ let next = idx;
65
+ if (e.key === nextKey) next = (idx + 1) % options.length;
66
+ else if (e.key === prevKey) next = (idx - 1 + options.length) % options.length;
67
+ else if (e.key === 'Home') next = 0;
68
+ else if (e.key === 'End') next = options.length - 1;
69
+ const opt = options[next];
70
+ const v = typeof opt === 'string' ? opt : opt.value;
71
+ if (onChange) onChange(v, e);
72
+ const root = e.currentTarget;
73
+ const inputs = root.querySelectorAll('input[type="radio"]');
74
+ if (inputs[next]) inputs[next].focus();
75
+ };
76
+ return h('fieldset', { key, class: 'ds-radio-group ' + (isHoriz ? 'horiz' : 'vert'), role: 'radiogroup', onkeydown: onKeyDown },
77
+ legend != null ? h('legend', { key: 'lg', class: 'ds-field-label' }, legend) : null,
78
+ ...options.map((o, i) => {
79
+ const v = typeof o === 'string' ? o : o.value;
80
+ const lab = typeof o === 'string' ? o : o.label;
81
+ const dis = typeof o === 'object' && o.disabled;
82
+ return Radio({ key: 'r' + i, name: groupName, value: v, label: lab, disabled: dis, checked: v === value, onChange });
83
+ })
84
+ );
85
+ }
86
+
87
+ export function Toggle({ checked, disabled, label, hint, onChange, ariaLabel, kind = 'switch', key, id } = {}) {
88
+ const btnId = id || uid('ds-toggle');
89
+ const hintId = hint ? btnId + '-hint' : null;
90
+ const toggle = () => { if (!disabled && onChange) onChange(!checked); };
91
+ const btn = h('button', {
92
+ key: 'b', id: btnId, type: 'button', role: kind, class: 'ds-toggle',
93
+ 'aria-checked': checked ? 'true' : 'false',
94
+ 'aria-label': ariaLabel || (label == null ? 'toggle' : null),
95
+ 'aria-describedby': hintId,
96
+ disabled: disabled ? true : null,
97
+ onclick: toggle,
98
+ onkeydown: (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle(); } }
99
+ }, h('span', { key: 'k', class: 'ds-toggle-knob', 'aria-hidden': 'true' }));
100
+ if (label == null && hint == null) return btn;
101
+ return h('label', { key, class: 'ds-toggle-row', for: btnId },
102
+ btn,
103
+ label != null ? h('span', { key: 'l', class: 'ds-toggle-label' }, label) : null,
104
+ hint != null ? h('span', { key: 'h', id: hintId, class: 'ds-field-hint' }, hint) : null
105
+ );
106
+ }
107
+
108
+ function cloneWithProps(node, extra) {
109
+ if (!node || typeof node !== 'object') return node;
110
+ return { ...node, props: { ...(node.props || {}), ...extra } };
111
+ }
112
+
113
+ export function Field({ label, hint, error, required, htmlFor, children, key } = {}) {
114
+ const autoId = htmlFor || uid('ds-field');
115
+ const hintId = hint != null ? autoId + '-hint' : null;
116
+ const errorId = error != null ? autoId + '-err' : null;
117
+ const describedBy = [hintId, errorId].filter(Boolean).join(' ') || null;
118
+ const list = Array.isArray(children) ? children : [children];
119
+ const decorated = list.map((c) => {
120
+ if (!c || typeof c !== 'object') return c;
121
+ const props = c.props || {};
122
+ const extra = { 'aria-describedby': describedBy };
123
+ if (error != null) extra['aria-invalid'] = 'true';
124
+ if (!props.id && !htmlFor) { /* leave id unset */ }
125
+ else if (!props.id) extra.id = autoId;
126
+ return cloneWithProps(c, extra);
127
+ });
128
+ return h('div', { key, class: 'ds-field-wrap' },
129
+ label != null ? h('label', { key: 'l', class: 'ds-field-label', for: autoId },
130
+ label,
131
+ required ? h('span', { key: 'r', class: 'ds-field-required', 'aria-hidden': 'true' }, ' *') : null
132
+ ) : null,
133
+ required ? h('span', { key: 'sr', class: 'sr-only' }, 'required') : null,
134
+ ...decorated,
135
+ error != null
136
+ ? h('div', { key: 'e', id: errorId, class: 'ds-field-error', role: 'alert' }, error)
137
+ : (hint != null ? h('div', { key: 'h', id: hintId, class: 'ds-field-hint' }, hint) : null)
138
+ );
139
+ }
140
+
141
+ const RULES = {
142
+ required: (v) => (v == null || v === '' || (Array.isArray(v) && v.length === 0)) ? 'required' : null,
143
+ email: (v) => (!v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) ? null : 'invalid email',
144
+ pattern: (v, r) => (v == null || v === '' || new RegExp(r.value).test(v)) ? null : (r.message || 'invalid format'),
145
+ min: (v, r) => (v == null || Number(v) >= r.value) ? null : (r.message || `min ${r.value}`),
146
+ max: (v, r) => (v == null || Number(v) <= r.value) ? null : (r.message || `max ${r.value}`),
147
+ custom: (v, r) => r.fn ? r.fn(v) : null
148
+ };
149
+
150
+ export function useFormValidation(schema = {}) {
151
+ const errors = {};
152
+ const validateField = (name, value) => {
153
+ const rules = schema[name] || [];
154
+ for (const r of rules) {
155
+ const fn = RULES[r.rule];
156
+ if (!fn) continue;
157
+ const out = fn(value, r);
158
+ if (out) { errors[name] = r.message || out; return errors[name]; }
159
+ }
160
+ delete errors[name];
161
+ return null;
162
+ };
163
+ const validate = (values = {}) => {
164
+ for (const name of Object.keys(schema)) validateField(name, values[name]);
165
+ return { valid: Object.keys(errors).length === 0, errors: { ...errors } };
166
+ };
167
+ return { errors, validate, validateField };
168
+ }
@@ -0,0 +1,217 @@
1
+ // Interaction primitives — pointer drag/drop + keyboard shortcuts.
2
+ // Pointer Events only (touch+mouse). Visuals via editor-primitives.css.
3
+ import * as webjsx from '../../vendor/webjsx/index.js';
4
+ const h = webjsx.createElement;
5
+ const DRAG_THRESHOLD = 5;
6
+ const IS_MAC = (typeof navigator !== 'undefined') && /Mac|iPhone|iPad/.test(navigator.platform || '');
7
+ const SHORTCUT_REGISTRY = new Set();
8
+ function dispatchDrag(el, detail) {
9
+ el.dispatchEvent(new CustomEvent('ds-drag', { detail, bubbles: true, composed: true }));
10
+ }
11
+
12
+ export function useDraggable(el, { data, kind, onDragStart, onDragEnd } = {}) {
13
+ if (!el) return { destroy() {} };
14
+ let startX = 0, startY = 0, active = false, started = false, pid = null;
15
+ let kbMode = false, kbTargets = [], kbIdx = 0;
16
+
17
+ const onMove = (e) => {
18
+ if (!active) return;
19
+ if (!started) {
20
+ const dx = e.clientX - startX, dy = e.clientY - startY;
21
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
22
+ started = true;
23
+ el.setAttribute('data-dragging', 'true');
24
+ if (onDragStart) onDragStart({ data, kind, pointerEvent: e });
25
+ }
26
+ const hit = document.elementFromPoint(e.clientX, e.clientY);
27
+ const target = hit && hit.closest('[data-drop-target]');
28
+ dispatchDrag(el, { phase: 'move', data, kind, pointerEvent: e, target });
29
+ };
30
+ const onUp = (e) => {
31
+ if (!active) return;
32
+ const wasStarted = started;
33
+ active = false; started = false;
34
+ try { if (pid != null) el.releasePointerCapture(pid); } catch {}
35
+ pid = null;
36
+ el.removeAttribute('data-dragging');
37
+ const hit = document.elementFromPoint(e.clientX, e.clientY);
38
+ const target = hit && hit.closest('[data-drop-target]');
39
+ dispatchDrag(el, { phase: 'end', data, kind, pointerEvent: e, target });
40
+ window.removeEventListener('pointermove', onMove);
41
+ window.removeEventListener('pointerup', onUp);
42
+ window.removeEventListener('pointercancel', onUp);
43
+ if (wasStarted && onDragEnd) onDragEnd({ drop: target, data, kind });
44
+ };
45
+ const onDown = (e) => {
46
+ if (e.button != null && e.button !== 0) return;
47
+ active = true; started = false;
48
+ startX = e.clientX; startY = e.clientY; pid = e.pointerId;
49
+ try { el.setPointerCapture(e.pointerId); } catch {}
50
+ window.addEventListener('pointermove', onMove);
51
+ window.addEventListener('pointerup', onUp);
52
+ window.addEventListener('pointercancel', onUp);
53
+ };
54
+ const clearActive = () => kbTargets.forEach(n => n.removeAttribute('data-drop-target-active'));
55
+ const onKey = (e) => {
56
+ if (e.key === ' ' || e.code === 'Space') {
57
+ e.preventDefault();
58
+ if (!kbMode) {
59
+ kbMode = true; kbIdx = 0;
60
+ kbTargets = Array.from(document.querySelectorAll('[data-drop-target]'));
61
+ el.setAttribute('data-dragging', 'true');
62
+ if (onDragStart) onDragStart({ data, kind, pointerEvent: null });
63
+ if (kbTargets[0]) kbTargets[0].setAttribute('data-drop-target-active', 'true');
64
+ } else {
65
+ const t = kbTargets[kbIdx];
66
+ kbMode = false; el.removeAttribute('data-dragging'); clearActive();
67
+ if (t) dispatchDrag(el, { phase: 'end', data, kind, pointerEvent: null, target: t });
68
+ if (onDragEnd) onDragEnd({ drop: t, data, kind });
69
+ }
70
+ } else if (kbMode && /^Arrow/.test(e.key)) {
71
+ e.preventDefault();
72
+ const dir = (e.key === 'ArrowDown' || e.key === 'ArrowRight') ? 1 : -1;
73
+ clearActive();
74
+ kbIdx = (kbIdx + dir + kbTargets.length) % kbTargets.length;
75
+ if (kbTargets[kbIdx]) kbTargets[kbIdx].setAttribute('data-drop-target-active', 'true');
76
+ } else if (kbMode && e.key === 'Escape') {
77
+ e.preventDefault(); kbMode = false; el.removeAttribute('data-dragging'); clearActive();
78
+ }
79
+ };
80
+ el.addEventListener('pointerdown', onDown);
81
+ el.addEventListener('keydown', onKey);
82
+ return { destroy() {
83
+ el.removeEventListener('pointerdown', onDown);
84
+ el.removeEventListener('keydown', onKey);
85
+ window.removeEventListener('pointermove', onMove);
86
+ window.removeEventListener('pointerup', onUp);
87
+ window.removeEventListener('pointercancel', onUp);
88
+ }};
89
+ }
90
+
91
+ export function useDropTarget(el, { accepts = [], onDrop, onDragOver } = {}) {
92
+ if (!el) return { destroy() {} };
93
+ el.setAttribute('data-drop-target', '');
94
+ const handler = (e) => {
95
+ const d = e.detail; if (!d) return;
96
+ if (accepts.length && !accepts.includes(d.kind)) return;
97
+ if (d.target !== el) {
98
+ el.removeAttribute('data-drop-target-active');
99
+ return;
100
+ }
101
+ if (d.phase === 'move') {
102
+ el.setAttribute('data-drop-target-active', 'true');
103
+ if (onDragOver) onDragOver({ data: d.data, kind: d.kind, pointerEvent: d.pointerEvent });
104
+ } else if (d.phase === 'end') {
105
+ el.removeAttribute('data-drop-target-active');
106
+ if (onDrop) onDrop({ data: d.data, kind: d.kind, pointerEvent: d.pointerEvent });
107
+ }
108
+ };
109
+ document.addEventListener('ds-drag', handler, true);
110
+ return { destroy() {
111
+ document.removeEventListener('ds-drag', handler, true);
112
+ el.removeAttribute('data-drop-target');
113
+ el.removeAttribute('data-drop-target-active');
114
+ }};
115
+ }
116
+
117
+ export function Reorderable({ items = [], getKey, renderItem, onReorder, axis = 'vertical', kind = 'reorder' } = {}) {
118
+ const order = items.map((_, i) => i);
119
+ const cls = 'ds-reorderable ds-reorderable-' + axis;
120
+ return h('div', { class: cls, role: 'list' },
121
+ ...items.map((item, i) => {
122
+ const key = getKey ? getKey(item, i) : i;
123
+ const onRef = (el) => {
124
+ if (!el || el._dsReorder) return;
125
+ el._dsReorder = true;
126
+ const handle = el.querySelector('.ds-reorder-handle') || el;
127
+ const drag = useDraggable(handle, {
128
+ data: { index: i }, kind,
129
+ onDragEnd: ({ drop }) => {
130
+ if (!drop) return;
131
+ const toIdx = Number(drop.getAttribute('data-reorder-index'));
132
+ if (Number.isNaN(toIdx) || toIdx === i) return;
133
+ const next = order.slice();
134
+ const [m] = next.splice(i, 1);
135
+ next.splice(toIdx, 0, m);
136
+ if (onReorder) onReorder(next);
137
+ },
138
+ });
139
+ const drop = useDropTarget(el, { accepts: [kind] });
140
+ el._dsReorderDestroy = () => { drag.destroy(); drop.destroy(); };
141
+ };
142
+ return h('div', {
143
+ key, ref: onRef, class: 'ds-reorder-item',
144
+ 'data-reorder-index': String(i), role: 'listitem',
145
+ },
146
+ h('button', {
147
+ type: 'button', class: 'ds-reorder-handle',
148
+ 'aria-label': 'Reorder', tabindex: '0',
149
+ }, '⋮⋮'),
150
+ renderItem ? renderItem(item, i) : null
151
+ );
152
+ })
153
+ );
154
+ }
155
+
156
+ function parseCombo(combo) {
157
+ const parts = combo.split('+').map(s => s.trim());
158
+ const key = parts.pop();
159
+ const mods = new Set(parts.map(s => s.toLowerCase()));
160
+ return { key: key.length === 1 ? key.toLowerCase() : key, mod: mods.has('mod'), shift: mods.has('shift'), alt: mods.has('alt'), ctrl: mods.has('ctrl') };
161
+ }
162
+ function matchEvent(e, spec) {
163
+ const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
164
+ if (k !== spec.key) return false;
165
+ const modOk = spec.mod ? (IS_MAC ? e.metaKey : e.ctrlKey) : true;
166
+ if (!modOk) return false;
167
+ if (spec.shift !== !!e.shiftKey) return false;
168
+ if (spec.alt !== !!e.altKey) return false;
169
+ if (spec.ctrl && !e.ctrlKey) return false;
170
+ return true;
171
+ }
172
+
173
+ export function formatShortcut(combo) {
174
+ const s = parseCombo(combo);
175
+ const mod = s.mod ? (IS_MAC ? '⌘' : 'Ctrl+') : '';
176
+ const shift = s.shift ? (IS_MAC ? '⇧' : 'Shift+') : '';
177
+ const alt = s.alt ? (IS_MAC ? '⌥' : 'Alt+') : '';
178
+ const key = s.key.length === 1 ? s.key.toUpperCase() : s.key;
179
+ return mod + alt + shift + key;
180
+ }
181
+
182
+ export function useKeyboardShortcut(map = {}, { scope = 'global', enabled = true } = {}) {
183
+ if (!enabled) return { destroy() {}, trigger() {} };
184
+ const target = scope === 'global' ? (typeof document !== 'undefined' ? document : null) : scope;
185
+ if (!target) return { destroy() {}, trigger() {} };
186
+ const specs = Object.entries(map).map(([combo, fn]) => ({ combo, spec: parseCombo(combo), fn }));
187
+ specs.forEach(s => SHORTCUT_REGISTRY.add({ combo: s.combo, scope: scope === 'global' ? 'global' : 'local' }));
188
+ const onKey = (e) => {
189
+ for (const s of specs) if (matchEvent(e, s.spec)) { e.preventDefault(); s.fn(e); return; }
190
+ };
191
+ target.addEventListener('keydown', onKey);
192
+ return {
193
+ destroy() { target.removeEventListener('keydown', onKey); },
194
+ trigger(combo) { const s = specs.find(x => x.combo === combo); if (s) s.fn(); },
195
+ };
196
+ }
197
+
198
+ export function ShortcutHint({ combo, kind = 'kbd' } = {}) { return h('kbd', { class: 'ds-kbd ds-kbd-' + kind }, formatShortcut(combo || '')); }
199
+
200
+ export function useKeyboardShortcutHelp() { return { registry: Array.from(SHORTCUT_REGISTRY) }; }
201
+ export function ShortcutHelpDialog({ open = false, onClose, registry } = {}) {
202
+ if (!open) return null;
203
+ const list = registry || Array.from(SHORTCUT_REGISTRY);
204
+ const groups = {};
205
+ list.forEach(r => { (groups[r.scope] = groups[r.scope] || []).push(r); });
206
+ return h('div', { class: 'ds-ep-dialog-backdrop', onmousedown: (e) => { if (e.target === e.currentTarget && onClose) onClose(); } },
207
+ h('div', { class: 'ds-ep-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts' },
208
+ h('h2', null, 'Keyboard shortcuts'),
209
+ ...Object.entries(groups).map(([scope, rows]) =>
210
+ h('section', { class: 'ds-kbd-group' },
211
+ h('h3', null, scope),
212
+ h('ul', null, ...rows.map(r => h('li', null, h(ShortcutHint, { combo: r.combo }))))
213
+ )
214
+ )
215
+ )
216
+ );
217
+ }
@@ -0,0 +1,219 @@
1
+ // Overlay primitives — Tooltip, Popover, Dropdown + useLongPress, useFloating.
2
+ // Shared positioning (auto-flip + viewport clamp) in useFloating; consumed by
3
+ // all three. No inline styles except runtime left/top. CSS classes scoped to
4
+ // .ds-247420 (see editor-primitives.css).
5
+
6
+ import * as webjsx from '../../vendor/webjsx/index.js';
7
+ const h = webjsx.createElement;
8
+ const kids = (c) => c == null ? [] : (Array.isArray(c) ? c : [c]);
9
+ const FOCUSABLE_SEL = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
10
+
11
+ // useFloating — compute left/top + auto-flip; re-runs on resize/scroll.
12
+ export function useFloating(anchorEl, contentEl, { placement = 'bottom-start', offset = 8 } = {}) {
13
+ if (!anchorEl || !contentEl) return { update() {}, dispose() {}, finalPlacement: placement };
14
+ let finalPlacement = placement;
15
+ const compute = () => {
16
+ const a = anchorEl.getBoundingClientRect(), c = contentEl.getBoundingClientRect();
17
+ const vw = window.innerWidth, vh = window.innerHeight;
18
+ const [side, align = 'start'] = placement.split('-');
19
+ let s = side;
20
+ if (s === 'bottom' && a.bottom + offset + c.height > vh && a.top - offset - c.height >= 0) s = 'top';
21
+ else if (s === 'top' && a.top - offset - c.height < 0 && a.bottom + offset + c.height <= vh) s = 'bottom';
22
+ else if (s === 'right' && a.right + offset + c.width > vw && a.left - offset - c.width >= 0) s = 'left';
23
+ else if (s === 'left' && a.left - offset - c.width < 0 && a.right + offset + c.width <= vw) s = 'right';
24
+ let x = 0, y = 0;
25
+ if (s === 'bottom' || s === 'top') {
26
+ y = s === 'bottom' ? a.bottom + offset : a.top - offset - c.height;
27
+ x = align === 'start' ? a.left : align === 'end' ? a.right - c.width : a.left + (a.width - c.width) / 2;
28
+ } else {
29
+ x = s === 'right' ? a.right + offset : a.left - offset - c.width;
30
+ y = align === 'start' ? a.top : align === 'end' ? a.bottom - c.height : a.top + (a.height - c.height) / 2;
31
+ }
32
+ x = Math.max(4, Math.min(vw - c.width - 4, x));
33
+ y = Math.max(4, Math.min(vh - c.height - 4, y));
34
+ contentEl.style.left = x + 'px';
35
+ contentEl.style.top = y + 'px';
36
+ finalPlacement = s + '-' + align;
37
+ };
38
+ compute();
39
+ const cb = () => compute();
40
+ window.addEventListener('resize', cb);
41
+ window.addEventListener('scroll', cb, true);
42
+ return {
43
+ update: compute,
44
+ dispose() { window.removeEventListener('resize', cb); window.removeEventListener('scroll', cb, true); },
45
+ get finalPlacement() { return finalPlacement; }
46
+ };
47
+ }
48
+
49
+ // useLongPress — fire callback after ms held without movement.
50
+ export function useLongPress(targetEl, callback, { ms = 500 } = {}) {
51
+ if (!targetEl) return () => {};
52
+ let timer = null, sx = 0, sy = 0;
53
+ const cancel = () => { if (timer) { clearTimeout(timer); timer = null; } };
54
+ const onDown = (e) => { sx = e.clientX || 0; sy = e.clientY || 0; cancel(); timer = setTimeout(() => { timer = null; callback(e); }, ms); };
55
+ const onMove = (e) => { if (!timer) return; const dx = (e.clientX || 0) - sx, dy = (e.clientY || 0) - sy; if (dx * dx + dy * dy > 64) cancel(); };
56
+ const evts = [['pointerdown', onDown], ['pointermove', onMove], ['pointerup', cancel], ['pointerleave', cancel], ['pointercancel', cancel]];
57
+ evts.forEach(([k, fn]) => targetEl.addEventListener(k, fn));
58
+ return () => { cancel(); evts.forEach(([k, fn]) => targetEl.removeEventListener(k, fn)); };
59
+ }
60
+
61
+ // Tooltip — single shared bubble appended to <body>.
62
+ let _tipEl = null, _tipFloat = null, _tipTimer = null, _tipId = 0;
63
+ function _hideTip() {
64
+ if (_tipTimer) { clearTimeout(_tipTimer); _tipTimer = null; }
65
+ if (_tipFloat) { _tipFloat.dispose(); _tipFloat = null; }
66
+ if (_tipEl) { _tipEl.hidden = true; _tipEl.className = 'ds-tooltip'; }
67
+ }
68
+ function _showTip(trigger, label, placement, kind) {
69
+ if (typeof document === 'undefined') return;
70
+ if (!_tipEl || !document.body.contains(_tipEl)) {
71
+ _tipEl = document.createElement('div');
72
+ _tipEl.className = 'ds-tooltip';
73
+ _tipEl.setAttribute('role', 'tooltip');
74
+ document.body.appendChild(_tipEl);
75
+ }
76
+ _tipEl.textContent = label;
77
+ _tipEl.className = 'ds-tooltip kind-' + (kind || 'default');
78
+ _tipEl.hidden = false;
79
+ _tipEl.id = 'ds-tip-' + (++_tipId);
80
+ trigger.setAttribute('aria-describedby', _tipEl.id);
81
+ if (_tipFloat) _tipFloat.dispose();
82
+ _tipFloat = useFloating(trigger, _tipEl, { placement, offset: 6 });
83
+ }
84
+
85
+ export function Tooltip({ children, label, placement = 'top', delay = 350, kind = 'default' } = {}) {
86
+ const child = kids(children)[0];
87
+ if (!child || !label) return child || null;
88
+ const refFn = (el) => {
89
+ if (!el || el._dsTip) return;
90
+ el._dsTip = true;
91
+ const schedule = () => { if (_tipTimer) clearTimeout(_tipTimer); _tipTimer = setTimeout(() => _showTip(el, label, placement, kind), delay); };
92
+ const show = () => _showTip(el, label, placement, kind);
93
+ el.addEventListener('pointerenter', schedule);
94
+ el.addEventListener('pointerleave', _hideTip);
95
+ el.addEventListener('focus', show);
96
+ el.addEventListener('blur', _hideTip);
97
+ el.addEventListener('keydown', (e) => { if (e.key === 'Escape') _hideTip(); });
98
+ window.addEventListener('scroll', _hideTip, true);
99
+ useLongPress(el, show, { ms: 500 });
100
+ };
101
+ const prevRef = child.props && child.props.ref;
102
+ const wrap = (el) => { refFn(el); if (typeof prevRef === 'function') prevRef(el); };
103
+ return webjsx.createElement(child.type, { ...(child.props || {}), ref: wrap }, ...(child.children || []));
104
+ }
105
+
106
+ // Popover — controlled, portaled to <body>.
107
+ const _popovers = new WeakMap();
108
+ export function Popover({ open, anchorEl, onClose, placement = 'bottom-start', children, ariaLabel } = {}) {
109
+ if (typeof document === 'undefined') return null;
110
+ const existing = anchorEl ? _popovers.get(anchorEl) : null;
111
+ if (!open) {
112
+ if (existing) { existing.dispose(); _popovers.delete(anchorEl); if (anchorEl && anchorEl.focus) anchorEl.focus(); }
113
+ return null;
114
+ }
115
+ if (existing || !anchorEl) return null;
116
+ const el = document.createElement('div');
117
+ el.className = 'ds-popover';
118
+ el.setAttribute('role', 'dialog');
119
+ if (ariaLabel) el.setAttribute('aria-label', ariaLabel);
120
+ el.tabIndex = -1;
121
+ document.body.appendChild(el);
122
+ webjsx.applyDiff(el, h('div', { class: 'ds-popover-inner' }, ...kids(children)));
123
+ const floating = useFloating(anchorEl, el, { placement, offset: 6 });
124
+ const close = () => onClose && onClose();
125
+ const onDown = (e) => { if (el.contains(e.target) || anchorEl.contains(e.target)) return; close(); };
126
+ const onKey = (e) => {
127
+ if (e.key === 'Escape') { e.preventDefault(); close(); return; }
128
+ if (e.key !== 'Tab') return;
129
+ const nodes = el.querySelectorAll(FOCUSABLE_SEL); if (!nodes.length) { e.preventDefault(); return; }
130
+ const first = nodes[0], last = nodes[nodes.length - 1], a = document.activeElement;
131
+ if (e.shiftKey && a === first) { e.preventDefault(); last.focus(); }
132
+ else if (!e.shiftKey && a === last) { e.preventDefault(); first.focus(); }
133
+ };
134
+ el.addEventListener('keydown', onKey);
135
+ document.addEventListener('mousedown', onDown, true);
136
+ queueMicrotask(() => { const f = el.querySelector(FOCUSABLE_SEL); (f || el).focus(); });
137
+ _popovers.set(anchorEl, { dispose() {
138
+ document.removeEventListener('mousedown', onDown, true);
139
+ floating.dispose();
140
+ if (el.parentNode) el.parentNode.removeChild(el);
141
+ }});
142
+ return null;
143
+ }
144
+
145
+ // Dropdown — button trigger + portaled menu.
146
+ export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-start', ariaLabel } = {}) {
147
+ let triggerEl = null, open = false, menuEl = null, floating = null, typeBuf = '', typeTimer = null;
148
+ const liveBtns = () => menuEl ? [...menuEl.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')] : [];
149
+ const focusItem = (idx) => { const b = liveBtns(); if (!b.length) return; b[((idx % b.length) + b.length) % b.length].focus(); };
150
+ const onDown = (e) => { if (menuEl && menuEl.contains(e.target)) return; if (triggerEl && triggerEl.contains(e.target)) return; close(false); };
151
+ const close = (restore = true) => {
152
+ if (!open) return; open = false;
153
+ if (floating) { floating.dispose(); floating = null; }
154
+ if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl);
155
+ menuEl = null;
156
+ document.removeEventListener('mousedown', onDown, true);
157
+ if (triggerEl) triggerEl.setAttribute('aria-expanded', 'false');
158
+ if (restore && triggerEl) triggerEl.focus();
159
+ };
160
+ const select = (it) => { if (it.disabled || it.separator) return; if (onSelect) onSelect(it.id, it); close(); };
161
+ const onMenuKey = (e) => {
162
+ const b = liveBtns(), idx = b.indexOf(document.activeElement);
163
+ if (e.key === 'Escape') { e.preventDefault(); close(); }
164
+ else if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(idx + 1); }
165
+ else if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(idx - 1); }
166
+ else if (e.key === 'Home') { e.preventDefault(); focusItem(0); }
167
+ else if (e.key === 'End') { e.preventDefault(); focusItem(b.length - 1); }
168
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (idx >= 0) b[idx].click(); }
169
+ else if (e.key.length === 1 && /\S/.test(e.key)) {
170
+ typeBuf += e.key.toLowerCase();
171
+ if (typeTimer) clearTimeout(typeTimer);
172
+ typeTimer = setTimeout(() => { typeBuf = ''; }, 600);
173
+ const m = items.findIndex(it => !it.separator && !it.disabled && (it.label || '').toLowerCase().startsWith(typeBuf));
174
+ if (m >= 0) focusItem(items.slice(0, m).filter(it => !it.separator && !it.disabled).length);
175
+ }
176
+ };
177
+ const openMenu = (focusFirst = true) => {
178
+ if (open || !triggerEl) return;
179
+ open = true;
180
+ menuEl = document.createElement('div');
181
+ menuEl.className = 'ds-popover ds-dropdown-menu';
182
+ menuEl.setAttribute('role', 'menu');
183
+ if (ariaLabel) menuEl.setAttribute('aria-label', ariaLabel);
184
+ menuEl.tabIndex = -1;
185
+ const tree = h('div', { class: 'ds-dropdown-list' },
186
+ ...items.map((it, i) => it.separator
187
+ ? h('div', { key: 'sep' + i, class: 'ds-dropdown-separator', role: 'separator' })
188
+ : h('button', {
189
+ key: it.id || i, type: 'button', role: 'menuitem',
190
+ class: 'ds-dropdown-item' + (it.danger ? ' is-danger' : ''),
191
+ 'aria-disabled': it.disabled ? 'true' : 'false',
192
+ tabindex: '-1', onclick: () => select(it),
193
+ },
194
+ it.glyph != null ? h('span', { class: 'ds-dropdown-glyph', 'aria-hidden': 'true' }, it.glyph) : null,
195
+ h('span', { class: 'ds-dropdown-label' }, it.label)
196
+ )));
197
+ webjsx.applyDiff(menuEl, tree);
198
+ document.body.appendChild(menuEl);
199
+ menuEl.addEventListener('keydown', onMenuKey);
200
+ floating = useFloating(triggerEl, menuEl, { placement, offset: 4 });
201
+ document.addEventListener('mousedown', onDown, true);
202
+ triggerEl.setAttribute('aria-expanded', 'true');
203
+ if (focusFirst) queueMicrotask(() => focusItem(0));
204
+ };
205
+ const onTrigClick = () => { if (open) close(false); else openMenu(true); };
206
+ const onTrigKey = (e) => { if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!open) openMenu(true); else focusItem(0); } };
207
+ const refFn = (el) => {
208
+ if (!el || el._dsDropdown) return;
209
+ el._dsDropdown = true; triggerEl = el;
210
+ el.addEventListener('click', onTrigClick);
211
+ el.addEventListener('keydown', onTrigKey);
212
+ el.setAttribute('aria-haspopup', 'menu');
213
+ el.setAttribute('aria-expanded', 'false');
214
+ };
215
+ const child = (typeof trigger === 'function') ? trigger() : trigger;
216
+ return (child && child.type)
217
+ ? webjsx.createElement(child.type, { ...(child.props || {}), ref: refFn }, ...(child.children || []))
218
+ : h('button', { type: 'button', class: 'ds-dropdown-trigger', ref: refFn }, child || 'Menu');
219
+ }
package/src/components.js CHANGED
@@ -45,6 +45,16 @@ export {
45
45
 
46
46
  export { ThemeToggle } from './components/theme-toggle.js';
47
47
 
48
+ export {
49
+ Checkbox, Radio, RadioGroup, Toggle, Field, useFormValidation
50
+ } from './components/form-primitives.js';
51
+
52
+ export {
53
+ useDraggable, useDropTarget, Reorderable,
54
+ useKeyboardShortcut, formatShortcut, ShortcutHint,
55
+ useKeyboardShortcutHelp, ShortcutHelpDialog
56
+ } from './components/interaction-primitives.js';
57
+
48
58
  export {
49
59
  Toolbar, Tabs,
50
60
  TreeView, TreeItem,
@@ -58,6 +68,10 @@ export {
58
68
  BP_SM, BP_MD, BP_LG, BP_XL
59
69
  } from './components/editor-primitives.js';
60
70
 
71
+ export {
72
+ Tooltip, Popover, Dropdown, useLongPress, useFloating
73
+ } from './components/overlay-primitives.js';
74
+
61
75
  export {
62
76
  FREDDIE_PAGES,
63
77
  home, chat, voice, sessions, projects, agents, analytics,