kmaterialize 2.3.3-1.0.28 → 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.
- package/components/breadcrumb/_breadcrumb.scss +13 -1
- package/components/collapsible/_collapsible.scss +1 -1
- package/components/extensions/divider.scss +5 -0
- package/components/extensions/tooltip-factory.test.mjs +35 -0
- package/components/extensions/tooltip-factory.ts +15 -1
- package/components/extensions/web/crazy-button.scss +13 -0
- package/components/tabs/emptyTabsSpec.js +50 -0
- package/components/tabs/tabs.ts +12 -4
- package/components/textfield/_input-fields.scss +51 -0
- package/components/textfield/_select.scss +37 -3
- package/components/textfield/forms.ts +2 -0
- package/components/textfield/outlined-notch.ts +81 -0
- package/components/textfield/outlinedNotchSpec.js +35 -0
- package/components/textfield/outlinedSelectSpec.js +25 -0
- package/components/textfield/select.ts +1 -1
- package/components/textfield/selectDropdownOwnershipSpec.js +26 -0
- package/dist/css/materialize.colors.min.css +1 -1
- package/dist/css/materialize.css +184 -12
- package/dist/css/materialize.min.css +2 -2
- package/dist/js/materialize.cjs.js +128 -7
- package/dist/js/materialize.d.ts +2 -2
- package/dist/js/materialize.js +128 -7
- package/dist/js/materialize.min.js +3 -3
- package/dist/js/materialize.mjs +128 -7
- package/dist/js/tippy.cjs.js +15 -1
- package/dist/js/tippy.mjs +15 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
37
|
+
display: none;
|
|
26
38
|
}
|
|
27
39
|
}
|
|
@@ -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 =
|
|
31
|
+
const instance = create(target, {
|
|
18
32
|
animateFill: !reducedMotion && !!fill,
|
|
19
33
|
arrow: false,
|
|
20
34
|
plugins: fill ? [fill] : [],
|
|
@@ -20,6 +20,19 @@
|
|
|
20
20
|
&[size="small"] .btn { --button-height: 30px; }
|
|
21
21
|
&[size="extra-large"] .btn { --button-height: 100px; }
|
|
22
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
|
+
|
|
23
36
|
.btn-group > :is(crazy-button, regular-btn) {
|
|
24
37
|
flex: 1 1 0;
|
|
25
38
|
min-width: 0;
|
|
@@ -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
|
+
});
|
package/components/tabs/tabs.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -119,11 +119,11 @@ select:disabled {
|
|
|
119
119
|
.input-field > .select-wrapper:has(.hide-select > select[placeholder]:not([placeholder=""])) > label,
|
|
120
120
|
.input-field > .select-wrapper:focus-within > label {
|
|
121
121
|
position: absolute !important;
|
|
122
|
-
top:
|
|
122
|
+
top: 4px !important;
|
|
123
123
|
left: 16px !important;
|
|
124
|
-
padding: 0
|
|
124
|
+
padding: 0 !important;
|
|
125
125
|
font-size: 16px !important;
|
|
126
|
-
background:
|
|
126
|
+
background: transparent;
|
|
127
127
|
transform: scale(0.75) !important;
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -156,3 +156,37 @@ select:disabled {
|
|
|
156
156
|
padding-left: 1rem;
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
+
|
|
160
|
+
// Generated select inputs are readonly for typing, but remain interactive.
|
|
161
|
+
// They must not inherit the filled input's extra top padding or disabled outline.
|
|
162
|
+
.input-field.outlined > .select-wrapper {
|
|
163
|
+
&:focus-within { outline: none; }
|
|
164
|
+
> input.select-dropdown,
|
|
165
|
+
> input.select-dropdown:not([type=checkbox]):not([type=radio]):not(:focus):not(:placeholder-shown) {
|
|
166
|
+
padding-top: 0;
|
|
167
|
+
padding-bottom: 0;
|
|
168
|
+
padding-right: 40px;
|
|
169
|
+
}
|
|
170
|
+
&.has-outlined-notch {
|
|
171
|
+
> input.select-dropdown + label { background: transparent; }
|
|
172
|
+
> .input-outline { border-color: var(--md-sys-color-on-surface-variant); }
|
|
173
|
+
&:focus-within > .input-outline {
|
|
174
|
+
border-color: var(--input-color);
|
|
175
|
+
border-width: 2px;
|
|
176
|
+
padding-inline: 11px;
|
|
177
|
+
> legend { max-width: 100%; }
|
|
178
|
+
}
|
|
179
|
+
&.disabled > .input-outline {
|
|
180
|
+
border-color: color-mix(in srgb, transparent, var(--md-sys-color-on-surface) 12%);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Filled selects share the text field's in-field floating label layout.
|
|
186
|
+
.input-field:not(.outlined) > .select-wrapper:has(> label) {
|
|
187
|
+
> input.select-dropdown:not([type=checkbox]):not([type=radio]):not(:placeholder-shown),
|
|
188
|
+
&:focus-within > input.select-dropdown:not([type=checkbox]):not([type=radio]) {
|
|
189
|
+
padding-top: 12px;
|
|
190
|
+
padding-right: 40px;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { initOutlinedNotches } from './outlined-notch';
|
|
1
2
|
import { Utils } from '../../src/utils';
|
|
2
3
|
|
|
3
4
|
export class Forms {
|
|
@@ -110,6 +111,7 @@ export class Forms {
|
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
static Init() {
|
|
114
|
+
initOutlinedNotches();
|
|
113
115
|
if (typeof document !== 'undefined')
|
|
114
116
|
document?.addEventListener('DOMContentLoaded', () => {
|
|
115
117
|
document.addEventListener('change', (e: KeyboardEvent) => {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Decorative native fieldset notches; the original input and label remain accessible. */
|
|
2
|
+
export function initOutlinedNotches() {
|
|
3
|
+
if (typeof document === 'undefined') return;
|
|
4
|
+
const start = () => {
|
|
5
|
+
const selector = '.input-field.outlined, .input-field.outlined > .select-wrapper';
|
|
6
|
+
const entries = new Map<HTMLElement, { input: HTMLElement; outline: HTMLFieldSetElement }>();
|
|
7
|
+
const position = (field: HTMLElement) => {
|
|
8
|
+
const entry = entries.get(field);
|
|
9
|
+
if (!entry) return;
|
|
10
|
+
const { input, outline } = entry;
|
|
11
|
+
outline.style.left = `${input.offsetLeft}px`;
|
|
12
|
+
outline.style.top = `${input.offsetTop - 8}px`;
|
|
13
|
+
outline.style.width = `${input.offsetWidth}px`;
|
|
14
|
+
outline.style.height = `${input.offsetHeight + 8}px`;
|
|
15
|
+
};
|
|
16
|
+
const resize = new ResizeObserver(records => records.forEach(record => {
|
|
17
|
+
const field = (record.target as HTMLElement).parentElement;
|
|
18
|
+
if (field) position(field);
|
|
19
|
+
}));
|
|
20
|
+
const sync = (field: HTMLElement) => {
|
|
21
|
+
const input = field.querySelector<HTMLElement>(':scope > input:not([type=checkbox]):not([type=radio]):not([type=hidden]), :scope > textarea');
|
|
22
|
+
const label = input?.nextElementSibling;
|
|
23
|
+
const old = entries.get(field);
|
|
24
|
+
if (!field.isConnected || !field.matches(selector) || !input || label?.tagName !== 'LABEL') {
|
|
25
|
+
if (old) {
|
|
26
|
+
resize.unobserve(old.input);
|
|
27
|
+
old.outline.remove();
|
|
28
|
+
entries.delete(field);
|
|
29
|
+
field.classList.remove('has-outlined-notch');
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (old && old.input !== input) {
|
|
34
|
+
resize.unobserve(old.input);
|
|
35
|
+
old.outline.remove();
|
|
36
|
+
entries.delete(field);
|
|
37
|
+
}
|
|
38
|
+
let entry = entries.get(field);
|
|
39
|
+
if (!entry) {
|
|
40
|
+
const outline = document.createElement('fieldset');
|
|
41
|
+
outline.className = 'input-outline';
|
|
42
|
+
outline.setAttribute('aria-hidden', 'true');
|
|
43
|
+
const legend = document.createElement('legend');
|
|
44
|
+
legend.appendChild(document.createElement('span'));
|
|
45
|
+
outline.appendChild(legend);
|
|
46
|
+
entry = { input, outline };
|
|
47
|
+
entries.set(field, entry);
|
|
48
|
+
field.appendChild(outline);
|
|
49
|
+
field.classList.add('has-outlined-notch');
|
|
50
|
+
resize.observe(input);
|
|
51
|
+
}
|
|
52
|
+
const text = entry.outline.querySelector('span');
|
|
53
|
+
if (text.textContent !== label.textContent) text.textContent = label.textContent;
|
|
54
|
+
position(field);
|
|
55
|
+
};
|
|
56
|
+
document.querySelectorAll<HTMLElement>(selector).forEach(sync);
|
|
57
|
+
const observer = new MutationObserver(records => {
|
|
58
|
+
const fields = new Set<HTMLElement>();
|
|
59
|
+
for (const record of records) {
|
|
60
|
+
const target = record.target instanceof Element ? record.target : record.target.parentElement;
|
|
61
|
+
if (target?.closest('.input-outline')) continue;
|
|
62
|
+
const field = target?.closest<HTMLElement>('.input-field');
|
|
63
|
+
if (field) fields.add(field);
|
|
64
|
+
for (const node of record.addedNodes) {
|
|
65
|
+
if (!(node instanceof HTMLElement) || node.matches('.input-outline')) continue;
|
|
66
|
+
if (node.matches(selector)) fields.add(node);
|
|
67
|
+
node.querySelectorAll<HTMLElement>(selector).forEach(el => fields.add(el));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Release observers for detached partials; reinsertion creates a fresh outline.
|
|
71
|
+
for (const field of entries.keys()) if (!field.isConnected) fields.add(field);
|
|
72
|
+
for (const field of [...fields]) {
|
|
73
|
+
field.querySelectorAll<HTMLElement>(selector).forEach(el => fields.add(el));
|
|
74
|
+
}
|
|
75
|
+
fields.forEach(sync);
|
|
76
|
+
});
|
|
77
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'placeholder', 'type'] });
|
|
78
|
+
};
|
|
79
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
80
|
+
else start();
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
describe('Outlined field background-independent notch', () => {
|
|
2
|
+
let field;
|
|
3
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 60));
|
|
4
|
+
beforeEach(async () => {
|
|
5
|
+
field = document.createElement('div');
|
|
6
|
+
field.className = 'input-field outlined';
|
|
7
|
+
field.style.background = 'linear-gradient(to right, #30393d, #546e7a)';
|
|
8
|
+
field.innerHTML = '<input id="notch-regression" placeholder=" "><label for="notch-regression">Email subject</label>';
|
|
9
|
+
document.body.append(field);
|
|
10
|
+
await settle();
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => field.remove());
|
|
13
|
+
it('keeps the accessible label and opens a real notch on focus', async () => {
|
|
14
|
+
const input = field.querySelector('input');
|
|
15
|
+
const label = field.querySelector('label');
|
|
16
|
+
const outline = field.querySelector('.input-outline');
|
|
17
|
+
expect(input.labels[0]).toBe(label);
|
|
18
|
+
expect(outline.getAttribute('aria-hidden')).toBe('true');
|
|
19
|
+
expect(outline.querySelector('legend').getBoundingClientRect().width).toBeLessThan(1);
|
|
20
|
+
input.focus();
|
|
21
|
+
await settle();
|
|
22
|
+
expect(outline.querySelector('legend').getBoundingClientRect().width).toBeGreaterThan(10);
|
|
23
|
+
expect(getComputedStyle(label).backgroundColor).toBe('rgba(0, 0, 0, 0)');
|
|
24
|
+
expect(getComputedStyle(input).borderTopColor).toBe('rgba(0, 0, 0, 0)');
|
|
25
|
+
});
|
|
26
|
+
it('updates label text and removes the outline when switching to a filled field', async () => {
|
|
27
|
+
field.querySelector('label').textContent = 'New label';
|
|
28
|
+
await settle();
|
|
29
|
+
expect(field.querySelector('legend').textContent).toBe('New label');
|
|
30
|
+
expect(field.querySelectorAll('.input-outline').length).toBe(1);
|
|
31
|
+
field.classList.remove('outlined');
|
|
32
|
+
await settle();
|
|
33
|
+
expect(field.querySelector('.input-outline')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
describe('Outlined FormSelect notch', () => {
|
|
2
|
+
it('centers the display text and keeps the floating label transparent', async () => {
|
|
3
|
+
const field = document.createElement('div');
|
|
4
|
+
field.className = 'input-field outlined';
|
|
5
|
+
field.innerHTML = '<select id="outlined-select-test"><option value="">Choose your option</option><option value="one">One</option></select><label for="outlined-select-test">Issue type</label>';
|
|
6
|
+
document.body.append(field);
|
|
7
|
+
const select = field.querySelector('select');
|
|
8
|
+
const instance = M.FormSelect.init(select);
|
|
9
|
+
try {
|
|
10
|
+
await new Promise(resolve => setTimeout(resolve, 60));
|
|
11
|
+
const input = field.querySelector('input.select-dropdown');
|
|
12
|
+
const label = field.querySelector('label');
|
|
13
|
+
const outline = field.querySelector('.input-outline');
|
|
14
|
+
expect(getComputedStyle(input).paddingTop).toBe('0px');
|
|
15
|
+
expect(outline).not.toBeNull();
|
|
16
|
+
expect(getComputedStyle(label).backgroundColor).toBe('rgba(0, 0, 0, 0)');
|
|
17
|
+
input.focus();
|
|
18
|
+
expect(getComputedStyle(outline).borderTopWidth).toBe('2px');
|
|
19
|
+
expect(getComputedStyle(label).backgroundColor).toBe('rgba(0, 0, 0, 0)');
|
|
20
|
+
} finally {
|
|
21
|
+
instance.destroy();
|
|
22
|
+
field.remove();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -255,7 +255,7 @@ export class FormSelect extends Component<FormSelectOptions> {
|
|
|
255
255
|
// Add input dropdown
|
|
256
256
|
this.input = document.createElement('input');
|
|
257
257
|
this.input.id = 'm_select-input-' + Utils.guid();
|
|
258
|
-
this.input.classList.add('select-dropdown', 'dropdown-trigger');
|
|
258
|
+
this.input.classList.add('select-dropdown', 'dropdown-trigger', 'no-autoinit');
|
|
259
259
|
this.input.type = 'text';
|
|
260
260
|
this.input.readOnly = true;
|
|
261
261
|
this.input.setAttribute('data-target', this.dropdownOptions.id);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
describe('FormSelect dropdown ownership', () => {
|
|
2
|
+
[false, true].forEach(multiple => {
|
|
3
|
+
it(`keeps its dropdown instance during generic initialization (multiple=${multiple})`, async () => {
|
|
4
|
+
const field = document.createElement('div');
|
|
5
|
+
field.className = 'input-field outlined';
|
|
6
|
+
field.innerHTML = `<select ${multiple ? 'multiple' : ''}><option value="1">One</option><option value="2">Two</option></select>`;
|
|
7
|
+
document.body.append(field);
|
|
8
|
+
const select = field.querySelector('select');
|
|
9
|
+
const instance = M.FormSelect.init(select, { dropdownOptions: { inDuration: 0, outDuration: 0 } });
|
|
10
|
+
try {
|
|
11
|
+
M.Dropdown.init(field.querySelectorAll('.dropdown-trigger:not(.no-autoinit)'));
|
|
12
|
+
expect(M.Dropdown.getInstance(instance.input)).toBe(instance.dropdown);
|
|
13
|
+
instance.input.click();
|
|
14
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
15
|
+
instance.dropdownOptions.querySelectorAll('li')[1].click();
|
|
16
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
17
|
+
expect(instance.getSelectedValues()).toContain('2');
|
|
18
|
+
expect(instance.dropdown.isOpen).toBe(multiple);
|
|
19
|
+
expect(getComputedStyle(instance.dropdownOptions).display === 'none').toBe(!multiple);
|
|
20
|
+
} finally {
|
|
21
|
+
instance.destroy();
|
|
22
|
+
field.remove();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|