kmaterialize 2.3.3-1.0.27 → 2.3.3-1.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/components/breadcrumb/_breadcrumb.scss +13 -1
  2. package/components/collapsible/_collapsible.scss +1 -1
  3. package/components/extensions/divider.scss +5 -0
  4. package/components/extensions/tooltip-factory.test.mjs +35 -0
  5. package/components/extensions/tooltip-factory.ts +15 -1
  6. package/components/extensions/web/crazy-button.scss +16 -1
  7. package/components/extensions/web/crazy-button.ts +4 -0
  8. package/components/otp-input/_otp-input.scss +94 -0
  9. package/components/otp-input/otp-input.ts +223 -0
  10. package/components/tabs/emptyTabsSpec.js +50 -0
  11. package/components/tabs/tabs.ts +12 -4
  12. package/components/textfield/_input-fields.scss +51 -0
  13. package/components/textfield/_select.scss +37 -3
  14. package/components/textfield/forms.ts +2 -0
  15. package/components/textfield/outlined-notch.ts +81 -0
  16. package/components/textfield/outlinedNotchSpec.js +35 -0
  17. package/components/textfield/outlinedSelectSpec.js +25 -0
  18. package/components/textfield/select.ts +1 -1
  19. package/components/textfield/selectDropdownOwnershipSpec.js +26 -0
  20. package/dist/css/materialize.colors.min.css +1 -1
  21. package/dist/css/materialize.css +366 -12
  22. package/dist/css/materialize.min.css +2 -2
  23. package/dist/js/materialize.cjs.js +374 -9
  24. package/dist/js/materialize.d.ts +59 -4
  25. package/dist/js/materialize.js +374 -9
  26. package/dist/js/materialize.min.js +3 -3
  27. package/dist/js/materialize.mjs +374 -10
  28. package/dist/js/tippy.cjs.js +15 -1
  29. package/dist/js/tippy.mjs +15 -1
  30. package/package.json +1 -1
  31. package/sass/materialize.scss +2 -0
@@ -1,10 +1,21 @@
1
1
  .breadcrumb-wrapper {
2
2
  display: flex;
3
3
  align-items: center;
4
+ min-width: 0;
5
+ max-width: 100%;
6
+ overflow-x: auto;
7
+ white-space: nowrap;
8
+
9
+ // Support both direct breadcrumbs and an optional inner wrapper.
10
+ > * { flex-shrink: 0; }
11
+
12
+ .breadcrumb,
13
+ .breadcrumb > * { flex-shrink: 0; }
4
14
  }
5
15
 
6
16
  .breadcrumb {
7
17
  display: inline-flex;
18
+ vertical-align: middle;
8
19
  align-items: center;
9
20
 
10
21
  i,
@@ -18,10 +29,11 @@
18
29
 
19
30
  &:before {
20
31
  content: '\E5CC';
32
+ opacity: 0.6;
21
33
  font-family: 'Material Symbols Outlined', 'Material Symbols Rounded', 'Material Symbols Sharp', 'Material Icons';
22
34
  }
23
35
 
24
36
  &:first-child:before {
25
- visibility: hidden;
37
+ display: none;
26
38
  }
27
39
  }
@@ -36,7 +36,7 @@
36
36
  outline: 0
37
37
  }
38
38
 
