arcane-os 0.16.2 → 0.17.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ - Add neutral `modal.configure({dismissible})` configuration, defaulting to
6
+ `true`. Setting it to `false` hides the close button and prevents Escape,
7
+ backdrop and close-button dismissal while preserving the owner's existing
8
+ programmatic close/destroy lifecycle and running-task behavior.
9
+ - Retain the configuration through population, open/close cycles and task
10
+ completion. Keep focus within useful content when hiding a focused close
11
+ control, and preserve default modal behavior for existing consumers.
12
+
13
+ ## 0.16.3
14
+
15
+ - Remember browser-reported PWA installation in app-scoped DBOPFS and suppress
16
+ the SDK's floating and inline installation controls on later visits. Capture
17
+ native events immediately while restoring the saved flag; keep rendering,
18
+ component loading and worker registration independent.
19
+ - Preserve confirmed installation across late reads, prompt results and display
20
+ changes. Save installed-app launches as well as `appinstalled`, without
21
+ mistaking prompt acceptance or ordinary fullscreen for installation. Expose
22
+ initial storage readiness and complete persistence errors through the shared
23
+ install owner, and retain confirmed-install writes through owner disposal.
24
+
3
25
  ## 0.16.2
4
26
 
5
27
  - Scope explicit application selection and workspace resolution to the named
