@swiftwc/ui 0.0.0-dev.67 → 0.0.0-dev.69

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.
@@ -11,7 +11,10 @@ export declare const alert: (title?: string, message?: string, actions?: {
11
11
  role?: string;
12
12
  }[], options?: {
13
13
  titleVisibility?: boolean;
14
- }) => Promise<string>;
14
+ signal?: AbortSignal;
15
+ }) => Promise<string | undefined> & {
16
+ dismiss: (reason?: unknown) => void;
17
+ };
15
18
  export declare const confirmationDialog: (trigger: HTMLElement, title: string, message?: string, actions?: {
16
19
  label?: string;
17
20
  image?: string;
@@ -5,6 +5,7 @@ import { NavigationPath } from '../internal/class/navigation-path';
5
5
  import { queryInsertPosition, startViewTransition } from '../internal/privateNamespace';
6
6
  import { $, devFlags, kebabCase, onoff } from '../internal/utils';
7
7
  import { Snapshot } from '../snapshot';
8
+ import { html, render } from '../tpl';
8
9
  //#region polyfills
9
10
  export const polyfills = new Map();
10
11
  for (const [k, Ctor] of Object.entries(Components)) {
@@ -174,47 +175,61 @@ if (mediaQueryList.matches)
174
175
  ])
175
176
  document.addEventListener(k, v, { passive: true });
176
177
  //#region fns
177
- export const alert = async (title, message, actions, options) => {
178
- return await navigator.locks.request('alert:', async () => {
179
- const dialog = $(`<dialog is="alert-dialog"></dialog>`, '>1'), vStack = dialog.querySelector(':scope>v-stack') ?? dialog.appendChild($(`<v-stack spacing="1" alignment="fill"></v-stack>`, '>1'));
180
- if (title) {
181
- const label = $(`<label-view font="headline"></label-view>`, '>1');
182
- label.setAttribute('title', title);
183
- vStack.insertAdjacentElement('beforeend', label);
184
- }
185
- if (message) {
186
- const label = $(`<label-view foreground="secondary" font="callout"></label-view>`, '>1');
187
- label.setAttribute('title', message);
188
- vStack.insertAdjacentElement('beforeend', label);
189
- }
190
- for (const [index, action] of (actions ?? [
191
- {
192
- role: 'close',
193
- },
194
- ]).entries()) {
195
- const btn = $(`<button type="button" tabindex="0" is="bordered-button"></button>`, '>1');
196
- btn.setAttribute('value', `${index}`);
197
- if (action?.role)
198
- btn.setAttribute('role', action.role);
199
- if (action.label || action.image) {
200
- const label = $(`<label-view></label-view>`, '>1');
201
- if (action.label)
202
- label.setAttribute('title', action.label);
203
- if (action.image)
204
- label.setAttribute('system-image', action.image);
205
- btn.appendChild(label);
206
- }
207
- dialog.insertAdjacentElement('beforeend', btn);
208
- }
178
+ // const a = alert('Saving…', 'Please wait')
179
+ // // later, e.g. save completed early
180
+ // a.dismiss() // silently resolves undefined, closes+removes dialog
181
+ // // or
182
+ // a.dismiss(new Error('cancelled')) // rejects instead
183
+ // const result = await a // still works normally if not dismissed
184
+ // const ac = new AbortController()
185
+ // const a = alert('Uploading…', 'Please wait', undefined, { signal: ac.signal })
186
+ // // elsewhere, e.g. upload finished before the user dismissed
187
+ // ac.abort() // dialog closes silently, a rejects with AbortError
188
+ // // or still works standalone
189
+ // a.dismiss() // same effect, no signal needed
190
+ export const alert = (title, message, actions, options) => {
191
+ const { promise, resolve, reject } = Promise.withResolvers();
192
+ let dialog;
193
+ let off;
194
+ const dismiss = (reason) => {
195
+ off?.();
196
+ dialog?.close();
197
+ dialog?.remove();
198
+ reason === undefined ? resolve(undefined) : reject(reason instanceof Error ? reason : new DOMException('Alert dismissed', 'AbortError'));
199
+ };
200
+ if (options?.signal)
201
+ if (options.signal.aborted)
202
+ dismiss(options.signal.reason); // already aborted before we even started — settle immediately, skip showing anything
203
+ else
204
+ options.signal.addEventListener('abort', () => dismiss(options.signal.reason), { once: true });
205
+ navigator.locks
206
+ .request('alert:', async () => {
207
+ if (options?.signal?.aborted)
208
+ return; // in case it was aborted while queued behind another lock holder
209
+ const mount = document.createElement('div');
210
+ render(html `<dialog is="alert-dialog">
211
+ <v-stack spacing="1" alignment="fill">
212
+ ${title ? html `<label-view font="headline" title="${title}"></label-view>` : null}
213
+ ${message ? html `<label-view foreground="secondary" font="callout" title="${message}"></label-view>` : null}
214
+ </v-stack>
215
+ ${(actions ?? [{ role: 'close' }]).map((action, index) => html `
216
+ <button type="button" tabindex="0" is="bordered-button" value="${index}" role="${action.role ?? null}">
217
+ ${action.label || action.image ? html `<label-view title="${action.label ?? null}" system-image="${action.image ?? null}"></label-view>` : null}
218
+ </button>
219
+ `)}
220
+ </dialog>`, mount);
221
+ dialog = mount.firstElementChild;
209
222
  document.body.insertAdjacentElement('beforeend', dialog);
210
223
  dialog.showModal();
211
- const { promise, resolve } = Promise.withResolvers(), off = onoff('alert:return', (evt) => {
224
+ off = onoff('alert:return', (evt) => {
212
225
  const { detail } = evt;
213
- off();
226
+ off?.();
214
227
  resolve(detail.returnValue);
215
228
  }, alertDialog, { once: true }).on();
216
229
  return promise;
217
- });
230
+ })
231
+ .catch(() => { });
232
+ return Object.assign(promise, { dismiss });
218
233
  };
219
234
  export const confirmationDialog = async (trigger, title, message, actions, options) => {
220
235
  const newAnchorName = `--confirmation-dialog-${self.crypto.randomUUID()}`;
@@ -0,0 +1,5 @@
1
+ export interface TemplateResult {
2
+ strings: TemplateStringsArray;
3
+ values: unknown[];
4
+ }
5
+ export default function (strings: TemplateStringsArray, ...values: unknown[]): TemplateResult;
@@ -0,0 +1,3 @@
1
+ export default function (strings, ...values) {
2
+ return { strings, values };
3
+ }
@@ -0,0 +1,4 @@
1
+ export { default as html } from './html';
2
+ export * from './html';
3
+ export { default as render } from './render';
4
+ export * from './render';
@@ -0,0 +1,4 @@
1
+ export { default as html } from './html';
2
+ export * from './html';
3
+ export { default as render } from './render';
4
+ export * from './render';
@@ -0,0 +1,2 @@
1
+ import type { TemplateResult } from './html';
2
+ export default function (result: TemplateResult, container: Element | ShadowRoot): void;
@@ -0,0 +1,131 @@
1
+ // --- Template preparation (runs ONCE per unique template literal) -----
2
+ const MARKER = '\uD83D\uDD25PART\uD83D\uDD25'; // unlikely-collision sentinel
3
+ const templateCache = new WeakMap();
4
+ function prepare(strings) {
5
+ let cached = templateCache.get(strings);
6
+ if (cached)
7
+ return cached;
8
+ const partDescriptors = [];
9
+ let htmlStr = '';
10
+ strings.forEach((chunk, i) => {
11
+ htmlStr += chunk;
12
+ if (i < strings.length - 1) {
13
+ // Is this interpolation inside an attribute value?
14
+ // e.g. `...aria-checked="${...}"` — chunk ends with `attr="` or `attr='` or `attr=`
15
+ const attrMatch = /([a-zA-Z0-9_-]+)=(["']?)$/.exec(chunk);
16
+ if (attrMatch) {
17
+ partDescriptors.push({ type: 'attr', name: attrMatch[1] });
18
+ htmlStr += MARKER; // becomes the attribute's value
19
+ }
20
+ else {
21
+ partDescriptors.push({ type: 'child' });
22
+ // start/end comment pair — gives us a stable region to patch later,
23
+ // and room to grow into list/array rendering without a redesign
24
+ htmlStr += `<!--${MARKER}-->`;
25
+ }
26
+ }
27
+ });
28
+ const element = document.createElement('template');
29
+ element.innerHTML = htmlStr;
30
+ cached = { element, partDescriptors };
31
+ templateCache.set(strings, cached);
32
+ return cached;
33
+ }
34
+ const instanceCache = new WeakMap();
35
+ function createInstance(prepared) {
36
+ const fragment = prepared.element.content.cloneNode(true);
37
+ const parts = [];
38
+ // Walk the clone once, in the same order `prepare()` emitted markers,
39
+ // and bind each descriptor to a real node.
40
+ const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT);
41
+ let descriptorIndex = 0;
42
+ const descriptors = prepared.partDescriptors;
43
+ let node;
44
+ while ((node = walker.nextNode())) {
45
+ if (descriptorIndex >= descriptors.length)
46
+ break;
47
+ if (node.nodeType === Node.COMMENT_NODE && node.data === MARKER) {
48
+ parts.push({ type: 'child', anchor: node, nodes: [], committed: undefined });
49
+ descriptorIndex++;
50
+ }
51
+ else if (node.nodeType === Node.ELEMENT_NODE) {
52
+ const el = node;
53
+ for (const attr of Array.from(el.attributes)) {
54
+ if (attr.value === MARKER) {
55
+ const desc = descriptors[descriptorIndex];
56
+ if (desc.type === 'attr') {
57
+ parts.push({ type: 'attr', el, name: desc.name, committed: undefined });
58
+ descriptorIndex++;
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ return { fragment, parts };
65
+ }
66
+ // --- Committing values into parts (the only thing that runs per update) -
67
+ function isTemplateResult(v) {
68
+ return !!v && typeof v === 'object' && 'strings' in v && 'values' in v;
69
+ }
70
+ function toNode(value) {
71
+ if (value instanceof Node)
72
+ return value;
73
+ if (isTemplateResult(value)) {
74
+ const { fragment, parts } = createInstance(prepare(value.strings));
75
+ parts.forEach((part, i) => {
76
+ const v = value.values[i];
77
+ part.type === 'child' ? commitChild(part, v) : commitAttr(part, v);
78
+ });
79
+ return fragment;
80
+ }
81
+ return document.createTextNode(String(value));
82
+ }
83
+ function commitChild(part, value) {
84
+ if (value === part.committed)
85
+ return; // dirty-check, skip untouched parts
86
+ for (const n of part.nodes)
87
+ n.remove();
88
+ const nodes = [];
89
+ for (const item of Array.isArray(value) ? value : [value]) {
90
+ if (item === null || item === undefined || item === false)
91
+ continue;
92
+ const node = toNode(item);
93
+ const inserted = node instanceof DocumentFragment ? Array.from(node.childNodes) : [node];
94
+ part.anchor.before(node);
95
+ nodes.push(...inserted);
96
+ }
97
+ part.committed = value;
98
+ }
99
+ function commitAttr(part, value) {
100
+ if (value === part.committed)
101
+ return;
102
+ if (value === null || value === undefined || value === false) {
103
+ part.el.removeAttribute(part.name);
104
+ }
105
+ else {
106
+ part.el.setAttribute(part.name, String(value));
107
+ }
108
+ part.committed = value;
109
+ }
110
+ // --- Public render() ----------------------------------------------------
111
+ export default function (result, container) {
112
+ const prepared = prepare(result.strings);
113
+ let instance = instanceCache.get(container);
114
+ if (!instance || instance.strings !== result.strings) {
115
+ // first render, OR the template shape changed entirely — full (re)build
116
+ container.textContent = '';
117
+ const { fragment, parts } = createInstance(prepared);
118
+ container.append(fragment);
119
+ instance = { strings: result.strings, fragmentRoot: fragment, parts };
120
+ instanceCache.set(container, instance);
121
+ }
122
+ // same template shape as last time → patch existing DOM nodes only,
123
+ // no innerHTML, no re-clone, no focus/scroll loss
124
+ instance.parts.forEach((part, i) => {
125
+ const value = result.values[i];
126
+ if (part.type === 'child')
127
+ commitChild(part, value);
128
+ else
129
+ commitAttr(part, value);
130
+ });
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftwc/ui",
3
- "version": "0.0.0-dev.67",
3
+ "version": "0.0.0-dev.69",
4
4
  "description": "Elegant SwiftUI-inspired web components for standalone web apps and web extensions.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -63,11 +63,12 @@
63
63
  "build:ts": "tsc",
64
64
  "build:scss": "sass --style=expanded --no-source-map scss/index.scss generated/css/index.css",
65
65
  "build:manifest": "tsx scripts/make-manifest.ts",
66
- "build:barrels": "npm run build:barrels:components && npm run build:barrels:events && npm run build:barrels:buses && npm run build:barrels:i18n",
66
+ "build:barrels": "npm run build:barrels:components && npm run build:barrels:events && npm run build:barrels:buses && npm run build:barrels:tpl && npm run build:barrels:i18n",
67
67
  "build:barrels:components": "barrelsby --noHeader --delete --singleQuotes --noSemicolon --directory ./js/components --exclude \".\\.test\\.ts\"",
68
68
  "build:barrels:events": "barrelsby --noHeader --delete --singleQuotes --noSemicolon --directory ./js/events --exclude \".\\.test\\.ts\"",
69
69
  "build:barrels:buses": "barrelsby --noHeader --delete --singleQuotes --noSemicolon --exportDefault --directory ./js/buses",
70
70
  "build:barrels:i18n": "barrelsby --noHeader --delete --singleQuotes --noSemicolon --exportDefault --fullPathname --directory ./js/i18n/locales",
71
+ "build:barrels:tpl": "barrelsby --noHeader --delete --singleQuotes --noSemicolon --exportDefault --directory ./js/tpl --exclude \".\\.test\\.ts\"",
71
72
  "build:barrels:decorators": "barrelsby --noHeader --delete --singleQuotes --noSemicolon --exportDefault --directory ./js/internal/decorators",
72
73
  "format:check": "prettier . --check",
73
74
  "format:write": "prettier . --write",