39
- i {
39
+ > i {
40
40
  width: 2rem;
41
41
  font-size: 1.6rem;
42
42
  display: inline-block;
@@ -22,6 +22,11 @@
22
22
  &.divider-middle {
23
23
  margin-inline: var(--divider-inset);
24
24
  }
25
+ &.divider-dashed,
26
+ &.divider-dotted {
27
+ --divider-color: var(--md-sys-color-outline);
28
+ --divider-thickness: 1.5px;
29
+ }
25
30
  &.divider-strong {
26
31
  --divider-color: var(--md-sys-color-outline);
27
32
  }
@@ -0,0 +1,35 @@
1
+ import { afterEach, expect, test, vi } from 'vitest';
2
+ import { createTooltipWith } from './tooltip-factory';
3
+
4
+ afterEach(() => vi.unstubAllGlobals());
5
+
6
+ for (const shape of ['function', 'default', 'nested-default']) {
7
+ test(`creates and cleans up a tooltip from a ${shape} export`, () => {
8
+ vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) });
9
+ const document = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
10
+ vi.stubGlobal('document', document);
11
+ const destroy = vi.fn();
12
+ const tooltip = { hide: vi.fn(), destroy };
13
+ const factory = vi.fn(() => tooltip);
14
+ const plugin = { name: 'animateFill' };
15
+ const module = shape === 'function' ? factory : shape === 'default'
16
+ ? { default: factory, animateFill: plugin }
17
+ : { default: { default: factory, animateFill: plugin } };
18
+ const target = {};
19
+ const result = createTooltipWith(module, undefined, target, 'classic', { content: 'Home & Tools' });
20
+ expect(factory).toHaveBeenCalledWith(target, expect.objectContaining({ content: 'Home & Tools' }));
21
+ if (shape !== 'function') expect(factory.mock.calls[0][1].plugins).toEqual([plugin]);
22
+ document.addEventListener.mock.calls[0][1]({ key: 'Escape' });
23
+ expect(tooltip.hide).toHaveBeenCalledOnce();
24
+ result.destroy();
25
+ expect(destroy).toHaveBeenCalledOnce();
26
+ expect(document.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function));
27
+ });
28
+ }
29
+
30
+ test('rejects missing or cyclic exports with a useful error', () => {
31
+ const cyclic = {}; cyclic.default = cyclic;
32
+ for (const value of [undefined, {}, { default: {} }, cyclic]) {
33
+ expect(() => createTooltipWith(value, undefined, {}, 'classic')).toThrow('did not export a tooltip function');
34
+ }
35
+ });
@@ -11,10 +11,24 @@ export function createTooltipWith(
11
11
  style: TooltipStyle,
12
12
  options: Partial<Props> = {},
13
13
  ): Instance {
14
+ // Dynamic imports of CommonJS builds can wrap Tippy in one or more
15
+ // default exports. Static ESM imports and browser globals are callable already.
16
+ let resolved: unknown = factory;
17
+ const seen = new Set<unknown>();
18
+ while (resolved && typeof resolved === "object" && !seen.has(resolved)) {
19
+ seen.add(resolved);
20
+ const module = resolved as { default?: unknown; animateFill?: typeof animateFill };
21
+ fill ??= module.animateFill;
22
+ resolved = module.default;
23
+ }
24
+ if (typeof resolved !== "function") {
25
+ throw new TypeError('kmaterialize: "tippy.js" did not export a tooltip function.');
26
+ }
27
+ const create = resolved as typeof tippy;
14
28
  const reducedMotion = window.matchMedia(
15
29
  "(prefers-reduced-motion: reduce)",
16
30
  ).matches;
17
- const instance = factory(target, {
31
+ const instance = create(target, {
18
32
  animateFill: !reducedMotion && !!fill,
19
33
  arrow: false,
20
34
  plugins: fill ? [fill] : [],
@@ -1,8 +1,10 @@
1
+ :where(crazy-button, regular-btn) { cursor: pointer; }
2
+ :where(crazy-button[disabled], regular-btn[disabled]) { cursor: default; }
1
3
  :is(crazy-button, regular-btn) {
2
4
  display: inline-flex;
3
5
  vertical-align: middle;
4
6
  max-width: 100%;
5
- > .btn, > .btn-split > .btn { position: relative; }
7
+ > .btn, > .btn-split > .btn { position: relative; cursor: inherit; }
6
8
  .btn > img { width: var(--button-icon); height: var(--button-icon); object-fit: contain; }
7
9
  // Image-only buttons fill the control and follow its round or square shape.
8
10
  .btn.btn-icon[part="button"] > img {
@@ -18,6 +20,19 @@
18
20
  &[size="small"] .btn { --button-height: 30px; }
19
21
  &[size="extra-large"] .btn { --button-height: 100px; }
20
22
  }
23
+ // Headings center their button and text as a row instead of aligning their
24
+ // different font baselines. Keep this default easy to override in app layouts.
25
+ :where(h1, h2, h3, h4, h5, h6):has(> :where(crazy-button, regular-btn)) {
26
+ display: flex;
27
+ align-items: center;
28
+ > :where(crazy-button, regular-btn) {
29
+ flex-shrink: 0;
30
+ // Legacy inline-block overrides must not inherit the heading's line box.
31
+ // The inner .btn defines its own line-height for its label and icon.
32
+ line-height: 0;
33
+ }
34
+ }
35
+
21
36
  .btn-group > :is(crazy-button, regular-btn) {
22
37
  flex: 1 1 0;
23
38
  min-width: 0;
@@ -27,6 +27,7 @@ export default class CrazyButton extends Kmcomponent {
27
27
  wave: string('light', ['light', 'dark', 'false']),
28
28
  'tooltip-style': string('classic', ['classic', 'material']),
29
29
  'tooltip-position': string('top', ['top', 'right', 'bottom', 'left']),
30
+ cursor: string(),
30
31
  label: string(), 'icon-class': string('material-icons'), 'icon-text': string(),
31
32
  'icon-image': string(), 'icon-image-style': string(),
32
33
  'icon-position': string('right', ['left', 'right']),
@@ -93,6 +94,9 @@ export default class CrazyButton extends Kmcomponent {
93
94
  const a = this.prepareContext().attributes;
94
95
  const button = this.querySelector<HTMLElement>('[part="button"]')!;
95
96
  for (const el of this.querySelectorAll<HTMLElement>('.btn')) {
97
+ // CSSOM validates the value and supports keywords, var(), and image URLs.
98
+ // Invalid/empty values fall back to the host cursor; disabled controls keep their default.
99
+ if (!a.disabled && a.cursor) el.style.cursor = String(a.cursor);
96
100
  this.applyColor(el, String(a['color-primary']), false);
97
101
  this.applyColor(el, String(a['color-secondary']), true);
98
102
  }
@@ -0,0 +1,94 @@
1
+ .otp-input {
2
+ --otp-slot-size: 48px;
3
+ --otp-slot-gap: 8px;
4
+ --otp-slot-radius: 4px;
5
+ position: relative;
6
+ width: max-content;
7
+ max-width: 100%;
8
+ margin-block: 8px;
9
+ border-radius: var(--otp-slot-radius);
10
+
11
+ // One native control preserves selection, password semantics, autofill and form validation.
12
+ // Its accessible name comes from the original label; decorative slots are aria-hidden.
13
+ > input.otp-input-native {
14
+ position: absolute !important;
15
+ inset: 0 !important;
16
+ width: 100% !important;
17
+ height: 100% !important;
18
+ margin: 0 !important;
19
+ padding: 0 !important;
20
+ opacity: 0;
21
+ font-size: 16px;
22
+ z-index: 1;
23
+ }
24
+ .otp-input-slots { display: flex; flex-wrap: wrap; align-items: center; gap: var(--otp-slot-gap); }
25
+ .otp-input-slot {
26
+ display: inline-grid;
27
+ place-items: center;
28
+ box-sizing: border-box;
29
+ flex: 0 0 var(--otp-slot-size);
30
+ width: var(--otp-slot-size);
31
+ height: calc(var(--otp-slot-size) + 8px);
32
+ border: 1px solid var(--md-sys-color-on-surface-variant);
33
+ border-radius: var(--otp-slot-radius);
34
+ background: transparent;
35
+ color: var(--md-sys-color-on-surface);
36
+ font-size: 24px;
37
+ font-weight: 500;
38
+ font-variant-numeric: tabular-nums;
39
+ line-height: 1;
40
+ transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
41
+ &.is-selected { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); }
42
+ &.is-active { border: 2px solid var(--input-color, var(--md-sys-color-primary)); }
43
+ }
44
+ .otp-input-group-start { margin-inline-start: var(--otp-slot-gap); }
45
+ .otp-input-separator { color: var(--md-sys-color-on-surface-variant); white-space: pre; }
46
+ &:hover:not(:has(> input:disabled)) .otp-input-slot:not(.is-active) { border-color: var(--md-sys-color-on-surface); }
47
+ &:has(> input:disabled) { opacity: .38; cursor: not-allowed; }
48
+ &[data-invalid], &:has(> input[aria-invalid='true']), &:has(> input:user-invalid) {
49
+ .otp-input-slot { border-color: var(--md-sys-color-error); }
50
+ .otp-input-slot.is-active { border-width: 2px; }
51
+ }
52
+ }
53
+ @media (max-width: 600px) { .otp-input { --otp-slot-size: 36px; --otp-slot-gap: 4px; } }
54
+ @media (prefers-reduced-motion: reduce) { .otp-input .otp-input-slot { transition: none; } }
55
+ @media (forced-colors: active) {
56
+ .otp-input .otp-input-slot { border-color: ButtonText; }
57
+ .otp-input .otp-input-slot.is-active { outline: 2px solid Highlight; outline-offset: 2px; }
58
+ .otp-input:has(> input:disabled) .otp-input-slot { color: GrayText; border-color: GrayText; }
59
+ }
60
+
61
+ // Standard text-field markup: input, label, supporting text. Keep the label
62
+ // above the entire code so it never overlaps an individual slot or separator.
63
+ .input-field:has(> .otp-input) {
64
+ display: flex;
65
+ flex-direction: column;
66
+ align-items: flex-start;
67
+ margin-block: 24px;
68
+ > .otp-input { margin: 0; }
69
+ > label {
70
+ position: static;
71
+ order: -1;
72
+ transform: none;
73
+ margin: 0 0 8px;
74
+ padding: 0;
75
+ font-size: 12px;
76
+ line-height: 1.4;
77
+ background: transparent;
78
+ }
79
+ > .supporting-text { display: block; padding-inline: 0; }
80
+ &:focus-within > label { color: var(--input-color); }
81
+ &:has(input:disabled) > label { opacity: .38; }
82
+ &.error, &:has(input[aria-invalid='true']), &:has(.otp-input[data-invalid]), &:has(input:user-invalid) {
83
+ > label, > .supporting-text { color: var(--md-sys-color-error); }
84
+ .otp-input-slot { border-color: var(--md-sys-color-error); }
85
+ }
86
+ }
87
+
88
+ // Match the standard text field: omit .outlined for bottom borders only.
89
+ // Set widths separately so focus, validation and forced-color tokens still apply.
90
+ .input-field:not(.outlined) > .otp-input .otp-input-slot {
91
+ border-width: 0 0 1px;
92
+ border-radius: var(--otp-slot-radius) var(--otp-slot-radius) 0 0;
93
+ &.is-active { border-width: 0 0 2px; }
94
+ }
@@ -0,0 +1,223 @@
1
+ import type { MaskitoOptions } from '@maskito/core';
2
+ import { Component, BaseOptions, InitElements, MElement } from '../../src/component';
3
+ import { MaskitoInput } from '../maskito-input/maskito-input';
4
+
5
+ export interface OtpInputOptions extends BaseOptions {
6
+ /** Number of editable slots (1–32); inferred when pattern is supplied. */
7
+ length?: number;
8
+ characters?: 'digits' | 'alphanumeric';
9
+ /** # = digit, A = ASCII letter, * = alphanumeric; backslash escapes literals. */
10
+ pattern?: string;
11
+ /** Maskito processing and overwrite behavior. The slot pattern owns the mask. */
12
+ maskOptions?: Partial<Omit<MaskitoOptions, 'mask'>>;
13
+ /** Visually group slots without adding separators to the submitted value. */
14
+ groupSize?: number;
15
+ /** Hide entered characters, using a native password input. */
16
+ masked?: boolean;
17
+ onComplete?: (value: string, instance: OtpInput) => void;
18
+ }
19
+
20
+ type Slot = { index: number; expression: string; literal?: string };
21
+ const tokenExpressions: Record<string, string> = { '#': '[0-9]', A: '[a-zA-Z]', '*': '[a-zA-Z0-9]' };
22
+ const escapePattern = (value: string) => value.replace(/[\\^$.*+?()[\]{}|/\-]/g, '\\$&');
23
+
24
+ /** A single accessible native input, rendered as Material OTP/PIN slots. Requires @maskito/core. */
25
+ export class OtpInput extends Component<OtpInputOptions> {
26
+ declare el: HTMLInputElement;
27
+ ready: Promise<void>;
28
+ private _mask?: MaskitoInput;
29
+ private _wrapper?: HTMLDivElement;
30
+ private _cells: Array<{ slot: Slot; el: HTMLSpanElement }> = [];
31
+ private _slots: Slot[];
32
+ private _attributes = new Map<string, string | null>();
33
+ private _observer?: MutationObserver;
34
+ private _form: HTMLFormElement | null;
35
+ private _resetTimer?: ReturnType<typeof setTimeout>;
36
+ private _destroyed = false;
37
+ private _lastComplete = '';
38
+ private _hadClass: boolean;
39
+
40
+ constructor(el: HTMLInputElement, options: Partial<OtpInputOptions> = {}) {
41
+ if (!['text', 'tel', 'password'].includes(el.type)) throw new TypeError('OtpInput requires a text, tel, or password input.');
42
+ const settings = { ...OtpInput.defaults, ...OtpInput._markup(el), ...options };
43
+ if (!['digits', 'alphanumeric'].includes(settings.characters!)) throw new TypeError('Unknown OTP character set.');
44
+ if (!Number.isInteger(settings.groupSize) || settings.groupSize! < 0 || settings.groupSize! > 32) throw new TypeError('OTP groupSize must be an integer between 0 and 32.');
45
+ if (settings.length !== undefined && (!Number.isInteger(settings.length) || settings.length < 1 || settings.length > 32)) throw new TypeError('OTP length must be an integer between 1 and 32.');
46
+ const pattern = settings.pattern ?? (settings.characters === 'alphanumeric' ? '*' : '#').repeat(settings.length ?? 6);
47
+ const slots: Slot[] = [];
48
+ let escaped = false;
49
+ for (const char of pattern) {
50
+ if (!escaped && char === '\\') { escaped = true; continue; }
51
+ const expression = !escaped && tokenExpressions[char];
52
+ if (char.length !== 1) throw new TypeError('OTP patterns support single UTF-16 code-unit characters.');
53
+ slots.push({ index: slots.length, expression: expression || escapePattern(char), ...(!expression ? { literal: char } : {}) });
54
+ escaped = false;
55
+ }
56
+ const length = slots.filter(slot => slot.literal === undefined).length;
57
+ if (escaped || length < 1 || length > 32 || slots.length > 64) throw new TypeError('OTP pattern must contain 1–32 editable slots and at most 64 characters, with paired escapes.');
58
+ if (settings.pattern && settings.length !== undefined && settings.length !== length) throw new TypeError('OTP length must match the editable slots in the pattern.');
59
+ // Validate before replacing an existing instance.
60
+ super(el, options, OtpInput);
61
+ this.el['M_OtpInput'] = this;
62
+ this.options = { ...settings, pattern, length };
63
+ this._slots = slots;
64
+ this._form = el.form;
65
+ this._hadClass = el.classList.contains('otp-input-native');
66
+ this.ready = this._setup().catch(error => { this.destroy(); throw error; });
67
+ }
68
+ static get defaults(): OtpInputOptions { return { characters: 'digits', groupSize: 0 }; }
69
+ static init(el: HTMLInputElement, options?: Partial<OtpInputOptions>): OtpInput;
70
+ static init(els: InitElements<HTMLInputElement | MElement>, options?: Partial<OtpInputOptions>): OtpInput[];
71
+ static init(els: HTMLInputElement | InitElements<HTMLInputElement | MElement>, options: Partial<OtpInputOptions> = {}): OtpInput | OtpInput[] {
72
+ return super.init(els, options, OtpInput);
73
+ }
74
+ static getInstance(el: HTMLInputElement): OtpInput { return el['M_OtpInput']; }
75
+ private static _markup(el: HTMLInputElement): Partial<OtpInputOptions> {
76
+ return {
77
+ ...(el.dataset.otpLength !== undefined ? { length: Number(el.dataset.otpLength) } : {}),
78
+ ...(el.dataset.otpPattern !== undefined ? { pattern: el.dataset.otpPattern } : {}),
79
+ ...(el.dataset.otpGroupSize !== undefined ? { groupSize: Number(el.dataset.otpGroupSize) } : {}),
80
+ ...(el.dataset.otpCharacters ? { characters: el.dataset.otpCharacters as OtpInputOptions['characters'] } : {}),
81
+ ...(el.dataset.otpMasked !== undefined ? { masked: el.dataset.otpMasked !== 'false' } : {})
82
+ };
83
+ }
84
+ private _setAttribute(name: string, value: string) {
85
+ this._attributes.set(name, this.el.getAttribute(name));
86
+ this.el.setAttribute(name, value);
87
+ }
88
+ private async _setup() {
89
+ this._mask = MaskitoInput.init(this.el, {
90
+ preset: 'pattern', pattern: this.options.pattern,
91
+ maskOptions: { overwriteMode: 'replace', ...this.options.maskOptions, mask: this._slots.map(slot => slot.literal ?? new RegExp(slot.expression)) }
92
+ });
93
+ await this._mask.ready;
94
+ if (this._destroyed) return;
95
+ this._setAttribute('maxlength', String(this._slots.length));
96
+ if (!this.el.hasAttribute('pattern')) this._setAttribute('pattern', this._slots.map(slot => slot.expression).join(''));
97
+ if (!this.el.hasAttribute('inputmode')) this._setAttribute('inputmode', this._slots.every(slot => slot.literal !== undefined || slot.expression === '[0-9]') ? 'numeric' : 'text');
98
+ if (this.options.masked !== undefined) this._setAttribute('type', this.options.masked ? 'password' : 'text');
99
+ if (!this.el.hasAttribute('autocomplete')) this._setAttribute('autocomplete', this.el.type === 'password' ? 'off' : 'one-time-code');
100
+ if (!this.el.hasAttribute('spellcheck')) this._setAttribute('spellcheck', 'false');
101
+ if (!this.el.hasAttribute('autocapitalize')) this._setAttribute('autocapitalize', 'off');
102
+ const wrapper = this._wrapper = document.createElement('div');
103
+ wrapper.className = 'otp-input';
104
+ wrapper.dir = 'ltr';
105
+ const cells = document.createElement('div');
106
+ cells.className = 'otp-input-slots';
107
+ cells.setAttribute('aria-hidden', 'true');
108
+ let editableIndex = 0;
109
+ for (const slot of this._slots) {
110
+ const cell = document.createElement('span');
111
+ cell.className = slot.literal !== undefined ? 'otp-input-separator' : 'otp-input-slot';
112
+ if (slot.literal !== undefined) cell.textContent = slot.literal;
113
+ else {
114
+ if (this.options.groupSize && editableIndex && editableIndex % this.options.groupSize === 0) cell.classList.add('otp-input-group-start');
115
+ editableIndex++;
116
+ }
117
+ cells.append(cell);
118
+ this._cells.push({ slot, el: cell });
119
+ }
120
+ this.el.before(wrapper);
121
+ wrapper.append(this.el, cells);
122
+ this.el.classList.add('otp-input-native');
123
+ // Register after Maskito so all displays and completion events see the formatted value.
124
+ this.el.addEventListener('input', this._onInput);
125
+ for (const event of ['focus', 'blur', 'keyup', 'click', 'select']) this.el.addEventListener(event, this._onSelection);
126
+ this.el.addEventListener('invalid', this._onInvalid);
127
+ document.addEventListener('selectionchange', this._onSelection);
128
+ wrapper.addEventListener('pointerdown', this._onPointerDown);
129
+ this._form?.addEventListener('reset', this._onReset);
130
+ this._observer = new MutationObserver(() => this._render(false));
131
+ this._observer.observe(this.el, { attributes: true, attributeFilter: ['type', 'disabled', 'readonly', 'aria-invalid'] });
132
+ this._render(false);
133
+ }
134
+ getValue(): string { return this.el.value; }
135
+ /** Editable characters only; pattern separators are omitted. */
136
+ getUnmaskedValue(): string { return this._slots.filter(slot => slot.literal === undefined).map(slot => this.el.value[slot.index] || '').join(''); }
137
+ isComplete(): boolean { return new RegExp(`^(?:${this._slots.map(slot => slot.expression).join('')})$`).test(this.el.value); }
138
+ async setValue(value: string, emit = true): Promise<void> {
139
+ await this.ready;
140
+ if (this._destroyed) return;
141
+ await this._mask!.setValue(value, emit);
142
+ this._render(false);
143
+ }
144
+ async clear(emit = true): Promise<void> { await this.setValue('', emit); }
145
+ async refresh(): Promise<void> {
146
+ await this.ready;
147
+ if (this._destroyed) return;
148
+ await this._mask!.refresh();
149
+ this._render(false);
150
+ }
151
+ focus(): void { if (!this._destroyed) this.el.focus(); }
152
+ private _onInput = () => {
153
+ if (this._wrapper) this._wrapper.removeAttribute('data-invalid');
154
+ this._render(true);
155
+ };
156
+ private _onSelection = () => { this._render(false); };
157
+ private _onInvalid = () => { this._wrapper?.setAttribute('data-invalid', 'true'); };
158
+ private _onReset = () => {
159
+ clearTimeout(this._resetTimer);
160
+ this._resetTimer = setTimeout(() => {
161
+ this._wrapper?.removeAttribute('data-invalid');
162
+ this._render(false);
163
+ }, 0);
164
+ };
165
+ private _onPointerDown = (event: PointerEvent) => {
166
+ if (event.button !== 0 || this.el.matches(':disabled')) return;
167
+ const cells = this._cells.filter(cell => cell.slot.literal === undefined);
168
+ let nearest = cells[0], distance = Infinity;
169
+ for (const cell of cells) {
170
+ const rect = cell.el.getBoundingClientRect();
171
+ const next = Math.abs(event.clientX - (rect.left + rect.width / 2)) + Math.abs(event.clientY - (rect.top + rect.height / 2));
172
+ if (next < distance) { nearest = cell; distance = next; }
173
+ }
174
+ event.preventDefault();
175
+ this.el.focus();
176
+ const index = Math.min(nearest.slot.index, this.el.value.length);
177
+ this.el.setSelectionRange(index, Math.min(index + 1, this.el.value.length));
178
+ this._render(false);
179
+ };
180
+ private _render(emit: boolean) {
181
+ if (this._destroyed || !this._wrapper) return;
182
+ const focused = document.activeElement === this.el;
183
+ const start = this.el.selectionStart ?? 0, end = this.el.selectionEnd ?? start;
184
+ const editable = this._cells.filter(cell => cell.slot.literal === undefined);
185
+ const active = editable.find(cell => cell.slot.index >= start) ?? editable[editable.length - 1];
186
+ for (const cell of editable) {
187
+ const value = this.el.value[cell.slot.index] || '';
188
+ cell.el.textContent = value ? (this.el.type === 'password' ? '•' : value) : '';
189
+ cell.el.classList.toggle('is-active', focused && cell === active);
190
+ cell.el.classList.toggle('is-selected', focused && cell.slot.index >= start && cell.slot.index < end);
191
+ cell.el.classList.toggle('is-filled', !!value);
192
+ }
193
+ this._wrapper.classList.toggle('is-complete', this.isComplete());
194
+ const complete = this.isComplete() ? this.el.value : '';
195
+ const changed = complete !== this._lastComplete;
196
+ this._lastComplete = complete;
197
+ if (emit && complete && changed) {
198
+ this.el.dispatchEvent(new CustomEvent('otpcomplete', { bubbles: true, detail: { value: complete, unmaskedValue: this.getUnmaskedValue() } }));
199
+ this.options.onComplete?.(complete, this);
200
+ }
201
+ }
202
+ destroy(): void {
203
+ if (this._destroyed) return;
204
+ this._destroyed = true;
205
+ clearTimeout(this._resetTimer);
206
+ this._observer?.disconnect();
207
+ this._mask?.destroy();
208
+ this.el.removeEventListener('input', this._onInput);
209
+ for (const event of ['focus', 'blur', 'keyup', 'click', 'select']) this.el.removeEventListener(event, this._onSelection);
210
+ this.el.removeEventListener('invalid', this._onInvalid);
211
+ document.removeEventListener('selectionchange', this._onSelection);
212
+ this._wrapper?.removeEventListener('pointerdown', this._onPointerDown);
213
+ this._form?.removeEventListener('reset', this._onReset);
214
+ if (this._wrapper?.contains(this.el)) this._wrapper.replaceWith(this.el);
215
+ else this._wrapper?.remove();
216
+ if (!this._hadClass) this.el.classList.remove('otp-input-native');
217
+ for (const [name, value] of this._attributes) {
218
+ if (value === null) this.el.removeAttribute(name);
219
+ else this.el.setAttribute(name, value);
220
+ }
221
+ if (OtpInput.getInstance(this.el) === this) this.el['M_OtpInput'] = undefined;
222
+ }
223
+ }
@@ -0,0 +1,50 @@
1
+ describe('Tabs with no tab links', () => {
2
+ let element;
3
+ let instance;
4
+
5
+ beforeEach(() => {
6
+ element = document.createElement('ul');
7
+ element.className = 'tabs';
8
+ document.body.appendChild(element);
9
+ });
10
+
11
+ afterEach(() => {
12
+ if (instance) instance.destroy();
13
+ instance = null;
14
+ element.remove();
15
+ });
16
+
17
+ [false, true].forEach((swipeable) => {
18
+ it(`handles an empty list with swipeable=${swipeable}`, () => {
19
+ instance = M.Tabs.init(element, { swipeable });
20
+ expect(M.Tabs.getInstance(element)).toBe(instance);
21
+ expect(instance.index).toBe(-1);
22
+ expect(element.querySelector('.indicator')).toBeNull();
23
+ expect(() => {
24
+ instance.updateTabIndicator();
25
+ instance.select('missing');
26
+ window.dispatchEvent(new Event('resize'));
27
+ element.click();
28
+ instance.destroy();
29
+ }).not.toThrow();
30
+ expect(M.Tabs.getInstance(element)).toBeUndefined();
31
+ instance = null;
32
+ });
33
+ });
34
+
35
+ it('ignores placeholder markup without tab anchors', () => {
36
+ element.innerHTML = '<li class="tab">Loading…</li>';
37
+ instance = M.Tabs.init(element);
38
+ expect(instance.index).toBe(-1);
39
+ });
40
+
41
+ it('can initialize normally after links are added', () => {
42
+ instance = M.Tabs.init(element);
43
+ element.innerHTML = '<li class="tab"><a href="#missing-panel">First</a></li>';
44
+ instance = M.Tabs.init(element);
45
+ expect(instance.index).toBe(0);
46
+ expect(element.querySelector('a').classList.contains('active')).toBeTrue();
47
+ expect(element.querySelector('.indicator')).not.toBeNull();
48
+ expect(() => instance.updateTabIndicator()).not.toThrow();
49
+ });
50
+ });
@@ -40,7 +40,7 @@ export class Tabs extends Component<TabsOptions> {
40
40
  _tabWidth: number;
41
41
  _tabsWidth: number;
42
42
  _tabsCarousel: Carousel;
43
- _activeTabLink: HTMLAnchorElement;
43
+ _activeTabLink: HTMLAnchorElement | null;
44
44
  _content: HTMLElement;
45
45
 
46
46
  constructor(el: HTMLElement, options: Partial<TabsOptions>) {
@@ -55,6 +55,8 @@ export class Tabs extends Component<TabsOptions> {
55
55
  this._tabLinks = this.el.querySelectorAll('li.tab > a');
56
56
  this._index = 0;
57
57
  this._setupActiveTabLink();
58
+ // Empty/unfinished tab markup is a valid inert instance. Reinitialize after adding links.
59
+ if (!this._activeTabLink) return;
58
60
  if (this.options.swipeable) {
59
61
  this._setupSwipeableTabs();
60
62
  } else {
@@ -100,9 +102,9 @@ export class Tabs extends Component<TabsOptions> {
100
102
 
101
103
  destroy() {
102
104
  this._removeEventHandlers();
103
- this._indicator.parentNode.removeChild(this._indicator);
105
+ this._indicator?.remove();
104
106
  if (this.options.swipeable) {
105
- this._teardownSwipeableTabs();
107
+ if (this._tabsCarousel) this._teardownSwipeableTabs();
106
108
  } else {
107
109
  this._teardownNormalTabs();
108
110
  }
@@ -134,6 +136,7 @@ export class Tabs extends Component<TabsOptions> {
134
136
  }
135
137
 
136
138
  _handleWindowResize = () => {
139
+ if (!this._activeTabLink || !this._indicator) return;
137
140
  this._setTabsAndTabWidth();
138
141
  if (this._tabWidth !== 0 && this._tabsWidth !== 0) {
139
142
  this._indicator.style.left = this._calcLeftPos(this._activeTabLink) + 'px';
@@ -152,7 +155,7 @@ export class Tabs extends Component<TabsOptions> {
152
155
  }
153
156
 
154
157
  // Handle click on tab link only
155
- if (!tabLink || !tab.classList.contains('tab')) return;
158
+ if (!tabLink || !tab || !tab.classList.contains('tab')) return;
156
159
  // is disabled?
157
160
  if (tab.classList.contains('disabled')) {
158
161
  e.preventDefault();
@@ -221,6 +224,10 @@ export class Tabs extends Component<TabsOptions> {
221
224
  }
222
225
  this._activeTabLink = activeTabLink as HTMLAnchorElement;
223
226
  }
227
+ if (!this._activeTabLink) {
228
+ this._index = -1;
229
+ return;
230
+ }
224
231
  Array.from(this._tabLinks).forEach((a: HTMLAnchorElement) => a.classList.remove('active'));
225
232
  this._activeTabLink.classList.add('active');
226
233
 
@@ -324,6 +331,7 @@ export class Tabs extends Component<TabsOptions> {
324
331
  * the indicator position is not correct.
325
332
  */
326
333
  updateTabIndicator() {
334
+ if (!this._activeTabLink || !this._indicator) return;
327
335
  this._setTabsAndTabWidth();
328
336
  this._animateIndicator(this._index);
329
337
  }
@@ -371,3 +371,54 @@ textarea {
371
371
  text-indent: -9999px;
372
372
  }
373
373
  }
374
+
375
+ // Native legend cuts a real notch: no background-color patch is needed.
376
+ .input-field.outlined.has-outlined-notch,
377
+ .input-field.outlined > .select-wrapper.has-outlined-notch {
378
+ > input:not([type=checkbox]):not([type=radio]), > textarea {
379
+ border-color: transparent;
380
+ &:focus:not([readonly]), &:disabled, &[readonly] { border-color: transparent; }
381
+ + label,
382
+ &:focus:not([readonly]) + label { background-color: transparent; }
383
+ }
384
+ > .input-outline {
385
+ position: absolute;
386
+ pointer-events: none;
387
+ box-sizing: border-box;
388
+ min-width: 0;
389
+ margin: 0;
390
+ padding: 0 12px;
391
+ border: 1px solid var(--md-sys-color-on-surface-variant);
392
+ border-radius: 4px;
393
+ background: transparent;
394
+ > legend {
395
+ float: none;
396
+ width: auto;
397
+ display: block;
398
+ max-width: 0.01px;
399
+ height: 16px;
400
+ padding: 0;
401
+ font: inherit;
402
+ font-size: 12px;
403
+ line-height: 16px;
404
+ visibility: hidden;
405
+ white-space: nowrap;
406
+ overflow: hidden;
407
+ > span { display: inline-block; padding: 0 3px; }
408
+ }
409
+ }
410
+ > input:focus:not([readonly]) ~ .input-outline,
411
+ > textarea:focus:not([readonly]) ~ .input-outline {
412
+ border-width: 2px;
413
+ border-color: var(--input-color);
414
+ padding-inline: 11px;
415
+ }
416
+ > :is(input, textarea):is(:focus:not([readonly]), :not([placeholder=' ']), :not(:placeholder-shown)) ~ .input-outline > legend {
417
+ max-width: 100%;
418
+ }
419
+ > :is(input, textarea):is(:disabled, [readonly]:not(.select-dropdown)) ~ .input-outline {
420
+ border-color: color-mix(in srgb, transparent, var(--md-sys-color-on-surface) 12%);
421
+ }
422
+ > :is(input, textarea).invalid ~ .input-outline,
423
+ &.error > .input-outline { border-color: var(--md-sys-color-error); }
424
+ }