platypicker 1.0.1 → 1.0.4
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/dist/platypicker.min.css +1 -1
- package/package.json +12 -2
- package/src/platypicker.css +80 -0
- package/src/platypicker.js +561 -0
package/dist/platypicker.min.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
@media (any-pointer:fine),(any-hover:hover){:has(>select.platypicker){anchor-scope:--platypicker
|
|
1
|
+
@media (any-pointer:fine),(any-hover:hover){:has(> select.platypicker){anchor-scope:--platypicker;position-anchor:--platypicker;top:calc(anchor(bottom) + .125rem)!important;left:anchor(left)!important;min-width:anchor-size();max-height:30rem;transform:none!important;flex-basis:50%}&+button.d-none+button:not(.d-none),&+button:not(.d-none){border-top-left-radius:var(--bs-border-radius);border-bottom-left-radius:var(--bs-border-radius)}}.input-group button:not(.d-none)+button:not(.d-none){border-left-width:0}li:has(.dropdown-item.d-none)~li:has(.dropdown-divider){display:none}li:has(.dropdown-item:not(.d-none))~li:has(.dropdown-divider){display:revert}li:has(.dropdown-divider):not(:has(~ li .dropdown-item:not(.d-none))),li:has(.dropdown-divider.d-none),li:has(.dropdown-header):not(:has(~ li .dropdown-item:not(.d-none))),li:has(.dropdown-header.d-none),li:has(.dropdown-item.d-none){display:none}.dropdown-item small::highlight(platypicker-highlight){background-color:#fee6b1;color:#000}
|
package/package.json
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "platypicker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "dist/platypicker.min.js",
|
|
6
6
|
"files": [
|
|
7
|
-
"dist"
|
|
7
|
+
"dist",
|
|
8
|
+
"src"
|
|
8
9
|
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build:js": "terser src/platypicker.js -o dist/platypicker.min.js --compress --mangle",
|
|
12
|
+
"build:css": "cleancss -o dist/platypicker.min.css src/platypicker.css",
|
|
13
|
+
"build": "npm run build:js && npm run build:css"
|
|
14
|
+
},
|
|
9
15
|
"repository": "https://github.com/Emberfire/UIComponents.git",
|
|
10
16
|
"exports": {
|
|
11
17
|
".": "./dist/platypicker.min.js"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"clean-css-cli": "^5.6.3",
|
|
21
|
+
"terser": "^5.46.0"
|
|
12
22
|
}
|
|
13
23
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
@media (any-pointer: fine), (any-hover: hover) {
|
|
2
|
+
*:has(> select.platypicker) {
|
|
3
|
+
anchor-scope: --platypicker;
|
|
4
|
+
|
|
5
|
+
select.platypicker {
|
|
6
|
+
anchor-name: --platypicker;
|
|
7
|
+
|
|
8
|
+
&:not([multiple]), &::picker(select) {
|
|
9
|
+
appearance: base-select !important;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
&::picker(select) {
|
|
13
|
+
display: none;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&::picker-icon {
|
|
17
|
+
display: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
& + .dropdown-menu[popover] {
|
|
21
|
+
display: revert;
|
|
22
|
+
position-anchor: --platypicker;
|
|
23
|
+
top: calc(anchor(bottom) + .125rem) !important;
|
|
24
|
+
left: anchor(left) !important;
|
|
25
|
+
min-width: anchor-size();
|
|
26
|
+
max-height: 30rem;
|
|
27
|
+
|
|
28
|
+
transform: none !important;
|
|
29
|
+
|
|
30
|
+
.input-group:not(:has(*:not(.d-none))) {
|
|
31
|
+
display: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.input-group input:not(:has(~ button:not(.d-none))) {
|
|
35
|
+
border-top-right-radius: var(--bs-border-radius);
|
|
36
|
+
border-bottom-right-radius: var(--bs-border-radius);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.input-group input.d-none {
|
|
40
|
+
& ~ button {
|
|
41
|
+
flex-grow: 1;
|
|
42
|
+
flex-basis: 50%;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
& + button:not(.d-none),
|
|
46
|
+
& + button.d-none + button:not(.d-none) {
|
|
47
|
+
border-top-left-radius: var(--bs-border-radius);
|
|
48
|
+
border-bottom-left-radius: var(--bs-border-radius);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.input-group button:not(.d-none) + button:not(.d-none) {
|
|
53
|
+
border-left-width: 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
li:has(.dropdown-item.d-none) ~ li:has(.dropdown-divider) {
|
|
57
|
+
display: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
li:has(.dropdown-item:not(.d-none)) ~ li:has(.dropdown-divider) {
|
|
61
|
+
display: revert;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
li:has(.dropdown-item.d-none), /* Hidden items */
|
|
65
|
+
li:has(.dropdown-header.d-none), /* Hidden headers */
|
|
66
|
+
li:has(.dropdown-divider.d-none), /* Hidden dividers */
|
|
67
|
+
li:has(.dropdown-header):not(:has(~ li .dropdown-item:not(.d-none))), /* Headers without any visible items */
|
|
68
|
+
li:has(.dropdown-divider):not(:has(~ li .dropdown-item:not(.d-none))) /* Dividers without any trailing items */ {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.dropdown-item::highlight(platypicker-highlight),
|
|
76
|
+
.dropdown-item small::highlight(platypicker-highlight) {
|
|
77
|
+
background-color: #FEE6B1;
|
|
78
|
+
color: black;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
export default class Platypicker {
|
|
4
|
+
static #selects = new Map();
|
|
5
|
+
static #highlight;
|
|
6
|
+
static maxHighlights = 100;
|
|
7
|
+
static languageMap = {
|
|
8
|
+
searchPlaceholder: "Type to filter...",
|
|
9
|
+
selectAllButton: "Select all",
|
|
10
|
+
selectNoneButton: "Select none"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
#select;
|
|
14
|
+
#popover;
|
|
15
|
+
#listControls;
|
|
16
|
+
#search;
|
|
17
|
+
#isShown;
|
|
18
|
+
#optionsChangeMutationObserver;
|
|
19
|
+
|
|
20
|
+
#selectClickListener;
|
|
21
|
+
#selectChangeListener;
|
|
22
|
+
#selectKeydownListener;
|
|
23
|
+
#popoverToggleListener;
|
|
24
|
+
#popoverHideListener;
|
|
25
|
+
|
|
26
|
+
get #isSearchEnabled() {
|
|
27
|
+
return this.#select.dataset?.search?.trim()?.toLowerCase() === "true";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get #areControlsEnabled() {
|
|
31
|
+
return this.#select.dataset.controls?.trim()?.toLowerCase() === "true";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static get(element) {
|
|
35
|
+
return this.#selects.get(element);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
constructor(selectElement) {
|
|
39
|
+
if (Platypicker.#selects.has(selectElement) ||
|
|
40
|
+
!window.matchMedia("(any-pointer: fine), (any-hover: hover)").matches)
|
|
41
|
+
return;
|
|
42
|
+
|
|
43
|
+
if (!selectElement["mutationObserver"]) {
|
|
44
|
+
// Watch the select element's attributes for changes, mirroring their state in the input mock.
|
|
45
|
+
selectElement["mutationObserver"] = new MutationObserver(() => {
|
|
46
|
+
this.#search.classList.toggle("d-none", !this.#isSearchEnabled);
|
|
47
|
+
for (const button of this.#listControls.querySelectorAll("button")) {
|
|
48
|
+
button.disabled = !this.#areControlsEnabled;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
selectElement["mutationObserver"].observe(selectElement, {
|
|
52
|
+
attributes: true,
|
|
53
|
+
attributeFilter: ["data-toggle", "data-search", "data-controls"]
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.#select = selectElement;
|
|
58
|
+
this.#setPopover();
|
|
59
|
+
|
|
60
|
+
this.#select["popoverElement"] = this.#popover;
|
|
61
|
+
this.#popover["selectElement"] = this.#select;
|
|
62
|
+
|
|
63
|
+
this.#popover.querySelector("ul").addEventListener("click", e => {
|
|
64
|
+
if (!this.#select.options?.length) return;
|
|
65
|
+
|
|
66
|
+
let closestListItem = e.target.closest("li:not(:has(.dropdown-divider, .dropdown-header)) .dropdown-item:not(.disabled)");
|
|
67
|
+
if (!closestListItem) return;
|
|
68
|
+
|
|
69
|
+
if (this.#select.multiple) {
|
|
70
|
+
closestListItem["option"].selected = !closestListItem["option"].selected;
|
|
71
|
+
} else {
|
|
72
|
+
this.#select.value = closestListItem["option"].value;
|
|
73
|
+
this.#updatePopover();
|
|
74
|
+
|
|
75
|
+
this.#popover.hidePopover();
|
|
76
|
+
this.#isShown = undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
closestListItem.scrollIntoView({ block: "nearest", });
|
|
80
|
+
|
|
81
|
+
if (!this.#select.multiple) this.#select.focus();
|
|
82
|
+
this.#select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.#selectChangeListener = async () => {
|
|
86
|
+
if (!this.#select.options?.length) return;
|
|
87
|
+
|
|
88
|
+
// Wait for any incomplete additions of options.
|
|
89
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
90
|
+
|
|
91
|
+
this.#updatePopover();
|
|
92
|
+
};
|
|
93
|
+
this.#select.addEventListener("change", this.#selectChangeListener);
|
|
94
|
+
|
|
95
|
+
this.#search.form.addEventListener("submit", e => e.preventDefault());
|
|
96
|
+
this.#search.addEventListener("input", e => this.#filterPopover(e.target.value));
|
|
97
|
+
this.#search.addEventListener("keydown", e => {
|
|
98
|
+
if (e.code === "Escape") this.#select.click();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.#selectClickListener = () => {
|
|
102
|
+
this.#isShown = this.#popover.togglePopover(!this.#isShown);
|
|
103
|
+
if (this.#isShown) this.#popover.querySelector("input:not(.d-none), .dropdown-item:not(.disabled, .d-none)")?.focus();
|
|
104
|
+
else this.#select.focus();
|
|
105
|
+
};
|
|
106
|
+
this.#select.addEventListener("click", this.#selectClickListener);
|
|
107
|
+
this.#popoverToggleListener = e => {
|
|
108
|
+
if (this.#popover.contains(e.target) || this.#select.contains(e.target)) return;
|
|
109
|
+
|
|
110
|
+
this.#popover.hidePopover();
|
|
111
|
+
this.#isShown = undefined;
|
|
112
|
+
};
|
|
113
|
+
document.addEventListener("click", this.#popoverToggleListener);
|
|
114
|
+
this.#popoverHideListener = e => {
|
|
115
|
+
if (e.code !== "Escape") return;
|
|
116
|
+
|
|
117
|
+
this.#popover.hidePopover();
|
|
118
|
+
this.#isShown = undefined;
|
|
119
|
+
};
|
|
120
|
+
document.addEventListener("keydown", this.#popoverHideListener);
|
|
121
|
+
|
|
122
|
+
this.#selectKeydownListener = e => {
|
|
123
|
+
if (e.code === "Enter" || e.code === "Space" || e.code === "ArrowDown") this.#isShown = this.#popover.togglePopover(!this.#isShown);
|
|
124
|
+
};
|
|
125
|
+
this.#select.addEventListener("keydown", this.#selectKeydownListener, true);
|
|
126
|
+
|
|
127
|
+
let debounced;
|
|
128
|
+
let tempValue = "";
|
|
129
|
+
this.#popover.addEventListener("keydown", e => {
|
|
130
|
+
if (this.#search.contains(e.target)) return;
|
|
131
|
+
|
|
132
|
+
let charCode = e.key.toLowerCase().charCodeAt(0);
|
|
133
|
+
if (e.ctrlKey || e.altKey || e.metaKey || e.key.length > 1 || charCode < 48 || (charCode > 57 && charCode < 97) || charCode > 122)
|
|
134
|
+
return;
|
|
135
|
+
|
|
136
|
+
tempValue += e.key.toLowerCase();
|
|
137
|
+
let firstAvailableOption =
|
|
138
|
+
[...this.#select.options].find(o =>
|
|
139
|
+
o !== this.#select.selectedOptions[0] &&
|
|
140
|
+
o.textContent.toLowerCase().trim().startsWith(tempValue) &&
|
|
141
|
+
!o.disabled &&
|
|
142
|
+
!o.closest("optgroup")?.disabled &&
|
|
143
|
+
!o["popoverItem"].classList.contains("d-none"));
|
|
144
|
+
if (!firstAvailableOption) firstAvailableOption =
|
|
145
|
+
this.#select.selectedOptions[0].textContent.toLowerCase().trim().startsWith(tempValue) &&
|
|
146
|
+
!this.#select.selectedOptions[0].disabled &&
|
|
147
|
+
!this.#select.selectedOptions[0].closest("optgroup")?.disabled &&
|
|
148
|
+
!this.#select.selectedOptions[0]["popoverItem"].classList.contains("d-none")
|
|
149
|
+
? this.#select.selectedOptions[0] : undefined;
|
|
150
|
+
if (firstAvailableOption) {
|
|
151
|
+
if (this.#select.multiple) {
|
|
152
|
+
this.#select.selectedIndex = -1;
|
|
153
|
+
firstAvailableOption.selected = true;
|
|
154
|
+
this.#select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
155
|
+
} else {
|
|
156
|
+
firstAvailableOption["popoverItem"].focus();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
requestAnimationFrame(() => firstAvailableOption["popoverItem"]?.scrollIntoView({ block: "nearest", }));
|
|
160
|
+
|
|
161
|
+
if (!debounced) debounced = Platypicker.#debounce(() => tempValue = "", 350);
|
|
162
|
+
else debounced();
|
|
163
|
+
} else {
|
|
164
|
+
tempValue = "";
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
this.#popover.querySelector("ul").addEventListener("keydown", e => {
|
|
169
|
+
if (e.code === "Escape") {
|
|
170
|
+
this.#isShown = this.#popover.togglePopover(false);
|
|
171
|
+
this.#select.focus();
|
|
172
|
+
}
|
|
173
|
+
}, true);
|
|
174
|
+
|
|
175
|
+
// Watch the select element's options for additions or removals, mirroring the structure every time.
|
|
176
|
+
this.#optionsChangeMutationObserver = new MutationObserver(() => {
|
|
177
|
+
// If the options count has changed (added/removed options) or it has stayed the same, but some options
|
|
178
|
+
// have been swapped (detected by the custom selectMenuOption property), regenerate the list items.
|
|
179
|
+
// This helps to not needlessly regenerate every time the slightest change is made, or the options
|
|
180
|
+
// have been manually regenerated by calling the #setListItems() method.
|
|
181
|
+
if (this.#select.options.length !== this.#select.length || [...this.#select.options].some(o => !o["option"]))
|
|
182
|
+
this.#setListItems();
|
|
183
|
+
});
|
|
184
|
+
this.#optionsChangeMutationObserver.observe(selectElement, { childList: true });
|
|
185
|
+
|
|
186
|
+
Platypicker.#selects.set(this.#select, this);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#setPopover() {
|
|
190
|
+
this.#popover = document.createElement("div");
|
|
191
|
+
this.#popover.classList.add("dropdown-menu", "rounded-3", "shadow", "p-0");
|
|
192
|
+
this.#popover.popover = "manual";
|
|
193
|
+
|
|
194
|
+
this.#select.parentElement.classList.add("dropdown");
|
|
195
|
+
this.#select.classList.add("platypicker");
|
|
196
|
+
this.#select.dataset.bsToggle = "dropdown";
|
|
197
|
+
this.#select.insertAdjacentElement("afterend", this.#popover);
|
|
198
|
+
|
|
199
|
+
this.#listControls = document.createElement("form");
|
|
200
|
+
this.#listControls.classList.add("input-group", "p-2", "bg-body-tertiary", "border-bottom", "sticky-top");
|
|
201
|
+
this.#popover.append(this.#listControls);
|
|
202
|
+
|
|
203
|
+
this.#search = document.createElement("input");
|
|
204
|
+
this.#search.classList.add("form-control");
|
|
205
|
+
this.#search.type = "search";
|
|
206
|
+
this.#search.name = "platypicker-search";
|
|
207
|
+
this.#search.placeholder = Platypicker.languageMap.searchPlaceholder;
|
|
208
|
+
this.#search.autofocus = true;
|
|
209
|
+
if (!this.#isSearchEnabled) this.#search.classList.add("d-none");
|
|
210
|
+
|
|
211
|
+
this.#listControls.append(this.#search);
|
|
212
|
+
|
|
213
|
+
const selectAllButton = document.createElement("button");
|
|
214
|
+
selectAllButton.classList.add("btn", "btn-outline-secondary")
|
|
215
|
+
selectAllButton.type = "button";
|
|
216
|
+
selectAllButton.textContent = Platypicker.languageMap.selectAllButton;
|
|
217
|
+
selectAllButton.addEventListener("click", () => {
|
|
218
|
+
for (const option of [...this.#select.options].filter(o =>
|
|
219
|
+
!o.disabled &&
|
|
220
|
+
!o["popoverItem"].classList.contains("d-none") &&
|
|
221
|
+
!o.closest("optgroup")?.disabled))
|
|
222
|
+
option.selected = true;
|
|
223
|
+
|
|
224
|
+
this.#select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
225
|
+
});
|
|
226
|
+
if (!this.#select.multiple || !this.#areControlsEnabled)
|
|
227
|
+
selectAllButton.classList.add("d-none");
|
|
228
|
+
|
|
229
|
+
this.#listControls.append(selectAllButton);
|
|
230
|
+
|
|
231
|
+
const selectNoneButton = document.createElement("button");
|
|
232
|
+
selectNoneButton.classList.add("btn", "btn-outline-secondary");
|
|
233
|
+
selectNoneButton.type = "button";
|
|
234
|
+
selectNoneButton.textContent = Platypicker.languageMap.selectNoneButton;
|
|
235
|
+
selectNoneButton.addEventListener("click", () => {
|
|
236
|
+
this.#select.selectedIndex = -1;
|
|
237
|
+
this.#select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
238
|
+
});
|
|
239
|
+
if (!this.#areControlsEnabled)
|
|
240
|
+
selectNoneButton.classList.add("d-none");
|
|
241
|
+
|
|
242
|
+
this.#listControls.append(selectNoneButton);
|
|
243
|
+
|
|
244
|
+
this.#setListItems();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#setListItems() {
|
|
248
|
+
let list = this.#popover.querySelector("ul");
|
|
249
|
+
if (!list) {
|
|
250
|
+
list = document.createElement("ul");
|
|
251
|
+
list.classList.add("list-unstyled", "d-grid", "gap-1", "p-2", "mb-0");
|
|
252
|
+
this.#popover.append(list);
|
|
253
|
+
} else list.innerHTML = "";
|
|
254
|
+
|
|
255
|
+
for (const child of this.#select.children) {
|
|
256
|
+
if (child instanceof HTMLOptionElement) {
|
|
257
|
+
this.#setItem(list, child);
|
|
258
|
+
} else if (child instanceof HTMLOptGroupElement) {
|
|
259
|
+
const listItem = document.createElement("li");
|
|
260
|
+
list.append(listItem);
|
|
261
|
+
const header = document.createElement("h6");
|
|
262
|
+
header.classList.add("dropdown-header");
|
|
263
|
+
header.textContent = child.label;
|
|
264
|
+
listItem.append(header);
|
|
265
|
+
|
|
266
|
+
header["optgroup"] = child;
|
|
267
|
+
child["header"] = header;
|
|
268
|
+
|
|
269
|
+
for (const option of child.children) {
|
|
270
|
+
this.#setItem(list, option);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (child.nextElementSibling instanceof HTMLOptionElement) {
|
|
274
|
+
const dividerListItem = document.createElement("li");
|
|
275
|
+
list.append(dividerListItem);
|
|
276
|
+
const divider = document.createElement("hr");
|
|
277
|
+
divider.classList.add("dropdown-divider");
|
|
278
|
+
dividerListItem.append(divider);
|
|
279
|
+
|
|
280
|
+
divider["optgroup"] = child;
|
|
281
|
+
child["divider"] = divider;
|
|
282
|
+
}
|
|
283
|
+
} else if (child instanceof HTMLHRElement) {
|
|
284
|
+
const listItem = document.createElement("li");
|
|
285
|
+
list.append(listItem);
|
|
286
|
+
const line = document.createElement("hr");
|
|
287
|
+
line.classList.add("dropdown-divider");
|
|
288
|
+
listItem.append(line);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#setItem(parent, option) {
|
|
294
|
+
const listItem = document.createElement("li");
|
|
295
|
+
parent.append(listItem);
|
|
296
|
+
|
|
297
|
+
const item = document.createElement("button");
|
|
298
|
+
item.classList.add("dropdown-item", "rounded-2");
|
|
299
|
+
item.type = "button";
|
|
300
|
+
item.textContent = option.textContent;
|
|
301
|
+
if (option.title) item.title = option.title;
|
|
302
|
+
|
|
303
|
+
listItem.append(item);
|
|
304
|
+
|
|
305
|
+
const subtext = document.createElement("small");
|
|
306
|
+
subtext.textContent = option.dataset.subtext;
|
|
307
|
+
if (option.selected && !option.disabled)
|
|
308
|
+
item.classList.add("active");
|
|
309
|
+
if (option.disabled || option.closest("optgroup")?.disabled)
|
|
310
|
+
item.classList.add("disabled");
|
|
311
|
+
item.append(subtext);
|
|
312
|
+
|
|
313
|
+
item["option"] = option;
|
|
314
|
+
option["popoverItem"] = item;
|
|
315
|
+
|
|
316
|
+
const searchValue = this.#search.value.trim().toLowerCase();
|
|
317
|
+
if (searchValue) this.#adjustHighlightRange(item, searchValue);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#updatePopover() {
|
|
321
|
+
const searchValue = this.#search.value.trim().toLowerCase();
|
|
322
|
+
for (const item of this.#popover.querySelectorAll("li:not(:has(.dropdown-divider, .dropdown-header)) .dropdown-item")) {
|
|
323
|
+
let hasContentBeenUpdated = false;
|
|
324
|
+
if (item.childNodes[0].textContent !== item["option"].textContent) {
|
|
325
|
+
// The list item might not have a text or value because
|
|
326
|
+
// the select option doesn't have one either.
|
|
327
|
+
item.childNodes[0].textContent = item["option"].textContent;
|
|
328
|
+
hasContentBeenUpdated = true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let subtextElement = item.querySelector("small");
|
|
332
|
+
if (item["option"].dataset.subtext && subtextElement.textContent !== item["option"].dataset.subtext) {
|
|
333
|
+
subtextElement.textContent = item["option"].dataset.subtext;
|
|
334
|
+
hasContentBeenUpdated = true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
item.classList.remove("active");
|
|
338
|
+
if (hasContentBeenUpdated && searchValue)
|
|
339
|
+
this.#adjustHighlightRange(item, searchValue);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const selectedOption of this.#select.selectedOptions) {
|
|
343
|
+
selectedOption["popoverItem"].classList.add("active");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!this.#select.selectedOptions.length)
|
|
347
|
+
this.#select.querySelector("selectedcontent").textContent = "0 selected";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#adjustHighlightRange(item, searchValue) {
|
|
351
|
+
searchValue = searchValue.trim().toLowerCase();
|
|
352
|
+
|
|
353
|
+
const optionText = item["option"].textContent.trim().toLowerCase();
|
|
354
|
+
let start = optionText.indexOf(searchValue);
|
|
355
|
+
if (start >= 0 && Platypicker.#highlight.size < Platypicker.maxHighlights) {
|
|
356
|
+
if (!item["textHighlightRange"]) item["textHighlightRange"] = new Range();
|
|
357
|
+
|
|
358
|
+
item["textHighlightRange"].setStart(item.childNodes[0], start);
|
|
359
|
+
item["textHighlightRange"].setEnd(item.childNodes[0], start + searchValue.length);
|
|
360
|
+
Platypicker.#highlight.add(item["textHighlightRange"]);
|
|
361
|
+
} else if (item["textHighlightRange"]) {
|
|
362
|
+
Platypicker.#highlight.delete(item["textHighlightRange"]);
|
|
363
|
+
delete item["textHighlightRange"];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const optionSubtext = item["option"].dataset.subtext?.trim().toLowerCase();
|
|
367
|
+
if (!(optionSubtext && optionSubtext !== optionText)) return;
|
|
368
|
+
|
|
369
|
+
start = optionSubtext.indexOf(searchValue);
|
|
370
|
+
if (start >= 0 && Platypicker.#highlight.size < Platypicker.maxHighlights) {
|
|
371
|
+
if (!item["subtextHighlightRange"]) item["subtextHighlightRange"] = new Range();
|
|
372
|
+
|
|
373
|
+
const subtextNode = item.querySelector("small").childNodes[0]
|
|
374
|
+
item["subtextHighlightRange"].setStart(subtextNode, start);
|
|
375
|
+
item["subtextHighlightRange"].setEnd(subtextNode, start + searchValue.length);
|
|
376
|
+
Platypicker.#highlight.add(item["subtextHighlightRange"]);
|
|
377
|
+
} else if (item["subtextHighlightRange"]) {
|
|
378
|
+
Platypicker.#highlight.delete(item["subtextHighlightRange"]);
|
|
379
|
+
delete item["subtextHighlightRange"];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#filterPopover(value, clearHighlights = true) {
|
|
384
|
+
const sanitizedValue = value.trim().toLowerCase();
|
|
385
|
+
if (!sanitizedValue) {
|
|
386
|
+
for (const item of this.#popover.querySelectorAll("li .dropdown-item.d-none, li .dropdown-divider.d-none, li .dropdown-header.d-none")) {
|
|
387
|
+
item.classList.remove("d-none");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (clearHighlights) Platypicker.#highlight?.clear();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (clearHighlights) Platypicker.#highlight?.clear();
|
|
395
|
+
|
|
396
|
+
let optgroupIsPartiallyMatching = false;
|
|
397
|
+
let optgroupElement = null;
|
|
398
|
+
for (const item of this.#popover.querySelectorAll("li:not(:has(.dropdown-divider, .dropdown-header)) .dropdown-item")) {
|
|
399
|
+
// if (item.classList.contains("dropdown-divider") &&
|
|
400
|
+
// item.parentElement.previousElementSibling.querySelector(".d-none") &&
|
|
401
|
+
// item.parentElement.nextElementSibling.querySelector(".d-none"))
|
|
402
|
+
// item.classList.add("d-none");
|
|
403
|
+
|
|
404
|
+
const normalizedOptionText = item["option"].textContent.toLowerCase().trim();
|
|
405
|
+
const normalizedOptionSubtext = item["option"].dataset.subtext?.toLowerCase().trim() ?? "";
|
|
406
|
+
if (!item["option"].closest("optgroup") || item["option"].closest("optgroup") !== optgroupElement)
|
|
407
|
+
optgroupIsPartiallyMatching = false;
|
|
408
|
+
|
|
409
|
+
optgroupElement = item["option"].closest("optgroup");
|
|
410
|
+
|
|
411
|
+
if (normalizedOptionText === sanitizedValue || normalizedOptionSubtext === sanitizedValue) {
|
|
412
|
+
item.classList.remove("d-none");
|
|
413
|
+
if (optgroupElement) {
|
|
414
|
+
optgroupIsPartiallyMatching = true;
|
|
415
|
+
optgroupElement["header"].classList.remove("d-none");
|
|
416
|
+
optgroupElement["divider"]?.classList.remove("d-none");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.#adjustHighlightRange(item, sanitizedValue);
|
|
420
|
+
} else if (normalizedOptionText.includes(sanitizedValue) || normalizedOptionSubtext.includes(sanitizedValue)) {
|
|
421
|
+
// Case in which we have a partially matching option.
|
|
422
|
+
item.classList.remove("d-none");
|
|
423
|
+
if (optgroupElement) {
|
|
424
|
+
optgroupIsPartiallyMatching = true;
|
|
425
|
+
optgroupElement["header"].classList.remove("d-none");
|
|
426
|
+
optgroupElement["divider"]?.classList.remove("d-none");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.#adjustHighlightRange(item, sanitizedValue);
|
|
430
|
+
} else {
|
|
431
|
+
// Case in which we have a non-matching option and must hide it.
|
|
432
|
+
item.classList.add("d-none");
|
|
433
|
+
if (optgroupElement && !optgroupIsPartiallyMatching) {
|
|
434
|
+
optgroupIsPartiallyMatching = true;
|
|
435
|
+
optgroupElement["header"].classList.add("d-none");
|
|
436
|
+
optgroupElement["divider"]?.classList.add("d-none");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.#adjustHighlightRange(item, sanitizedValue);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
static init() {
|
|
445
|
+
this.#highlight = new Highlight();
|
|
446
|
+
CSS.highlights.set("platypicker-highlight", this.#highlight);
|
|
447
|
+
|
|
448
|
+
for (const select of document.querySelectorAll("select[data-toggle=platypicker]")) {
|
|
449
|
+
new Platypicker(select);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Place an observer to create a select whenever a select element with a select toggle gets added.
|
|
453
|
+
new MutationObserver(mutations => {
|
|
454
|
+
for (const mutation of mutations) {
|
|
455
|
+
for (const addedNode of mutation.addedNodes) {
|
|
456
|
+
if (!(addedNode instanceof HTMLElement)) continue;
|
|
457
|
+
|
|
458
|
+
let toggles = [];
|
|
459
|
+
if (addedNode.dataset?.toggle === "platypicker") toggles.push(addedNode);
|
|
460
|
+
toggles.push(...addedNode.querySelectorAll("[data-toggle=platypicker]"));
|
|
461
|
+
|
|
462
|
+
for (const toggle of toggles) {
|
|
463
|
+
if (this.#selects.has(toggle)) continue;
|
|
464
|
+
|
|
465
|
+
new Platypicker(toggle);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (const removedNode of mutation.removedNodes) {
|
|
470
|
+
if (!(removedNode instanceof HTMLElement)) continue;
|
|
471
|
+
|
|
472
|
+
let toggles = [];
|
|
473
|
+
if (removedNode.dataset?.toggle === "platypicker") toggles.push(removedNode);
|
|
474
|
+
toggles.push(...removedNode.querySelectorAll("[data-toggle=platypicker]"));
|
|
475
|
+
|
|
476
|
+
for (const toggle of toggles) {
|
|
477
|
+
Platypicker.get(toggle)?.#destroy();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}).observe(document, {
|
|
482
|
+
childList: true,
|
|
483
|
+
subtree: true
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#destroy() {
|
|
488
|
+
if (!Platypicker.#selects.has(this.#select)) return;
|
|
489
|
+
|
|
490
|
+
this.#select.removeEventListener("change", this.#selectChangeListener);
|
|
491
|
+
this.#select.removeEventListener("click", this.#selectClickListener);
|
|
492
|
+
this.#select.removeEventListener("keydown", this.#selectKeydownListener);
|
|
493
|
+
document.removeEventListener("click", this.#popoverToggleListener);
|
|
494
|
+
document.removeEventListener("click", this.#popoverHideListener);
|
|
495
|
+
|
|
496
|
+
// Disconnect all associated mutation observers.
|
|
497
|
+
this.#optionsChangeMutationObserver.disconnect();
|
|
498
|
+
|
|
499
|
+
this.#select.classList.remove("platypicker");
|
|
500
|
+
this.#select.parentElement.classList.remove("dropdown");
|
|
501
|
+
this.#select.removeAttribute("data-bs-toggle");
|
|
502
|
+
|
|
503
|
+
// Remove the mocks from the DOM tree and the select collection.
|
|
504
|
+
this.#popover.remove();
|
|
505
|
+
Platypicker.#selects.delete(this.#select);
|
|
506
|
+
|
|
507
|
+
this.#select = null;
|
|
508
|
+
this.#popover = null;
|
|
509
|
+
this.#listControls = null;
|
|
510
|
+
this.#search = null;
|
|
511
|
+
this.#isShown = null;
|
|
512
|
+
this.#optionsChangeMutationObserver = null;
|
|
513
|
+
|
|
514
|
+
this.#popoverToggleListener = null;
|
|
515
|
+
this.#popoverHideListener = null;
|
|
516
|
+
|
|
517
|
+
delete this;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
static #debounce(func, wait = 50, immediate) {
|
|
521
|
+
// The returned function will be able to reference this due to closure.
|
|
522
|
+
// Each call to the returned function will share this common timer.
|
|
523
|
+
let timeout;
|
|
524
|
+
|
|
525
|
+
return function () {
|
|
526
|
+
// reference the context and args for the setTimeout function
|
|
527
|
+
const context = this,
|
|
528
|
+
args = arguments;
|
|
529
|
+
|
|
530
|
+
// Should the function be called now? If immediate is true
|
|
531
|
+
// and not already in a timeout, then the answer is: Yes
|
|
532
|
+
const callNow = immediate && !timeout;
|
|
533
|
+
|
|
534
|
+
// This is the basic debounced behavior where you can call this
|
|
535
|
+
// function several times, but it will only execute once
|
|
536
|
+
// [before or after imposing a delay].
|
|
537
|
+
// Each time the returned function is called, the timer starts over.
|
|
538
|
+
clearTimeout(timeout);
|
|
539
|
+
|
|
540
|
+
// Set the new timeout
|
|
541
|
+
timeout = window.setTimeout(function () {
|
|
542
|
+
// Inside the timeout function, clear the timeout variable
|
|
543
|
+
// which will let the next execution run when in 'immediate' mode
|
|
544
|
+
timeout = null;
|
|
545
|
+
|
|
546
|
+
// Check if the function already ran with the immediate flag
|
|
547
|
+
if (!immediate) {
|
|
548
|
+
// Call the original function with "apply".
|
|
549
|
+
// Apply lets you define the 'this' object as well as the arguments
|
|
550
|
+
// (both captured before setTimeout)
|
|
551
|
+
func.apply(context, args);
|
|
552
|
+
}
|
|
553
|
+
}, wait);
|
|
554
|
+
|
|
555
|
+
// Immediate mode and no wait timer? Execute the function.
|
|
556
|
+
if (callNow) func.apply(context, args);
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
Platypicker.init();
|