kmaterialize 2.3.3-1.0.21 → 2.3.3-1.0.23

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 (47) hide show
  1. package/components/air-datepicker/air-datepicker.js +152 -0
  2. package/components/alert/alert.js +54 -0
  3. package/components/button/buttons.js +220 -0
  4. package/components/card/cards.js +149 -0
  5. package/components/carousel/carousel.js +574 -0
  6. package/components/carousel/slider.js +340 -0
  7. package/components/chip/chips.js +341 -0
  8. package/components/collapsible/collapsible.js +156 -0
  9. package/components/color-input/color-input.js +166 -0
  10. package/components/datepicker/datepicker.js +1035 -0
  11. package/components/dialog/materialbox.js +377 -0
  12. package/components/dialog/modal.js +72 -0
  13. package/components/dropdown/dropdown.js +505 -0
  14. package/components/file-input/file-input.js +169 -0
  15. package/components/kanban/_kanban.scss +69 -0
  16. package/components/kanban/kanban.js +168 -0
  17. package/components/kanban/kanban.ts +57 -8
  18. package/components/loading/_loading.scss +68 -0
  19. package/components/loading/loading.ts +104 -0
  20. package/components/loading/loadingSpec.js +102 -0
  21. package/components/navigation-drawer/sidenav.js +454 -0
  22. package/components/number-input/number-input.js +68 -0
  23. package/components/password-input/password-input.js +49 -0
  24. package/components/popup/_popup.scss +106 -0
  25. package/components/popup/popup.ts +113 -0
  26. package/components/popup/popupSpec.js +140 -0
  27. package/components/search/autocomplete.js +470 -0
  28. package/components/slider/range.js +169 -0
  29. package/components/snackbar/toasts.js +244 -0
  30. package/components/tabs/tabs.js +289 -0
  31. package/components/textfield/forms.js +172 -0
  32. package/components/textfield/select.js +385 -0
  33. package/components/timepicker/timepicker.js +698 -0
  34. package/components/tom-select/tom-select-field.js +130 -0
  35. package/components/toolbar/toolbar.js +134 -0
  36. package/components/tooltip/tooltip.js +270 -0
  37. package/dist/css/materialize.colors.min.css +1 -1
  38. package/dist/css/materialize.css +322 -1
  39. package/dist/css/materialize.min.css +2 -2
  40. package/dist/css/materialize.min.css.map +1 -1
  41. package/dist/js/materialize.cjs.js +258 -42
  42. package/dist/js/materialize.d.ts +50 -3
  43. package/dist/js/materialize.js +258 -42
  44. package/dist/js/materialize.min.js +2 -2
  45. package/dist/js/materialize.mjs +257 -43
  46. package/package.json +6 -1
  47. package/sass/materialize.scss +2 -0
@@ -71,6 +71,7 @@
71
71
  }
72
72
 
