kmaterialize 2.3.3-1.0.33 → 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
+ });
@@ -1,72 +1,122 @@
1
1
  import { Component, BaseOptions, InitElements, MElement } from '../../src/component';
2
2
 
3
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type
4
- export interface PasswordInputOptions extends BaseOptions {}
3
+ export interface PasswordInputOptions extends BaseOptions {
4
+ /** Accessible name when the password is hidden. */
5
+ showLabel: string;
6
+ /** Accessible name when the password is visible. */
7
+ hideLabel: string;
8
+ }
9
+
10
+ type PasswordInputElement = HTMLInputElement & { M_PasswordInput?: PasswordInput };
5
11
 
6
- const _defaults: PasswordInputOptions = {};
12
+ const _defaults: PasswordInputOptions = {
13
+ showLabel: 'Show password',
14
+ hideLabel: 'Hide password'
15
+ };
7
16
 
8
- // @implement /Users/kzarshenas/Sites/CrazyProject/CrazyPHP/src/Front/Library/Utility/Form/Password.ts
9
- // Show/hide toggle for a password input - no third-party dependency, just
10
- // Materialize's own .prefix/.suffix icon-slot convention (see
11
- // components/textfield/_input-fields.scss), so unlike the other new form
12
- // enhancements in this batch this one needs no peer-loader/dynamic import.
17
+ /**
18
+ * Show or hide a password with an accessible suffix button.
19
+ * Existing data-password-toggle-icon wrappers remain supported.
20
+ */
13
21
  export class PasswordInput extends Component<PasswordInputOptions> {
14
- declare el: HTMLInputElement;
15
- private _suffixEl: HTMLElement | null;
22
+ declare el: PasswordInputElement;
23
+ private _suffixEl: HTMLElement | null;
24
+ private _stateObserver: MutationObserver;
25
+ private _originalAttributes: Map<string, string | null> = new Map();
26
+
27
+ constructor(el: HTMLInputElement, options: Partial<PasswordInputOptions>) {
28
+ super(el, options, PasswordInput);
29
+ this.el.M_PasswordInput = this;
30
+ this.options = { ...PasswordInput.defaults, ...options };
31
+ this._suffixEl = this.el.parentElement?.querySelector<HTMLElement>('[data-password-toggle-icon]') ?? null;
32
+
33
+ if (this._suffixEl) {
34
+ for (const name of ['type', 'role', 'tabindex', 'aria-label', 'aria-pressed', 'aria-controls', 'aria-disabled', 'disabled'])
35
+ this._originalAttributes.set(name, this._suffixEl.getAttribute(name));
36
+
37
+ if (this._suffixEl instanceof HTMLButtonElement) {
38
+ this._suffixEl.type = 'button';
39
+ } else {
40
+ this._suffixEl.setAttribute('role', 'button');
41
+ this._suffixEl.tabIndex = 0;
42
+ }
43
+
44
+ if (this.el.id) this._suffixEl.setAttribute('aria-controls', this.el.id);
45
+ }
46
+
47
+ // Read the actual input type, including an initially visible password.
48
+ this._syncState();
49
+ this._suffixEl?.addEventListener('click', this._handleToggleClick);
50
+ this._suffixEl?.addEventListener('keydown', this._handleToggleKeydown);
51
+ this._stateObserver = new MutationObserver(() => this._syncState());
52
+ this._stateObserver.observe(this.el, { attributes: true, attributeFilter: ['type', 'disabled'] });
53
+ for (let parent = this.el.parentElement; parent; parent = parent.parentElement) {
54
+ if (parent instanceof HTMLFieldSetElement)
55
+ this._stateObserver.observe(parent, { attributes: true, attributeFilter: ['disabled'] });
56
+ }
57
+ }
16
58
 
17
- constructor(el: HTMLInputElement, options: Partial<PasswordInputOptions>) {
18
- super(el, options, PasswordInput);
19
- (this.el as any).M_PasswordInput = this;
59
+ static get defaults(): PasswordInputOptions {
60
+ return _defaults;
61
+ }
20
62
 
21
- this.options = {
22
- ...PasswordInput.defaults,
23
- ...options
63
+ static init(el: HTMLInputElement, options?: Partial<PasswordInputOptions>): PasswordInput;
64
+ static init(els: InitElements<HTMLInputElement | MElement>, options?: Partial<PasswordInputOptions>): PasswordInput[];
65
+ static init(
66
+ els: HTMLInputElement | InitElements<HTMLInputElement | MElement>,
67
+ options: Partial<PasswordInputOptions> = {}
68
+ ): PasswordInput | PasswordInput[] {
69
+ return super.init(els, options, PasswordInput);
70
+ }
71
+
72
+ static getInstance(el: HTMLInputElement): PasswordInput {
73
+ return (el as PasswordInputElement).M_PasswordInput!;
74
+ }
75
+
76
+ /** Remove listeners and restore the supplied button attributes. */
77
+ destroy() {
78
+ this._stateObserver.disconnect();
79
+ this._suffixEl?.removeEventListener('click', this._handleToggleClick);
80
+ this._suffixEl?.removeEventListener('keydown', this._handleToggleKeydown);
81
+ for (const [name, value] of this._originalAttributes) {
82
+ if (value === null) this._suffixEl?.removeAttribute(name);
83
+ else this._suffixEl?.setAttribute(name, value);
84
+ }
85
+ this.el.M_PasswordInput = undefined;
86
+ }
87
+
88
+ /** Toggle without submitting the form or changing its value or selection. */
89
+ toggle() {
90
+ if (this.el.matches(':disabled')) return;
91
+ const start = this.el.selectionStart;
92
+ const end = this.el.selectionEnd;
93
+ const direction = this.el.selectionDirection;
94
+ this.el.type = this.el.type === 'password' ? 'text' : 'password';
95
+ if (start !== null && end !== null) this.el.setSelectionRange(start, end, direction ?? undefined);
96
+ this._syncState();
97
+ }
98
+
99
+ private _syncState() {
100
+ const visible = this.el.type === 'text';
101
+ const disabled = this.el.matches(':disabled');
102
+ this.el.dataset.passwordVisible = visible ? '1' : '0';
103
+ this._suffixEl?.setAttribute('aria-label', visible ? this.options.hideLabel : this.options.showLabel);
104
+ this._suffixEl?.setAttribute('aria-pressed', String(visible));
105
+ this._suffixEl?.setAttribute('aria-disabled', String(disabled));
106
+ if (this._suffixEl instanceof HTMLButtonElement) this._suffixEl.disabled = disabled;
107
+ const icon = this._suffixEl?.querySelector('i');
108
+ if (icon) icon.textContent = visible ? 'visibility_off' : 'visibility';
109
+ }
110
+
111
+ private _handleToggleClick = (event: MouseEvent) => {
112
+ event.preventDefault();
113
+ this.toggle();
24
114
  };
25
115
 
26
- this._suffixEl = this.el.parentElement?.querySelector<HTMLElement>('[data-password-toggle-icon]') ?? null;
27
- if (!this.el.dataset.passwordVisible) this.el.dataset.passwordVisible = '0';
28
- this._setupEventHandlers();
29
- }
30
-
31
- static get defaults(): PasswordInputOptions {
32
- return _defaults;
33
- }
34
-
35
- static init(el: HTMLInputElement, options?: Partial<PasswordInputOptions>): PasswordInput;
36
- static init(
37
- els: InitElements<HTMLInputElement | MElement>,
38
- options?: Partial<PasswordInputOptions>
39
- ): PasswordInput[];
40
- static init(
41
- els: HTMLInputElement | InitElements<HTMLInputElement | MElement>,
42
- options: Partial<PasswordInputOptions> = {}
43
- ): PasswordInput | PasswordInput[] {
44
- return super.init(els, options, PasswordInput);
45
- }
46
-
47
- static getInstance(el: HTMLInputElement): PasswordInput {
48
- return (el as any).M_PasswordInput;
49
- }
50
-
51
- destroy() {
52
- this._removeEventHandlers();
53
- (this.el as any).M_PasswordInput = undefined;
54
- }
55
-
56
- _setupEventHandlers() {
57
- this._suffixEl?.addEventListener('click', this._handleToggleClick);
58
- }
59
-
60
- _removeEventHandlers() {
61
- this._suffixEl?.removeEventListener('click', this._handleToggleClick);
62
- }
63
-
64
- _handleToggleClick = () => {
65
- const visible = this.el.dataset.passwordVisible === '1';
66
- this.el.type = visible ? 'password' : 'text';
67
- this.el.dataset.passwordVisible = visible ? '0' : '1';
68
-
69
- const iconEl = this._suffixEl?.querySelector('i');
70
- if (iconEl) iconEl.textContent = visible ? 'visibility' : 'visibility_off';
71
- };
116
+ private _handleToggleKeydown = (event: KeyboardEvent) => {
117
+ // Native buttons already dispatch clicks for Enter and Space.
118
+ if (this._suffixEl instanceof HTMLButtonElement || !['Enter', ' '].includes(event.key)) return;
119
+ event.preventDefault();
120
+ this.toggle();
121
+ };
72
122
  }