@@ -11,7 +11,11 @@ export function getPwaInstall() {
11
11
  if (sharedOwner && sharedOwner.state.status !== 'disposed') {
12
12
  return sharedOwner;
13
13
  }
14
- const owner = {get state() { return snapshot(); }, subscribe, prompt, dismiss, dispose};
14
+ const owner = {
15
+ get state() { return snapshot(); },
16
+ get ready() { return ready; },
17
+ subscribe, prompt, dismiss, dispose
18
+ };
15
19
  const source = createArcaneEventSource(owner, {
16
20
  source: 'arcane.pwa.install', eventTypes: [PWA_INSTALL_STATE_EVENT]
17
21
  });
@@ -20,6 +24,9 @@ export function getPwaInstall() {
20
24
  '(display-mode: standalone), (display-mode: minimal-ui), '
21
25
  + '(display-mode: fullscreen), (display-mode: window-controls-overlay)'
22
26
  );
27
+ const installedDisplayMode = globalThis.matchMedia?.(
28
+ '(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)'
29
+ );
23
30
  const manifestUrl = globalThis.document?.querySelector('link[rel~="manifest"]')?.href;
24
31
  const dismissalKey = `arcane.pwa.install.dismissed:${manifestUrl ?? globalThis.location?.href ?? ''}`;
25
32
  let deferredPrompt = null;
@@ -28,6 +35,11 @@ export function getPwaInstall() {
28
35
  let status = isRunningAsApp() ? 'running' : 'waiting';
29
36
  let outcome = null;
30
37
  let error = null;
38
+ let installed = isInstalledApp();
39
+ let storedInstalled = false;
40
+ let storageReady = false;
41
+ let storageError = null;
42
+ let saveTask = null;
31
43
  try {
32
44
  dismissed = globalThis.sessionStorage?.getItem(dismissalKey) === 'true';
33
45
  } catch (storageError) {
@@ -38,9 +50,17 @@ export function getPwaInstall() {
38
50
  return displayMode?.matches === true || globalThis.navigator?.standalone === true;
39
51
  }
40
52
 
53
+ function isInstalledApp() {
54
+ // Ordinary browser fullscreen is not evidence of installation.
55
+ return installedDisplayMode?.matches === true || globalThis.navigator?.standalone === true;
56
+ }
57
+
41
58
  function snapshot() {
42
- return {status, available: deferredPrompt !== null && !disposed,
43
- dismissed, outcome, error};
59
+ return {
60
+ status,
61
+ available: storageReady && deferredPrompt !== null && !installed && !isRunningAsApp() && !disposed,
62
+ installed, dismissed, outcome, error, storageError
63
+ };
44
64
  }
45
65
 
46
66
  function publish(nextStatus, nextError = null) {
@@ -67,24 +87,92 @@ export function getPwaInstall() {
67
87
  }
68
88
  }
69
89
 
90
+ async function loadInstallStorage() {
91
+ if (!globalThis.dbopfs) {
92
+ await import('arcane/DBOPFS');
93
+ }
94
+ const storage = globalThis.dbopfs;
95
+ if (!storage) {
96
+ throw new Error('PWA installation state could not open DBOPFS.');
97
+ }
98
+ await storage.readyPromise;
99
+ return storage;
100
+ }
101
+
102
+ function reportStorageError(failure) {
103
+ storageError = failure;
104
+ console.warn('Arcane PWA installation state could not be persisted or restored:', failure);
105
+ publish(status, error);
106
+ }
107
+
108
+ async function restoreInstallation() {
109
+ try {
110
+ const storage = await storageTask;
111
+ const record = await storage.get('pwa', 'installed.json', true);
112
+ storedInstalled = record?.installed === true;
113
+ if (!disposed && storedInstalled) {
114
+ installed = true;
115
+ deferredPrompt = null;
116
+ }
117
+ } catch (failure) {
118
+ reportStorageError(failure);
119
+ }
120
+ storageReady = true;
121
+ if (!disposed) {
122
+ if (installed) {
123
+ publish(isRunningAsApp() ? 'running' : 'installed', error);
124
+ } else if (deferredPrompt && !isRunningAsApp()) {
125
+ publish('available', error);
126
+ } else {
127
+ publish(status, error);
128
+ }
129
+ }
130
+ return snapshot();
131
+ }
132
+
133
+ async function saveInstallation() {
134
+ // The initial read avoids rewriting an already remembered installation.
135
+ await ready;
136
+ if (storedInstalled) return;
137
+ const storage = await storageTask;
138
+ await storage.set(
139
+ 'pwa',
140
+ 'installed.json',
141
+ {installed: true}
142
+ );
143
+ storedInstalled = true;
144
+ storageError = null;
145
+ publish(status, error);
146
+ }
147
+
148
+ function rememberInstallation() {
149
+ installed = true;
150
+ deferredPrompt = null;
151
+ // Retain and observe this durable write even if the page owner detaches.
152
+ saveTask ??= saveInstallation().catch(reportStorageError);
153
+ }
154
+
70
155
  function onBeforeInstallPrompt(event) {
71
- if (disposed || isRunningAsApp() || status === 'installed' || status === 'accepted') return;
156
+ if (disposed || installed || isRunningAsApp() || status === 'accepted') return;
72
157
  event.preventDefault();
73
158
  deferredPrompt = event;
74
159
  outcome = null;
75
- publish('available');
160
+ publish(storageReady ? 'available' : 'waiting');
76
161
  }
77
162
 
78
163
  function onInstalled() {
79
- deferredPrompt = null;
164
+ rememberInstallation();
80
165
  // This event may precede Android's completion of WebAPK creation.
81
166
  publish('installed');
82
167
  }
83
168
 
84
169
  function onDisplayModeChange() {
170
+ if (isInstalledApp()) rememberInstallation();
85
171
  if (isRunningAsApp()) {
86
172
  deferredPrompt = null;
87
173
  publish('running');
174
+ } else if (installed) {
175
+ publish('installed');
88
176
  } else if (status === 'running') {
89
177
  publish('waiting');
90
178
  }
@@ -108,7 +196,7 @@ export function getPwaInstall() {
108
196
  }
109
197
 
110
198
  function prompt() {
111
- if (disposed || !deferredPrompt) return Promise.resolve(null);
199
+ if (!snapshot().available) return Promise.resolve(null);
112
200
  const event = deferredPrompt;
113
201
  deferredPrompt = null;
114
202
  publish('prompting');
@@ -162,8 +250,12 @@ export function getPwaInstall() {
162
250
  observe(globalThis, 'beforeinstallprompt', onBeforeInstallPrompt);
163
251
  observe(globalThis, 'appinstalled', onInstalled);
164
252
  observe(displayMode, 'change', onDisplayModeChange);
253
+ observe(installedDisplayMode, 'change', onDisplayModeChange);
165
254
  observe(globalThis, 'pagehide', onPageHide);
166
255
  sharedOwner = owner;
256
+ const storageTask = loadInstallStorage();
257
+ const ready = restoreInstallation();
258
+ if (installed) rememberInstallation();
167
259
  return owner;
168
260
  }
169
261
 
@@ -293,18 +293,29 @@ does not infer installation support from the user-agent string. See
293
293
  ## getPwaInstall()
294
294
 
295
295
  Import `getPwaInstall` and `PWA_INSTALL_STATE_EVENT` from `arcane-os/pwa`.
296
- `getPwaInstall()` synchronously returns the shared page owner with `state`,
296
+ `getPwaInstall()` synchronously returns the shared page owner with `state`, `ready`,
297
297
  `subscribe`, `prompt`, `dismiss` and `dispose`. Call it early when owning a
298
298
  separate install entry point so it can capture `beforeinstallprompt` before
299
299
  loading the UI. The generated bootstrap already does this through
300
300
  `mountPwaInstallPrompt()`.
301
301
 
302
- `state` contains `status`, `available`, `dismissed`, `outcome` and `error`.
302
+ `state` contains `status`, `available`, `installed`, `dismissed`, `outcome`,
303
+ `error` and `storageError`.
303
304
  Status is `waiting`, `available`, `prompting`, `accepted`, `dismissed`,
304
305
  `installed`, `running`, `error` or `disposed`. `available` means a native event
305
- is retained; a dismissed floating suggestion can still have `available: true`.
306
+ is retained, the initial installation-record read has settled, and installation
307
+ has not been recorded or detected. A dismissed floating suggestion can still
308
+ have `available: true`. `installed` is true after browser-reported installation
309
+ or restoration of that app's saved installation record.
306
310
  `outcome` is the browser's `accepted` or `dismissed` choice, or `null` before a
307
- choice. `error` carries the complete prompt error, or `null`.
311
+ choice. `error` carries the complete prompt error, or `null`; `storageError`
312
+ carries the complete DBOPFS read or write error, or `null`.
313
+
314
+ `ready` resolves to the state after the initial DBOPFS read settles. Native
315
+ events are captured synchronously while this read runs. Only installation
316
+ availability waits for it; page rendering, component loading and worker
317
+ registration continue independently. A storage failure is logged and published
318
+ as `storageError`; `ready` still resolves and native installation remains usable.
308
319
 
309
320
  `subscribe(listener, {emitCurrent: true, signal} = {})` immediately replays
310
321
  state by default and returns an unsubscribe function. Later state travels
@@ -315,7 +326,8 @@ registration, storage initialization or model readiness.
315
326
  Call `prompt()` directly from the user's install click, before any asynchronous
316
327
  wait. It invokes the browser prompt in the same call stack, consumes the event
317
328
  once, and returns a promise for the browser's choice. It resolves to `null`
318
- when there is no retained event or the owner is disposed. Failure publishes
329
+ when installation is unavailable, including while the saved state is loading
330
+ or after installation is remembered. Failure publishes
319
331
  `error` state and rejects. A new native event is required for another prompt.
320
332
 
321
333
  ```javascript
@@ -336,8 +348,24 @@ installButton.addEventListener('click', function requestInstallation() {
336
348
 
337
349
  `dismiss()` remembers the session choice without consuming the retained event
338
350
  and returns the current state. A browser-native dismissed choice is remembered
339
- too. `appinstalled` clears the event and publishes `installed`; running in an
340
- app display mode publishes `running` and suppresses the prompt. These states
351
+ too. `appinstalled` clears the event, publishes `installed`, and saves
352
+ `{installed: true}` as `pwa/installed.json` through the current app-scoped
353
+ DBOPFS singleton. An installed-app launch in standalone, minimal-ui or
354
+ window-controls-overlay mode, or with `navigator.standalone === true`, also
355
+ records installation. Ordinary fullscreen suppresses the current prompt but
356
+ does not record installation because browsers can enter fullscreen without
357
+ installing an app; see the [display-mode specification](https://drafts.csswg.org/mediaqueries-5/#display-modes).
358
+
359
+ Every new owner reads the saved flag, so both floating and explicit inline
360
+ controls stay hidden on subsequent visits in that browser origin's app scope.
361
+ The SDK never clears this flag or resets it when display mode changes. Prompt
362
+ acceptance and dismissal alone do not write it. The record is independent of
363
+ resource-check history and other application data. A pending read cannot undo
364
+ newly observed installation, and a confirmed installation's pending write is
365
+ retained through owner disposal. A write failure remains observable without
366
+ making the current installed session eligible again.
367
+
368
+ Running in an app display mode publishes `running`. These states
341
369
  do not establish offline readiness. On Android, `appinstalled` can arrive
342
370
  before WebAPK creation finishes. See the
343
371
  [browser lifecycle distinction](https://web.dev/learn/pwa/detection/).
@@ -79,7 +79,7 @@ appropriate.
79
79
  | [`markdown-document.html`](#markdown-documenthtml) | Renders and navigates a complete Markdown document with focusable fragments. | `configure()`<br>`load()`<br>`render()`<br>`clear()`<br>`fail()`<br>`focus()`<br>`focusFragment()`<br>`destroy()` | `markdown-document-ready`<br>`markdown-document-state`<br>`markdown-document-loading`<br>`markdown-document-rendered`<br>`markdown-document-empty`<br>`markdown-document-error`<br>`markdown-document-navigate` | Complete Markdown/state normalized; malformed input and Marked/DOM failures remain visible |
80
80
  | [`markdown-editor.html`](#markdown-editorhtml) | Configurable Markdown authoring, toolbar, preview, title, and save surface. | `configure()`<br>`focus()`<br>`clear()`<br>`saveEntry()`<br>`destroy()` | `markdown-editor-ready`<br>`markdown-editor-change`<br>`markdown-editor-saved` | Editor values normalized; injected save result mixed |
81
81
  | [`media-embed.html`](#media-embedhtml) | Loads a parsed YouTube video or playlist embed with ordinary hosting by default, optional privacy enhancement, and an external-platform action. | `configure()`<br>`load()`<br>`destroy()` | `media-embed-ready`<br>`media-load`<br>`media-error`<br>`media-open-platform` | URL/error normalized; iframe/platform behavior native |
82
- | [`modal.html`](#modalhtml) | Generic modal with population, open/close, actions, and sequential task execution. | `populate()`<br>`open()`<br>`close()`<br>`runTasks()`<br>`destroy()` | `modal-ready`<br>`modal-opened`<br>`modal-closed`<br>`modal-action` | Modal state normalized; injected task results mixed |
82
+ | [`modal.html`](#modalhtml) | Generic modal with population, configurable user dismissal, actions, and concurrent task execution. | `configure()`<br>`populate()`<br>`open()`<br>`close()`<br>`runTasks()`<br>`destroy()`<br>`running`<br>`opened` | `modal-ready`<br>`modal-opened`<br>`modal-closed`<br>`modal-action` | Modal state normalized; injected task results mixed |
83
83
  | [`output-panel.html`](#output-panelhtml) | Presents status, output, body, coverage, actions, pending, error, and cleared states. | `configure()`<br>`setOutput()`<br>`setBody()`<br>`setCoverage()`<br>`setActions()`<br>`setPending()`<br>`setStatus()`<br>`setError()`<br>`clear()`<br>`destroy()` | `output-panel-ready`<br>`output-panel-state`<br>`output-panel-change`<br>`output-panel-action`<br>`output-panel-error`<br>`output-panel-cleared` | DOM-normalized |
84
84
  | [`preferences-form.html`](#preferences-formhtml) | Builds a schema-driven preferences form with submit, reset, busy, and status behavior. | `configure()`<br>`getValues()`<br>`setValues()`<br>`setBusy()`<br>`setStatus()`<br>`destroy()` | `preferences-form-ready`<br>`preferences-change`<br>`preferences-submit`<br>`preferences-reset` | Normalized form values |
85
85
  | [`pwa-install.html`](#pwa-installhtml) | Presents a dismissible browser installation action with floating or inline placement. | `configure()`<br>`install()`<br>`dismiss()`<br>`destroy()`<br>`state`<br>`ready` | `pwa-install-ready`<br>`pwa-install-change`<br>`pwa-install-dismissed` | Browser install availability and outcome supplied by the shared PWA owner |
@@ -944,11 +944,28 @@ Shared dependencies: [`YouTubeMedia.js`](runtime-modules.md#youtubemediajs).
944
944
 
945
945
  ### Overview
946
946
 
947
- Generic modal with population, open/close, actions, and sequential task execution.
947
+ Generic modal with population, configurable user dismissal, actions, and concurrent task execution.
948
948
 
949
949
  ### Public surface
950
950
 
951
- Methods/properties: `populate()`, `open()`, `close()`, `runTasks()`, `destroy()`.
951
+ Methods/properties: `configure()`, `populate()`, `open()`, `close()`, `runTasks()`,
952
+ `destroy()`, `running`, `opened`.
953
+
954
+ `configure({dismissible})` synchronously returns the current `{dismissible}`
955
+ configuration, or `false` after destruction. `dismissible` starts as `true`;
956
+ omitting it preserves the current choice. A supplied value must be a boolean.
957
+ Setting it to `false` hides the close button and prevents user dismissal through
958
+ Escape, backdrop clicks and the close button. Setting it back to `true` restores
959
+ normal user dismissal. The choice persists across population, open/close cycles
960
+ and task completion. If the close button held focus when disabled, focus moves
961
+ to a content control or the focusable modal body.
962
+
963
+ This option does not prevent the owning application from calling `close()` or
964
+ `destroy()`. The existing running-task behavior remains: `close()` leaves a
965
+ running modal open unless called with its existing second `force` argument set
966
+ to `true`; `destroy()` releases the component. Product-specific conditions for
967
+ closing a modal belong to the application. Content, actions and task results
968
+ are preserved, and `runTasks()` continues to execute independent jobs concurrently.
952
969
 
953
970
  Events: `modal-ready`, `modal-opened`, `modal-closed`, `modal-action`.
954
971
 
@@ -1069,6 +1086,12 @@ availability comes from the browser's `beforeinstallprompt` event through
1069
1086
  `getPwaInstall()`. Without an available event, the suggestion stays hidden;
1070
1087
  absence does not identify why installation is unavailable. Installed-app events,
1071
1088
  accepted prompts, and an already running installed display mode hide it.
1089
+ The shared SDK owner persists browser-reported installation in app-scoped
1090
+ DBOPFS. Both floating and inline controls stay hidden while that record is
1091
+ loading and remain hidden on later visits when installation is remembered.
1092
+ Acceptance alone does not save installation. The component's `ready` means
1093
+ its methods and subscription are attached; storage completion belongs to the
1094
+ shared owner's `ready` promise and does not delay page rendering.
1072
1095
 
1073
1096
  ### Example
1074
1097
 
@@ -6231,13 +6231,24 @@ payload and current-state subscription behavior.
6231
6231
 
6232
6232
  ### Overview
6233
6233
 
6234
- `getPwaInstall()` returns one synchronous page owner exposing `state`,
6234
+ `getPwaInstall()` returns one synchronous page owner exposing `state`, `ready`,
6235
6235
  `subscribe`, `prompt`, `dismiss` and `dispose`. It captures the browser's
6236
6236
  `beforeinstallprompt` event and observes `appinstalled` and app display-mode
6237
6237
  changes. A missing event leaves installation availability unknown and the owner
6238
6238
  waiting; it does not prove browser incompatibility.
6239
6239
 
6240
- State contains `status`, `available`, `dismissed`, `outcome` and `error`.
6240
+ State contains `status`, `available`, `installed`, `dismissed`, `outcome`,
6241
+ `error` and `storageError`. The owner restores `pwa/installed.json` through the
6242
+ app-scoped DBOPFS singleton and saves `{installed: true}` after `appinstalled`
6243
+ or an installed-app launch. Remembered installation suppresses both floating
6244
+ and inline controls on later visits. Acceptance alone and ordinary fullscreen
6245
+ do not record installation.
6246
+
6247
+ `ready` resolves to the state once the initial read settles. Native events are
6248
+ captured immediately, while install availability waits for that read. Rendering
6249
+ and worker registration remain independent. Storage failures are logged and
6250
+ published in `storageError`; they do not reject `ready` or reverse an observed
6251
+ installation. Confirmed-install writes remain owned after disposal.
6241
6252
  Subscriptions replay current state by default. `prompt()` must be called
6242
6253
  directly within the install click to preserve native user activation. It
6243
6254
  consumes the event once and returns the browser choice, resolves to `null` when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -68,7 +68,7 @@
68
68
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
69
69
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
70
70
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
71
- "test:functional": "node ./bin/arcane-test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/update-check.test.mjs",
71
+ "test:functional": "node ./bin/arcane-test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/update-check.test.mjs",
72
72
  "test:integration": "node ./bin/arcane-test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
73
73
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
74
74
  "check": "node tools/check-source.mjs && npm test",
@@ -150,7 +150,7 @@
150
150
  <button id="close" class="modal-button" part="close" type="button" aria-label="Close" title="Close">&#10005;</button>
151
151
  <header id="modal-header" class="modal-header" part="header" hidden><slot id="header-slot" name="header"></slot></header>
152
152
  <input type="search" id="modal-search" class="modal-search hidden" part="search" placeholder="Search..." aria-label="Search modal content">
153
- <div id="modal-content" class="modal-content" part="body"><slot name="body"></slot></div>
153
+ <div id="modal-content" class="modal-content" part="body" tabindex="-1"><slot name="body"></slot></div>
154
154
  <footer id="modal-footer" class="modal-footer" part="footer" hidden><slot id="footer-slot" name="footer"></slot></footer>
155
155
  </div>
156
156
  </dialog>
@@ -174,11 +174,13 @@
174
174
  const modalStack=window.modalStack=window.modalStack||[];
175
175
 
176
176
  let running=false;
177
+ let dismissible = true;
177
178
  let disposed=false;
178
179
  let activeRun=Promise.resolve([]);
179
180
  let returnFocus=null;
180
181
 
181
182
  host.ready=false;
183
+ host.configure = configure;
182
184
  host.populate=populate;
183
185
  host.open=open;
184
186
  host.close=close;
@@ -195,13 +197,18 @@
195
197
  opened:{get:function getOpenedState(){return dialog.open;}}
196
198
  });
197
199
 
198
- dialog.addEventListener('cancel',function preventRunningDialogCancellation(event){
199
- event.preventDefault();
200
- close(event);
201
- });
200
+ dialog.addEventListener(
201
+ 'cancel',
202
+ function handleDialogCancellation(event) {
203
+ event.preventDefault();
204
+ if (dismissible) {
205
+ close(event);
206
+ }
207
+ }
208
+ );
202
209
 
203
210
  dialog.addEventListener('click',function closeFromDialogBackdrop(event){
204
- if(event.target!==dialog||running){
211
+ if (event.target !== dialog || running || !dismissible) {
205
212
  return;
206
213
  }
207
214
  const bounds=dialog.getBoundingClientRect();
@@ -214,9 +221,45 @@
214
221
  }
215
222
  });
216
223
 
217
- closeButton.addEventListener('click',function closeFromButton(event){
218
- close(event);
219
- });
224
+ closeButton.addEventListener(
225
+ 'click',
226
+ function closeFromButton(event) {
227
+ if (dismissible) {
228
+ close(event);
229
+ }
230
+ }
231
+ );
232
+
233
+ function configure(input = {}) {
234
+ if (disposed) return false;
235
+ if (input === null || !is.object(input) || is.array(input)) {
236
+ throw new TypeError('Modal configuration must be an object.');
237
+ }
238
+ if (input.dismissible !== undefined) {
239
+ if (!is.boolean(input.dismissible)) {
240
+ throw new TypeError('Modal dismissible must be a boolean.');
241
+ }
242
+ dismissible = input.dismissible;
243
+ }
244
+ const closeButtonFocused = shadowRoot.activeElement === closeButton;
245
+ updateCloseButton();
246
+ if (dialog.open && !dismissible && closeButtonFocused) {
247
+ focusOpenedModal();
248
+ }
249
+ return {dismissible};
250
+ }
251
+
252
+ function updateCloseButton() {
253
+ closeButton.classList.toggle('hidden', running || !dismissible);
254
+ }
255
+
256
+ function focusOpenedModal() {
257
+ const focusTarget = modalContent.querySelector('[autofocus],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),a[href]')
258
+ || (running || !dismissible ? modalContent : closeButton);
259
+ focusTarget.focus(
260
+ {preventScroll: true}
261
+ );
262
+ }
220
263
 
221
264
  for(const slot of [headerSlot,footerSlot]){
222
265
  const region=slot.parentElement;
@@ -253,10 +296,7 @@
253
296
  ?document.activeElement
254
297
  :null;
255
298
  dialog.showModal();
256
- queueMicrotask(function focusOpenedModal(){
257
- const focusTarget=modalContent.querySelector('[autofocus],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),a[href]')||closeButton;
258
- focusTarget?.focus({preventScroll:true});
259
- });
299
+ queueMicrotask(focusOpenedModal);
260
300
  emit('modal-opened',{}, {opened:true});
261
301
  }
262
302
  return true;
@@ -274,7 +314,7 @@
274
314
  if(wasOpen){
275
315
  dialog.close();
276
316
  }
277
- closeButton.classList.remove('hidden');
317
+ updateCloseButton();
278
318
 
279
319
  const stackIndex=modalStack.indexOf(host);
280
320
  if(stackIndex>-1){
@@ -317,7 +357,7 @@
317
357
  }
318
358
 
319
359
  async function populate(content='<p>Content goes here</p>',search=false){
320
- closeButton.classList.remove('hidden');
360
+ updateCloseButton();
321
361
  modalSearch.classList.toggle('hidden',!search);
322
362
  if(content instanceof Node){
323
363
  modalContent.replaceChildren(content);
@@ -349,7 +389,7 @@
349
389
  }
350
390
 
351
391
  running=true;
352
- closeButton.classList.add('hidden');
392
+ updateCloseButton();
353
393
  modalSearch.classList.add('hidden');
354
394
  modalContent.replaceChildren();
355
395
 
@@ -399,16 +439,20 @@
399
439
  return result.status==='rejected';
400
440
  });
401
441
  running=false;
402
- closeButton.classList.remove('hidden');
442
+ updateCloseButton();
403
443
 
404
444
  if(failed){
405
445
  heading.innerText='Some Tasks Could Not Be Completed';
406
- message.innerText='You may close this message and try again.';
446
+ message.innerText = dismissible
447
+ ? 'You may close this message and try again.'
448
+ : 'Review the failed tasks and try again.';
407
449
  return results;
408
450
  }
409
451
 
410
452
  heading.innerText='All Tasks Complete';
411
- message.innerText='Every task completed. You may close this message.';
453
+ message.innerText = dismissible
454
+ ? 'Every task completed. You may close this message.'
455
+ : 'Every task completed.';
412
456
  return results;
413
457
  }
414
458