73
73
  .kanban-card {
74
+ --kanban-card-accent: var(--md-sys-color-primary);
74
75
  position: relative;
75
76
  display: flex;
76
77
  flex-direction: column;
@@ -83,6 +84,8 @@
83
84
  box-shadow: var(--md-sys-elevation-level1, 0 1px 2px rgba(0, 0, 0, 0.14));
84
85
  cursor: grab;
85
86
  user-select: none;
87
+ overflow: hidden;
88
+ transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
86
89
 
87
90
  &:active,
88
91
  &.is-dragging {
@@ -91,12 +94,78 @@
91
94
 
92
95
  &.is-dragging {
93
96
  opacity: 0.45;
97
+ box-shadow: var(--md-sys-elevation-level3, 0 8px 18px rgba(0, 0, 0, 0.2));
94
98
  }
95
99
 
96
100
  &:focus-visible {
97
101
  outline: 2px solid var(--md-sys-color-primary);
98
102
  outline-offset: 2px;
99
103
  }
104
+
105
+ &.kanban-card-accent {
106
+ &::after {
107
+ position: absolute;
108
+ right: 0;
109
+ bottom: 0;
110
+ left: 0;
111
+ height: 0.25rem;
112
+ background: var(--kanban-card-accent);
113
+ content: '';
114
+ pointer-events: none;
115
+ }
116
+ }
117
+
118
+ &.kanban-card-accent-top::after {
119
+ top: 0;
120
+ bottom: auto;
121
+ left: 0;
122
+ width: 100%;
123
+ height: 0.25rem;
124
+ }
125
+
126
+ &.kanban-card-accent-right::after {
127
+ top: 0;
128
+ right: 0;
129
+ bottom: 0;
130
+ left: auto;
131
+ width: 0.25rem;
132
+ height: 100%;
133
+ }
134
+
135
+ &.kanban-card-accent-left::after {
136
+ top: 0;
137
+ right: auto;
138
+ bottom: 0;
139
+ left: 0;
140
+ width: 0.25rem;
141
+ height: 100%;
142
+ }
143
+
144
+ &.is-disabled,
145
+ &[aria-disabled='true'] {
146
+ cursor: not-allowed;
147
+ opacity: 0.55;
148
+ filter: saturate(0.55);
149
+ }
150
+ }
151
+
152
+ .kanban-drag-image {
153
+ position: fixed !important;
154
+ top: -10000px !important;
155
+ left: -10000px !important;
156
+ z-index: -1;
157
+ pointer-events: none;
158
+ transform: rotate(2deg) scale(1.015);
159
+ transform-origin: center;
160
+ }
161
+
162
+ .kanban-drop-preview {
163
+ min-height: 4rem;
164
+ box-sizing: border-box;
165
+ border: 2px dashed var(--md-sys-color-primary);
166
+ border-radius: 0.75rem;
167
+ background: color-mix(in srgb, var(--md-sys-color-primary-container) 45%, transparent);
168
+ pointer-events: none;
100
169
  }
101
170
 
102
171
  .kanban-card-title {
@@ -0,0 +1,168 @@
1
+ import { Component } from '../../src/component';
2
+ const _defaults = {
3
+ draggable: true
4
+ };
5
+ /** A lightweight, dependency-free board for columns of draggable cards. */
6
+ export class Kanban extends Component {
7
+ _draggedCard = null;
8
+ _dragSourceColumn = null;
9
+ _dropPreview = null;
10
+ _dragImage = null;
11
+ _previewHeight = 64;
12
+ _onDragStart = (event) => {
13
+ const card = event.target?.closest('.kanban-card');
14
+ if (!card || !this.options.draggable || card.classList.contains('is-disabled') || card.getAttribute('aria-disabled') === 'true')
15
+ return;
16
+ this._draggedCard = card;
17
+ this._dragSourceColumn = card.closest('.kanban-column');
18
+ this._previewHeight = Math.max(64, Math.round(card.getBoundingClientRect().height));
19
+ card.classList.add('is-dragging');
20
+ this._dragImage = card.cloneNode(true);
21
+ this._dragImage.classList.remove('is-dragging');
22
+ this._dragImage.classList.add('kanban-drag-image');
23
+ this._dragImage.setAttribute('aria-hidden', 'true');
24
+ this._dragImage.style.width = `${card.getBoundingClientRect().width}px`;
25
+ document.body.appendChild(this._dragImage);
26
+ event.dataTransfer?.setDragImage(this._dragImage, card.offsetWidth / 2, card.offsetHeight / 2);
27
+ event.dataTransfer?.setData('text/plain', card.dataset.kanbanCard || '');
28
+ if (event.dataTransfer)
29
+ event.dataTransfer.effectAllowed = 'move';
30
+ };
31
+ _onDragOver = (event) => {
32
+ if (!this._draggedCard)
33
+ return;
34
+ const column = event.target?.closest('.kanban-column');
35
+ if (!column || !this.el.contains(column))
36
+ return;
37
+ event.preventDefault();
38
+ if (event.dataTransfer)
39
+ event.dataTransfer.dropEffect = 'move';
40
+ this.el.querySelectorAll('.kanban-column.is-drag-over').forEach((item) => item.classList.remove('is-drag-over'));
41
+ column.classList.add('is-drag-over');
42
+ this._updateDropPreview(column, event);
43
+ };
44
+ _onDrop = (event) => {
45
+ if (!this._draggedCard)
46
+ return;
47
+ const column = event.target?.closest('.kanban-column');
48
+ if (!column || !this.el.contains(column))
49
+ return;
50
+ event.preventDefault();
51
+ const body = column.querySelector('.kanban-column-body') || column;
52
+ const targetCard = event.target?.closest('.kanban-card');
53
+ const source = this._dragSourceColumn;
54
+ const movedCard = this._draggedCard;
55
+ if (this._dropPreview?.parentElement === body) {
56
+ body.insertBefore(movedCard, this._dropPreview);
57
+ this._dropPreview.remove();
58
+ }
59
+ else if (targetCard && targetCard !== movedCard && targetCard.parentElement === body) {
60
+ body.insertBefore(movedCard, targetCard);
61
+ }
62
+ else {
63
+ body.appendChild(movedCard);
64
+ }
65
+ this._clearDragState();
66
+ this._updateCounts();
67
+ if (source && source !== column)
68
+ this.options.onMove?.({ card: movedCard, from: source, to: column });
69
+ };
70
+ _onDragEnd = () => this._clearDragState();
71
+ constructor(el, options) {
72
+ super(el, options, Kanban);
73
+ this.options = { ...Kanban.defaults, ...options };
74
+ this.el['M_Kanban'] = this;
75
+ this._prepareMarkup();
76
+ if (this.options.draggable)
77
+ this._bindEvents();
78
+ }
79
+ static get defaults() {
80
+ return _defaults;
81
+ }
82
+ static init(els, options = {}) {
83
+ return super.init(els, options, Kanban);
84
+ }
85
+ static getInstance(el) {
86
+ return el['M_Kanban'];
87
+ }
88
+ _prepareMarkup() {
89
+ this.el.setAttribute('role', 'region');
90
+ this.el.querySelectorAll('.kanban-column').forEach((column) => {
91
+ column.setAttribute('role', 'group');
92
+ const body = column.querySelector('.kanban-column-body');
93
+ if (body)
94
+ body.setAttribute('role', 'list');
95
+ column.querySelectorAll('.kanban-card').forEach((card) => {
96
+ card.setAttribute('role', 'listitem');
97
+ const disabled = card.classList.contains('is-disabled') || card.getAttribute('aria-disabled') === 'true';
98
+ card.setAttribute('tabindex', disabled ? '-1' : '0');
99
+ if (disabled) {
100
+ card.setAttribute('aria-disabled', 'true');
101
+ card.draggable = false;
102
+ }
103
+ else if (this.options.draggable) {
104
+ card.draggable = true;
105
+ }
106
+ });
107
+ });
108
+ this._updateCounts();
109
+ }
110
+ _bindEvents() {
111
+ this.el.addEventListener('dragstart', this._onDragStart);
112
+ this.el.addEventListener('dragover', this._onDragOver);
113
+ this.el.addEventListener('drop', this._onDrop);
114
+ this.el.addEventListener('dragend', this._onDragEnd);
115
+ }
116
+ _clearDragState() {
117
+ this._dragImage?.remove();
118
+ this._dragImage = null;
119
+ this._dropPreview?.remove();
120
+ this._dropPreview = null;
121
+ this._draggedCard?.classList.remove('is-dragging');
122
+ this.el.querySelectorAll('.kanban-column.is-drag-over').forEach((item) => item.classList.remove('is-drag-over'));
123
+ this._draggedCard = null;
124
+ this._dragSourceColumn = null;
125
+ this._previewHeight = 64;
126
+ }
127
+ _updateCounts() {
128
+ this.el.querySelectorAll('.kanban-column').forEach((column) => {
129
+ const count = column.querySelector('.kanban-column-count');
130
+ const body = column.querySelector('.kanban-column-body');
131
+ if (count && body)
132
+ count.textContent = String(body.querySelectorAll(':scope > .kanban-card').length);
133
+ const empty = body?.querySelector('.kanban-empty');
134
+ if (empty && body)
135
+ empty.hidden = body.querySelectorAll(':scope > .kanban-card').length > 0;
136
+ });
137
+ }
138
+ _updateDropPreview(column, event) {
139
+ const body = column.querySelector('.kanban-column-body') || column;
140
+ const targetCard = event.target?.closest('.kanban-card');
141
+ if (targetCard === this._draggedCard)
142
+ return;
143
+ if (!this._dropPreview) {
144
+ this._dropPreview = document.createElement('div');
145
+ this._dropPreview.className = 'kanban-drop-preview';
146
+ this._dropPreview.setAttribute('aria-hidden', 'true');
147
+ }
148
+ // Keep the preview tied to the dragged card's original height. Using the
149
+ // hovered card's height makes the placeholder jump as the pointer crosses
150
+ // cards with different content lengths.
151
+ this._dropPreview.style.height = `${this._previewHeight}px`;
152
+ if (targetCard && targetCard.parentElement === body) {
153
+ const box = targetCard.getBoundingClientRect();
154
+ body.insertBefore(this._dropPreview, event.clientY < box.top + box.height / 2 ? targetCard : targetCard.nextSibling);
155
+ }
156
+ else if (this._dropPreview.parentElement !== body) {
157
+ body.appendChild(this._dropPreview);
158
+ }
159
+ }
160
+ destroy() {
161
+ this._clearDragState();
162
+ this.el.removeEventListener('dragstart', this._onDragStart);
163
+ this.el.removeEventListener('dragover', this._onDragOver);
164
+ this.el.removeEventListener('drop', this._onDrop);
165
+ this.el.removeEventListener('dragend', this._onDragEnd);
166
+ delete this.el['M_Kanban'];
167
+ }
168
+ }
@@ -21,13 +21,24 @@ const _defaults: KanbanOptions = {
21
21
  export class Kanban extends Component<KanbanOptions> {
22
22
  private _draggedCard: HTMLElement | null = null;
23
23
  private _dragSourceColumn: HTMLElement | null = null;
24
+ private _dropPreview: HTMLElement | null = null;
25
+ private _dragImage: HTMLElement | null = null;
26
+ private _previewHeight = 64;
24
27
 
25
28
  private _onDragStart = (event: DragEvent) => {
26
29
  const card = (event.target as HTMLElement | null)?.closest<HTMLElement>('.kanban-card');
27
- if (!card || !this.options.draggable) return;
30
+ if (!card || !this.options.draggable || card.classList.contains('is-disabled') || card.getAttribute('aria-disabled') === 'true') return;
28
31
  this._draggedCard = card;
29
32
  this._dragSourceColumn = card.closest<HTMLElement>('.kanban-column');
33
+ this._previewHeight = Math.max(64, Math.round(card.getBoundingClientRect().height));
30
34
  card.classList.add('is-dragging');
35
+ this._dragImage = card.cloneNode(true) as HTMLElement;
36
+ this._dragImage.classList.remove('is-dragging');
37
+ this._dragImage.classList.add('kanban-drag-image');
38
+ this._dragImage.setAttribute('aria-hidden', 'true');
39
+ this._dragImage.style.width = `${card.getBoundingClientRect().width}px`;
40
+ document.body.appendChild(this._dragImage);
41
+ event.dataTransfer?.setDragImage(this._dragImage, card.offsetWidth / 2, card.offsetHeight / 2);
31
42
  event.dataTransfer?.setData('text/plain', card.dataset.kanbanCard || '');
32
43
  if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
33
44
  };
@@ -40,6 +51,7 @@ export class Kanban extends Component<KanbanOptions> {
40
51
  if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
41
52
  this.el.querySelectorAll('.kanban-column.is-drag-over').forEach((item) => item.classList.remove('is-drag-over'));
42
53
  column.classList.add('is-drag-over');
54
+ this._updateDropPreview(column, event);
43
55
  };
44
56
 
45
57
  private _onDrop = (event: DragEvent) => {
@@ -50,15 +62,18 @@ export class Kanban extends Component<KanbanOptions> {
50
62
  const body = column.querySelector<HTMLElement>('.kanban-column-body') || column;
51
63
  const targetCard = (event.target as HTMLElement | null)?.closest<HTMLElement>('.kanban-card');
52
64
  const source = this._dragSourceColumn;
53
- if (targetCard && targetCard !== this._draggedCard && targetCard.parentElement === body) {
54
- const box = targetCard.getBoundingClientRect();
55
- body.insertBefore(this._draggedCard, event.clientY < box.top + box.height / 2 ? targetCard : targetCard.nextSibling);
65
+ const movedCard = this._draggedCard;
66
+ if (this._dropPreview?.parentElement === body) {
67
+ body.insertBefore(movedCard, this._dropPreview);
68
+ this._dropPreview.remove();
69
+ } else if (targetCard && targetCard !== movedCard && targetCard.parentElement === body) {
70
+ body.insertBefore(movedCard, targetCard);
56
71
  } else {
57
- body.appendChild(this._draggedCard);
72
+ body.appendChild(movedCard);
58
73
  }
59
74
  this._clearDragState();
60
75
  this._updateCounts();
61
- if (source && source !== column) this.options.onMove?.({ card: this._draggedCard, from: source, to: column });
76
+ if (source && source !== column) this.options.onMove?.({ card: movedCard, from: source, to: column });
62
77
  };
63
78
 
64
79
  private _onDragEnd = () => this._clearDragState();
@@ -93,8 +108,14 @@ export class Kanban extends Component<KanbanOptions> {
93
108
  if (body) body.setAttribute('role', 'list');
94
109
  column.querySelectorAll<HTMLElement>('.kanban-card').forEach((card) => {
95
110
  card.setAttribute('role', 'listitem');
96
- card.setAttribute('tabindex', '0');
97
- if (this.options.draggable) card.draggable = true;
111
+ const disabled = card.classList.contains('is-disabled') || card.getAttribute('aria-disabled') === 'true';
112
+ card.setAttribute('tabindex', disabled ? '-1' : '0');
113
+ if (disabled) {
114
+ card.setAttribute('aria-disabled', 'true');
115
+ card.draggable = false;
116
+ } else if (this.options.draggable) {
117
+ card.draggable = true;
118
+ }
98
119
  });
99
120
  });
100
121
  this._updateCounts();
@@ -108,10 +129,15 @@ export class Kanban extends Component<KanbanOptions> {
108
129
  }
109
130
 
110
131
  private _clearDragState() {
132
+ this._dragImage?.remove();
133
+ this._dragImage = null;
134
+ this._dropPreview?.remove();
135
+ this._dropPreview = null;
111
136
  this._draggedCard?.classList.remove('is-dragging');
112
137
  this.el.querySelectorAll('.kanban-column.is-drag-over').forEach((item) => item.classList.remove('is-drag-over'));
113
138
  this._draggedCard = null;
114
139
  this._dragSourceColumn = null;
140
+ this._previewHeight = 64;
115
141
  }
116
142
 
117
143
  private _updateCounts() {
@@ -119,9 +145,32 @@ export class Kanban extends Component<KanbanOptions> {
119
145
  const count = column.querySelector<HTMLElement>('.kanban-column-count');
120
146
  const body = column.querySelector<HTMLElement>('.kanban-column-body');
121
147
  if (count && body) count.textContent = String(body.querySelectorAll(':scope > .kanban-card').length);
148
+ const empty = body?.querySelector<HTMLElement>('.kanban-empty');
149
+ if (empty && body) empty.hidden = body.querySelectorAll(':scope > .kanban-card').length > 0;
122
150
  });
123
151
  }
124
152
 
153
+ private _updateDropPreview(column: HTMLElement, event: DragEvent) {
154
+ const body = column.querySelector<HTMLElement>('.kanban-column-body') || column;
155
+ const targetCard = (event.target as HTMLElement | null)?.closest<HTMLElement>('.kanban-card');
156
+ if (targetCard === this._draggedCard) return;
157
+ if (!this._dropPreview) {
158
+ this._dropPreview = document.createElement('div');
159
+ this._dropPreview.className = 'kanban-drop-preview';
160
+ this._dropPreview.setAttribute('aria-hidden', 'true');
161
+ }
162
+ // Keep the preview tied to the dragged card's original height. Using the
163
+ // hovered card's height makes the placeholder jump as the pointer crosses
164
+ // cards with different content lengths.
165
+ this._dropPreview.style.height = `${this._previewHeight}px`;
166
+ if (targetCard && targetCard.parentElement === body) {
167
+ const box = targetCard.getBoundingClientRect();
168
+ body.insertBefore(this._dropPreview, event.clientY < box.top + box.height / 2 ? targetCard : targetCard.nextSibling);
169
+ } else if (this._dropPreview.parentElement !== body) {
170
+ body.appendChild(this._dropPreview);
171
+ }
172
+ }
173
+
125
174
  destroy() {
126
175
  this._clearDragState();
127
176
  this.el.removeEventListener('dragstart', this._onDragStart);
@@ -0,0 +1,68 @@
1
+ // A spinner surrounding stationary content, inspired by LoadingScreenBtn.
2
+ .loading {
3
+ --loading-size: 7rem;
4
+ --loading-color: var(--md-sys-color-primary);
5
+ --loading-background: var(--md-sys-color-surface-container, var(--md-sys-color-surface));
6
+ --loading-thickness: 3px;
7
+
8
+ position: relative;
9
+ display: inline-flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ flex: 0 0 auto;
13
+ width: var(--loading-size);
14
+ height: var(--loading-size);
15
+ vertical-align: middle;
16
+ border-radius: 50%;
17
+ background: var(--loading-background);
18
+ color: var(--loading-color);
19
+ isolation: isolate;
20
+
21
+ > .preloader-wrapper {
22
+ position: absolute;
23
+ inset: 5%;
24
+ width: 90%;
25
+ height: 90%;
26
+ pointer-events: none;
27
+
28
+ .spinner-layer { border-color: var(--loading-color); }
29
+ .circle-clipper .circle { border-width: var(--loading-thickness); }
30
+ }
31
+
32
+ &:not(.active) > .preloader-wrapper {
33
+ visibility: hidden;
34
+
35
+ &, .spinner-layer, .circle-clipper .circle { animation: none; }
36
+ }
37
+
38
+ &.loading-small { --loading-size: 4rem; }
39
+ &.loading-large { --loading-size: 10rem; }
40
+
41
+ // A stylesheet display rule must never override the native hidden attribute.
42
+ &[hidden] { display: none; }
43
+
44
+ .loading-content {
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ width: 72%;
49
+ height: 72%;
50
+ overflow: hidden;
51
+ border-radius: inherit;
52
+
53
+ img, svg {
54
+ display: block;
55
+ width: 100%;
56
+ height: 100%;
57
+ object-fit: contain;
58
+ }
59
+
60
+ .material-icons { font-size: calc(var(--loading-size) * 0.35); }
61
+ }
62
+ }
63
+
64
+ @media (prefers-reduced-motion: reduce) {
65
+ .loading > .preloader-wrapper {
66
+ &, .spinner-layer, .circle-clipper .circle { animation: none; }
67
+ }
68
+ }
@@ -0,0 +1,104 @@
1
+ import { BaseOptions, Component, InitElements, MElement } from '../../src/component';
2
+
3
+ export interface LoadingOptions extends BaseOptions {
4
+ /** Start animating immediately. */
5
+ active: boolean;
6
+ /** Accessible status while work is in progress. */
7
+ label: string;
8
+ /** Accessible status when work has finished. */
9
+ completeLabel: string;
10
+ }
11
+
12
+ const _defaults: LoadingOptions = {
13
+ active: true,
14
+ label: 'Loading…',
15
+ completeLabel: 'Ready'
16
+ };
17
+
18
+ /** A circular loading indicator with an optional centered logo or icon. */
19
+ export class Loading extends Component<LoadingOptions> {
20
+ private _originalAttributes: Record<string, string | null> = {};
21
+ private _originalActive: boolean;
22
+ private _originalLoading: boolean;
23
+ private _generatedSpinner: HTMLElement | null = null;
24
+
25
+ constructor(el: HTMLElement, options: Partial<LoadingOptions> = {}) {
26
+ super(el, options, Loading);
27
+ this.options = { ...Loading.defaults, ...options };
28
+ this.el['M_Loading'] = this;
29
+ this._originalActive = this.el.classList.contains('active');
30
+ this._originalLoading = this.el.classList.contains('loading');
31
+ for (const name of ['role', 'aria-live', 'aria-busy', 'aria-label']) {
32
+ this._originalAttributes[name] = this.el.getAttribute(name);
33
+ }
34
+ // Preserve an accessible name supplied in the markup unless overridden.
35
+ if (options.label === undefined && this.el.hasAttribute('aria-label')) {
36
+ this.options.label = this.el.getAttribute('aria-label');
37
+ }
38
+ this.el.classList.add('loading');
39
+ // Reuse authored Preloader markup; generate it for the compact JS API.
40
+ if (!this.el.querySelector(':scope > .preloader-wrapper')) {
41
+ this._generatedSpinner = document.createElement('div');
42
+ this._generatedSpinner.className = 'preloader-wrapper active';
43
+ this._generatedSpinner.setAttribute('aria-hidden', 'true');
44
+ this._generatedSpinner.innerHTML = `<div class="spinner-layer">
45
+ <div class="circle-clipper left"><div class="circle"></div></div>
46
+ <div class="gap-patch"><div class="circle"></div></div>
47
+ <div class="circle-clipper right"><div class="circle"></div></div>
48
+ </div>`;
49
+ this.el.appendChild(this._generatedSpinner);
50
+ }
51
+ if (!this.el.hasAttribute('role')) this.el.setAttribute('role', 'status');
52
+ if (!this.el.hasAttribute('aria-live')) this.el.setAttribute('aria-live', 'polite');
53
+ if (this.options.active) this.start();
54
+ else this.stop();
55
+ }
56
+
57
+ static get defaults(): LoadingOptions {
58
+ return _defaults;
59
+ }
60
+
61
+ static init(el: HTMLElement, options?: Partial<LoadingOptions>): Loading;
62
+ static init(els: InitElements<MElement>, options?: Partial<LoadingOptions>): Loading[];
63
+ static init(
64
+ els: HTMLElement | InitElements<MElement>,
65
+ options: Partial<LoadingOptions> = {}
66
+ ): Loading | Loading[] {
67
+ return super.init(els, options, Loading);
68
+ }
69
+
70
+ static getInstance(el: HTMLElement): Loading {
71
+ return el['M_Loading'];
72
+ }
73
+
74
+ get isActive(): boolean {
75
+ return this.el.classList.contains('active');
76
+ }
77
+
78
+ /** Show the animated ring and announce the loading status. */
79
+ start(label: string = this.options.label): void {
80
+ this.el.setAttribute('aria-label', label);
81
+ this.el.setAttribute('aria-busy', 'true');
82
+ this.el.classList.add('active');
83
+ }
84
+
85
+ /** Hide the ring, keeping the centered content visible. */
86
+ stop(label: string = this.options.completeLabel): void {
87
+ this.el.setAttribute('aria-label', label);
88
+ this.el.setAttribute('aria-busy', 'false');
89
+ this.el.classList.remove('active');
90
+ }
91
+
92
+ /** Restore the original markup state and remove the instance. */
93
+ destroy(): void {
94
+ this._generatedSpinner?.remove();
95
+ this._generatedSpinner = null;
96
+ for (const [name, value] of Object.entries(this._originalAttributes)) {
97
+ if (value === null) this.el.removeAttribute(name);
98
+ else this.el.setAttribute(name, value);
99
+ }
100
+ this.el.classList.toggle('active', this._originalActive);
101
+ this.el.classList.toggle('loading', this._originalLoading);
102
+ delete this.el['M_Loading'];
103
+ }
104
+ }
@@ -0,0 +1,102 @@
1
+ describe('Loading', function () {
2
+ let fixture;
3
+ let el;
4
+
5
+ beforeEach(function () {
6
+ fixture = document.createElement('div');
7
+ fixture.innerHTML = '<div class="loading" aria-label="Fetching assets"><span class="loading-content"><img src="data:," alt=""></span></div>';
8
+ document.body.appendChild(fixture);
9
+ el = fixture.firstElementChild;
10
+ });
11
+
12
+ afterEach(function () {
13
+ fixture.querySelectorAll('.loading').forEach(function (element) {
14
+ M.Loading.getInstance(element)?.destroy();
15
+ });
16
+ fixture.remove();
17
+ });
18
+
19
+ it('starts and stops without replacing centered content', function () {
20
+ const content = el.firstElementChild;
21
+ const loading = M.Loading.init(el);
22
+ expect(loading.isActive).toBeTrue();
23
+ expect(el.getAttribute('aria-label')).toBe('Fetching assets');
24
+ expect(el.getAttribute('aria-busy')).toBe('true');
25
+ loading.stop('Assets ready');
26
+ expect(loading.isActive).toBeFalse();
27
+ expect(el.getAttribute('aria-label')).toBe('Assets ready');
28
+ expect(el.getAttribute('aria-busy')).toBe('false');
29
+ expect(getComputedStyle(el.querySelector('.preloader-wrapper')).visibility).toBe('hidden');
30
+ expect(getComputedStyle(el.querySelector('.circle')).animationName).toBe('none');
31
+ expect(getComputedStyle(content).visibility).toBe('visible');
32
+ loading.start('Refreshing');
33
+ expect(loading.isActive).toBeTrue();
34
+ expect(el.getAttribute('aria-label')).toBe('Refreshing');
35
+ expect(el.firstElementChild).toBe(content);
36
+ });
37
+
38
+ it('supports inactive initialization and customized status labels', function () {
39
+ const loading = M.Loading.init(el, { active: false, label: 'Syncing', completeLabel: 'Synced' });
40
+ expect(loading.isActive).toBeFalse();
41
+ expect(el.getAttribute('aria-label')).toBe('Synced');
42
+ loading.start();
43
+ expect(el.getAttribute('aria-label')).toBe('Syncing');
44
+ loading.stop();
45
+ expect(el.getAttribute('aria-label')).toBe('Synced');
46
+ });
47
+
48
+ it('restores original attributes and content when destroyed or reinitialized', function () {
49
+ el.classList.add('active');
50
+ el.setAttribute('role', 'img');
51
+ el.setAttribute('aria-live', 'off');
52
+ el.setAttribute('aria-busy', 'false');
53
+ const original = el.outerHTML;
54
+ const first = M.Loading.init(el, { label: 'First' });
55
+ const second = M.Loading.init(el, { active: false });
56
+ expect(second).not.toBe(first);
57
+ expect(M.Loading.getInstance(el)).toBe(second);
58
+ second.destroy();
59
+ expect(el.outerHTML).toBe(original);
60
+ expect(M.Loading.getInstance(el)).toBeUndefined();
61
+ });
62
+
63
+ it('initializes collections and respects the AutoInit opt-out', function () {
64
+ fixture.insertAdjacentHTML('beforeend', '<div class="loading"></div><div class="loading no-autoinit"></div>');
65
+ M.AutoInit(fixture, { Loading: { active: false } });
66
+ expect(M.Loading.getInstance(el).isActive).toBeFalse();
67
+ expect(M.Loading.getInstance(fixture.children[1])).toBeDefined();
68
+ expect(M.Loading.getInstance(fixture.children[2])).toBeUndefined();
69
+ const instances = M.Loading.init(fixture.querySelectorAll('.loading'));
70
+ expect(instances.length).toBe(3);
71
+ expect(instances.every(instance => instance.isActive)).toBeTrue();
72
+ });
73
+
74
+ it('uses the same animations as Preloader and preserves authored spinner markup', function () {
75
+ const loading = M.Loading.init(el);
76
+ const spinner = el.querySelector('.preloader-wrapper');
77
+ const reference = spinner.cloneNode(true);
78
+ fixture.appendChild(reference);
79
+ for (const selector of [null, '.spinner-layer', '.circle-clipper.left .circle', '.circle-clipper.right .circle']) {
80
+ const actual = getComputedStyle(selector ? spinner.querySelector(selector) : spinner);
81
+ const expected = getComputedStyle(selector ? reference.querySelector(selector) : reference);
82
+ expect(actual.animationName).toBe(expected.animationName);
83
+ expect(actual.animationDuration).toBe(expected.animationDuration);
84
+ expect(actual.animationTimingFunction).toBe(expected.animationTimingFunction);
85
+ }
86
+ expect(getComputedStyle(el.querySelector('.loading-content')).animationName).toBe('none');
87
+ loading.destroy();
88
+ expect(el.querySelector('.preloader-wrapper')).toBeNull();
89
+ el.appendChild(reference);
90
+ const original = el.outerHTML;
91
+ M.Loading.init(el);
92
+ expect(el.querySelectorAll('.preloader-wrapper').length).toBe(1);
93
+ M.Loading.getInstance(el).destroy();
94
+ expect(el.outerHTML).toBe(original);
95
+ });
96
+
97
+ it('keeps hidden indicators hidden', function () {
98
+ M.Loading.init(el);
99
+ el.hidden = true;
100
+ expect(getComputedStyle(el).display).toBe('none');
101
+ });
102
+ });