@studiocms/ui 0.4.12 → 0.4.13

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.
@@ -1,143 +1,356 @@
1
1
  function loadSelects() {
2
- const allSelects = document.querySelectorAll(".sui-select-label");
3
- for (const container of allSelects) {
4
- let hideOnClickOutside2 = function(element) {
5
- const outsideClickListener = (event) => {
6
- if (!element.contains(event.target) && isVisible(element) && active === true) {
7
- closeDropdown();
8
- }
9
- };
10
- document.addEventListener("click", outsideClickListener);
11
- };
12
- var hideOnClickOutside = hideOnClickOutside2;
13
- const hiddenSelect = container.querySelector("select");
14
- const button = container.querySelector("button");
15
- const valueSpan = container.querySelector(".sui-select-value-span");
16
- const dropdown = container.querySelector(".sui-select-dropdown");
17
- const optionElements = container.querySelectorAll(".sui-select-option");
18
- const options = JSON.parse(container.dataset.options);
19
- const id = container.dataset.id;
20
- let active = false;
21
- const closeDropdown = () => {
22
- dropdown.classList.remove("active", "above");
23
- active = false;
24
- button.ariaExpanded = "false";
25
- focusIndex = -1;
26
- for (const entry of optionElements) {
27
- entry.classList.remove("focused");
2
+ const CONSTANTS = {
3
+ OPTION_HEIGHT: 36,
4
+ BORDER_SIZE: 2,
5
+ MARGIN: 4,
6
+ BADGE_PADDING: 80
7
+ };
8
+ const observerMap = /* @__PURE__ */ new WeakMap();
9
+ function observeResize(element, callback) {
10
+ if (observerMap.has(element)) {
11
+ unobserveResize(element);
12
+ }
13
+ const observer = new ResizeObserver((entries) => {
14
+ for (const entry of entries) {
15
+ const { width, height } = entry.contentRect;
16
+ callback(width, height, entry.target);
28
17
  }
18
+ });
19
+ observer.observe(element);
20
+ observerMap.set(element, { observer, callback });
21
+ return () => unobserveResize(element);
22
+ }
23
+ function unobserveResize(element) {
24
+ const data = observerMap.get(element);
25
+ if (data) {
26
+ data.observer.disconnect();
27
+ observerMap.delete(element);
28
+ }
29
+ }
30
+ const isVisible = (elem) => elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0;
31
+ const getDropdownPosition = (button, optionsCount) => {
32
+ const rect = button.getBoundingClientRect();
33
+ const dropdownHeight = optionsCount * CONSTANTS.OPTION_HEIGHT + CONSTANTS.BORDER_SIZE + CONSTANTS.MARGIN;
34
+ const customRect = {
35
+ top: rect.bottom + CONSTANTS.MARGIN,
36
+ bottom: rect.bottom + CONSTANTS.MARGIN + dropdownHeight,
37
+ left: rect.left,
38
+ right: rect.right,
39
+ width: rect.width,
40
+ x: rect.x,
41
+ y: rect.y + rect.height + CONSTANTS.MARGIN,
42
+ height: dropdownHeight
29
43
  };
30
- const openDropdown = (toggle) => {
31
- const { bottom, left, right, width, x, y, height } = button.getBoundingClientRect();
32
- const optionHeight = 36;
33
- const totalBorderSize = 2;
34
- const margin = 4;
35
- const dropdownHeight = options.length * optionHeight + totalBorderSize + margin;
36
- const CustomRect = {
37
- top: bottom + margin,
38
- left,
39
- right,
40
- bottom: bottom + margin + dropdownHeight,
41
- width,
42
- height: dropdownHeight,
43
- x,
44
- y: y + height + margin
45
- };
46
- if (active && toggle) {
47
- closeDropdown();
48
- return;
44
+ return {
45
+ isAbove: customRect.top >= 0 && customRect.left >= 0 && customRect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && customRect.right <= (window.innerWidth || document.documentElement.clientWidth),
46
+ customRect
47
+ };
48
+ };
49
+ const closeDropdown = (container) => {
50
+ if (!container?.button || !container?.dropdown) return;
51
+ container.dropdown.classList.remove("active", "above");
52
+ container.button.ariaExpanded = "false";
53
+ };
54
+ const openDropdown = (state2, container) => {
55
+ if (!container?.button || !container?.dropdown) return;
56
+ const { isAbove } = getDropdownPosition(
57
+ container.button,
58
+ state2.optionsMap[container.dataset.id]?.length ?? 0
59
+ );
60
+ container.button.ariaExpanded = "true";
61
+ container.dropdown.classList.add("active", ...isAbove ? [] : ["above"]);
62
+ };
63
+ const createSelectBadge = (value, label) => {
64
+ const badge = document.createElement("span");
65
+ badge.classList.add("sui-badge", "primary", "sm", "default", "full", "sui-select-badge");
66
+ badge.setAttribute("data-value", value);
67
+ badge.innerHTML = `${label} <svg style='min-width: 8px' xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 24 24'><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 18L18 6M6 6l12 12'></path></svg>`;
68
+ return badge;
69
+ };
70
+ const measureBadgesWidth = (activeSelects) => {
71
+ const tempContainer = document.createElement("div");
72
+ tempContainer.classList.add("sui-select-badge-container");
73
+ tempContainer.style.position = "absolute";
74
+ tempContainer.style.visibility = "hidden";
75
+ document.body.appendChild(tempContainer);
76
+ const badges = Array.from(activeSelects).map(
77
+ (select) => createSelectBadge(
78
+ select.getAttribute("value") ?? "",
79
+ select.innerText.trim()
80
+ )
81
+ );
82
+ for (const badge of badges) {
83
+ tempContainer.appendChild(badge);
84
+ }
85
+ const totalWidth = badges.reduce((width, badge) => {
86
+ const badgeStyle = window.getComputedStyle(badge);
87
+ return width + badge.offsetWidth + (Number.parseFloat(badgeStyle.marginLeft) || 0) + (Number.parseFloat(badgeStyle.marginRight) || 0);
88
+ }, 0);
89
+ return { totalWidth, badges, tempContainer };
90
+ };
91
+ const handleBadgeOverflow = (state2, container) => {
92
+ const buttonContainer = container?.button?.parentElement?.parentElement;
93
+ const buttonValueSpan = container?.button?.querySelector(
94
+ ".sui-select-value-span"
95
+ );
96
+ const activeSelects = container?.dropdown?.querySelectorAll(".sui-select-option.selected");
97
+ const overflowContainer = container?.querySelector(".sui-select-badge-container-below");
98
+ if (!buttonContainer || !overflowContainer || !container?.button || !activeSelects) return;
99
+ const parentContainer = buttonContainer.parentElement;
100
+ if (!parentContainer) return;
101
+ overflowContainer.innerHTML = "";
102
+ buttonValueSpan.innerHTML = "";
103
+ if (activeSelects.length === 0) {
104
+ buttonValueSpan.innerText = state2.placeholder;
105
+ return;
106
+ }
107
+ const { totalWidth, badges, tempContainer } = measureBadgesWidth(activeSelects);
108
+ const parentStyles = window.getComputedStyle(parentContainer);
109
+ const availableWidth = parentContainer.clientWidth - (Number.parseFloat(parentStyles.paddingLeft) || 0) - (Number.parseFloat(parentStyles.paddingRight) || 0);
110
+ const effectiveAvailableWidth = availableWidth - CONSTANTS.BADGE_PADDING;
111
+ document.body.removeChild(tempContainer);
112
+ const finalBadgeContainer = document.createElement("div");
113
+ finalBadgeContainer.classList.add("sui-select-badge-container");
114
+ for (const badge of badges) {
115
+ badge.querySelector("svg")?.setAttribute("tabindex", "0");
116
+ finalBadgeContainer.appendChild(badge.cloneNode(true));
117
+ }
118
+ if (totalWidth > effectiveAvailableWidth) {
119
+ overflowContainer.appendChild(finalBadgeContainer);
120
+ } else {
121
+ buttonValueSpan.appendChild(finalBadgeContainer);
122
+ }
123
+ };
124
+ const updateLabel = (state2, container) => {
125
+ const isMultiple = state2.isMultipleMap[container?.dataset.id];
126
+ if (isMultiple) {
127
+ handleBadgeOverflow(state2, container);
128
+ } else {
129
+ const selected = container?.querySelector(".sui-select-option.selected");
130
+ const selectedButtonSpan = container?.button?.querySelector(
131
+ ".sui-select-value-span"
132
+ );
133
+ if (selected && selectedButtonSpan) {
134
+ selectedButtonSpan.innerText = selected.innerText.trim();
49
135
  }
50
- active = true;
51
- button.ariaExpanded = "true";
52
- focusIndex = Array.from(optionElements).findIndex((x2) => x2.classList.contains("selected"));
53
- if (CustomRect.top >= 0 && CustomRect.left >= 0 && CustomRect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && CustomRect.right <= (window.innerWidth || document.documentElement.clientWidth)) {
54
- dropdown.classList.add("active");
55
- } else {
56
- dropdown.classList.add("active", "above");
136
+ }
137
+ };
138
+ const deselectMultiOption = (state2, id, container) => {
139
+ const selectOpt = container?.dropdown?.querySelector(
140
+ `.sui-select-option[value='${id}']`
141
+ );
142
+ const max = Number.parseInt(container?.dataset.multipleMax);
143
+ const selectedCount = container?.querySelectorAll(".sui-select-option.selected").length ?? 0;
144
+ const selectedCountEl = container?.querySelector(
145
+ ".sui-select-max-span .sui-select-select-count"
146
+ );
147
+ const isSelected = selectOpt?.classList.contains("selected");
148
+ if (selectOpt && (isSelected || Number.isNaN(max) || selectedCount < max)) {
149
+ selectOpt.classList.toggle("selected");
150
+ const selectOptEl = container?.select?.querySelector(
151
+ `option[value='${selectOpt.getAttribute("value")}']`
152
+ );
153
+ if (selectOptEl) {
154
+ selectOptEl.selected = !selectOpt.selected;
57
155
  }
58
- };
59
- button.addEventListener("click", () => openDropdown(true));
60
- let focusIndex = -1;
61
- const recomputeOptions = () => {
62
- for (const entry of optionElements) {
63
- if (Number.parseInt(entry.dataset.optionIndex) === focusIndex) {
64
- entry.classList.add("focused");
65
- } else {
66
- entry.classList.remove("focused");
67
- }
156
+ if (selectedCountEl) {
157
+ selectedCountEl.innerText = String(selectedCount + (isSelected ? -1 : 1));
68
158
  }
69
- };
70
- button.addEventListener("keydown", (e) => {
71
- if (e.key === "Tab" || e.key === "Escape") {
72
- closeDropdown();
73
- return;
159
+ updateLabel(state2, container);
160
+ }
161
+ };
162
+ const recomputeOptions = (state2, container) => {
163
+ const optionElements = container?.dropdown?.querySelectorAll(
164
+ ".sui-select-option"
165
+ );
166
+ for (const entry of optionElements) {
167
+ if (Number.parseInt(entry.dataset.optionIndex) === state2.focusIndex) {
168
+ entry.classList.add("focused");
169
+ } else {
170
+ entry.classList.remove("focused");
74
171
  }
75
- if (e.key === " " && !active) openDropdown(false);
76
- if (e.key === "Enter") {
77
- const currentlyFocused = container.querySelector(".focused");
78
- if (currentlyFocused) {
79
- currentlyFocused.classList.remove("focused");
80
- currentlyFocused.click();
81
- e.preventDefault();
82
- e.stopImmediatePropagation();
172
+ }
173
+ };
174
+ const getInteractiveOptions = (container) => {
175
+ const allOptions = container?.dropdown?.querySelectorAll(
176
+ ".sui-select-option"
177
+ );
178
+ return Array.from(allOptions).filter(
179
+ (option) => !option.classList.contains("hidden") && !option.classList.contains("disabled") && !option.hasAttribute("disabled")
180
+ );
181
+ };
182
+ const handleOptionSelect = (target, state2, container) => {
183
+ const option = target.closest(".sui-select-option");
184
+ const lastActive = container?.dropdown?.querySelector(".sui-select-option.selected");
185
+ const isMultiple = state2.isMultipleMap[container.dataset.id];
186
+ if (isMultiple) {
187
+ deselectMultiOption(state2, option?.getAttribute("value"), container);
188
+ } else {
189
+ if (lastActive) {
190
+ lastActive.classList.remove("selected");
191
+ const lastSelectOpt = container?.select?.querySelector(
192
+ `option[value='${lastActive.getAttribute("value")}']`
193
+ );
194
+ if (lastSelectOpt) {
195
+ lastSelectOpt.selected = false;
83
196
  }
84
- return;
85
197
  }
86
- e.preventDefault();
87
- e.stopImmediatePropagation();
88
- const neighbor = (offset) => {
89
- return optionElements.item(
90
- (Array.from(optionElements).findIndex((x) => x.classList.contains("selected")) ?? -1) + offset
198
+ if (option) {
199
+ option.classList.add("selected");
200
+ const selectOpt = container?.select?.querySelector(
201
+ `option[value='${option.getAttribute("value")}']`
91
202
  );
92
- };
93
- if (e.key === "ArrowUp" && (focusIndex > 0 || !active)) {
94
- if (!active) return neighbor(-1)?.click();
95
- focusIndex--;
96
- recomputeOptions();
97
- }
98
- if (e.key === "ArrowDown" && focusIndex + 1 < optionElements.length) {
99
- if (!active) return neighbor(1)?.click();
100
- focusIndex++;
101
- recomputeOptions();
203
+ if (selectOpt) {
204
+ selectOpt.selected = true;
205
+ }
206
+ updateLabel(state2, container);
102
207
  }
103
- if (e.key === "PageUp") {
104
- focusIndex = 0;
105
- if (!active) return optionElements.item(focusIndex)?.click();
106
- recomputeOptions();
208
+ closeDropdown(container);
209
+ }
210
+ };
211
+ const handleContainerClick = (e, state2, container) => {
212
+ const target = e.target;
213
+ if (target.closest(".sui-select-badge svg")) {
214
+ deselectMultiOption(
215
+ state2,
216
+ target.closest(".sui-select-badge")?.getAttribute("data-value"),
217
+ container
218
+ );
219
+ handleBadgeOverflow(state2, container);
220
+ }
221
+ if (target.closest(".sui-select-button")) {
222
+ const container2 = target.closest(".sui-select-label");
223
+ if (container2.dropdown?.classList.contains("active")) {
224
+ closeDropdown(container2);
225
+ } else {
226
+ openDropdown(state2, container2);
107
227
  }
108
- if (e.key === "PageDown") {
109
- focusIndex = optionElements.length - 1;
110
- if (!active) return optionElements.item(focusIndex)?.click();
111
- recomputeOptions();
228
+ }
229
+ if (target.closest(".sui-select-dropdown.active")) {
230
+ handleOptionSelect(target, state2, container);
231
+ }
232
+ };
233
+ const handleSelectKeyDown = (e, state2, container) => {
234
+ const active = !!container.dropdown?.classList.contains("active");
235
+ const focusedElement = document.activeElement;
236
+ if (e.key === "Tab" || e.key === "Escape") {
237
+ closeDropdown(container);
238
+ return;
239
+ }
240
+ if ((e.key === "Enter" || e.key === " ") && focusedElement?.tagName.toLowerCase() === "svg") {
241
+ const badgeElement = focusedElement?.closest(".sui-select-badge");
242
+ if (badgeElement && state2.isMultipleMap[container?.dataset.id]) {
243
+ const badgeValue = badgeElement.getAttribute("data-value");
244
+ let nextBadge = badgeElement.previousElementSibling;
245
+ if (!nextBadge) {
246
+ nextBadge = badgeElement.nextElementSibling;
247
+ }
248
+ const nextBadgeValue = nextBadge?.getAttribute("data-value");
249
+ deselectMultiOption(state2, badgeValue, container);
250
+ handleBadgeOverflow(state2, container);
251
+ setTimeout(() => {
252
+ if (nextBadgeValue) {
253
+ const badgeToFocus = container?.querySelector(
254
+ `.sui-select-badge[data-value="${nextBadgeValue}"] svg`
255
+ );
256
+ if (badgeToFocus) {
257
+ badgeToFocus.focus();
258
+ }
259
+ } else {
260
+ container?.button?.focus();
261
+ }
262
+ }, 0);
263
+ e.preventDefault();
264
+ e.stopImmediatePropagation();
265
+ return;
112
266
  }
113
- });
114
- const handleSelection = (e, option) => {
267
+ }
268
+ if ((e.key === " " || e.key === "Enter") && !active) {
269
+ openDropdown(state2, container);
270
+ e.preventDefault();
115
271
  e.stopImmediatePropagation();
116
- if (option.id === `${id}-selected` || !id) return;
117
- const currentlySelected = document.getElementById(`${id}-selected`);
118
- if (currentlySelected) {
119
- currentlySelected.classList.remove("selected");
120
- currentlySelected.id = "";
272
+ return;
273
+ }
274
+ if (e.key === "Enter" && active) {
275
+ const currentlyFocused = container?.querySelector(".sui-select-option.focused");
276
+ if (currentlyFocused) {
277
+ currentlyFocused.click();
278
+ e.preventDefault();
279
+ e.stopImmediatePropagation();
121
280
  }
122
- option.id = `${id}-selected`;
123
- option.classList.add("selected");
124
- const opt = options[Number.parseInt(option.dataset.optionIndex)];
125
- hiddenSelect.value = opt.value;
126
- valueSpan.textContent = opt.label;
127
- closeDropdown();
128
- };
129
- for (const option of optionElements) {
130
- const handleSelectionForOption = (e) => handleSelection(e, option);
131
- option.addEventListener("click", handleSelectionForOption);
132
- }
133
- window.addEventListener("scroll", closeDropdown);
134
- document.addEventListener("keydown", (e) => {
135
- if (e.key === "Escape" && dropdown.classList.contains("active")) {
136
- closeDropdown();
281
+ return;
282
+ }
283
+ e.preventDefault();
284
+ e.stopImmediatePropagation();
285
+ const interactiveOptions = getInteractiveOptions(container);
286
+ const currentInteractiveIndex = interactiveOptions.findIndex(
287
+ (option) => option.classList.contains("focused")
288
+ );
289
+ if (e.key === "ArrowUp" && currentInteractiveIndex > 0) {
290
+ state2.focusIndex = Array.from(
291
+ container?.dropdown?.querySelectorAll(".sui-select-option") || []
292
+ ).indexOf(interactiveOptions[currentInteractiveIndex - 1]);
293
+ recomputeOptions(state2, container);
294
+ }
295
+ if (e.key === "ArrowDown" && currentInteractiveIndex < interactiveOptions.length - 1) {
296
+ state2.focusIndex = Array.from(
297
+ container?.dropdown?.querySelectorAll(".sui-select-option") || []
298
+ ).indexOf(interactiveOptions[currentInteractiveIndex + 1]);
299
+ recomputeOptions(state2, container);
300
+ }
301
+ if (e.key === "PageUp") {
302
+ state2.focusIndex = Array.from(
303
+ container?.dropdown?.querySelectorAll(".sui-select-option") || []
304
+ ).indexOf(interactiveOptions[0]);
305
+ recomputeOptions(state2, container);
306
+ }
307
+ if (e.key === "PageDown") {
308
+ state2.focusIndex = Array.from(
309
+ container?.dropdown?.querySelectorAll(".sui-select-option") || []
310
+ ).indexOf(interactiveOptions[interactiveOptions.length - 1]);
311
+ recomputeOptions(state2, container);
312
+ }
313
+ };
314
+ const state = {
315
+ optionsMap: {},
316
+ isMultipleMap: {},
317
+ focusIndex: -1,
318
+ placeholder: ""
319
+ };
320
+ const selects = document.querySelectorAll(".sui-select-label");
321
+ document.addEventListener("click", ({ target }) => {
322
+ for (const container of selects) {
323
+ if (!container.dropdown?.classList.contains("active") || !target)
324
+ continue;
325
+ if (!container.contains(target) && isVisible(container)) {
326
+ closeDropdown(container);
137
327
  }
328
+ }
329
+ });
330
+ for (const container of selects) {
331
+ const id = container.dataset.id;
332
+ const specialContainer = Object.assign(container, {
333
+ button: container.querySelector("button"),
334
+ dropdown: container.querySelector(".sui-select-dropdown"),
335
+ select: container.querySelector("select")
138
336
  });
139
- hideOnClickOutside2(container);
140
- const isVisible = (elem) => !!elem && !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length);
337
+ state.placeholder = specialContainer.button?.querySelector(".sui-select-value-span")?.innerText ?? "";
338
+ state.optionsMap[id] = JSON.parse(container.dataset.options);
339
+ state.isMultipleMap[id] = container.dataset.multiple === "true";
340
+ specialContainer.addEventListener(
341
+ "click",
342
+ (e) => handleContainerClick(e, state, specialContainer)
343
+ );
344
+ specialContainer.addEventListener(
345
+ "keydown",
346
+ (e) => handleSelectKeyDown(e, state, specialContainer)
347
+ );
348
+ if (state.isMultipleMap[id]) {
349
+ observeResize(specialContainer.button, () => {
350
+ handleBadgeOverflow(state, specialContainer);
351
+ });
352
+ handleBadgeOverflow(state, specialContainer);
353
+ }
141
354
  }
142
355
  }
143
356
  document.addEventListener("astro:page-load", loadSelects);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studiocms/ui",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "The UI library for StudioCMS. Includes the layouts & components we use to build StudioCMS.",
5
5
  "repository": {
6
6
  "type": "git",