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.
- package/components/air-datepicker/air-datepicker.js +152 -0
- package/components/alert/alert.js +54 -0
- package/components/button/buttons.js +220 -0
- package/components/card/cards.js +149 -0
- package/components/carousel/carousel.js +574 -0
- package/components/carousel/slider.js +340 -0
- package/components/chip/chips.js +341 -0
- package/components/collapsible/collapsible.js +156 -0
- package/components/color-input/color-input.js +166 -0
- package/components/datepicker/datepicker.js +1035 -0
- package/components/dialog/materialbox.js +377 -0
- package/components/dialog/modal.js +72 -0
- package/components/dropdown/dropdown.js +505 -0
- package/components/file-input/file-input.js +169 -0
- package/components/kanban/_kanban.scss +69 -0
- package/components/kanban/kanban.js +168 -0
- package/components/kanban/kanban.ts +57 -8
- package/components/loading/_loading.scss +68 -0
- package/components/loading/loading.ts +104 -0
- package/components/loading/loadingSpec.js +102 -0
- package/components/navigation-drawer/sidenav.js +454 -0
- package/components/number-input/number-input.js +68 -0
- package/components/password-input/password-input.js +49 -0
- package/components/popup/_popup.scss +106 -0
- package/components/popup/popup.ts +113 -0
- package/components/popup/popupSpec.js +140 -0
- package/components/search/autocomplete.js +470 -0
- package/components/slider/range.js +169 -0
- package/components/snackbar/toasts.js +244 -0
- package/components/tabs/tabs.js +289 -0
- package/components/textfield/forms.js +172 -0
- package/components/textfield/select.js +385 -0
- package/components/timepicker/timepicker.js +698 -0
- package/components/tom-select/tom-select-field.js +130 -0
- package/components/toolbar/toolbar.js +134 -0
- package/components/tooltip/tooltip.js +270 -0
- package/dist/css/materialize.colors.min.css +1 -1
- package/dist/css/materialize.css +322 -1
- package/dist/css/materialize.min.css +2 -2
- package/dist/css/materialize.min.css.map +1 -1
- package/dist/js/materialize.cjs.js +258 -42
- package/dist/js/materialize.d.ts +50 -3
- package/dist/js/materialize.js +258 -42
- package/dist/js/materialize.min.js +2 -2
- package/dist/js/materialize.mjs +257 -43
- package/package.json +6 -1
- 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
|
-
|
|
54
|
-
|
|
55
|
-
body.insertBefore(
|
|
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(
|
|
72
|
+
body.appendChild(movedCard);
|
|
58
73
|
}
|
|
59
74
|
this._clearDragState();
|
|
60
75
|
this._updateCounts();
|
|
61
|
-
if (source && source !== column) this.options.onMove?.({ card:
|
|
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.
|
|
97
|
-
|
|
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
|
+
});
|