@openchamber/sdk 1.23.2-preview.1

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 (75) hide show
  1. package/API.md +529 -0
  2. package/DOCUMENTATION.md +81 -0
  3. package/GUEST_SERVICES.md +166 -0
  4. package/LICENSE +21 -0
  5. package/README.md +186 -0
  6. package/dist/api-version.d.ts +6 -0
  7. package/dist/api-version.js +5 -0
  8. package/dist/contract.d.ts +471 -0
  9. package/dist/contract.js +256 -0
  10. package/dist/host-version.d.ts +16 -0
  11. package/dist/host-version.js +50 -0
  12. package/dist/host.d.ts +101 -0
  13. package/dist/host.js +606 -0
  14. package/dist/index.d.ts +12 -0
  15. package/dist/index.js +7 -0
  16. package/dist/manifest.d.ts +298 -0
  17. package/dist/manifest.js +224 -0
  18. package/dist/parse.d.ts +279 -0
  19. package/dist/parse.js +379 -0
  20. package/dist/protocol.d.ts +1092 -0
  21. package/dist/protocol.js +450 -0
  22. package/dist/schemas.d.ts +4 -0
  23. package/dist/schemas.js +6 -0
  24. package/dist/ui/badge.d.ts +10 -0
  25. package/dist/ui/badge.js +26 -0
  26. package/dist/ui/banner.d.ts +13 -0
  27. package/dist/ui/banner.js +48 -0
  28. package/dist/ui/button.d.ts +14 -0
  29. package/dist/ui/button.js +49 -0
  30. package/dist/ui/checkbox.d.ts +12 -0
  31. package/dist/ui/checkbox.js +45 -0
  32. package/dist/ui/dom.d.ts +15 -0
  33. package/dist/ui/dom.js +60 -0
  34. package/dist/ui/empty.d.ts +11 -0
  35. package/dist/ui/empty.js +44 -0
  36. package/dist/ui/field.d.ts +18 -0
  37. package/dist/ui/field.js +49 -0
  38. package/dist/ui/icons.d.ts +10 -0
  39. package/dist/ui/icons.js +23 -0
  40. package/dist/ui/index.d.ts +35 -0
  41. package/dist/ui/index.js +17 -0
  42. package/dist/ui/list.d.ts +26 -0
  43. package/dist/ui/list.js +90 -0
  44. package/dist/ui/menu.d.ts +20 -0
  45. package/dist/ui/menu.js +111 -0
  46. package/dist/ui/navigation.d.ts +18 -0
  47. package/dist/ui/navigation.js +38 -0
  48. package/dist/ui/option.d.ts +16 -0
  49. package/dist/ui/option.js +35 -0
  50. package/dist/ui/popup.d.ts +5 -0
  51. package/dist/ui/popup.js +43 -0
  52. package/dist/ui/progress.d.ts +11 -0
  53. package/dist/ui/progress.js +44 -0
  54. package/dist/ui/search.d.ts +11 -0
  55. package/dist/ui/search.js +62 -0
  56. package/dist/ui/select.d.ts +22 -0
  57. package/dist/ui/select.js +164 -0
  58. package/dist/ui/separator.d.ts +6 -0
  59. package/dist/ui/separator.js +26 -0
  60. package/dist/ui/spinner.d.ts +8 -0
  61. package/dist/ui/spinner.js +29 -0
  62. package/dist/ui/style.d.ts +1 -0
  63. package/dist/ui/style.js +190 -0
  64. package/dist/ui/tabs.d.ts +15 -0
  65. package/dist/ui/tabs.js +61 -0
  66. package/dist/ui/text.d.ts +23 -0
  67. package/dist/ui/text.js +89 -0
  68. package/dist/ui/theme.d.ts +22 -0
  69. package/dist/ui/theme.js +69 -0
  70. package/dist/workspace-schemas.d.ts +136 -0
  71. package/dist/workspace-schemas.js +44 -0
  72. package/dist/workspace.d.ts +109 -0
  73. package/dist/workspace.js +4 -0
  74. package/package.json +55 -0
  75. package/scripts/bundle-guest.ts +44 -0
