pith-ui 0.1.0 → 0.1.4

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 (124) hide show
  1. package/AI.md +20 -1
  2. package/API.md +834 -272
  3. package/README.md +3 -1
  4. package/dist/composer.d.ts.map +1 -1
  5. package/dist/composer.js +48 -29
  6. package/dist/composer.js.map +1 -1
  7. package/dist/content/conversation-reader.d.ts.map +1 -1
  8. package/dist/content/conversation-reader.js +8 -1
  9. package/dist/content/conversation-reader.js.map +1 -1
  10. package/dist/content/conversation-view.d.ts +21 -4
  11. package/dist/content/conversation-view.d.ts.map +1 -1
  12. package/dist/content/conversation-view.js +72 -13
  13. package/dist/content/conversation-view.js.map +1 -1
  14. package/dist/content/image-viewer.d.ts +4 -2
  15. package/dist/content/image-viewer.d.ts.map +1 -1
  16. package/dist/content/image-viewer.js +36 -15
  17. package/dist/content/image-viewer.js.map +1 -1
  18. package/dist/content/paged-preview.d.ts +1 -1
  19. package/dist/content/paged-preview.d.ts.map +1 -1
  20. package/dist/content/paged-preview.js +10 -5
  21. package/dist/content/paged-preview.js.map +1 -1
  22. package/dist/content/views.d.ts +3 -0
  23. package/dist/content/views.d.ts.map +1 -1
  24. package/dist/content/views.js +110 -41
  25. package/dist/content/views.js.map +1 -1
  26. package/dist/content/welcome.d.ts.map +1 -1
  27. package/dist/content/welcome.js +19 -7
  28. package/dist/content/welcome.js.map +1 -1
  29. package/dist/dom.js +1 -1
  30. package/dist/dom.js.map +1 -1
  31. package/dist/files/panels.d.ts +8 -1
  32. package/dist/files/panels.d.ts.map +1 -1
  33. package/dist/files/panels.js +235 -120
  34. package/dist/files/panels.js.map +1 -1
  35. package/dist/files/systems.d.ts.map +1 -1
  36. package/dist/files/systems.js +81 -91
  37. package/dist/files/systems.js.map +1 -1
  38. package/dist/forms/actions.d.ts +28 -7
  39. package/dist/forms/actions.d.ts.map +1 -1
  40. package/dist/forms/actions.js +59 -11
  41. package/dist/forms/actions.js.map +1 -1
  42. package/dist/forms/custom-connection.d.ts +2 -1
  43. package/dist/forms/custom-connection.d.ts.map +1 -1
  44. package/dist/forms/custom-connection.js +36 -28
  45. package/dist/forms/custom-connection.js.map +1 -1
  46. package/dist/forms/dialog-frame.d.ts +2 -0
  47. package/dist/forms/dialog-frame.d.ts.map +1 -1
  48. package/dist/forms/dialog-frame.js +46 -26
  49. package/dist/forms/dialog-frame.js.map +1 -1
  50. package/dist/forms/history.d.ts +1 -1
  51. package/dist/forms/history.d.ts.map +1 -1
  52. package/dist/forms/history.js +78 -15
  53. package/dist/forms/history.js.map +1 -1
  54. package/dist/forms/identity.d.ts +12 -0
  55. package/dist/forms/identity.d.ts.map +1 -1
  56. package/dist/forms/identity.js +16 -0
  57. package/dist/forms/identity.js.map +1 -1
  58. package/dist/forms/model-editor.d.ts +8 -3
  59. package/dist/forms/model-editor.d.ts.map +1 -1
  60. package/dist/forms/model-editor.js +73 -62
  61. package/dist/forms/model-editor.js.map +1 -1
  62. package/dist/forms/panels.d.ts +103 -7
  63. package/dist/forms/panels.d.ts.map +1 -1
  64. package/dist/forms/panels.js +183 -77
  65. package/dist/forms/panels.js.map +1 -1
  66. package/dist/forms/provider-login.d.ts.map +1 -1
  67. package/dist/forms/provider-login.js +25 -39
  68. package/dist/forms/provider-login.js.map +1 -1
  69. package/dist/forms/resource-editor.d.ts +9 -5
  70. package/dist/forms/resource-editor.d.ts.map +1 -1
  71. package/dist/forms/resource-editor.js +20 -9
  72. package/dist/forms/resource-editor.js.map +1 -1
  73. package/dist/foundations.d.ts +95 -0
  74. package/dist/foundations.d.ts.map +1 -0
  75. package/dist/foundations.js +299 -0
  76. package/dist/foundations.js.map +1 -0
  77. package/dist/navigation/context-menu.d.ts +8 -1
  78. package/dist/navigation/context-menu.d.ts.map +1 -1
  79. package/dist/navigation/context-menu.js +67 -50
  80. package/dist/navigation/context-menu.js.map +1 -1
  81. package/dist/navigation/navigation.d.ts +55 -7
  82. package/dist/navigation/navigation.d.ts.map +1 -1
  83. package/dist/navigation/navigation.js +167 -18
  84. package/dist/navigation/navigation.js.map +1 -1
  85. package/dist/operation.d.ts +12 -0
  86. package/dist/operation.d.ts.map +1 -0
  87. package/dist/operation.js +49 -0
  88. package/dist/operation.js.map +1 -0
  89. package/dist/primitives.d.ts +11 -1
  90. package/dist/primitives.d.ts.map +1 -1
  91. package/dist/primitives.js +164 -301
  92. package/dist/primitives.js.map +1 -1
  93. package/dist/reconcile.d.ts +11 -0
  94. package/dist/reconcile.d.ts.map +1 -0
  95. package/dist/reconcile.js +36 -0
  96. package/dist/reconcile.js.map +1 -0
  97. package/dist/select.d.ts +14 -0
  98. package/dist/select.d.ts.map +1 -0
  99. package/dist/select.js +304 -0
  100. package/dist/select.js.map +1 -0
  101. package/dist/styles.css +452 -382
  102. package/dist/surface.d.ts +60 -0
  103. package/dist/surface.d.ts.map +1 -0
  104. package/dist/surface.js +344 -0
  105. package/dist/surface.js.map +1 -0
  106. package/dist/tasks/approval.d.ts.map +1 -1
  107. package/dist/tasks/approval.js +86 -45
  108. package/dist/tasks/approval.js.map +1 -1
  109. package/dist/tasks/permission-element.d.ts +3 -0
  110. package/dist/tasks/permission-element.d.ts.map +1 -0
  111. package/dist/tasks/permission-element.js +10 -0
  112. package/dist/tasks/permission-element.js.map +1 -0
  113. package/dist/tasks/permission.d.ts +5 -3
  114. package/dist/tasks/permission.d.ts.map +1 -1
  115. package/dist/tasks/permission.js +47 -22
  116. package/dist/tasks/permission.js.map +1 -1
  117. package/dist/tasks/runtime.d.ts.map +1 -1
  118. package/dist/tasks/runtime.js +30 -13
  119. package/dist/tasks/runtime.js.map +1 -1
  120. package/dist/workspace.d.ts +58 -4
  121. package/dist/workspace.d.ts.map +1 -1
  122. package/dist/workspace.js +202 -44
  123. package/dist/workspace.js.map +1 -1
  124. package/package.json +1 -1
