stimeo-ui 0.2.0 → 0.2.1
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/CHANGELOG.md +59 -0
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/carousel_controller.d.ts +100 -0
- package/dist/controllers/carousel_controller.js +272 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +76 -0
- package/dist/controllers/color_picker_controller.js +213 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +71 -0
- package/dist/controllers/data_grid_controller.js +168 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +87 -0
- package/dist/controllers/date_range_picker_controller.js +417 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +168 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +41 -18
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +33 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menubar_controller.d.ts +62 -0
- package/dist/controllers/menubar_controller.js +433 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/multi_select_controller.d.ts +115 -0
- package/dist/controllers/multi_select_controller.js +472 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +99 -0
- package/dist/controllers/navigation_menu_controller.js +384 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
- package/dist/controllers/overflow_indicator_controller.js +178 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/range_slider_controller.d.ts +87 -0
- package/dist/controllers/range_slider_controller.js +166 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +194 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.js +15 -2
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.js +8 -4
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js +33 -11
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +54 -0
- package/dist/controllers/separator_controller.js +87 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.js +8 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +88 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +3 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +275 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.js +20 -10
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +212 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +1 -9
- package/dist/controllers/toast_controller.js +36 -9
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +54 -0
- package/dist/controllers/tree_view_controller.js +275 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1818
- package/dist/index.js +811 -295
- package/dist/index.js.map +1 -1
- package/dist/inspector/examples.json +2 -2
- package/dist/inspector/manifest.json +15 -3
- package/package.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible multi-select combobox with chips.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--multi-select`):
|
|
7
|
+
* <div data-controller="stimeo--multi-select">
|
|
8
|
+
* <ul data-stimeo--multi-select-target="tags" aria-label="Selected"></ul>
|
|
9
|
+
* <input type="text" role="combobox" aria-expanded="false"
|
|
10
|
+
* aria-autocomplete="list" aria-controls="ms-list"
|
|
11
|
+
* data-stimeo--multi-select-target="input"
|
|
12
|
+
* data-action="input->stimeo--multi-select#filter
|
|
13
|
+
* keydown->stimeo--multi-select#onKeydown
|
|
14
|
+
* focus->stimeo--multi-select#open" />
|
|
15
|
+
* <ul id="ms-list" role="listbox" aria-multiselectable="true" hidden
|
|
16
|
+
* data-stimeo--multi-select-target="list">
|
|
17
|
+
* <li id="ms-opt-1" role="option" aria-selected="false" data-value="apple"
|
|
18
|
+
* data-stimeo--multi-select-target="option"
|
|
19
|
+
* data-action="click->stimeo--multi-select#toggleOption">Apple</li>
|
|
20
|
+
* </ul>
|
|
21
|
+
* <span role="status" aria-live="polite" class="visually-hidden"
|
|
22
|
+
* data-stimeo--multi-select-target="status"></span>
|
|
23
|
+
* <!-- Optional: submit the selection as name="fruits[]" hidden inputs. -->
|
|
24
|
+
* <div data-stimeo--multi-select-target="fields"></div>
|
|
25
|
+
* <template data-stimeo--multi-select-target="tagTemplate">…</template>
|
|
26
|
+
* </div>
|
|
27
|
+
*
|
|
28
|
+
* Implements the WAI-ARIA APG **Combobox** pattern in its list-autocomplete,
|
|
29
|
+
* multi-select form. Focus stays on the input; the active option is tracked with
|
|
30
|
+
* `aria-activedescendant` and selection with `aria-selected`. Selected options are
|
|
31
|
+
* mirrored as removable chips. For single selection use {@link ListboxController}
|
|
32
|
+
* or Combobox; for free-text tags use {@link TagsInputController}.
|
|
33
|
+
*
|
|
34
|
+
* Behavior provided:
|
|
35
|
+
* - Typing filters options by substring and opens the list; `ArrowDown`/`ArrowUp`
|
|
36
|
+
* (wrapping), `Home`/`End` move the active option; `Enter` toggles it (the list
|
|
37
|
+
* stays open); `Escape`/`Tab`/outside click close.
|
|
38
|
+
* - Toggling syncs `aria-selected`, adds/removes a `Remove {label}` chip, mirrors
|
|
39
|
+
* the live region, and dispatches `stimeo--multi-select:change` with `values`;
|
|
40
|
+
* filtering dispatches `stimeo--multi-select:filter` for async candidates.
|
|
41
|
+
* - The chips are one roving Tab stop: `ArrowLeft`/`ArrowRight` move between them,
|
|
42
|
+
* `Delete`/`Backspace` remove the focused chip, and `Backspace` on an empty
|
|
43
|
+
* input removes the last; removal re-homes focus to a neighbor or the input.
|
|
44
|
+
* - `max` caps the selection (`0` = unlimited).
|
|
45
|
+
* - With a `fields` target the selected values are mirrored into named hidden
|
|
46
|
+
* inputs (default name `options[]`), so the selection submits with a normal
|
|
47
|
+
* form and no consumer JS — parity with {@link TagsInputController}. An optional
|
|
48
|
+
* `form` value sets the hidden inputs' `form` attribute, associating them with a
|
|
49
|
+
* `<form>` by id even when the picker lives outside it. Purely additive: with no
|
|
50
|
+
* `fields` target the behavior is unchanged.
|
|
51
|
+
*/
|
|
52
|
+
declare class MultiSelectController extends Controller<HTMLElement> {
|
|
53
|
+
#private;
|
|
54
|
+
static targets: string[];
|
|
55
|
+
static values: {
|
|
56
|
+
max: {
|
|
57
|
+
type: NumberConstructor;
|
|
58
|
+
default: number;
|
|
59
|
+
};
|
|
60
|
+
name: {
|
|
61
|
+
type: StringConstructor;
|
|
62
|
+
default: string;
|
|
63
|
+
};
|
|
64
|
+
form: {
|
|
65
|
+
type: StringConstructor;
|
|
66
|
+
default: string;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
static actions: readonly ["close", "filter", "onKeydown", "open", "toggleOption"];
|
|
70
|
+
static events: readonly ["change", "filter"];
|
|
71
|
+
readonly inputTarget: HTMLInputElement;
|
|
72
|
+
readonly listTarget: HTMLElement;
|
|
73
|
+
readonly optionTargets: HTMLElement[];
|
|
74
|
+
readonly tagsTarget: HTMLElement;
|
|
75
|
+
readonly tagTargets: HTMLElement[];
|
|
76
|
+
readonly tagTemplateTarget: HTMLTemplateElement;
|
|
77
|
+
readonly statusTarget: HTMLElement;
|
|
78
|
+
readonly fieldsTarget: HTMLElement;
|
|
79
|
+
readonly hasListTarget: boolean;
|
|
80
|
+
readonly hasInputTarget: boolean;
|
|
81
|
+
readonly hasTagsTarget: boolean;
|
|
82
|
+
readonly hasTagTemplateTarget: boolean;
|
|
83
|
+
readonly hasStatusTarget: boolean;
|
|
84
|
+
readonly hasFieldsTarget: boolean;
|
|
85
|
+
maxValue: number;
|
|
86
|
+
nameValue: string;
|
|
87
|
+
formValue: string;
|
|
88
|
+
readonly hasFormValue: boolean;
|
|
89
|
+
/** Starts closed, syncs chips for any pre-selected options, and listens out. */
|
|
90
|
+
connect(): void;
|
|
91
|
+
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
92
|
+
disconnect(): void;
|
|
93
|
+
/** Tracks an input added initially or after connect. */
|
|
94
|
+
inputTargetConnected(input: HTMLInputElement): void;
|
|
95
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
96
|
+
inputTargetDisconnected(input: HTMLInputElement): void;
|
|
97
|
+
/** Filters confirmed input text, opens, and re-seeds the active option. */
|
|
98
|
+
filter(event?: InputEvent): void;
|
|
99
|
+
/** Opens the list and activates the first visible option when none is active. */
|
|
100
|
+
open(): void;
|
|
101
|
+
/** Closes the list and clears the active option. */
|
|
102
|
+
close(): void;
|
|
103
|
+
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
104
|
+
onKeydown(event: KeyboardEvent): void;
|
|
105
|
+
/**
|
|
106
|
+
* Toggles the clicked option's selection. Bound via `data-action`. Focus is
|
|
107
|
+
* re-homed to the input afterwards: options are non-focusable, so the click blurs
|
|
108
|
+
* the input to `body` — and with the list deliberately staying open, every
|
|
109
|
+
* keyboard affordance (Escape, arrows, typing) is bound to the input and would
|
|
110
|
+
* otherwise go dead until the user clicks back in ("focus stays on the input").
|
|
111
|
+
*/
|
|
112
|
+
toggleOption(event: Event): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export { MultiSelectController };
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/multi_select_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/aria_ids.ts
|
|
6
|
+
var counter = 0;
|
|
7
|
+
function uniqueId(prefix = "stimeo") {
|
|
8
|
+
let candidate;
|
|
9
|
+
do {
|
|
10
|
+
counter += 1;
|
|
11
|
+
candidate = `${prefix}-${counter}`;
|
|
12
|
+
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
function ensureId(element, prefix = "stimeo") {
|
|
16
|
+
if (element.id) return element.id;
|
|
17
|
+
const id = uniqueId(prefix);
|
|
18
|
+
element.id = id;
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/composition_tracker.ts
|
|
23
|
+
var CompositionTracker = class {
|
|
24
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
25
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
26
|
+
#onStart;
|
|
27
|
+
#onEnd;
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.#onStart = options.onStart;
|
|
30
|
+
this.#onEnd = options.onEnd;
|
|
31
|
+
}
|
|
32
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
33
|
+
observe(target) {
|
|
34
|
+
if (this.#observedTargets.has(target)) return;
|
|
35
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
36
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
37
|
+
this.#observedTargets.add(target);
|
|
38
|
+
}
|
|
39
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
40
|
+
unobserve(target) {
|
|
41
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
42
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
43
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
44
|
+
this.#activeTargets.delete(target);
|
|
45
|
+
}
|
|
46
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
47
|
+
disconnect() {
|
|
48
|
+
for (const target of this.#observedTargets) {
|
|
49
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
50
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
51
|
+
}
|
|
52
|
+
this.#observedTargets.clear();
|
|
53
|
+
this.#activeTargets.clear();
|
|
54
|
+
}
|
|
55
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
56
|
+
isComposing(event) {
|
|
57
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
58
|
+
}
|
|
59
|
+
#handleStart = (event) => {
|
|
60
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
61
|
+
this.#onStart?.(event);
|
|
62
|
+
};
|
|
63
|
+
#handleEnd = (event) => {
|
|
64
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
65
|
+
this.#onEnd?.(event);
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// src/utils/option_scroll.ts
|
|
70
|
+
function scrollOptionIntoView(list, option) {
|
|
71
|
+
if (list.scrollHeight <= list.clientHeight) return;
|
|
72
|
+
const listRect = list.getBoundingClientRect();
|
|
73
|
+
const optionRect = option.getBoundingClientRect();
|
|
74
|
+
if (optionRect.top < listRect.top) {
|
|
75
|
+
list.scrollTop -= listRect.top - optionRect.top;
|
|
76
|
+
} else if (optionRect.bottom > listRect.bottom) {
|
|
77
|
+
list.scrollTop += optionRect.bottom - listRect.bottom;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/utils/roving_tabindex.ts
|
|
82
|
+
var RovingTabindex = class {
|
|
83
|
+
/** Returns the current ordered item elements; called on every operation. */
|
|
84
|
+
#getItems;
|
|
85
|
+
/**
|
|
86
|
+
* @param getItems - Returns the current ordered item elements. Called on every
|
|
87
|
+
* operation so the live target list is always used.
|
|
88
|
+
*/
|
|
89
|
+
constructor(getItems) {
|
|
90
|
+
this.#getItems = getItems;
|
|
91
|
+
}
|
|
92
|
+
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
93
|
+
get activeIndex() {
|
|
94
|
+
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
98
|
+
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
99
|
+
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
100
|
+
* "nothing is currently tabbable".
|
|
101
|
+
*
|
|
102
|
+
* @param index - Position of the item to make tabbable.
|
|
103
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item.
|
|
104
|
+
*/
|
|
105
|
+
setActive(index, { focus = false } = {}) {
|
|
106
|
+
const items = this.#getItems();
|
|
107
|
+
items.forEach((item, i) => {
|
|
108
|
+
item.tabIndex = i === index ? 0 : -1;
|
|
109
|
+
});
|
|
110
|
+
if (focus) items[index]?.focus();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// src/controllers/multi_select_controller.ts
|
|
115
|
+
var MultiSelectController = class extends Controller {
|
|
116
|
+
static targets = [
|
|
117
|
+
"input",
|
|
118
|
+
"list",
|
|
119
|
+
"option",
|
|
120
|
+
"tags",
|
|
121
|
+
"tag",
|
|
122
|
+
"tagTemplate",
|
|
123
|
+
"status",
|
|
124
|
+
"fields"
|
|
125
|
+
];
|
|
126
|
+
static values = {
|
|
127
|
+
max: { type: Number, default: 0 },
|
|
128
|
+
name: { type: String, default: "options[]" },
|
|
129
|
+
form: { type: String, default: "" }
|
|
130
|
+
};
|
|
131
|
+
static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
|
|
132
|
+
static events = ["change", "filter"];
|
|
133
|
+
/** The active option (tracked via `aria-activedescendant`), or null. */
|
|
134
|
+
#activeOption = null;
|
|
135
|
+
/** Absorbs the browser's redundant final input after compositionend. */
|
|
136
|
+
#ignorePostCompositionInput = false;
|
|
137
|
+
/** Owns IME lifecycle state; confirmed text emits one filter result. */
|
|
138
|
+
#composition = new CompositionTracker({
|
|
139
|
+
onStart: () => {
|
|
140
|
+
this.#ignorePostCompositionInput = false;
|
|
141
|
+
},
|
|
142
|
+
onEnd: () => {
|
|
143
|
+
this.#ignorePostCompositionInput = true;
|
|
144
|
+
queueMicrotask(() => {
|
|
145
|
+
this.#ignorePostCompositionInput = false;
|
|
146
|
+
});
|
|
147
|
+
this.filter();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
#roving = new RovingTabindex(() => this.#removeButtons);
|
|
151
|
+
/** Starts closed, syncs chips for any pre-selected options, and listens out. */
|
|
152
|
+
connect() {
|
|
153
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
154
|
+
this.close();
|
|
155
|
+
if (this.hasTagsTarget) {
|
|
156
|
+
this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
|
|
157
|
+
this.tagsTarget.addEventListener("click", this.#onTagClick);
|
|
158
|
+
for (const tag of this.tagTargets) tag.remove();
|
|
159
|
+
for (const option of this.#selectedOptions) this.#appendTag(option);
|
|
160
|
+
if (this.#removeButtons.length > 0) this.#roving.setActive(0);
|
|
161
|
+
}
|
|
162
|
+
this.#syncFields();
|
|
163
|
+
document.addEventListener("click", this.#onOutsideClick);
|
|
164
|
+
}
|
|
165
|
+
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
166
|
+
disconnect() {
|
|
167
|
+
this.#composition.disconnect();
|
|
168
|
+
this.#ignorePostCompositionInput = false;
|
|
169
|
+
if (this.hasTagsTarget) {
|
|
170
|
+
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
171
|
+
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
172
|
+
}
|
|
173
|
+
document.removeEventListener("click", this.#onOutsideClick);
|
|
174
|
+
}
|
|
175
|
+
/** Tracks an input added initially or after connect. */
|
|
176
|
+
inputTargetConnected(input) {
|
|
177
|
+
this.#composition.observe(input);
|
|
178
|
+
}
|
|
179
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
180
|
+
inputTargetDisconnected(input) {
|
|
181
|
+
this.#composition.unobserve(input);
|
|
182
|
+
this.#ignorePostCompositionInput = false;
|
|
183
|
+
}
|
|
184
|
+
/** Filters confirmed input text, opens, and re-seeds the active option. */
|
|
185
|
+
filter(event) {
|
|
186
|
+
if (event && this.#ignorePostCompositionInput) {
|
|
187
|
+
this.#ignorePostCompositionInput = false;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (this.#composition.isComposing(event)) return;
|
|
191
|
+
const query = this.inputTarget.value.trim().toLowerCase();
|
|
192
|
+
for (const option of this.optionTargets) {
|
|
193
|
+
const label = (option.textContent ?? "").trim().toLowerCase();
|
|
194
|
+
option.hidden = query !== "" && !label.includes(query);
|
|
195
|
+
}
|
|
196
|
+
this.open();
|
|
197
|
+
const visible = this.#visibleOptions;
|
|
198
|
+
this.element.toggleAttribute("data-stimeo--multi-select-empty", visible.length === 0);
|
|
199
|
+
this.#setActive(visible[0] ?? null);
|
|
200
|
+
this.dispatch("filter", { detail: { query } });
|
|
201
|
+
}
|
|
202
|
+
/** Opens the list and activates the first visible option when none is active. */
|
|
203
|
+
open() {
|
|
204
|
+
if (!this.hasListTarget) return;
|
|
205
|
+
this.listTarget.hidden = false;
|
|
206
|
+
this.inputTarget.setAttribute("aria-expanded", "true");
|
|
207
|
+
if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
|
|
208
|
+
}
|
|
209
|
+
/** Closes the list and clears the active option. */
|
|
210
|
+
close() {
|
|
211
|
+
if (!this.hasListTarget) return;
|
|
212
|
+
this.listTarget.hidden = true;
|
|
213
|
+
this.inputTarget.setAttribute("aria-expanded", "false");
|
|
214
|
+
this.#setActive(null);
|
|
215
|
+
}
|
|
216
|
+
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
217
|
+
onKeydown(event) {
|
|
218
|
+
if (this.#composition.isComposing(event)) return;
|
|
219
|
+
switch (event.key) {
|
|
220
|
+
case "ArrowDown":
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
if (this.#isClosed) this.open();
|
|
223
|
+
else this.#moveActive(1);
|
|
224
|
+
break;
|
|
225
|
+
case "ArrowUp":
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
if (this.#isClosed) this.open();
|
|
228
|
+
else this.#moveActive(-1);
|
|
229
|
+
break;
|
|
230
|
+
case "Home":
|
|
231
|
+
if (!this.#isClosed) {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
this.#setActive(this.#visibleOptions[0] ?? null);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
case "End": {
|
|
237
|
+
if (!this.#isClosed) {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
const visible = this.#visibleOptions;
|
|
240
|
+
this.#setActive(visible[visible.length - 1] ?? null);
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case "Enter":
|
|
245
|
+
if (!this.#isClosed && this.#activeOption) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
this.#toggleSelection(this.#activeOption);
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
case "Escape":
|
|
251
|
+
if (event.defaultPrevented || this.#isClosed) break;
|
|
252
|
+
event.preventDefault();
|
|
253
|
+
this.close();
|
|
254
|
+
break;
|
|
255
|
+
case "Backspace":
|
|
256
|
+
if (this.inputTarget.value === "") {
|
|
257
|
+
const buttons = this.#removeButtons;
|
|
258
|
+
if (buttons.length > 0) {
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
this.#removeTagAt(buttons.length - 1);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
break;
|
|
264
|
+
case "ArrowLeft":
|
|
265
|
+
if (this.inputTarget.value === "" && this.#removeButtons.length > 0) {
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
this.#roving.setActive(this.#removeButtons.length - 1, { focus: true });
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
case "Tab":
|
|
271
|
+
this.close();
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Toggles the clicked option's selection. Bound via `data-action`. Focus is
|
|
277
|
+
* re-homed to the input afterwards: options are non-focusable, so the click blurs
|
|
278
|
+
* the input to `body` — and with the list deliberately staying open, every
|
|
279
|
+
* keyboard affordance (Escape, arrows, typing) is bound to the input and would
|
|
280
|
+
* otherwise go dead until the user clicks back in ("focus stays on the input").
|
|
281
|
+
*/
|
|
282
|
+
toggleOption(event) {
|
|
283
|
+
const option = event.currentTarget.closest('[role="option"]');
|
|
284
|
+
if (!option) return;
|
|
285
|
+
this.#toggleSelection(option);
|
|
286
|
+
this.inputTarget.focus();
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Removes the chip whose remove button was clicked, deselecting its option.
|
|
290
|
+
* Delegated on the tags container (like `#onTagKeydown`) rather than bound
|
|
291
|
+
* per chip via `data-action`, so it works the instant a chip is appended without
|
|
292
|
+
* waiting on Stimulus to wire a freshly created element.
|
|
293
|
+
*/
|
|
294
|
+
#onTagClick = (event) => {
|
|
295
|
+
const button = event.target.closest("button");
|
|
296
|
+
if (!button || !this.tagsTarget.contains(button)) return;
|
|
297
|
+
const index = this.#removeButtons.indexOf(button);
|
|
298
|
+
if (index !== -1) this.#removeTagAt(index);
|
|
299
|
+
};
|
|
300
|
+
/** Moves the active option by `delta` among visible options, wrapping. */
|
|
301
|
+
#moveActive(delta) {
|
|
302
|
+
const visible = this.#visibleOptions;
|
|
303
|
+
if (visible.length === 0) return;
|
|
304
|
+
const current = this.#activeOption ? visible.indexOf(this.#activeOption) : -1;
|
|
305
|
+
const next = (current + delta + visible.length) % visible.length;
|
|
306
|
+
this.#setActive(visible[next] ?? null);
|
|
307
|
+
}
|
|
308
|
+
/** Selects/deselects `option`, honoring `max`, and syncs chip + live region. */
|
|
309
|
+
#toggleSelection(option) {
|
|
310
|
+
const selected = option.getAttribute("aria-selected") === "true";
|
|
311
|
+
if (!selected && this.maxValue > 0 && this.#selectedOptions.length >= this.maxValue) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
option.setAttribute("aria-selected", String(!selected));
|
|
315
|
+
if (selected) {
|
|
316
|
+
this.#removeTagFor(option);
|
|
317
|
+
} else {
|
|
318
|
+
this.#appendTag(option);
|
|
319
|
+
}
|
|
320
|
+
this.#announce(this.#optionLabel(option));
|
|
321
|
+
this.#refreshRoving();
|
|
322
|
+
this.#syncFields();
|
|
323
|
+
this.dispatch("change", { detail: { values: this.#values } });
|
|
324
|
+
}
|
|
325
|
+
/** Builds one chip from the template for `option`. */
|
|
326
|
+
#appendTag(option) {
|
|
327
|
+
if (!this.hasTagTemplateTarget || !this.hasTagsTarget) return;
|
|
328
|
+
const fragment = this.tagTemplateTarget.content.cloneNode(true);
|
|
329
|
+
const tag = fragment.querySelector('[data-stimeo--multi-select-target="tag"]');
|
|
330
|
+
const label = fragment.querySelector('[data-multi-select-slot="label"]');
|
|
331
|
+
const button = fragment.querySelector("button");
|
|
332
|
+
if (!tag || !button) return;
|
|
333
|
+
const text = this.#optionLabel(option);
|
|
334
|
+
tag.dataset.value = this.#optionValue(option);
|
|
335
|
+
if (label) label.textContent = text;
|
|
336
|
+
button.setAttribute("aria-label", `Remove ${text}`);
|
|
337
|
+
button.tabIndex = -1;
|
|
338
|
+
this.tagsTarget.appendChild(fragment);
|
|
339
|
+
}
|
|
340
|
+
/** Removes the chip mirroring `option`, if present. */
|
|
341
|
+
#removeTagFor(option) {
|
|
342
|
+
const value = this.#optionValue(option);
|
|
343
|
+
const tag = this.tagTargets.find((candidate) => candidate.dataset.value === value);
|
|
344
|
+
tag?.remove();
|
|
345
|
+
}
|
|
346
|
+
/** Removes chip `index` and deselects its option, re-homing focus. */
|
|
347
|
+
#removeTagAt(index) {
|
|
348
|
+
const tag = this.tagTargets[index];
|
|
349
|
+
if (!tag) return;
|
|
350
|
+
const value = tag.dataset.value ?? "";
|
|
351
|
+
const option = this.optionTargets.find((candidate) => this.#optionValue(candidate) === value);
|
|
352
|
+
if (option) option.setAttribute("aria-selected", "false");
|
|
353
|
+
tag.remove();
|
|
354
|
+
this.#announce(option ? this.#optionLabel(option) : value);
|
|
355
|
+
this.#refreshRoving();
|
|
356
|
+
this.#syncFields();
|
|
357
|
+
this.dispatch("change", { detail: { values: this.#values } });
|
|
358
|
+
const remaining = this.#removeButtons;
|
|
359
|
+
if (remaining.length === 0) {
|
|
360
|
+
this.inputTarget.focus();
|
|
361
|
+
} else {
|
|
362
|
+
this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** Arrow navigation and deletion within the chip list (delegated). */
|
|
366
|
+
#onTagKeydown = (event) => {
|
|
367
|
+
const button = event.target.closest("button");
|
|
368
|
+
if (!button) return;
|
|
369
|
+
const buttons = this.#removeButtons;
|
|
370
|
+
const index = buttons.indexOf(button);
|
|
371
|
+
if (index === -1) return;
|
|
372
|
+
switch (event.key) {
|
|
373
|
+
case "ArrowLeft":
|
|
374
|
+
if (index > 0) {
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
this.#roving.setActive(index - 1, { focus: true });
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
case "ArrowRight":
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
|
|
382
|
+
else this.inputTarget.focus();
|
|
383
|
+
break;
|
|
384
|
+
case "Delete":
|
|
385
|
+
case "Backspace":
|
|
386
|
+
event.preventDefault();
|
|
387
|
+
this.#removeTagAt(index);
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* Marks `option` active via `data-active` and the input's
|
|
393
|
+
* `aria-activedescendant` (the attribute is removed, not emptied, when null).
|
|
394
|
+
*/
|
|
395
|
+
#setActive(option) {
|
|
396
|
+
this.#activeOption = option;
|
|
397
|
+
for (const candidate of this.optionTargets) {
|
|
398
|
+
candidate.toggleAttribute("data-active", candidate === option);
|
|
399
|
+
}
|
|
400
|
+
if (option) {
|
|
401
|
+
this.inputTarget.setAttribute("aria-activedescendant", ensureId(option, "stimeo-ms-opt"));
|
|
402
|
+
if (this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
|
|
403
|
+
} else {
|
|
404
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Mirrors the selected values into named hidden inputs under the `fields`
|
|
409
|
+
* target so the selection submits with a normal form (parity with tags-input).
|
|
410
|
+
* No-ops without a `fields` target, keeping the control back-compat. When the
|
|
411
|
+
* `form` value is set, each input gets a matching `form` attribute so the picker
|
|
412
|
+
* can submit with a `<form>` it lives outside of.
|
|
413
|
+
*/
|
|
414
|
+
#syncFields() {
|
|
415
|
+
if (!this.hasFieldsTarget) return;
|
|
416
|
+
this.fieldsTarget.replaceChildren(
|
|
417
|
+
...this.#values.map((value) => {
|
|
418
|
+
const input = document.createElement("input");
|
|
419
|
+
input.type = "hidden";
|
|
420
|
+
input.name = this.nameValue;
|
|
421
|
+
input.value = value;
|
|
422
|
+
if (this.hasFormValue && this.formValue !== "") input.setAttribute("form", this.formValue);
|
|
423
|
+
return input;
|
|
424
|
+
})
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
/** Keeps exactly one chip remove button tabbable after the set changes. */
|
|
428
|
+
#refreshRoving() {
|
|
429
|
+
if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1)
|
|
430
|
+
this.#roving.setActive(0);
|
|
431
|
+
}
|
|
432
|
+
/** Mirrors the changed option label into the live region. */
|
|
433
|
+
#announce(text) {
|
|
434
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = text;
|
|
435
|
+
}
|
|
436
|
+
/** Closes the list on a click outside the controller element. */
|
|
437
|
+
#onOutsideClick = (event) => {
|
|
438
|
+
if (!this.#isClosed && !this.element.contains(event.target)) this.close();
|
|
439
|
+
};
|
|
440
|
+
/** Trimmed visible label of an option. */
|
|
441
|
+
#optionLabel(option) {
|
|
442
|
+
return (option.textContent ?? "").trim();
|
|
443
|
+
}
|
|
444
|
+
/** An option's stable value: its `data-value`, else its display label. */
|
|
445
|
+
#optionValue(option) {
|
|
446
|
+
return option.dataset.value ?? this.#optionLabel(option);
|
|
447
|
+
}
|
|
448
|
+
/** Options not hidden by the current filter. */
|
|
449
|
+
get #visibleOptions() {
|
|
450
|
+
return this.optionTargets.filter((option) => !option.hidden);
|
|
451
|
+
}
|
|
452
|
+
/** Options currently selected. */
|
|
453
|
+
get #selectedOptions() {
|
|
454
|
+
return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");
|
|
455
|
+
}
|
|
456
|
+
/** Selected values in option order. */
|
|
457
|
+
get #values() {
|
|
458
|
+
return this.#selectedOptions.map((option) => this.#optionValue(option));
|
|
459
|
+
}
|
|
460
|
+
/** The chip remove buttons in order (the roving navigation set). */
|
|
461
|
+
get #removeButtons() {
|
|
462
|
+
return this.hasTagsTarget ? Array.from(this.tagsTarget.querySelectorAll("button")) : [];
|
|
463
|
+
}
|
|
464
|
+
/** Whether the list is currently hidden. */
|
|
465
|
+
get #isClosed() {
|
|
466
|
+
return !this.hasListTarget || this.listTarget.hidden !== false;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
export { MultiSelectController };
|
|
471
|
+
//# sourceMappingURL=multi_select_controller.js.map
|
|
472
|
+
//# sourceMappingURL=multi_select_controller.js.map
|