@@ -0,0 +1,15 @@
1
+ /** Every mount returns this. `update` merges props and repaints; `dispose` removes the node and every listener. */
2
+ export type Handle<P> = {
3
+ update: (next: Partial<P>) => void;
4
+ dispose: () => void;
5
+ };
6
+ export declare const clearNode: (node: Element) => void;
7
+ export declare const ensureStyle: (css: string) => void;
8
+ export declare const el: <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string) => HTMLElementTagNameMap[K];
9
+ export declare const button: (className: string) => HTMLButtonElement;
10
+ /** Writes text only when it changed so a repaint does not disturb selection or layout. */
11
+ export declare const setText: (node: Element, text: string | null | undefined) => void;
12
+ /** Sets or removes an attribute depending on whether a value is present. */
13
+ export declare const setAttr: (node: Element, name: string, value: string | null | undefined) => void;
14
+ /** Runs `handler` on a pointer press outside `node`. Returns the disposer. */
15
+ export declare const onOutsideClick: (node: Element, handler: () => void) => (() => void);
package/dist/ui/dom.js ADDED
@@ -0,0 +1,60 @@
1
+ const STYLE_ID = 'oc-sdk-ui-style';
2
+ export const clearNode = (node) => {
3
+ while (node.firstChild) {
4
+ node.removeChild(node.firstChild);
5
+ }
6
+ };
7
+ export const ensureStyle = (css) => {
8
+ const existing = document.getElementById(STYLE_ID);
9
+ if (existing instanceof HTMLStyleElement) {
10
+ if (existing.textContent !== css) {
11
+ existing.textContent = css;
12
+ }
13
+ return;
14
+ }
15
+ const style = document.createElement('style');
16
+ style.id = STYLE_ID;
17
+ style.textContent = css;
18
+ document.head.appendChild(style);
19
+ };
20
+ export const el = (tag, className) => {
21
+ const node = document.createElement(tag);
22
+ if (className) {
23
+ node.className = className;
24
+ }
25
+ return node;
26
+ };
27
+ export const button = (className) => {
28
+ const node = el('button', className);
29
+ node.type = 'button';
30
+ return node;
31
+ };
32
+ /** Writes text only when it changed so a repaint does not disturb selection or layout. */
33
+ export const setText = (node, text) => {
34
+ const next = text ?? '';
35
+ if (node.textContent !== next) {
36
+ node.textContent = next;
37
+ }
38
+ };
39
+ /** Sets or removes an attribute depending on whether a value is present. */
40
+ export const setAttr = (node, name, value) => {
41
+ if (value === undefined || value === null || value === '') {
42
+ node.removeAttribute(name);
43
+ }
44
+ else if (node.getAttribute(name) !== value) {
45
+ node.setAttribute(name, value);
46
+ }
47
+ };
48
+ /** Runs `handler` on a pointer press outside `node`. Returns the disposer. */
49
+ export const onOutsideClick = (node, handler) => {
50
+ const listener = (event) => {
51
+ if (event.target instanceof Node && node.contains(event.target)) {
52
+ return;
53
+ }
54
+ handler();
55
+ };
56
+ document.addEventListener('pointerdown', listener, true);
57
+ return () => {
58
+ document.removeEventListener('pointerdown', listener, true);
59
+ };
60
+ };
@@ -0,0 +1,11 @@
1
+ import { type Handle } from './dom.ts';
2
+ export type EmptyProps = {
3
+ title: string;
4
+ body?: string;
5
+ action?: {
6
+ label: string;
7
+ onClick: () => void;
8
+ };
9
+ };
10
+ export type EmptyHandle = Handle<EmptyProps>;
11
+ export declare const mountEmpty: (root: Element, initial: EmptyProps) => EmptyHandle;
@@ -0,0 +1,44 @@
1
+ import { mountButton } from "./button.js";
2
+ import { el, ensureStyle, setText } from "./dom.js";
3
+ import { UI_CSS } from "./style.js";
4
+ export const mountEmpty = (root, initial) => {
5
+ ensureStyle(UI_CSS);
6
+ let props = initial;
7
+ const shell = el('div', 'oc-sdk oc-sdk-empty');
8
+ const title = el('h2', 'oc-sdk-empty-title');
9
+ const body = el('p', 'oc-sdk-empty-body');
10
+ const slot = el('div', 'oc-sdk-empty-action');
11
+ shell.append(title, body, slot);
12
+ root.append(shell);
13
+ let action = null;
14
+ const paint = () => {
15
+ setText(title, props.title);
16
+ setText(body, props.body);
17
+ body.hidden = !props.body;
18
+ slot.hidden = !props.action;
19
+ if (!props.action) {
20
+ action?.dispose();
21
+ action = null;
22
+ return;
23
+ }
24
+ const next = { label: props.action.label, onClick: props.action.onClick };
25
+ if (action) {
26
+ action.update(next);
27
+ }
28
+ else {
29
+ action = mountButton(slot, { ...next, variant: 'outline', size: 'sm' });
30
+ }
31
+ };
32
+ paint();
33
+ return {
34
+ update: (next) => {
35
+ props = { ...props, ...next };
36
+ paint();
37
+ },
38
+ dispose: () => {
39
+ action?.dispose();
40
+ action = null;
41
+ shell.remove();
42
+ },
43
+ };
44
+ };
@@ -0,0 +1,18 @@
1
+ import { type Handle } from './dom.ts';
2
+ export type TextFieldProps = {
3
+ label?: string;
4
+ value: string;
5
+ onChange: (value: string) => void;
6
+ placeholder?: string;
7
+ password?: boolean;
8
+ multiline?: boolean;
9
+ rows?: number;
10
+ disabled?: boolean;
11
+ /** Error text. Turns the ring red and replaces `helper`. */
12
+ error?: string;
13
+ helper?: string;
14
+ /** Monospace input, for tokens and identifiers. */
15
+ mono?: boolean;
16
+ };
17
+ export type TextFieldHandle = Handle<TextFieldProps>;
18
+ export declare const mountTextField: (root: Element, initial: TextFieldProps) => TextFieldHandle;
@@ -0,0 +1,49 @@
1
+ import { el, ensureStyle, setAttr, setText } from "./dom.js";
2
+ import { UI_CSS } from "./style.js";
3
+ export const mountTextField = (root, initial) => {
4
+ ensureStyle(UI_CSS);
5
+ let props = initial;
6
+ const field = el('label', 'oc-sdk oc-sdk-field');
7
+ const caption = el('span', 'oc-sdk-field-label');
8
+ const input = props.multiline ? el('textarea', 'oc-sdk-input') : el('input', 'oc-sdk-input');
9
+ const note = el('span', 'oc-sdk-field-note');
10
+ field.append(caption, input, note);
11
+ root.append(field);
12
+ const paint = () => {
13
+ setText(caption, props.label);
14
+ caption.hidden = !props.label;
15
+ if (input instanceof HTMLInputElement) {
16
+ input.type = props.password ? 'password' : 'text';
17
+ }
18
+ else {
19
+ input.rows = props.rows ?? 3;
20
+ }
21
+ if (input.value !== props.value) {
22
+ input.value = props.value;
23
+ }
24
+ input.disabled = Boolean(props.disabled);
25
+ setAttr(input, 'placeholder', props.placeholder);
26
+ input.dataset.mono = props.mono ? 'true' : 'false';
27
+ const invalid = Boolean(props.error);
28
+ field.dataset.invalid = invalid ? 'true' : 'false';
29
+ input.setAttribute('aria-invalid', invalid ? 'true' : 'false');
30
+ const text = props.error ?? props.helper ?? '';
31
+ setText(note, text);
32
+ note.hidden = text === '';
33
+ };
34
+ const onInput = () => {
35
+ props.onChange(input.value);
36
+ };
37
+ input.addEventListener('input', onInput);
38
+ paint();
39
+ return {
40
+ update: (next) => {
41
+ props = { ...props, ...next };
42
+ paint();
43
+ },
44
+ dispose: () => {
45
+ input.removeEventListener('input', onInput);
46
+ field.remove();
47
+ },
48
+ };
49
+ };
@@ -0,0 +1,10 @@
1
+ /** Remixicon outlines. Add a shape only when a primitive paints it. */
2
+ declare const ICON_PATH: {
3
+ readonly search: "M18.031 16.617l4.283 4.282-1.415 1.415-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9 9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617zm-2.006-.742A6.977 6.977 0 0 0 18 11c0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7a6.977 6.977 0 0 0 4.875-1.975l.15-.15z";
4
+ readonly chevron: "M12 13.172l4.95-4.95 1.414 1.414L12 16 5.636 9.636 7.05 8.222z";
5
+ readonly check: "M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z";
6
+ readonly close: "M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636z";
7
+ };
8
+ type IconName = keyof typeof ICON_PATH;
9
+ export declare const icon: (name: IconName, size: number, className?: string) => SVGSVGElement;
10
+ export {};
@@ -0,0 +1,23 @@
1
+ const SVG_NS = 'http://www.w3.org/2000/svg';
2
+ /** Remixicon outlines. Add a shape only when a primitive paints it. */
3
+ const ICON_PATH = {
4
+ search: 'M18.031 16.617l4.283 4.282-1.415 1.415-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9 9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617zm-2.006-.742A6.977 6.977 0 0 0 18 11c0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7a6.977 6.977 0 0 0 4.875-1.975l.15-.15z',
5
+ chevron: 'M12 13.172l4.95-4.95 1.414 1.414L12 16 5.636 9.636 7.05 8.222z',
6
+ check: 'M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z',
7
+ close: 'M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636z',
8
+ };
9
+ export const icon = (name, size, className) => {
10
+ const node = document.createElementNS(SVG_NS, 'svg');
11
+ node.setAttribute('viewBox', '0 0 24 24');
12
+ node.setAttribute('width', String(size));
13
+ node.setAttribute('height', String(size));
14
+ node.setAttribute('aria-hidden', 'true');
15
+ node.setAttribute('fill', 'currentColor');
16
+ if (className) {
17
+ node.setAttribute('class', className);
18
+ }
19
+ const path = document.createElementNS(SVG_NS, 'path');
20
+ path.setAttribute('d', ICON_PATH[name]);
21
+ node.append(path);
22
+ return node;
23
+ };
@@ -0,0 +1,35 @@
1
+ export { applyHostReady, applyHostTheme } from './theme.ts';
2
+ export type { ThemeRoot } from './theme.ts';
3
+ export type { Handle } from './dom.ts';
4
+ export { mountButton } from './button.ts';
5
+ export type { ButtonHandle, ButtonProps, ButtonSize, ButtonVariant } from './button.ts';
6
+ export { mountTextField } from './field.ts';
7
+ export type { TextFieldHandle, TextFieldProps } from './field.ts';
8
+ export { mountSearchField } from './search.ts';
9
+ export type { SearchFieldHandle, SearchFieldProps } from './search.ts';
10
+ export { filterSelectOptions, mountSelect } from './select.ts';
11
+ export type { SelectHandle, SelectOption, SelectProps } from './select.ts';
12
+ export { mountCheckbox, mountSwitch } from './checkbox.ts';
13
+ export type { CheckboxHandle, CheckboxProps } from './checkbox.ts';
14
+ export { mountTabs } from './tabs.ts';
15
+ export type { TabItem, TabsHandle, TabsProps } from './tabs.ts';
16
+ export { mountBadge } from './badge.ts';
17
+ export type { BadgeHandle, BadgeProps, Tone } from './badge.ts';
18
+ export { mountList } from './list.ts';
19
+ export type { ListHandle, ListItem, ListProps } from './list.ts';
20
+ export { moveListSelection, navigationKey } from './navigation.ts';
21
+ export type { NavigableItem, NavigationKey } from './navigation.ts';
22
+ export { mountEmpty } from './empty.ts';
23
+ export type { EmptyHandle, EmptyProps } from './empty.ts';
24
+ export { mountSpinner } from './spinner.ts';
25
+ export type { SpinnerHandle, SpinnerProps } from './spinner.ts';
26
+ export { mountBanner } from './banner.ts';
27
+ export type { BannerHandle, BannerProps, BannerTone } from './banner.ts';
28
+ export { mountSeparator } from './separator.ts';
29
+ export type { SeparatorHandle, SeparatorProps } from './separator.ts';
30
+ export { mountProgress } from './progress.ts';
31
+ export type { ProgressHandle, ProgressProps } from './progress.ts';
32
+ export { mountMenu } from './menu.ts';
33
+ export type { MenuHandle, MenuItem, MenuProps } from './menu.ts';
34
+ export { mountText, splitTextMedia } from './text.ts';
35
+ export type { TextHandle, TextPart, TextProps } from './text.ts';
@@ -0,0 +1,17 @@
1
+ export { applyHostReady, applyHostTheme } from "./theme.js";
2
+ export { mountButton } from "./button.js";
3
+ export { mountTextField } from "./field.js";
4
+ export { mountSearchField } from "./search.js";
5
+ export { filterSelectOptions, mountSelect } from "./select.js";
6
+ export { mountCheckbox, mountSwitch } from "./checkbox.js";
7
+ export { mountTabs } from "./tabs.js";
8
+ export { mountBadge } from "./badge.js";
9
+ export { mountList } from "./list.js";
10
+ export { moveListSelection, navigationKey } from "./navigation.js";
11
+ export { mountEmpty } from "./empty.js";
12
+ export { mountSpinner } from "./spinner.js";
13
+ export { mountBanner } from "./banner.js";
14
+ export { mountSeparator } from "./separator.js";
15
+ export { mountProgress } from "./progress.js";
16
+ export { mountMenu } from "./menu.js";
17
+ export { mountText, splitTextMedia } from "./text.js";
@@ -0,0 +1,26 @@
1
+ import { type Tone } from './badge.ts';
2
+ import { type Handle } from './dom.ts';
3
+ export type ListItem = {
4
+ id: string;
5
+ title: string;
6
+ /** Micro muted line under the title. */
7
+ subtitle?: string;
8
+ /** Fixed-width mono text before the title, like an issue key. */
9
+ leading?: string;
10
+ /** Muted tabular text at the right, like a date or count. */
11
+ meta?: string;
12
+ badge?: {
13
+ label: string;
14
+ tone?: Tone;
15
+ };
16
+ disabled?: boolean;
17
+ };
18
+ export type ListProps = {
19
+ items: ListItem[];
20
+ selectedId?: string | null;
21
+ onSelect: (id: string) => void;
22
+ emptyText?: string;
23
+ ariaLabel?: string;
24
+ };
25
+ export type ListHandle = Handle<ListProps>;
26
+ export declare const mountList: (root: Element, initial: ListProps) => ListHandle;
@@ -0,0 +1,90 @@
1
+ import { applyTone } from "./badge.js";
2
+ import { button, clearNode, el, ensureStyle, setAttr } from "./dom.js";
3
+ import { moveListSelection, navigationKey } from "./navigation.js";
4
+ import { UI_CSS } from "./style.js";
5
+ let listCount = 0;
6
+ export const mountList = (root, initial) => {
7
+ ensureStyle(UI_CSS);
8
+ let props = initial;
9
+ const uid = `oc-sdk-list-${listCount += 1}`;
10
+ const list = el('div', 'oc-sdk oc-sdk-list');
11
+ list.setAttribute('role', 'listbox');
12
+ list.tabIndex = 0;
13
+ root.append(list);
14
+ let activeId = null;
15
+ const rowId = (id) => `${uid}-${id}`;
16
+ const setActive = (id) => {
17
+ activeId = id;
18
+ for (const row of Array.from(list.children)) {
19
+ if (row instanceof HTMLElement) {
20
+ row.dataset.active = row.id === rowId(id ?? '') ? 'true' : 'false';
21
+ }
22
+ }
23
+ setAttr(list, 'aria-activedescendant', id ? rowId(id) : null);
24
+ list.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' });
25
+ };
26
+ const span = (className, text) => {
27
+ const node = el('span', className);
28
+ node.textContent = text;
29
+ return node;
30
+ };
31
+ const paint = () => {
32
+ clearNode(list);
33
+ setAttr(list, 'aria-label', props.ariaLabel);
34
+ if (props.items.length === 0) {
35
+ list.append(span('oc-sdk-list-empty', props.emptyText ?? 'Nothing here'));
36
+ setActive(null);
37
+ return;
38
+ }
39
+ for (const item of props.items) {
40
+ const row = button('oc-sdk-row');
41
+ row.id = rowId(item.id);
42
+ row.setAttribute('role', 'option');
43
+ row.setAttribute('aria-selected', item.id === props.selectedId ? 'true' : 'false');
44
+ row.disabled = Boolean(item.disabled);
45
+ row.tabIndex = -1;
46
+ if (item.leading)
47
+ row.append(span('oc-sdk-row-lead', item.leading));
48
+ const main = el('span', 'oc-sdk-row-main');
49
+ main.append(span('oc-sdk-row-title', item.title));
50
+ if (item.subtitle)
51
+ main.append(span('oc-sdk-row-sub', item.subtitle));
52
+ row.append(main);
53
+ if (item.badge) {
54
+ const badge = span('oc-sdk-badge', item.badge.label);
55
+ applyTone(badge, item.badge.tone);
56
+ row.append(badge);
57
+ }
58
+ if (item.meta)
59
+ row.append(span('oc-sdk-row-meta', item.meta));
60
+ row.addEventListener('click', () => props.onSelect(item.id));
61
+ list.append(row);
62
+ }
63
+ const stillThere = props.items.some((item) => item.id === activeId && !item.disabled);
64
+ setActive(stillThere ? activeId : props.selectedId ?? null);
65
+ };
66
+ const onKeyDown = (event) => {
67
+ const step = navigationKey(event);
68
+ if (step) {
69
+ event.preventDefault();
70
+ setActive(moveListSelection(props.items, activeId, step));
71
+ return;
72
+ }
73
+ if ((event.key === 'Enter' || event.key === ' ') && activeId) {
74
+ event.preventDefault();
75
+ props.onSelect(activeId);
76
+ }
77
+ };
78
+ list.addEventListener('keydown', onKeyDown);
79
+ paint();
80
+ return {
81
+ update: (next) => {
82
+ props = { ...props, ...next };
83
+ paint();
84
+ },
85
+ dispose: () => {
86
+ list.removeEventListener('keydown', onKeyDown);
87
+ list.remove();
88
+ },
89
+ };
90
+ };
@@ -0,0 +1,20 @@
1
+ import { type ButtonSize, type ButtonVariant } from './button.ts';
2
+ import { type Handle } from './dom.ts';
3
+ export type MenuItem = {
4
+ id: string;
5
+ label: string;
6
+ destructive?: boolean;
7
+ disabled?: boolean;
8
+ } | {
9
+ separator: true;
10
+ };
11
+ export type MenuProps = {
12
+ /** Trigger button label. */
13
+ label: string;
14
+ variant?: ButtonVariant;
15
+ size?: ButtonSize;
16
+ items: MenuItem[];
17
+ onSelect: (id: string) => void;
18
+ };
19
+ export type MenuHandle = Handle<MenuProps>;
20
+ export declare const mountMenu: (root: Element, initial: MenuProps) => MenuHandle;
@@ -0,0 +1,111 @@
1
+ import { mountButton } from "./button.js";
2
+ import { clearNode, el, ensureStyle } from "./dom.js";
3
+ import { moveListSelection, navigationKey } from "./navigation.js";
4
+ import { createOption, highlightOption } from "./option.js";
5
+ import { openPopup } from "./popup.js";
6
+ import { UI_CSS } from "./style.js";
7
+ const actions = (items) => (items.filter((item) => !('separator' in item)));
8
+ let menuCount = 0;
9
+ export const mountMenu = (root, initial) => {
10
+ ensureStyle(UI_CSS);
11
+ let props = initial;
12
+ const uid = `oc-sdk-menu-${menuCount += 1}`;
13
+ const wrap = el('div', 'oc-sdk oc-sdk-menu');
14
+ root.append(wrap);
15
+ const popup = el('div', 'oc-sdk oc-sdk-popup');
16
+ popup.setAttribute('role', 'menu');
17
+ popup.tabIndex = -1;
18
+ let closePopup = null;
19
+ let activeId = null;
20
+ const trigger = mountButton(wrap, {
21
+ label: props.label,
22
+ variant: props.variant,
23
+ size: props.size,
24
+ onClick: () => {
25
+ if (closePopup)
26
+ close();
27
+ else
28
+ open();
29
+ },
30
+ });
31
+ const triggerNode = wrap.querySelector('button');
32
+ triggerNode?.setAttribute('aria-haspopup', 'menu');
33
+ const setActive = (id) => {
34
+ activeId = id;
35
+ highlightOption(popup, popup, uid, id);
36
+ };
37
+ const paintItems = () => {
38
+ clearNode(popup);
39
+ for (const item of props.items) {
40
+ if ('separator' in item) {
41
+ const line = el('div', 'oc-sdk-separator');
42
+ line.dataset.labeled = 'false';
43
+ popup.append(line);
44
+ continue;
45
+ }
46
+ popup.append(createOption(uid, 'menuitem', item, {
47
+ hover: () => setActive(item.id),
48
+ pick: () => pick(item.id),
49
+ }));
50
+ }
51
+ setActive(actions(props.items).some((item) => item.id === activeId) ? activeId : null);
52
+ };
53
+ const close = () => {
54
+ closePopup?.();
55
+ closePopup = null;
56
+ activeId = null;
57
+ triggerNode?.setAttribute('aria-expanded', 'false');
58
+ };
59
+ const open = () => {
60
+ if (closePopup || !triggerNode) {
61
+ return;
62
+ }
63
+ paintItems();
64
+ closePopup = openPopup(wrap, triggerNode, popup, close);
65
+ triggerNode.setAttribute('aria-expanded', 'true');
66
+ popup.focus();
67
+ };
68
+ const pick = (id) => {
69
+ close();
70
+ triggerNode?.focus();
71
+ props.onSelect(id);
72
+ };
73
+ const onPopupKey = (event) => {
74
+ const step = navigationKey(event);
75
+ if (step) {
76
+ event.preventDefault();
77
+ setActive(moveListSelection(actions(props.items), activeId, step));
78
+ }
79
+ else if (event.key === 'Enter' || event.key === ' ') {
80
+ event.preventDefault();
81
+ if (activeId)
82
+ pick(activeId);
83
+ }
84
+ else if (event.key === 'Escape') {
85
+ event.preventDefault();
86
+ close();
87
+ triggerNode?.focus();
88
+ }
89
+ };
90
+ const onFocusOut = (event) => {
91
+ if (closePopup && !(event.relatedTarget instanceof Node && wrap.contains(event.relatedTarget)))
92
+ close();
93
+ };
94
+ popup.addEventListener('keydown', onPopupKey);
95
+ wrap.addEventListener('focusout', onFocusOut);
96
+ return {
97
+ update: (next) => {
98
+ props = { ...props, ...next };
99
+ trigger.update({ label: props.label, variant: props.variant, size: props.size });
100
+ if (closePopup)
101
+ paintItems();
102
+ },
103
+ dispose: () => {
104
+ close();
105
+ popup.removeEventListener('keydown', onPopupKey);
106
+ wrap.removeEventListener('focusout', onFocusOut);
107
+ trigger.dispose();
108
+ wrap.remove();
109
+ },
110
+ };
111
+ };
@@ -0,0 +1,18 @@
1
+ /** Pure keyboard navigation shared by list, select, menu, and tabs. No DOM here. */
2
+ export type NavigableItem = {
3
+ id: string;
4
+ disabled?: boolean;
5
+ };
6
+ export type NavigationKey = 'next' | 'previous' | 'first' | 'last';
7
+ type KeyLike = {
8
+ key: string;
9
+ ctrlKey: boolean;
10
+ };
11
+ /** Maps a keyboard event to a navigation step. Ctrl+N / Ctrl+P mirror the arrow keys, as in the host. */
12
+ export declare const navigationKey: (event: KeyLike, axis?: "vertical" | "horizontal") => NavigationKey | null;
13
+ /**
14
+ * Returns the id that `key` lands on from `currentId`, skipping disabled items and
15
+ * stopping at the edges. Returns `null` when nothing is enabled.
16
+ */
17
+ export declare const moveListSelection: (items: readonly NavigableItem[], currentId: string | null, key: NavigationKey) => string | null;
18
+ export {};
@@ -0,0 +1,38 @@
1
+ /** Pure keyboard navigation shared by list, select, menu, and tabs. No DOM here. */
2
+ /** Maps a keyboard event to a navigation step. Ctrl+N / Ctrl+P mirror the arrow keys, as in the host. */
3
+ export const navigationKey = (event, axis = 'vertical') => {
4
+ const [next, previous] = axis === 'vertical' ? ['ArrowDown', 'ArrowUp'] : ['ArrowRight', 'ArrowLeft'];
5
+ if (event.key === next || (event.ctrlKey && event.key.toLowerCase() === 'n'))
6
+ return 'next';
7
+ if (event.key === previous || (event.ctrlKey && event.key.toLowerCase() === 'p'))
8
+ return 'previous';
9
+ if (event.key === 'Home')
10
+ return 'first';
11
+ if (event.key === 'End')
12
+ return 'last';
13
+ return null;
14
+ };
15
+ /**
16
+ * Returns the id that `key` lands on from `currentId`, skipping disabled items and
17
+ * stopping at the edges. Returns `null` when nothing is enabled.
18
+ */
19
+ export const moveListSelection = (items, currentId, key) => {
20
+ const enabled = items.filter((item) => !item.disabled);
21
+ if (enabled.length === 0) {
22
+ return null;
23
+ }
24
+ const first = enabled[0];
25
+ const last = enabled[enabled.length - 1];
26
+ if (key === 'first' || !first || !last) {
27
+ return first?.id ?? null;
28
+ }
29
+ if (key === 'last') {
30
+ return last.id;
31
+ }
32
+ const index = enabled.findIndex((item) => item.id === currentId);
33
+ if (index === -1) {
34
+ return key === 'next' ? first.id : last.id;
35
+ }
36
+ const target = enabled[Math.min(enabled.length - 1, Math.max(0, index + (key === 'next' ? 1 : -1)))];
37
+ return target?.id ?? null;
38
+ };
@@ -0,0 +1,16 @@
1
+ /** One popup row shared by select (`role="option"`) and menu (`role="menuitem"`). */
2
+ type OptionSpec = {
3
+ id: string;
4
+ label: string;
5
+ hint?: string;
6
+ selected?: boolean;
7
+ destructive?: boolean;
8
+ disabled?: boolean;
9
+ };
10
+ export declare const createOption: (uid: string, role: "option" | "menuitem", spec: OptionSpec, on: {
11
+ hover: () => void;
12
+ pick: () => void;
13
+ }) => HTMLButtonElement;
14
+ /** Moves the `data-active` highlight and `aria-activedescendant`; scrolls the row into view. */
15
+ export declare const highlightOption: (container: HTMLElement, focusOwner: HTMLElement, uid: string, id: string | null) => void;
16
+ export {};
@@ -0,0 +1,35 @@
1
+ import { button, el, setAttr } from "./dom.js";
2
+ const optionId = (uid, id) => `${uid}-${id ?? ''}`;
3
+ export const createOption = (uid, role, spec, on) => {
4
+ const node = button('oc-sdk-option');
5
+ node.id = optionId(uid, spec.id);
6
+ node.setAttribute('role', role);
7
+ node.tabIndex = -1;
8
+ node.disabled = Boolean(spec.disabled);
9
+ if (role === 'option') {
10
+ node.setAttribute('aria-selected', spec.selected ? 'true' : 'false');
11
+ }
12
+ node.dataset.destructive = spec.destructive ? 'true' : 'false';
13
+ const label = el('span', 'oc-sdk-option-label');
14
+ label.textContent = spec.label;
15
+ node.append(label);
16
+ if (spec.hint) {
17
+ const hint = el('span', 'oc-sdk-option-hint');
18
+ hint.textContent = spec.hint;
19
+ node.append(hint);
20
+ }
21
+ node.addEventListener('pointerenter', on.hover);
22
+ node.addEventListener('click', on.pick);
23
+ return node;
24
+ };
25
+ /** Moves the `data-active` highlight and `aria-activedescendant`; scrolls the row into view. */
26
+ export const highlightOption = (container, focusOwner, uid, id) => {
27
+ const target = optionId(uid, id);
28
+ for (const child of Array.from(container.children)) {
29
+ if (child instanceof HTMLElement && child.classList.contains('oc-sdk-option')) {
30
+ child.dataset.active = child.id === target ? 'true' : 'false';
31
+ }
32
+ }
33
+ setAttr(focusOwner, 'aria-activedescendant', id ? target : null);
34
+ container.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' });
35
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Attaches `popup` next to `trigger` and wires the ways it closes: outside press,
3
+ * viewport resize, or scroll. Returns the disposer; the caller decides what `close` does.
4
+ */
5
+ export declare const openPopup: (host: Element, trigger: HTMLElement, popup: HTMLElement, close: () => void) => (() => void);