@@ -1,64 +1,22 @@
1
- import { Generation, Lifetime, setControlPending } from './core.js';
2
- import { element, focusableElements, iconElement, setText, uniqueId } from './dom.js';
1
+ import { Generation, Lifetime } from './core.js';
2
+ import { element, iconElement, setText, uniqueId } from './dom.js';
3
+ import { adoptAction, adoptActionLayout, adoptFeedback, adoptNativeControl, reconcileSelectOptions } from './foundations.js';
4
+ import { installAnchoredSurface, installDialogLifecycle, installMenuNavigation } from './surface.js';
5
+ import { reconcileKeyed } from './reconcile.js';
6
+ import { createOperation } from './operation.js';
3
7
  export function createButton(options) {
4
8
  const node = element('button', 'pui-button');
5
9
  node.type = options.type ?? 'button';
6
10
  const caption = element('span');
7
11
  node.append(caption);
8
- const life = new Lifetime();
9
- const generation = new Generation();
10
- let state = options;
11
- let localPending = false;
12
- let currentIcon = '';
13
- let glyph;
12
+ const action = adoptAction(node, { caption, onAction: options.onAction, onError: options.onError });
14
13
  const update = (next) => {
15
- state = next;
16
- setText(caption, next.label);
17
- node.className = `pui-button pui-${next.intent ?? 'neutral'} pui-${next.density ?? 'normal'}`;
18
- node.disabled = !!(next.disabled || next.pending || localPending);
19
- setControlPending(node, !!(next.pending || localPending));
20
- node.setAttribute('aria-busy', String(!!(next.pending || localPending)));
21
- node.title = next.title ?? '';
22
- if (currentIcon !== (next.icon ?? '')) {
23
- glyph?.remove();
24
- currentIcon = next.icon ?? '';
25
- glyph = next.icon ? iconElement(next.icon) : undefined;
26
- if (glyph)
27
- node.prepend(glyph);
28
- }
14
+ action.update({ ...next, intent: next.intent ?? 'neutral', density: next.density ?? 'normal',
15
+ disabled: !!next.disabled, pending: !!next.pending, title: next.title ?? '', icon: next.icon ?? '',
16
+ appearance: next.appearance ?? 'standard' });
29
17
  };
30
- life.listen(node, 'click', (() => {
31
- if (!options.onAction || node.disabled)
32
- return;
33
- const seq = generation.next();
34
- localPending = true;
35
- update(state);
36
- const complete = (result) => {
37
- if (generation.current(seq) && result && !result.ok)
38
- options.onError?.(result.error);
39
- };
40
- const failed = (error) => { if (generation.current(seq))
41
- options.onError?.(error instanceof Error ? error.message : String(error)); };
42
- const finish = () => { if (generation.current(seq)) {
43
- localPending = false;
44
- update(state);
45
- } };
46
- try {
47
- const result = options.onAction();
48
- if (result && 'then' in result)
49
- void result.then(complete).catch(failed).finally(finish);
50
- else {
51
- complete(result);
52
- finish();
53
- }
54
- }
55
- catch (error) {
56
- failed(error);
57
- finish();
58
- }
59
- }));
60
18
  update(options);
61
- return { element: node, update, destroy() { generation.destroy(); life.destroy(); } };
19
+ return { element: node, update, destroy() { action.destroy(); } };
62
20
  }
