kmaterialize 2.3.3-1.0.34 → 2.3.3-1.0.35

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.
@@ -0,0 +1,91 @@
1
+ // Match the documentation's compact code blocks and Atom One Dark palette.
2
+ .card.code-card {
3
+ --code-card-background: #282c34;
4
+ --code-card-color: #abb2bf;
5
+ --code-card-border-color: #8888;
6
+ --code-card-font-size: 0.85rem;
7
+ --code-card-padding: 1em;
8
+
9
+ position: relative;
10
+ min-width: 0;
11
+ max-width: 100%;
12
+ margin: 1em 0;
13
+ overflow: hidden;
14
+ font: var(--code-card-font-size)/1.25 monospace, monospace;
15
+ background: var(--code-card-background);
16
+ color: var(--code-card-color);
17
+ border: 1px solid var(--code-card-border-color);
18
+ // The standard .card supplies the 12px border radius.
19
+ box-shadow: none;
20
+
21
+ .code-card-status, .code-card-copy {
22
+ position: absolute;
23
+ z-index: 2;
24
+ color: var(--md-sys-color-on-surface-variant, #abb2bf);
25
+ }
26
+
27
+ .code-card-status {
28
+ top: 15px;
29
+ right: 45px;
30
+ font: 14px/1.25 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
31
+ pointer-events: none;
32
+ }
33
+
34
+ .code-card-copy {
35
+ top: 6px;
36
+ right: 6px;
37
+
38
+ > .btn {
39
+ --button-icon: 24px;
40
+ color: inherit;
41
+ }
42
+ }
43
+ .code-card-copy[hidden] { display: none; }
44
+ .code-card-copy > .btn:focus-visible, .code-card-body:focus-visible {
45
+ outline: 2px solid var(--md-sys-color-primary, #61aeee);
46
+ outline-offset: -2px;
47
+ }
48
+
49
+ pre.code-card-body {
50
+ display: block;
51
+ margin: 0;
52
+ padding: 0;
53
+ max-height: var(--code-card-max-height, none);
54
+ overflow: auto;
55
+ border: 0;
56
+ border-radius: 0;
57
+ background: transparent;
58
+ color: inherit;
59
+ text-align: start;
60
+ direction: ltr;
61
+ white-space: pre;
62
+ }
63
+ pre.code-card-body, pre.code-card-body > code {
64
+ font: inherit;
65
+ text-shadow: none;
66
+ }
67
+ pre.code-card-body > code {
68
+ display: block;
69
+ width: max-content;
70
+ min-width: 100%;
71
+ margin: 0;
72
+ padding: var(--code-card-padding);
73
+ border: 0;
74
+ background: transparent;
75
+ color: inherit;
76
+ white-space: inherit;
77
+ }
78
+
79
+ .token { background: transparent; text-shadow: none; }
80
+ .token.comment, .token.prolog, .token.doctype, .token.cdata { color: #5c6370; font-style: italic; }
81
+ .token.punctuation, .token.operator, .token.property { color: var(--code-card-color); }
82
+ .token.keyword, .token.atrule { color: #c678dd; }
83
+ .token.tag, .token.selector, .token.deleted { color: #e06c75; }
84
+ .token.function, .token.function-variable, .token.url { color: #61aeee; }
85
+ .token.class-name, .token.builtin { color: #e6c07b; }
86
+ .token.attr-name, .token.number, .token.constant, .token.variable, .token.builtin-variable { color: #d19a66; }
87
+ .token.string, .token.string-property, .token.attr-value, .token.regex, .token.inserted { color: #98c379; }
88
+ .token.boolean, .token.entity { color: #56b6c2; }
89
+ .token.important, .token.bold { font-weight: bold; }
90
+ .token.italic { font-style: italic; }
91
+ }
@@ -0,0 +1,198 @@
1
+ import { BaseOptions, Component, InitElements, MElement } from "../../src/component";
2
+ import CrazyButton from "../extensions/web/crazy-button";
3
+ import { loadPeer } from "../../src/peer-loader";
4
+ import type * as Prism from "prismjs";
5
+
6
+ /** Display source code with optional syntax highlighting and clipboard controls. */
7
+ export interface CodeCardOptions extends BaseOptions {
8
+ code:string;
9
+ language:string;
10
+ title:string;
11
+ copy:boolean;
12
+ highlight:boolean;
13
+ copyLabel:string;
14
+ copiedLabel:string;
15
+ errorLabel:string;
16
+ }
17
+
18
+ const _defaults:CodeCardOptions = {
19
+ code: "",
20
+ language: "plain",
21
+ title: "",
22
+ copy: true,
23
+ highlight: true,
24
+ copyLabel: "Copy code",
25
+ copiedLabel: "Copied!",
26
+ errorLabel: "Unable to copy",
27
+ };
28
+
29
+ /** A themed code card. Source text is never executed or interpreted as markup. */
30
+ export class CodeCard extends Component<CodeCardOptions> {
31
+
32
+ public ready:Promise<void>;
33
+ private _prism?:typeof Prism;
34
+ private _originalNodes:Node[];
35
+ private _addedClasses:string[];
36
+ private _code:HTMLElement;
37
+ private _pre:HTMLPreElement;
38
+ private _button:CrazyButton;
39
+ private _status:HTMLElement;
40
+ private _timer:ReturnType<typeof setTimeout>;
41
+ private _copying:boolean = false;
42
+ private _destroyed:boolean = false;
43
+ private _revision:number = 0;
44
+
45
+ public constructor(el:HTMLElement, options:Partial<CodeCardOptions> = {}){
46
+ super(el, options, CodeCard);
47
+ const source = el.querySelector("pre > code, code");
48
+ const language = Array.from(source?.classList || []).find(name => name.startsWith("language-"))?.slice(9);
49
+ this.options = {
50
+ ...CodeCard.defaults,
51
+ code: source?.textContent || "",
52
+ language: el.dataset.codeLanguage || language || "plain",
53
+ title: el.dataset.codeTitle || "",
54
+ copy: el.dataset.codeCopy !== "false",
55
+ highlight: el.dataset.codeHighlight !== "false",
56
+ ...options,
57
+ };
58
+ this._originalNodes = Array.from(el.childNodes);
59
+ this._addedClasses = ["card", "code-card"].filter(name => !el.classList.contains(name));
60
+ el.classList.add(...this._addedClasses);
61
+ el["M_CodeCard"] = this;
62
+ this._createElements();
63
+ this._render();
64
+ this.ready = this.update({});
65
+ }
66
+
67
+ public static get defaults():CodeCardOptions { return _defaults; }
68
+ public static init(el:HTMLElement, options?:Partial<CodeCardOptions>):CodeCard;
69
+ public static init(els:InitElements<MElement>, options?:Partial<CodeCardOptions>):CodeCard[];
70
+ public static init(els:HTMLElement|InitElements<MElement>, options:Partial<CodeCardOptions> = {}):CodeCard|CodeCard[] {
71
+ return super.init(els, options, CodeCard);
72
+ }
73
+ public static getInstance(el:HTMLElement):CodeCard { return el["M_CodeCard"]; }
74
+
75
+ /** Update content, language, title, or the optional copy control. */
76
+ public async update(options:Partial<CodeCardOptions>):Promise<void> {
77
+ if(this._destroyed) return;
78
+ this.options = { ...this.options, ...options };
79
+ this._revision++;
80
+ clearTimeout(this._timer);
81
+ this._setCopyFeedback("content_copy");
82
+ this._render();
83
+ if(this.options.highlight && !this._prism){
84
+ this._prism = await loadPeer<typeof Prism>({
85
+ specifier: "prismjs",
86
+ globalName: "Prism",
87
+ feature: "CodeCard syntax highlighting",
88
+ cdnHint: '<script src="path/to/prism.js" data-manual></script> (include the language grammars you use)',
89
+ }, async() => {
90
+ const scope = window as Window & { Prism?:typeof Prism|{ manual:boolean } };
91
+ const created = !scope.Prism;
92
+ if(created) scope.Prism = { manual: true };
93
+ try { return await import("prismjs"); }
94
+ catch(error){ if(created) delete scope.Prism; throw error; }
95
+ });
96
+ }
97
+ if(!this._destroyed) this._render();
98
+ }
99
+
100
+ public getCode():string { return this.options.code; }
101
+
102
+ /** Copy the original source, preserving whitespace and excluding UI labels. */
103
+ public async copy():Promise<boolean> {
104
+ if(this._destroyed || this._copying || !this.options.copy) return false;
105
+ const revision = this._revision;
106
+ const restoreFocus = this._button.contains(document.activeElement);
107
+ this._copying = true;
108
+ this._button.setProperty("disabled", true);
109
+ clearTimeout(this._timer);
110
+ this._setCopyFeedback("content_copy");
111
+ let success = false;
112
+ try {
113
+ await navigator.clipboard.writeText(this.options.code);
114
+ success = true;
115
+ }catch{
116
+ // Leave the source available for manual selection when clipboard access fails.
117
+ }finally{
118
+ this._copying = false;
119
+ }
120
+ if(this._destroyed) return success;
121
+ this._button.setProperty("disabled", false);
122
+ if(revision === this._revision){
123
+ this._setCopyFeedback(success ? "check" : "error_outline", success ? this.options.copiedLabel : this.options.errorLabel);
124
+ this._timer = setTimeout(() => this._setCopyFeedback("content_copy"), 2000);
125
+ }
126
+ await this._button.updateComplete;
127
+ if(this._destroyed) return success;
128
+ if(restoreFocus && document.activeElement === document.body && this.options.copy && this._button.isConnected)
129
+ this._button.querySelector("button")?.focus({ preventScroll: true });
130
+ this.el.dispatchEvent(new CustomEvent("codecopy", { bubbles: true, detail: { success } }));
131
+ return success;
132
+ }
133
+
134
+ /** Restore the authored markup and remove component-owned listeners. */
135
+ public destroy():void {
136
+ if(this._destroyed) return;
137
+ this._destroyed = true;
138
+ clearTimeout(this._timer);
139
+ this._button.removeEventListener("buttonaction", this._onCopy);
140
+ this.el.replaceChildren(...this._originalNodes);
141
+ this.el.classList.remove(...this._addedClasses);
142
+ delete this.el["M_CodeCard"];
143
+ }
144
+
145
+ private _onCopy = ():void => { void this.copy(); };
146
+
147
+ private _createElements():void {
148
+ this._status = document.createElement("span");
149
+ this._status.className = "code-card-status";
150
+ this._status.setAttribute("role", "status");
151
+ this._button = new CrazyButton();
152
+ this._button.className = "code-card-copy";
153
+ this._button.setAttribute("type", "icon");
154
+ this._button.setAttribute("variant", "standard");
155
+ this._button.setAttribute("size", "xs");
156
+ this._button.addEventListener("buttonaction", this._onCopy);
157
+ this._pre = document.createElement("pre");
158
+ this._pre.className = "code-card-body";
159
+ this._pre.tabIndex = 0;
160
+ this._code = document.createElement("code");
161
+ this._pre.append(this._code);
162
+ this.el.replaceChildren(this._status, this._button, this._pre);
163
+ }
164
+
165
+ private _setCopyFeedback(icon:string, message:string = ""):void {
166
+ this._status.textContent = message;
167
+ this._button.setAttribute("icon-text", icon);
168
+ this._button.setAttribute("aria-label", message || this.options.copyLabel);
169
+ this._button.title = message || this.options.copyLabel;
170
+ }
171
+
172
+ private _render():void {
173
+ const requested = this.options.language.trim().toLowerCase();
174
+ const aliases = { html: "markup", xml: "markup", js: "javascript", ts: "typescript", shell: "bash", text: "plain", plaintext: "plain" };
175
+ const language = /^[a-z0-9-]+$/.test(requested) ? aliases[requested] || requested : "plain";
176
+ const labels = { markup: "HTML", css: "CSS", javascript: "JavaScript", typescript: "TypeScript", php: "PHP", json: "JSON", yaml: "YAML", bash: "Shell", python: "Python", handlebars: "Handlebars", plain: "Plain text" };
177
+ this._pre.setAttribute("aria-label", this.options.title || (labels[language] || requested.toUpperCase()) + " code");
178
+ this._button.hidden = !this.options.copy;
179
+ this._code.className = "language-" + language;
180
+ let grammar = this._prism?.languages[language];
181
+ if(grammar && (language === "javascript" || language === "typescript")){
182
+ // Match the documentation's class and browser-global colors without changing shared grammars.
183
+ const classes = grammar["class-name"];
184
+ grammar = {
185
+ ...grammar,
186
+ "class-name": [
187
+ ...(Array.isArray(classes) ? classes : classes ? [classes] : []),
188
+ { pattern: /\b[A-Z][\w$]*(?=\s*\.)/ },
189
+ ],
190
+ "builtin-variable": /\b(?:document|window|console|navigator|globalThis)\b/,
191
+ };
192
+ }
193
+ if(this.options.highlight && grammar)
194
+ this._code.innerHTML = this._prism.highlight(this.options.code, grammar, language);
195
+ else
196
+ this._code.textContent = this.options.code;
197
+ }
198
+ }
@@ -0,0 +1,159 @@
1
+ describe("CodeCard", function(){
2
+ let host;
3
+ let instance;
4
+ let clipboard;
5
+ let writeText;
6
+
7
+ beforeEach(function(){
8
+ host = document.createElement("div");
9
+ host.innerHTML = '<pre><code class="language-javascript">const answer = 42;</code></pre>';
10
+ document.body.append(host);
11
+ clipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
12
+ writeText = jasmine.createSpy("writeText").and.returnValue(Promise.resolve());
13
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
14
+ });
15
+
16
+ afterEach(function(){
17
+ instance?.destroy();
18
+ instance = undefined;
19
+ host.remove();
20
+ if(clipboard) Object.defineProperty(navigator, "clipboard", clipboard);
21
+ else delete navigator.clipboard;
22
+ });
23
+
24
+ it("renders source as text without executing HTML", async function(){
25
+ const code = '<img src=x onerror="window.__codeExecuted=true">\n<script>alert(1)</script>';
26
+ instance = M.CodeCard.init(host, { code, language: "html", highlight: false });
27
+ await instance.ready;
28
+ expect(host.querySelector("pre code").textContent).toBe(code);
29
+ expect(host.querySelector("img, script")).toBeNull();
30
+ expect(instance.getCode()).toBe(code);
31
+ });
32
+
33
+ it("copies original source including indentation and trailing newline", async function(){
34
+ const code = "first\n second\n";
35
+ instance = M.CodeCard.init(host, { code, title: "Do not copy this title", highlight: false });
36
+ await instance.ready;
37
+ expect(await instance.copy()).toBeTrue();
38
+ expect(writeText).toHaveBeenCalledOnceWith(code);
39
+ expect(host.querySelector('[role="status"]').textContent).toBe("Copied!");
40
+ expect(host.querySelector("crazy-button i").textContent).toBe("check");
41
+ expect(host.querySelector("button").getAttribute("aria-label")).toBe("Copied!");
42
+ });
43
+
44
+ it("hides and disables the copy action when requested", async function(){
45
+ host.dataset.codeCopy = "false";
46
+ instance = M.CodeCard.init(host, { highlight: false });
47
+ await instance.ready;
48
+ expect(host.querySelector("crazy-button").hidden).toBeTrue();
49
+ expect(await instance.copy()).toBeFalse();
50
+ expect(writeText).not.toHaveBeenCalled();
51
+ await instance.update({ copy: true });
52
+ expect(host.querySelector("crazy-button").hidden).toBeFalse();
53
+ });
54
+
55
+ it("reports clipboard errors instead of announcing success", async function(){
56
+ writeText.and.returnValue(Promise.reject(new Error("Permission denied")));
57
+ instance = M.CodeCard.init(host, { highlight: false });
58
+ await instance.ready;
59
+ expect(await instance.copy()).toBeFalse();
60
+ expect(host.querySelector('[role="status"]').textContent).toBe("Unable to copy");
61
+ expect(host.querySelector("button").disabled).toBeFalse();
62
+ expect(host.querySelector("crazy-button i").textContent).toBe("error_outline");
63
+ expect(host.querySelector("button").getAttribute("aria-label")).toBe("Unable to copy");
64
+ });
65
+
66
+ it("updates custom content and treats unknown language names as plain text", async function(){
67
+ instance = M.CodeCard.init(host, { highlight: false });
68
+ await instance.ready;
69
+ await instance.update({ code: "custom => value", language: "custom", title: "My format", copy: false });
70
+ expect(host.querySelector("pre code").textContent).toBe("custom => value");
71
+ expect(host.querySelector("pre").getAttribute("aria-label")).toBe("My format");
72
+ await instance.update({ title: "" });
73
+ expect(host.querySelector("pre").getAttribute("aria-label")).toBe("CUSTOM code");
74
+ expect(host.querySelector("crazy-button").hidden).toBeTrue();
75
+ });
76
+
77
+ it("restores the authored DOM and listeners on destroy", async function(){
78
+ const original = host.firstElementChild;
79
+ instance = M.CodeCard.init(host, { highlight: false });
80
+ await instance.ready;
81
+ const button = host.querySelector("button");
82
+ instance.destroy();
83
+ instance = undefined;
84
+ button.click();
85
+ expect(writeText).not.toHaveBeenCalled();
86
+ expect(host.firstElementChild).toBe(original);
87
+ expect(host.classList.contains("code-card")).toBeFalse();
88
+ expect(M.CodeCard.getInstance(host)).toBeUndefined();
89
+ });
90
+
91
+ it("highlights JavaScript while preserving the source text", async function(){
92
+ instance = M.CodeCard.init(host);
93
+ await instance.ready;
94
+ expect(host.querySelectorAll(".token").length).toBeGreaterThan(0);
95
+ expect(host.querySelector("pre code").textContent).toBe("const answer = 42;");
96
+ });
97
+
98
+ it("keeps a replacement instance intact when an old owner destroys twice", async function(){
99
+ const old = M.CodeCard.init(host, { highlight: false });
100
+ await old.ready;
101
+ instance = M.CodeCard.init(host, { code: "replacement", highlight: false });
102
+ await instance.ready;
103
+ old.destroy();
104
+ expect(M.CodeCard.getInstance(host)).toBe(instance);
105
+ expect(host.querySelector("pre code").textContent).toBe("replacement");
106
+ });
107
+
108
+ it("does not mutate restored markup when a pending copy completes", async function(){
109
+ let resolve;
110
+ writeText.and.returnValue(new Promise(done => { resolve = done; }));
111
+ instance = M.CodeCard.init(host, { highlight: false });
112
+ await instance.ready;
113
+ const copying = instance.copy();
114
+ instance.destroy();
115
+ instance = undefined;
116
+ const restored = host.innerHTML;
117
+ resolve();
118
+ await copying;
119
+ expect(host.innerHTML).toBe(restored);
120
+ });
121
+
122
+ it("uses CrazyButton activation and restores its icon after success feedback", async function(){
123
+ jasmine.clock().install();
124
+ try {
125
+ instance = M.CodeCard.init(host, { highlight: false });
126
+ await instance.ready;
127
+ const button = host.querySelector("crazy-button");
128
+ const copied = new Promise(resolve => host.addEventListener("codecopy", resolve, { once: true }));
129
+ button.querySelector("button").click();
130
+ await copied;
131
+ expect(writeText).toHaveBeenCalledTimes(1);
132
+ expect(button.querySelector("i").textContent).toBe("check");
133
+ jasmine.clock().tick(2000);
134
+ await button.updateComplete;
135
+ expect(button.querySelector("i").textContent).toBe("content_copy");
136
+ expect(button.querySelector("button").getAttribute("aria-label")).toBe("Copy code");
137
+ expect(host.querySelector('[role="status"]').textContent).toBe("");
138
+ }finally{
139
+ jasmine.clock().uninstall();
140
+ }
141
+ });
142
+
143
+ it("does not show stale success when the source changes during a copy", async function(){
144
+ let resolve;
145
+ writeText.and.returnValue(new Promise(done => { resolve = done; }));
146
+ instance = M.CodeCard.init(host, { highlight: false });
147
+ await instance.ready;
148
+ const copying = instance.copy();
149
+ expect(await instance.copy()).toBeFalse();
150
+ await instance.update({ code: "new source" });
151
+ resolve();
152
+ await copying;
153
+ expect(writeText).toHaveBeenCalledTimes(1);
154
+ expect(host.querySelector("crazy-button i").textContent).toBe("content_copy");
155
+ expect(host.querySelector("button").getAttribute("aria-label")).toBe("Copy code");
156
+ expect(host.querySelector('[role="status"]').textContent).toBe("");
157
+ });
158
+
159
+ });
@@ -49,6 +49,10 @@
49
49
  animation: popup-stepper-spin 1s linear infinite;
50
50
  }
51
51
  }
52
+ &[data-state='waiting'] .popup-stepper-marker {
53
+ background: var(--md-sys-color-primary-container);
54
+ color: var(--md-sys-color-on-primary-container);
55
+ }
52
56
  &[data-state='complete'] .popup-stepper-marker {
53
57
  background: var(--md-sys-color-primary);
54
58
  color: var(--md-sys-color-on-primary);
@@ -70,6 +74,9 @@
70
74
  color: var(--md-sys-color-on-surface-variant);
71
75
  font-weight: 600;
72
76
  }
77
+ .popup-stepper-content:not(:empty) { margin-top: 16px; }
78
+ .popup-stepper-validation { color: var(--md-sys-color-error); }
79
+ .popup-stepper-content .input-field { margin-block: 16px 8px; }
73
80
  .popup-stepper-state { display: block; font-size: .75rem; }
74
81
  .popup-stepper-status { font-size: .875rem; color: var(--md-sys-color-on-surface-variant); }
75
82
  }
@@ -1,6 +1,14 @@
1
1
  import type Swal from 'sweetalert2';
2
2
  import type { PopupOptions, PopupResult } from './popup';
3
3
 
4
+ export interface PopupStepConfirmationOptions<T> {
5
+ /** Custom DOM content. Insert user-provided text using textContent. */
6
+ content: HTMLElement;
7
+ confirmButtonText?: string;
8
+ /** Read and validate the content. Throw an Error to stay on this step. */
9
+ readValue(): T | Promise<T>;
10
+ }
11
+
4
12
  export interface PopupStepContext<T = unknown> {
5
13
  /** Aborted when the popup closes. Pass this signal to fetch or other work. */
6
14
  signal: AbortSignal;
@@ -8,6 +16,8 @@ export interface PopupStepContext<T = unknown> {
8
16
  results: readonly T[];
9
17
  /** Update the active step's plain-text progress message. */
10
18
  setMessage(message: string): void;
19
+ /** Pause for user confirmation. Rejected when the popup closes. */
20
+ waitForConfirmation<R>(options: PopupStepConfirmationOptions<R>): Promise<R>;
11
21
  }
12
22
  export interface PopupStep<T = unknown> {
13
23
  title: string;
@@ -67,10 +77,12 @@ export async function runPopupSteps<T>(
67
77
  state.textContent = 'Waiting';
68
78
  const message = document.createElement('p');
69
79
  message.textContent = step.description || '';
70
- body.append(title, state, message);
80
+ const content = document.createElement('div');
81
+ content.className = 'popup-stepper-content';
82
+ body.append(title, state, message, content);
71
83
  row.append(marker, body);
72
84
  list.append(row);
73
- return { row, marker, state, message };
85
+ return { row, marker, state, message, content };
74
86
  });
75
87
  const status = document.createElement('p');
76
88
  status.className = 'popup-stepper-status';
@@ -81,6 +93,8 @@ export async function runPopupSteps<T>(
81
93
  let popup: HTMLElement | undefined;
82
94
  let running = false;
83
95
  let complete = false;
96
+ let confirmation: { submit(): Promise<void> } | undefined;
97
+ let activeStep: symbol | undefined;
84
98
  const alive = () => !controller.signal.aborted && popup === swal.getPopup();
85
99
  const run = async () => {
86
100
  if (running || complete || !alive()) return;
@@ -92,6 +106,9 @@ export async function runPopupSteps<T>(
92
106
  const index = results.length;
93
107
  const step = steps[index];
94
108
  const view = rows[index];
109
+ const token = activeStep = Symbol();
110
+ const current = () => alive() && running && activeStep === token;
111
+ view.content.replaceChildren();
95
112
  view.row.dataset.state = 'active';
96
113
  view.row.setAttribute('aria-current', 'step');
97
114
  view.marker.textContent = String(index + 1);
@@ -102,7 +119,63 @@ export async function runPopupSteps<T>(
102
119
  signal: controller.signal,
103
120
  results: Object.freeze([...results]),
104
121
  setMessage(message) {
105
- if (alive() && running && results.length === index) view.message.textContent = message;
122
+ if (current()) view.message.textContent = message;
123
+ },
124
+ waitForConfirmation<R>(request: PopupStepConfirmationOptions<R>): Promise<R> {
125
+ if (!current()) return Promise.reject(new DOMException('Cancelled', 'AbortError'));
126
+ if (confirmation) return Promise.reject(new Error('Await the current confirmation before requesting another.'));
127
+ return new Promise<R>((resolve, reject) => {
128
+ let validating = false;
129
+ const error = document.createElement('p');
130
+ error.className = 'popup-stepper-validation';
131
+ error.setAttribute('role', 'alert');
132
+ error.hidden = true;
133
+ view.content.replaceChildren(request.content, error);
134
+ view.row.dataset.state = 'waiting';
135
+ view.state.textContent = 'Waiting for you';
136
+ status.textContent = `Step ${index + 1} of ${steps.length}: ${step.title}. Confirm to continue.`;
137
+ list.removeAttribute('aria-busy');
138
+ const cleanup = () => {
139
+ controller.signal.removeEventListener('abort', abort);
140
+ if (confirmation === pending) confirmation = undefined;
141
+ };
142
+ const abort = () => {
143
+ cleanup();
144
+ reject(new DOMException('Cancelled', 'AbortError'));
145
+ };
146
+ const pending = {
147
+ async submit() {
148
+ if (validating || !current()) return;
149
+ validating = true;
150
+ error.hidden = true;
151
+ swal.getConfirmButton()?.setAttribute('disabled', '');
152
+ try {
153
+ const value = await request.readValue();
154
+ if (!current()) return;
155
+ cleanup();
156
+ view.row.dataset.state = 'active';
157
+ view.state.textContent = 'In progress';
158
+ list.setAttribute('aria-busy', 'true');
159
+ swal.update({ showConfirmButton: false });
160
+ resolve(value);
161
+ } catch (reason) {
162
+ if (!current()) return;
163
+ error.textContent = reason instanceof Error ? reason.message : String(reason);
164
+ error.hidden = false;
165
+ view.content.querySelector<HTMLElement>('[aria-invalid="true"], :invalid')?.focus();
166
+ } finally {
167
+ validating = false;
168
+ if (alive()) swal.getConfirmButton()?.removeAttribute('disabled');
169
+ }
170
+ }
171
+ };
172
+ confirmation = pending;
173
+ controller.signal.addEventListener('abort', abort, { once: true });
174
+ swal.update({ showConfirmButton: true, confirmButtonText: request.confirmButtonText || 'Continue' });
175
+ const focusable = 'input:not([type="hidden"]):not(:disabled), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), [tabindex="0"]';
176
+ const focus = request.content.matches(focusable) ? request.content : view.content.querySelector<HTMLElement>(focusable);
177
+ (focus || swal.getConfirmButton())?.focus();
178
+ });
106
179
  }
107
180
  });
108
181
  if (!alive()) return;
@@ -112,6 +185,7 @@ export async function runPopupSteps<T>(
112
185
  view.row.removeAttribute('aria-current');
113
186
  view.marker.textContent = '✓';
114
187
  view.state.textContent = 'Complete';
188
+ if (index < steps.length - 1) view.content.replaceChildren();
115
189
  }
116
190
  if (!alive()) return;
117
191
  complete = true;
@@ -133,6 +207,7 @@ export async function runPopupSteps<T>(
133
207
  confirmButtonText: options.retryButtonText || 'Retry step' });
134
208
  swal.getConfirmButton()?.focus();
135
209
  } finally {
210
+ activeStep = undefined;
136
211
  running = false;
137
212
  list.removeAttribute('aria-busy');
138
213
  }
@@ -151,6 +226,10 @@ export async function runPopupSteps<T>(
151
226
  willClose() { controller.abort(); },
152
227
  didDestroy() { controller.abort(); },
153
228
  preConfirm() {
229
+ if (confirmation) {
230
+ void confirmation.submit();
231
+ return false;
232
+ }
154
233
  if (complete) return [...results];
155
234
  void run();
156
235
  return false;