63
21
  export function createIconButton(options) {
64
22
  const block = createButton(options);
@@ -116,35 +74,21 @@ export function createKeyValue(options) {
116
74
  const root = element('dl', 'pui-key-value');
117
75
  const rows = new Map();
118
76
  const update = (state) => {
119
- const keep = new Set(state.items.map(item => item.key));
120
- for (const [key, row] of rows)
121
- if (!keep.has(key)) {
122
- row.node.remove();
123
- rows.delete(key);
124
- }
125
- for (const item of state.items) {
126
- let row = rows.get(item.key);
127
- if (!row) {
128
- const label = element('dt');
129
- const value = element('dd');
130
- row = { node: element('div', '', label, value), label, value };
131
- rows.set(item.key, row);
132
- }
133
- setText(row.label, item.label);
134
- setText(row.value, item.value);
135
- root.append(row.node);
136
- }
77
+ reconcileKeyed(root, rows, state.items, { key: item => item.key, element: row => row.node,
78
+ create() { const label = element('dt'), value = element('dd'); return { node: element('div', '', label, value), label, value }; },
79
+ update(row, item) { setText(row.label, item.label); setText(row.value, item.value); }, destroy() { },
80
+ });
137
81
  };
138
82
  update(options);
139
83
  return { element: root, update, destroy() { rows.clear(); } };
140
84
  }
141
85
  export function createToolbar(options) {
142
86
  const root = element('div', 'pui-toolbar');
143
- root.setAttribute('role', 'group');
144
87
  root.append(...(options.children ?? []));
145
- const update = (state) => { root.setAttribute('aria-label', state.label); root.classList.toggle('pui-compact', state.density === 'compact'); };
88
+ const layout = adoptActionLayout(root, 'toolbar');
89
+ const update = (state) => { layout.update({ ...state, density: state.density ?? 'normal' }); };
146
90
  update(options);
147
- return { element: root, update, destroy() { } };
91
+ return { element: root, update, destroy() { layout.destroy(); } };
148
92
  }
149
93
  export function createScrollArea(options) {
150
94
  const root = element('div', 'pui-scroll-area', options.content);
@@ -160,33 +104,19 @@ export function createField(options) {
160
104
  const description = element('div', 'pui-field-description');
161
105
  const error = element('div', 'pui-field-error');
162
106
  const control = options.control;
163
- if (!control.id)
164
- control.id = uniqueId('pui-input');
165
- label.htmlFor = control.id;
166
107
  description.id = uniqueId('pui-description');
167
108
  error.id = uniqueId('pui-error');
168
- error.setAttribute('role', 'alert');
169
109
  control.classList.add('pui-control');
170
110
  root.append(label, control, description, error);
111
+ const field = adoptNativeControl(control, { label, description, error });
171
112
  const update = (state) => {
172
- setText(label, `${state.label}${state.required ? ' *' : ''}`);
113
+ field.update({ ...state, label: `${state.label}${state.required ? ' *' : ''}`,
114
+ description: state.description ?? '', error: state.error ?? '', disabled: !!state.disabled, required: !!state.required });
173
115
  label.hidden = !state.label;
174
- setText(description, state.description);
175
- description.hidden = !state.description;
176
- setText(error, state.error);
177
- error.hidden = !state.error;
178
- control.disabled = !!state.disabled;
179
- control.required = !!state.required;
180
- control.setAttribute('aria-invalid', String(!!state.error));
181
- const describedBy = [state.description ? description.id : '', state.error ? error.id : ''].filter(Boolean).join(' ');
182
- if (describedBy)
183
- control.setAttribute('aria-describedby', describedBy);
184
- else
185
- control.removeAttribute('aria-describedby');
186
116
  root.classList.toggle('pui-invalid', !!state.error);
187
117
  };
188
118
  update(options);
189
- return { element: root, control, update, destroy() { } };
119
+ return { element: root, control, update, destroy() { field.destroy(); } };
190
120
  }
191
121
  function textControl(options, type) {
192
122
  const control = type === 'textarea' ? element('textarea') : element('input');
@@ -201,22 +131,19 @@ function textControl(options, type) {
201
131
  }
202
132
  if (options.autocomplete)
203
133
  control.setAttribute('autocomplete', options.autocomplete);
134
+ control.defaultValue = options.value ?? '';
204
135
  control.value = options.value ?? '';
205
136
  const field = createField({ ...options, control });
206
- const life = new Lifetime();
207
- life.listen(control, 'input', (event => options.onInput?.(control.value, event)));
208
- life.listen(control, 'change', (event => options.onChange?.(control.value, event)));
137
+ const input = adoptNativeControl(control, { onInput: options.onInput, onChange: options.onChange });
209
138
  const update = (state) => {
210
139
  field.update(state);
211
- control.placeholder = state.placeholder ?? '';
212
- control.readOnly = !!state.readOnly;
140
+ input.update({ placeholder: state.placeholder ?? '', readOnly: !!state.readOnly });
213
141
  control.classList.toggle('pui-compact', state.density === 'compact');
214
142
  };
215
143
  update(options);
216
- return { element: field.element, control, update, getValue: () => control.value,
217
- reset(value) { if (control.value !== value)
218
- control.value = value; }, focus() { control.focus(); },
219
- destroy() { life.destroy(); field.destroy(); } };
144
+ return { element: field.element, control, update, getValue: input.getValue,
145
+ reset: input.reset, focus: input.focus,
146
+ destroy() { input.destroy(); field.destroy(); } };
220
147
  }
221
148
  export const createTextInput = (options) => textControl(options, 'text');
222
149
  export const createSearchInput = (options) => textControl(options, 'search');
@@ -238,6 +165,7 @@ export function createSecretInput(options) {
238
165
  block.update = state => { update(state); toggle.update({ label: control.type === 'password' ? 'Show' : 'Hide', disabled: state.disabled }); };
239
166
  const destroy = block.destroy;
240
167
  block.destroy = () => { destroy(); toggle.destroy(); };
168
+ block.update(options);
241
169
  return block;
242
170
  }
243
171
  export function createSelect(options) {
@@ -246,67 +174,79 @@ export function createSelect(options) {
246
174
  if (options.size)
247
175
  control.size = options.size;
248
176
  const field = createField({ ...options, control });
249
- const life = new Lifetime();
177
+ let disposed = false;
178
+ const getValues = () => [...control.selectedOptions].map(option => option.value);
179
+ const input = adoptNativeControl(control, { onChange: () => { options.onChange?.(control.value); options.onValuesChange?.(getValues()); } });
180
+ const resetValues = (values) => {
181
+ if (disposed)
182
+ return;
183
+ if (!control.multiple) {
184
+ input.reset(values[0] ?? '');
185
+ return;
186
+ }
187
+ const selected = new Set(values);
188
+ for (const option of control.options)
189
+ option.selected = selected.has(option.value);
190
+ input.update({});
191
+ };
250
192
  let signature = '';
251
193
  const update = (state) => {
194
+ if (disposed)
195
+ return;
252
196
  field.update(state);
253
197
  control.classList.toggle('pui-compact', state.density === 'compact');
254
198
  const next = JSON.stringify(state.options);
255
199
  if (next !== signature) {
256
200
  signature = next;
257
- const selected = new Set([...control.selectedOptions].map(option => option.value));
258
- const nodes = state.options.map(item => {
259
- const option = element('option', '', item.label);
260
- option.value = item.value;
261
- option.disabled = !!item.disabled;
262
- option.selected = selected.has(item.value);
263
- return option;
264
- });
265
- control.replaceChildren(...nodes);
201
+ reconcileSelectOptions(control, state.options);
202
+ if (!control.multiple && control.selectedIndex < 0)
203
+ control.selectedIndex = [...control.options].findIndex(option => !option.disabled);
266
204
  }
205
+ input.update({});
267
206
  };
268
- life.listen(control, 'change', (() => options.onChange?.(control.value)));
269
207
  update(options);
270
- if (options.value != null)
271
- control.value = options.value;
272
- return { element: field.element, control, update, getValue: () => control.value,
273
- reset(value) { control.value = value; }, focus() { control.focus(); }, destroy() { life.destroy(); } };
208
+ const initial = options.values ?? (options.value == null ? undefined : [options.value]);
209
+ if (initial) {
210
+ const selected = new Set(control.multiple ? initial : initial.slice(0, 1));
211
+ for (const option of control.options)
212
+ option.defaultSelected = selected.has(option.value);
213
+ resetValues(initial);
214
+ }
215
+ return { element: field.element, control, update, getValue: input.getValue, getValues,
216
+ reset: input.reset, resetValues, focus: input.focus, destroy() { if (disposed)
217
+ return; disposed = true; input.destroy(); field.destroy(); } };
274
218
  }
275
219
  export function createCheckbox(options) {
276
220
  const control = element('input');
277
221
  control.type = 'checkbox';
222
+ control.defaultChecked = !!options.checked;
278
223
  control.checked = !!options.checked;
279
224
  const caption = element('span');
280
225
  const description = element('small');
281
226
  const root = element('label', 'pui-checkbox', control, element('span', '', caption, description));
282
- const life = new Lifetime();
227
+ const input = adoptNativeControl(control, { onChange: () => options.onChange?.(control.checked) });
283
228
  const update = (state) => {
284
229
  setText(caption, state.label);
285
230
  setText(description, state.description);
286
231
  description.hidden = !state.description;
287
- control.disabled = !!state.disabled;
232
+ input.update({ disabled: !!state.disabled });
288
233
  };
289
- life.listen(control, 'change', (() => options.onChange?.(control.checked)));
290
234
  update(options);
291
- return { element: root, control, update, getValue: () => control.checked,
292
- reset(value) { control.checked = value; }, focus() { control.focus(); }, destroy() { life.destroy(); } };
235
+ return { element: root, control, update, getValue: input.getChecked,
236
+ reset: input.reset, focus: input.focus, destroy() { input.destroy(); } };
293
237
  }
294
238
  export function createNotice(state) {
295
239
  const root = element('div', 'pui-notice');
296
- const update = (next) => {
297
- root.className = `pui-notice pui-notice-${next.tone ?? 'info'}`;
298
- root.setAttribute('role', next.tone === 'error' || next.tone === 'warning' ? 'alert' : 'status');
299
- root.hidden = !next.message;
300
- setText(root, next.message);
301
- };
302
- update(state);
303
- return { element: root, update, destroy() { } };
240
+ const notice = adoptFeedback(root, { presentation: 'notice' });
241
+ notice.update(state);
242
+ return notice;
304
243
  }
305
244
  export function createStatus(state) {
306
245
  const root = element('span', 'pui-status');
307
- const update = (next) => { root.className = `pui-status pui-status-${next.tone ?? 'neutral'}`; setText(root, next.label); };
246
+ const status = adoptFeedback(root, { presentation: 'status', autoHide: false });
247
+ const update = (next) => { status.update({ message: next.label, tone: next.tone }); };
308
248
  update(state);
309
- return { element: root, update, destroy() { } };
249
+ return { element: root, update, destroy() { status.destroy(); } };
310
250
  }
311
251
  export function createProgress(state) {
312
252
  const control = element('progress');
@@ -336,9 +276,10 @@ export function createFormSection(options) {
336
276
  export function createActions(options = {}) {
337
277
  const root = element('div', 'pui-actions');
338
278
  root.append(...(options.children ?? []));
339
- const update = (state) => { root.dataset.align = state.align ?? 'end'; };
279
+ const layout = adoptActionLayout(root, 'actions');
280
+ const update = (state) => { layout.update({ align: state.align ?? 'end' }); };
340
281
  update(options);
341
- return { element: root, update, destroy() { } };
282
+ return { element: root, update, destroy() { layout.destroy(); } };
342
283
  }
343
284
  export function createDisclosure(options) {
344
285
  const root = element('details', 'pui-disclosure');
@@ -424,9 +365,7 @@ export function createDialog(options) {
424
365
  root.replaceChildren(heading, body);
425
366
  if (options.actions)
426
367
  root.append(options.actions);
427
- const life = new Lifetime();
428
368
  let state = options;
429
- let previousFocus = null;
430
369
  let disposed = false;
431
370
  const update = (next) => {
432
371
  state = next;
@@ -441,39 +380,14 @@ export function createDialog(options) {
441
380
  root.removeAttribute('aria-describedby');
442
381
  close.update({ label: `Close ${next.title}`, icon: 'close', disabled: next.closeDisabled });
443
382
  };
444
- life.listen(root, 'cancel', (event => { if (state.closeDisabled)
445
- event.preventDefault(); }));
446
- life.listen(root, 'click', (event => {
447
- if (!options.closeOnBackdrop || state.closeDisabled || event.target !== root)
448
- return;
449
- const pointer = event;
450
- const rect = root.getBoundingClientRect();
451
- if (pointer.clientX < rect.left || pointer.clientX > rect.right || pointer.clientY < rect.top || pointer.clientY > rect.bottom)
452
- root.close();
453
- }));
454
- life.listen(root, 'close', (() => {
455
- // Native close events are queued; an old close must not affect a reopened dialog.
456
- if (root.open)
457
- return;
458
- options.onClose?.();
459
- if (previousFocus?.isConnected)
460
- previousFocus.focus({ preventScroll: true });
461
- previousFocus = null;
462
- }));
383
+ const surface = installDialogLifecycle(root, { closeOnBackdrop: options.closeOnBackdrop ?? !!options.dialog,
384
+ canClose: () => !state.closeDisabled, onClose: options.onClose });
463
385
  update(options);
464
386
  return { element: root, body, update,
465
- open() {
466
- if (disposed || root.open)
467
- return;
468
- previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
469
- if (!root.isConnected)
470
- document.body.append(root);
471
- root.showModal();
472
- }, close() { if (root.open)
473
- root.close(); },
387
+ open() { if (!disposed)
388
+ surface.open(); }, close() { surface.close(); },
474
389
  destroy() { if (disposed)
475
- return; disposed = true; if (root.open)
476
- root.close(); life.destroy(); close.destroy(); root.remove(); } };
390
+ return; disposed = true; surface.close(); surface.destroy(); close.destroy(); root.remove(); } };
477
391
  }
478
392
  export function createTextEditDialog(options = {}) {
479
393
  // The editor has one form-level error, so it does not need the general field's
@@ -483,10 +397,12 @@ export function createTextEditDialog(options = {}) {
483
397
  control.id = options.ids?.control ?? uniqueId('pui-input');
484
398
  const label = element('label', 'pui-field-label', options.label ?? 'Message');
485
399
  label.htmlFor = control.id;
400
+ const input = adoptNativeControl(control, { label, onInput: value => options.onInput?.(value) });
486
401
  const field = element('div', 'pui-field', label, control);
487
402
  const note = element('p', 'pui-muted');
488
403
  const error = createNotice({ message: '', tone: 'error' });
489
- const cancel = createButton({ label: 'Cancel', onAction() { dialog.close(); } });
404
+ const cancel = createButton({ label: 'Cancel', onAction() { if (!state.closeDisabled)
405
+ dialog.close(); } });
490
406
  const submit = createButton({ label: options.submitLabel ?? 'Save', intent: 'primary', type: 'submit' });
491
407
  const actions = createActions({ children: [cancel.element, submit.element] });
492
408
  const form = element('form', 'pui-text-edit', field, note, error.element, actions.element);
@@ -499,11 +415,10 @@ export function createTextEditDialog(options = {}) {
499
415
  error.element.id = ids.error;
500
416
  if (ids?.cancel)
501
417
  cancel.element.id = ids.cancel;
502
- const generation = new Generation();
503
418
  const life = new Lifetime();
504
419
  let pending = false;
505
420
  let state = { title: options.title ?? 'Edit message', ...options };
506
- const dialog = createDialog({ ...state, dialog: options.dialog, content: form, onClose() { generation.invalidate(); pending = false; update(state); options.onClose?.(); } });
421
+ const dialog = createDialog({ ...state, dialog: options.dialog, content: form, onClose() { operation.invalidate(); options.onClose?.(); } });
507
422
  if (ids?.title) {
508
423
  dialog.element.querySelector('h2').id = ids.title;
509
424
  dialog.element.setAttribute('aria-labelledby', ids.title);
@@ -513,83 +428,50 @@ export function createTextEditDialog(options = {}) {
513
428
  dialog.update(next);
514
429
  setText(note, next.note);
515
430
  note.hidden = !next.note;
431
+ cancel.update({ label: 'Cancel', disabled: !!next.closeDisabled });
516
432
  error.update({ message: next.error ?? '', tone: 'error' });
517
433
  submit.update({ label: next.submitLabel ?? options.submitLabel ?? 'Save', intent: 'primary', disabled: next.disabled, pending: !!(next.pending || pending) });
518
- control.disabled = !!next.inputDisabled;
434
+ input.update({ disabled: !!next.inputDisabled });
519
435
  };
520
- life.listen(control, 'input', () => options.onInput?.(control.value));
436
+ const operation = createOperation({
437
+ unavailable: () => !!(state.pending || state.disabled || submit.element.disabled || !dialog.element.open),
438
+ onPending(value) { pending = value; update(state); },
439
+ onError(error) { if (dialog.element.open)
440
+ update({ ...state, error }); },
441
+ });
521
442
  life.listen(form, 'submit', (event => {
522
443
  event.preventDefault();
523
- if (pending || state.pending || state.disabled || submit.element.disabled || !options.onSubmit)
444
+ if (!options.onSubmit)
524
445
  return;
525
- const seq = generation.next();
526
446
  const value = control.value;
527
- pending = true;
528
- update(state);
529
- void Promise.resolve().then(() => options.onSubmit(value)).then(result => {
530
- if (!generation.current(seq) || !dialog.element.open)
531
- return;
447
+ operation.run(() => options.onSubmit(value), result => { if (dialog.element.open) {
532
448
  if (result.ok)
533
449
  dialog.close();
534
- else {
535
- state = { ...state, error: result.error };
536
- }
537
- }).catch((reason) => {
538
- if (generation.current(seq))
539
- state = { ...state, error: reason instanceof Error ? reason.message : String(reason) };
540
- }).finally(() => { if (generation.current(seq)) {
541
- pending = false;
542
- update(state);
450
+ else
451
+ update({ ...state, error: result.error });
543
452
  } });
544
453
  }));
545
454
  update(state);
546
455
  return { element: dialog.element, control, submitButton: submit.element, update,
547
- open(input) {
548
- generation.invalidate();
549
- pending = false;
550
- update({ ...state, error: '', title: input.title ?? options.title ?? 'Edit message', description: input.description ?? options.description, note: input.note ?? options.note });
551
- control.value = input.value;
456
+ open(target) {
457
+ operation.invalidate();
458
+ update({ ...state, error: '', title: target.title ?? options.title ?? 'Edit message', description: target.description ?? options.description, note: target.note ?? options.note });
459
+ input.reset(target.value);
552
460
  dialog.open();
553
- control.focus();
554
- }, close() { generation.invalidate(); dialog.close(); }, getValue: () => control.value, focus() { control.focus(); },
555
- destroy() { generation.destroy(); life.destroy(); dialog.destroy(); cancel.destroy(); submit.destroy(); } };
461
+ input.focus();
462
+ }, close() { operation.invalidate(); dialog.close(); }, getValue: input.getValue, focus: input.focus,
463
+ destroy() { operation.destroy(); life.destroy(); input.destroy(); dialog.destroy(); cancel.destroy(); submit.destroy(); } };
556
464
  }
557
465
  export function createConfirmDialog(options) {
558
466
  const message = element('p', 'pui-modal-description');
559
467
  const hint = element('p', 'pui-field-hint');
560
468
  const error = createNotice({ message: '', tone: 'error' });
561
- const generation = new Generation();
562
469
  let state = options;
563
470
  let pending = false;
564
- const cancel = createButton({ label: 'Cancel', onAction() { dialog.close(); } });
471
+ const cancel = createButton({ label: 'Cancel', onAction() { if (!state.closeDisabled)
472
+ dialog.close(); } });
565
473
  const confirm = createButton({ label: options.confirmLabel ?? 'Remove', intent: 'danger', type: 'submit' });
566
474
  const life = new Lifetime();
567
- const run = async () => {
568
- if (pending || state.disabled || state.pending || confirm.element.disabled || options.unavailable?.())
569
- return;
570
- const seq = generation.next();
571
- pending = true;
572
- update(state);
573
- try {
574
- const result = await options.onConfirm();
575
- if (!generation.current(seq) || !dialog.element.open)
576
- return;
577
- if (result.ok)
578
- dialog.close();
579
- else
580
- state = { ...state, error: result.error };
581
- }
582
- catch (reason) {
583
- if (generation.current(seq))
584
- state = { ...state, error: reason instanceof Error ? reason.message : String(reason) };
585
- }
586
- finally {
587
- if (generation.current(seq)) {
588
- pending = false;
589
- update(state);
590
- }
591
- }
592
- };
593
475
  const actions = createActions({ children: [cancel.element, confirm.element] });
594
476
  const form = element('form', 'pui-confirm', message, hint, error.element, actions.element);
595
477
  if (options.ids?.form)
@@ -599,7 +481,7 @@ export function createConfirmDialog(options) {
599
481
  if (options.ids?.cancel)
600
482
  cancel.element.id = options.ids.cancel;
601
483
  const dialog = createDialog({ ...options, content: form,
602
- onClose() { generation.invalidate(); pending = false; update(state); options.onClose?.(); } });
484
+ onClose() { operation.invalidate(); options.onClose?.(); } });
603
485
  if (options.ids?.title) {
604
486
  dialog.element.querySelector('h2').id = options.ids.title;
605
487
  dialog.element.setAttribute('aria-labelledby', options.ids.title);
@@ -608,6 +490,7 @@ export function createConfirmDialog(options) {
608
490
  const update = (next) => {
609
491
  state = next;
610
492
  dialog.update(next);
493
+ cancel.update({ label: 'Cancel', disabled: !!next.closeDisabled });
611
494
  if (next.message !== currentMessage) {
612
495
  currentMessage = next.message;
613
496
  if (next.message instanceof Node)
@@ -618,20 +501,30 @@ export function createConfirmDialog(options) {
618
501
  setText(hint, next.hint);
619
502
  hint.hidden = !next.hint;
620
503
  error.update({ message: next.error ?? '', tone: 'error' });
621
- confirm.update({ label: next.confirmLabel ?? 'Remove', intent: 'danger', disabled: next.disabled, pending: !!(next.pending || pending) });
504
+ confirm.update({ label: next.confirmLabel ?? 'Remove', intent: 'danger', disabled: !!(next.disabled || options.unavailable?.()), pending: !!(next.pending || pending) });
622
505
  };
623
- life.listen(form, 'submit', (event => { event.preventDefault(); void run(); }));
506
+ const operation = createOperation({
507
+ unavailable: () => !!(state.disabled || state.pending || confirm.element.disabled || options.unavailable?.() || !dialog.element.open),
508
+ onPending(value) { pending = value; update(state); },
509
+ onError(error) { if (dialog.element.open)
510
+ update({ ...state, error }); },
511
+ });
512
+ life.listen(form, 'submit', (event => { event.preventDefault(); operation.run(options.onConfirm, result => { if (dialog.element.open) {
513
+ if (result.ok)
514
+ dialog.close();
515
+ else
516
+ update({ ...state, error: result.error });
517
+ } }); }));
624
518
  update(options);
625
- return { element: dialog.element, confirmButton: confirm.element, cancelButton: cancel.element, update, open(next) { generation.invalidate(); pending = false; update({ ...(next ?? state), error: '' }); dialog.open(); cancel.element.focus(); },
626
- close() { generation.invalidate(); dialog.close(); }, destroy() { generation.destroy(); life.destroy(); dialog.destroy(); confirm.destroy(); cancel.destroy(); } };
519
+ return { element: dialog.element, confirmButton: confirm.element, cancelButton: cancel.element, update, open(next) { operation.invalidate(); update({ ...(next ?? state), error: '' }); dialog.open(); cancel.element.focus(); },
520
+ close() { operation.invalidate(); dialog.close(); }, refreshAvailability() { update(state); }, destroy() { operation.destroy(); life.destroy(); dialog.destroy(); confirm.destroy(); cancel.destroy(); } };
627
521
  }
628
522
  export function createPopover(options) {
629
- let open = false;
630
523
  let state = options;
631
- const trigger = createButton({ label: options.label, onAction() { if (open)
632
- close();
524
+ const trigger = createButton({ label: options.label, onAction() { if (surface.isOpen())
525
+ surface.close();
633
526
  else
634
- show(); } });
527
+ surface.open(); } });
635
528
  const panel = element('div', 'pui-popover-panel', options.content);
636
529
  panel.hidden = true;
637
530
  panel.id = uniqueId('pui-popover');
@@ -640,102 +533,72 @@ export function createPopover(options) {
640
533
  trigger.element.setAttribute('aria-controls', panel.id);
641
534
  trigger.element.setAttribute('aria-expanded', 'false');
642
535
  const root = element('div', 'pui-popover', trigger.element, panel);
643
- const life = new Lifetime();
644
- function close(restoreFocus = false) {
645
- if (!open)
646
- return;
647
- open = false;
648
- panel.hidden = true;
649
- trigger.element.setAttribute('aria-expanded', 'false');
650
- options.onToggle?.(false);
651
- if (restoreFocus && trigger.element.isConnected)
652
- trigger.element.focus();
653
- }
654
- function show() {
655
- if (state.disabled || open)
656
- return;
657
- open = true;
658
- panel.hidden = false;
659
- trigger.element.setAttribute('aria-expanded', 'true');
660
- options.onToggle?.(true);
661
- }
662
- life.listen(document, 'pointerdown', (event => { if (open && event.target instanceof Node && !root.contains(event.target))
663
- close(); }));
664
- life.listen(root, 'keydown', (event => { if (open && event.key === 'Escape') {
665
- event.preventDefault();
666
- event.stopPropagation();
667
- close(true);
668
- } }));
669
- life.listen(root, 'focusout', (() => { queueMicrotask(() => { if (open && !root.contains(document.activeElement))
670
- close(); }); }));
536
+ const surface = installAnchoredSurface({ trigger: trigger.element, panel, canOpen: () => !state.disabled, onToggle: options.onToggle });
671
537
  const update = (next) => { state = next; trigger.update(next); panel.setAttribute('aria-label', next.label); if (next.disabled)
672
- close(); };
538
+ surface.close(); };
673
539
  update(options);
674
- return { element: root, trigger: trigger.element, panel, update, open: show, close, isOpen: () => open,
675
- destroy() { close(); life.destroy(); trigger.destroy(); } };
540
+ return { element: root, trigger: trigger.element, panel, update, open: surface.open, close: surface.close, isOpen: surface.isOpen,
541
+ destroy() { surface.destroy(); trigger.destroy(); } };
676
542
  }
677
543
  export function createMenu(options) {
678
544
  const content = element('div', 'pui-menu');
679
545
  content.setAttribute('role', 'menu');
546
+ content.tabIndex = -1;
680
547
  const blocks = new Map();
681
548
  let items = options.items;
682
- const popover = createPopover({ label: options.label, content, disabled: options.disabled });
549
+ const session = new Generation();
550
+ const feedback = createNotice({ message: '', tone: 'error' });
551
+ const menuBody = element('div', '', content, feedback.element);
552
+ const popover = createPopover({ label: options.label, content: menuBody, disabled: options.disabled, onToggle() { session.invalidate(); feedback.update({ message: '', tone: 'error' }); } });
683
553
  popover.trigger.setAttribute('aria-haspopup', 'menu');
684
554
  const life = new Lifetime();
685
555
  const update = (state) => {
686
556
  items = state.items;
687
557
  popover.update(state);
688
- const keep = new Set(state.items.map(item => item.id));
689
- for (const [id, block] of blocks)
690
- if (!keep.has(id)) {
691
- block.destroy();
692
- block.element.remove();
693
- blocks.delete(id);
694
- }
695
- for (const item of state.items) {
696
- let block = blocks.get(item.id);
697
- if (!block) {
698
- block = createButton({ ...item, onAction: async () => {
558
+ reconcileKeyed(content, blocks, state.items, { key: item => item.id, element: block => block.element,
559
+ create(item) {
560
+ let sequence = 0;
561
+ return createButton({ ...item, onAction: async () => {
699
562
  const current = items.find(candidate => candidate.id === item.id);
700
- if (!current || current.disabled)
563
+ if (!current || current.disabled || !popover.isOpen())
701
564
  return;
565
+ // Pending disables the action button. Keep focus inside its menu so
566
+ // focus-out dismissal cannot discard the eventual result feedback.
567
+ content.focus({ preventScroll: true });
568
+ sequence = session.next();
569
+ feedback.update({ message: '', tone: 'error' });
702
570
  const result = await current.onAction();
703
- if (!result || result.ok)
571
+ if (session.current(sequence) && blocks.has(item.id) && popover.isOpen() && (!result || result.ok))
704
572
  popover.close();
705
573
  return result;
706
- } });
707
- blocks.set(item.id, block);
708
- }
709
- block.update(item);
710
- block.element.setAttribute('role', item.checked == null ? 'menuitem' : 'menuitemcheckbox');
711
- if (item.checked != null)
712
- block.element.setAttribute('aria-checked', String(item.checked));
713
- else
714
- block.element.removeAttribute('aria-checked');
715
- content.append(block.element);
716
- }
574
+ }, onError(message) { if (session.current(sequence) && popover.isOpen()) {
575
+ feedback.update({ message, tone: 'error' });
576
+ options.onError?.(message);
577
+ } } });
578
+ },
579
+ update(block, item) {
580
+ block.update(item);
581
+ block.element.setAttribute('role', item.checked == null ? 'menuitem' : 'menuitemcheckbox');
582
+ if (item.checked != null)
583
+ block.element.setAttribute('aria-checked', String(item.checked));
584
+ else
585
+ block.element.removeAttribute('aria-checked');
586
+ }, destroy: block => block.destroy(),
587
+ });
717
588
  };
718
589
  life.listen(popover.trigger, 'keydown', (event => {
719
590
  const key = event.key;
720
591
  if (key === 'ArrowDown' || key === 'ArrowUp') {
721
592
  event.preventDefault();
722
593
  popover.open();
723
- const targets = focusableElements(content);
724
- (key === 'ArrowDown' ? targets[0] : targets.at(-1))?.focus();
594
+ navigation.focus(key === 'ArrowUp' ? 'last' : 'first');
725
595
  }
726
596
  }));
727
- life.listen(content, 'keydown', (event => {
728
- const key = event.key;
729
- if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(key))
730
- return;
731
- event.preventDefault();
732
- const targets = focusableElements(content);
733
- const index = targets.indexOf(document.activeElement);
734
- const next = key === 'Home' ? 0 : key === 'End' ? targets.length - 1 : (index + (key === 'ArrowUp' ? -1 : 1) + targets.length) % targets.length;
735
- targets[next]?.focus();
736
- }));
597
+ const navigation = installMenuNavigation(content, { trigger: popover.trigger, isOpen: popover.isOpen,
598
+ onDismiss(restoreFocus) { popover.close(); if (restoreFocus)
599
+ popover.trigger.focus(); } });
737
600
  update(options);
738
- return { ...popover, update, destroy() { life.destroy(); popover.destroy(); for (const block of blocks.values())
739
- block.destroy(); } };
601
+ return { ...popover, update, destroy() { session.destroy(); navigation.destroy(); life.destroy(); popover.destroy(); feedback.destroy(); for (const block of blocks.values())
602
+ block.destroy(); blocks.clear(); } };
740
603
  }
741
604
  //# sourceMappingURL=primitives.js.map