stimeo-ui 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/controllers/accordion_controller.d.ts +59 -0
- package/dist/controllers/accordion_controller.js +76 -0
- package/dist/controllers/accordion_controller.js.map +1 -0
- package/dist/controllers/announcer_controller.d.ts +78 -0
- package/dist/controllers/announcer_controller.js +184 -0
- package/dist/controllers/announcer_controller.js.map +1 -0
- package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
- package/dist/controllers/aspect_ratio_controller.js +36 -0
- package/dist/controllers/aspect_ratio_controller.js.map +1 -0
- package/dist/controllers/auto_submit_controller.d.ts +74 -0
- package/dist/controllers/auto_submit_controller.js +147 -0
- package/dist/controllers/auto_submit_controller.js.map +1 -0
- package/dist/controllers/avatar_controller.d.ts +50 -0
- package/dist/controllers/avatar_controller.js +66 -0
- package/dist/controllers/avatar_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +60 -0
- package/dist/controllers/breadcrumb_controller.js +123 -0
- package/dist/controllers/breadcrumb_controller.js.map +1 -0
- package/dist/controllers/bulk_select_controller.d.ts +67 -0
- package/dist/controllers/bulk_select_controller.js +104 -0
- package/dist/controllers/bulk_select_controller.js.map +1 -0
- package/dist/controllers/calendar_controller.d.ts +100 -0
- package/dist/controllers/calendar_controller.js +394 -0
- package/dist/controllers/calendar_controller.js.map +1 -0
- package/dist/controllers/character_counter_controller.d.ts +63 -0
- package/dist/controllers/character_counter_controller.js +179 -0
- package/dist/controllers/character_counter_controller.js.map +1 -0
- package/dist/controllers/checkbox_controller.d.ts +52 -0
- package/dist/controllers/checkbox_controller.js +73 -0
- package/dist/controllers/checkbox_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +76 -0
- package/dist/controllers/combobox_controller.js +186 -0
- package/dist/controllers/combobox_controller.js.map +1 -0
- package/dist/controllers/command_palette_controller.d.ts +103 -0
- package/dist/controllers/command_palette_controller.js +381 -0
- package/dist/controllers/command_palette_controller.js.map +1 -0
- package/dist/controllers/conditional_fields_controller.d.ts +56 -0
- package/dist/controllers/conditional_fields_controller.js +112 -0
- package/dist/controllers/conditional_fields_controller.js.map +1 -0
- package/dist/controllers/confirm_controller.d.ts +83 -0
- package/dist/controllers/confirm_controller.js +276 -0
- package/dist/controllers/confirm_controller.js.map +1 -0
- package/dist/controllers/context_menu_controller.d.ts +81 -0
- package/dist/controllers/context_menu_controller.js +112 -0
- package/dist/controllers/context_menu_controller.js.map +1 -0
- package/dist/controllers/countdown_controller.d.ts +93 -0
- package/dist/controllers/countdown_controller.js +202 -0
- package/dist/controllers/countdown_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.d.ts +57 -0
- package/dist/controllers/dialog_controller.js +207 -0
- package/dist/controllers/dialog_controller.js.map +1 -0
- package/dist/controllers/direct_upload_controller.d.ts +77 -0
- package/dist/controllers/direct_upload_controller.js +212 -0
- package/dist/controllers/direct_upload_controller.js.map +1 -0
- package/dist/controllers/dirty_form_controller.d.ts +52 -0
- package/dist/controllers/dirty_form_controller.js +128 -0
- package/dist/controllers/dirty_form_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +49 -0
- package/dist/controllers/dropdown_controller.js +66 -0
- package/dist/controllers/dropdown_controller.js.map +1 -0
- package/dist/controllers/empty_state_controller.d.ts +60 -0
- package/dist/controllers/empty_state_controller.js +67 -0
- package/dist/controllers/empty_state_controller.js.map +1 -0
- package/dist/controllers/flash_controller.d.ts +63 -0
- package/dist/controllers/flash_controller.js +221 -0
- package/dist/controllers/flash_controller.js.map +1 -0
- package/dist/controllers/focus_controller.d.ts +69 -0
- package/dist/controllers/focus_controller.js +216 -0
- package/dist/controllers/focus_controller.js.map +1 -0
- package/dist/controllers/form_field_controller.d.ts +79 -0
- package/dist/controllers/form_field_controller.js +154 -0
- package/dist/controllers/form_field_controller.js.map +1 -0
- package/dist/controllers/form_validation_controller.d.ts +114 -0
- package/dist/controllers/form_validation_controller.js +202 -0
- package/dist/controllers/form_validation_controller.js.map +1 -0
- package/dist/controllers/frame_loading_controller.d.ts +58 -0
- package/dist/controllers/frame_loading_controller.js +177 -0
- package/dist/controllers/frame_loading_controller.js.map +1 -0
- package/dist/controllers/highlight_controller.d.ts +50 -0
- package/dist/controllers/highlight_controller.js +107 -0
- package/dist/controllers/highlight_controller.js.map +1 -0
- package/dist/controllers/hover_card_controller.d.ts +79 -0
- package/dist/controllers/hover_card_controller.js +165 -0
- package/dist/controllers/hover_card_controller.js.map +1 -0
- package/dist/controllers/idle_controller.d.ts +51 -0
- package/dist/controllers/idle_controller.js +141 -0
- package/dist/controllers/idle_controller.js.map +1 -0
- package/dist/controllers/input_mask_controller.d.ts +74 -0
- package/dist/controllers/input_mask_controller.js +166 -0
- package/dist/controllers/input_mask_controller.js.map +1 -0
- package/dist/controllers/lazy_frame_controller.d.ts +53 -0
- package/dist/controllers/lazy_frame_controller.js +68 -0
- package/dist/controllers/lazy_frame_controller.js.map +1 -0
- package/dist/controllers/listbox_controller.d.ts +88 -0
- package/dist/controllers/listbox_controller.js +256 -0
- package/dist/controllers/listbox_controller.js.map +1 -0
- package/dist/controllers/local_time_controller.d.ts +60 -0
- package/dist/controllers/local_time_controller.js +81 -0
- package/dist/controllers/local_time_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +81 -0
- package/dist/controllers/menu_controller.js +134 -0
- package/dist/controllers/menu_controller.js.map +1 -0
- package/dist/controllers/meter_controller.d.ts +93 -0
- package/dist/controllers/meter_controller.js +96 -0
- package/dist/controllers/meter_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +85 -0
- package/dist/controllers/nested_form_controller.js +131 -0
- package/dist/controllers/nested_form_controller.js.map +1 -0
- package/dist/controllers/network_status_controller.d.ts +49 -0
- package/dist/controllers/network_status_controller.js +126 -0
- package/dist/controllers/network_status_controller.js.map +1 -0
- package/dist/controllers/number_input_controller.d.ts +93 -0
- package/dist/controllers/number_input_controller.js +306 -0
- package/dist/controllers/number_input_controller.js.map +1 -0
- package/dist/controllers/otp_controller.d.ts +68 -0
- package/dist/controllers/otp_controller.js +201 -0
- package/dist/controllers/otp_controller.js.map +1 -0
- package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
- package/dist/controllers/overflow_indicator_controller.js +169 -0
- package/dist/controllers/overflow_indicator_controller.js.map +1 -0
- package/dist/controllers/overflow_menu_controller.d.ts +64 -0
- package/dist/controllers/overflow_menu_controller.js +274 -0
- package/dist/controllers/overflow_menu_controller.js.map +1 -0
- package/dist/controllers/pagination_controller.d.ts +69 -0
- package/dist/controllers/pagination_controller.js +89 -0
- package/dist/controllers/pagination_controller.js.map +1 -0
- package/dist/controllers/password_strength_controller.d.ts +67 -0
- package/dist/controllers/password_strength_controller.js +175 -0
- package/dist/controllers/password_strength_controller.js.map +1 -0
- package/dist/controllers/persist_controller.d.ts +63 -0
- package/dist/controllers/persist_controller.js +259 -0
- package/dist/controllers/persist_controller.js.map +1 -0
- package/dist/controllers/popover_controller.d.ts +63 -0
- package/dist/controllers/popover_controller.js +94 -0
- package/dist/controllers/popover_controller.js.map +1 -0
- package/dist/controllers/portal_controller.d.ts +58 -0
- package/dist/controllers/portal_controller.js +63 -0
- package/dist/controllers/portal_controller.js.map +1 -0
- package/dist/controllers/preview_guard_controller.d.ts +47 -0
- package/dist/controllers/preview_guard_controller.js +69 -0
- package/dist/controllers/preview_guard_controller.js.map +1 -0
- package/dist/controllers/progress_controller.d.ts +85 -0
- package/dist/controllers/progress_controller.js +93 -0
- package/dist/controllers/progress_controller.js.map +1 -0
- package/dist/controllers/radio_group_controller.d.ts +49 -0
- package/dist/controllers/radio_group_controller.js +128 -0
- package/dist/controllers/radio_group_controller.js.map +1 -0
- package/dist/controllers/rating_controller.d.ts +82 -0
- package/dist/controllers/rating_controller.js +179 -0
- package/dist/controllers/rating_controller.js.map +1 -0
- package/dist/controllers/relative_time_controller.d.ts +48 -0
- package/dist/controllers/relative_time_controller.js +129 -0
- package/dist/controllers/relative_time_controller.js.map +1 -0
- package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
- package/dist/controllers/reset_before_cache_controller.js +62 -0
- package/dist/controllers/reset_before_cache_controller.js.map +1 -0
- package/dist/controllers/resizable_controller.d.ts +81 -0
- package/dist/controllers/resizable_controller.js +163 -0
- package/dist/controllers/resizable_controller.js.map +1 -0
- package/dist/controllers/roving_controller.d.ts +63 -0
- package/dist/controllers/roving_controller.js +116 -0
- package/dist/controllers/roving_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.d.ts +45 -0
- package/dist/controllers/scroll_area_controller.js +183 -0
- package/dist/controllers/scroll_area_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
- package/dist/controllers/scroll_visibility_controller.js +103 -0
- package/dist/controllers/scroll_visibility_controller.js.map +1 -0
- package/dist/controllers/scrollspy_controller.d.ts +65 -0
- package/dist/controllers/scrollspy_controller.js +171 -0
- package/dist/controllers/scrollspy_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.d.ts +50 -0
- package/dist/controllers/skeleton_controller.js +125 -0
- package/dist/controllers/skeleton_controller.js.map +1 -0
- package/dist/controllers/slider_controller.d.ts +76 -0
- package/dist/controllers/slider_controller.js +109 -0
- package/dist/controllers/slider_controller.js.map +1 -0
- package/dist/controllers/spinner_controller.d.ts +62 -0
- package/dist/controllers/spinner_controller.js +164 -0
- package/dist/controllers/spinner_controller.js.map +1 -0
- package/dist/controllers/step_indicator_controller.d.ts +55 -0
- package/dist/controllers/step_indicator_controller.js +55 -0
- package/dist/controllers/step_indicator_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +66 -0
- package/dist/controllers/stepper_controller.js +78 -0
- package/dist/controllers/stepper_controller.js.map +1 -0
- package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
- package/dist/controllers/stick_to_bottom_controller.js +100 -0
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
- package/dist/controllers/sticky_observer_controller.d.ts +54 -0
- package/dist/controllers/sticky_observer_controller.js +53 -0
- package/dist/controllers/sticky_observer_controller.js.map +1 -0
- package/dist/controllers/submit_once_controller.d.ts +66 -0
- package/dist/controllers/submit_once_controller.js +206 -0
- package/dist/controllers/submit_once_controller.js.map +1 -0
- package/dist/controllers/switch_controller.d.ts +43 -0
- package/dist/controllers/switch_controller.js +50 -0
- package/dist/controllers/switch_controller.js.map +1 -0
- package/dist/controllers/tabs_controller.d.ts +48 -0
- package/dist/controllers/tabs_controller.js +63 -0
- package/dist/controllers/tabs_controller.js.map +1 -0
- package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
- package/dist/controllers/textarea_autosize_controller.js +72 -0
- package/dist/controllers/textarea_autosize_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +70 -0
- package/dist/controllers/theme_controller.js +154 -0
- package/dist/controllers/theme_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +123 -0
- package/dist/controllers/toast_controller.js +310 -0
- package/dist/controllers/toast_controller.js.map +1 -0
- package/dist/controllers/toggle_group_controller.d.ts +56 -0
- package/dist/controllers/toggle_group_controller.js +130 -0
- package/dist/controllers/toggle_group_controller.js.map +1 -0
- package/dist/controllers/toolbar_controller.d.ts +59 -0
- package/dist/controllers/toolbar_controller.js +113 -0
- package/dist/controllers/toolbar_controller.js.map +1 -0
- package/dist/controllers/tooltip_controller.d.ts +74 -0
- package/dist/controllers/tooltip_controller.js +165 -0
- package/dist/controllers/tooltip_controller.js.map +1 -0
- package/dist/controllers/transition_controller.d.ts +86 -0
- package/dist/controllers/transition_controller.js +203 -0
- package/dist/controllers/transition_controller.js.map +1 -0
- package/dist/index.d.ts +1931 -0
- package/dist/index.js +12241 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector/cli.d.ts +149 -0
- package/dist/inspector/cli.js +663 -0
- package/dist/inspector/cli.js.map +1 -0
- package/dist/inspector/cli_bin.d.ts +2 -0
- package/dist/inspector/cli_bin.js +665 -0
- package/dist/inspector/cli_bin.js.map +1 -0
- package/dist/inspector/manifest.json +2235 -0
- package/dist/positioning/index.d.ts +226 -0
- package/dist/positioning/index.js +145 -0
- package/dist/positioning/index.js.map +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless **debounced auto-submit** for forms (no dedicated APG pattern). Submits
|
|
5
|
+
* the form a configurable delay after `input`/`change`, so Rails search/filter
|
|
6
|
+
* forms refresh via Turbo without a submit button.
|
|
7
|
+
*
|
|
8
|
+
* Markup contract (identifier: `stimeo--auto-submit`):
|
|
9
|
+
* <form data-controller="stimeo--auto-submit"
|
|
10
|
+
* data-stimeo--auto-submit-debounce-value="300"
|
|
11
|
+
* data-action="input->stimeo--auto-submit#submit
|
|
12
|
+
* change->stimeo--auto-submit#submit">
|
|
13
|
+
* <input type="search" name="q">
|
|
14
|
+
* </form>
|
|
15
|
+
*
|
|
16
|
+
* <!-- Or with the form as a target nested under the controller element: -->
|
|
17
|
+
* <div data-controller="stimeo--auto-submit">
|
|
18
|
+
* <form data-stimeo--auto-submit-target="form"> … </form>
|
|
19
|
+
* </div>
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Behavior only — it owns *triggering* the submit (debounce + `requestSubmit`),
|
|
23
|
+
* never the submit itself (Turbo / native form submission) or validation. It
|
|
24
|
+
* **never moves focus** (WCAG 2.2 3.2.2 / 4.1.3): auto-submitting must not yank the
|
|
25
|
+
* caret out of the field. While a result swap is silent for screen-reader users,
|
|
26
|
+
* setting `announce` bridges the completion to the shared `stimeo--announcer`
|
|
27
|
+
* as a safety net; apps can also listen for `stimeo--auto-submit:done`
|
|
28
|
+
* and announce richer text themselves. `aria-busy` marks the in-flight window and
|
|
29
|
+
* `data-auto-submit-pending` the debounce window, for consumer CSS. During IME
|
|
30
|
+
* composition it holds the submit until `compositionend` (the confirmed
|
|
31
|
+
* conversion) so it does not fire on each intermediate keystroke. The debounce
|
|
32
|
+
* timer and the `turbo:submit-end`/composition listeners are torn down on
|
|
33
|
+
* `disconnect()`.
|
|
34
|
+
*/
|
|
35
|
+
declare class AutoSubmitController extends Controller<HTMLElement> {
|
|
36
|
+
#private;
|
|
37
|
+
static targets: string[];
|
|
38
|
+
static values: {
|
|
39
|
+
debounce: {
|
|
40
|
+
type: NumberConstructor;
|
|
41
|
+
default: number;
|
|
42
|
+
};
|
|
43
|
+
on: {
|
|
44
|
+
type: StringConstructor;
|
|
45
|
+
default: string;
|
|
46
|
+
};
|
|
47
|
+
announce: {
|
|
48
|
+
type: BooleanConstructor;
|
|
49
|
+
default: boolean;
|
|
50
|
+
};
|
|
51
|
+
message: {
|
|
52
|
+
type: StringConstructor;
|
|
53
|
+
default: string;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
static actions: readonly ["submit"];
|
|
57
|
+
static events: readonly ["submit", "done"];
|
|
58
|
+
readonly formTarget: HTMLFormElement;
|
|
59
|
+
readonly hasFormTarget: boolean;
|
|
60
|
+
debounceValue: number;
|
|
61
|
+
onValue: string;
|
|
62
|
+
announceValue: boolean;
|
|
63
|
+
messageValue: string;
|
|
64
|
+
connect(): void;
|
|
65
|
+
disconnect(): void;
|
|
66
|
+
/**
|
|
67
|
+
* Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
|
|
68
|
+
* allowlist so a configured subset (e.g. only `change`) is honored even when both
|
|
69
|
+
* are bound in markup. Coalesces rapid events into a single `requestSubmit`.
|
|
70
|
+
*/
|
|
71
|
+
submit(event: Event): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { AutoSubmitController };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/auto_submit_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_timeout.ts
|
|
6
|
+
var TimerRegistry = class {
|
|
7
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
8
|
+
ids = /* @__PURE__ */ new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Cancels a single tracked timer.
|
|
11
|
+
*
|
|
12
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
13
|
+
* registry), so callers can clear defensively without guarding.
|
|
14
|
+
*/
|
|
15
|
+
clear(id) {
|
|
16
|
+
if (this.ids.delete(id)) {
|
|
17
|
+
this.cancel(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
22
|
+
* to guarantee no timer outlives the element.
|
|
23
|
+
*/
|
|
24
|
+
clearAll() {
|
|
25
|
+
for (const id of this.ids) {
|
|
26
|
+
this.cancel(id);
|
|
27
|
+
}
|
|
28
|
+
this.ids.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Number of timers currently tracked (pending). */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.ids.size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
36
|
+
/**
|
|
37
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
38
|
+
*
|
|
39
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
40
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
41
|
+
*/
|
|
42
|
+
set(callback, delay) {
|
|
43
|
+
const id = this.schedule(() => {
|
|
44
|
+
this.ids.delete(id);
|
|
45
|
+
callback();
|
|
46
|
+
}, delay);
|
|
47
|
+
this.ids.add(id);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
schedule(callback, delay) {
|
|
51
|
+
return window.setTimeout(callback, delay);
|
|
52
|
+
}
|
|
53
|
+
cancel(id) {
|
|
54
|
+
window.clearTimeout(id);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/controllers/auto_submit_controller.ts
|
|
59
|
+
var AutoSubmitController = class extends Controller {
|
|
60
|
+
static targets = ["form"];
|
|
61
|
+
static values = {
|
|
62
|
+
debounce: { type: Number, default: 300 },
|
|
63
|
+
on: { type: String, default: "input change" },
|
|
64
|
+
announce: { type: Boolean, default: false },
|
|
65
|
+
message: { type: String, default: "" }
|
|
66
|
+
};
|
|
67
|
+
static actions = ["submit"];
|
|
68
|
+
static events = ["submit", "done"];
|
|
69
|
+
/** Debounce timer registry; one `clearAll()` in disconnect tears it down. */
|
|
70
|
+
#timers = new SafeTimeout();
|
|
71
|
+
/** Id of the pending debounce timer, so a new keystroke can reset it. */
|
|
72
|
+
#pendingId = 0;
|
|
73
|
+
/** Clears `aria-busy` and emits completion once Turbo finishes the submit. */
|
|
74
|
+
#onSubmitEnd = () => {
|
|
75
|
+
this.#form.removeAttribute("aria-busy");
|
|
76
|
+
const message = this.messageValue;
|
|
77
|
+
this.dispatch("done", { detail: { message: message || void 0 } });
|
|
78
|
+
if (this.announceValue && message) {
|
|
79
|
+
window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** True while an IME composition is in progress on one of the form's fields. */
|
|
83
|
+
#composing = false;
|
|
84
|
+
/** Marks composition active so `input` events mid-conversion don't submit. */
|
|
85
|
+
#onCompositionStart = () => {
|
|
86
|
+
this.#composing = true;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Composition finished (the IME conversion is confirmed): clear the flag and
|
|
90
|
+
* schedule a submit as if `input` fired, so the settled text triggers a submit
|
|
91
|
+
* even on browsers whose post-composition `input` still reads `isComposing`.
|
|
92
|
+
*/
|
|
93
|
+
#onCompositionEnd = (event) => {
|
|
94
|
+
this.#composing = false;
|
|
95
|
+
if (this.#triggers("input")) this.#schedule(event.target ?? null);
|
|
96
|
+
};
|
|
97
|
+
connect() {
|
|
98
|
+
this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
99
|
+
this.#form.addEventListener("compositionstart", this.#onCompositionStart);
|
|
100
|
+
this.#form.addEventListener("compositionend", this.#onCompositionEnd);
|
|
101
|
+
}
|
|
102
|
+
disconnect() {
|
|
103
|
+
this.#timers.clearAll();
|
|
104
|
+
this.#pendingId = 0;
|
|
105
|
+
this.#composing = false;
|
|
106
|
+
this.#form.removeAttribute("data-auto-submit-pending");
|
|
107
|
+
this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
108
|
+
this.#form.removeEventListener("compositionstart", this.#onCompositionStart);
|
|
109
|
+
this.#form.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
|
|
113
|
+
* allowlist so a configured subset (e.g. only `change`) is honored even when both
|
|
114
|
+
* are bound in markup. Coalesces rapid events into a single `requestSubmit`.
|
|
115
|
+
*/
|
|
116
|
+
submit(event) {
|
|
117
|
+
if (!this.#triggers(event.type)) return;
|
|
118
|
+
if (event.type === "input" && (this.#composing || event.isComposing)) return;
|
|
119
|
+
this.#schedule(event.target ?? null);
|
|
120
|
+
}
|
|
121
|
+
/** Schedules (and coalesces) the debounced submit for the given trigger. */
|
|
122
|
+
#schedule(trigger) {
|
|
123
|
+
this.#form.setAttribute("data-auto-submit-pending", "true");
|
|
124
|
+
if (this.#pendingId) this.#timers.clear(this.#pendingId);
|
|
125
|
+
this.#pendingId = this.#timers.set(() => {
|
|
126
|
+
this.#pendingId = 0;
|
|
127
|
+
this.#form.removeAttribute("data-auto-submit-pending");
|
|
128
|
+
this.dispatch("submit", { detail: { trigger } });
|
|
129
|
+
if (this.#form.checkValidity()) {
|
|
130
|
+
this.#form.setAttribute("aria-busy", "true");
|
|
131
|
+
}
|
|
132
|
+
this.#form.requestSubmit();
|
|
133
|
+
}, this.debounceValue);
|
|
134
|
+
}
|
|
135
|
+
/** Resolves the form element (explicit `form` target, else the controller root). */
|
|
136
|
+
get #form() {
|
|
137
|
+
return this.hasFormTarget ? this.formTarget : this.element;
|
|
138
|
+
}
|
|
139
|
+
/** Whether `type` is one of the whitespace-separated event types in `on`. */
|
|
140
|
+
#triggers(type) {
|
|
141
|
+
return this.onValue.split(/\s+/).filter(Boolean).includes(type);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export { AutoSubmitController };
|
|
146
|
+
//# sourceMappingURL=auto_submit_controller.js.map
|
|
147
|
+
//# sourceMappingURL=auto_submit_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/auto_submit_controller.ts"],"names":[],"mappings":"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","file":"auto_submit_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless **debounced auto-submit** for forms (no dedicated APG pattern). Submits\n * the form a configurable delay after `input`/`change`, so Rails search/filter\n * forms refresh via Turbo without a submit button.\n *\n * Markup contract (identifier: `stimeo--auto-submit`):\n * <form data-controller=\"stimeo--auto-submit\"\n * data-stimeo--auto-submit-debounce-value=\"300\"\n * data-action=\"input->stimeo--auto-submit#submit\n * change->stimeo--auto-submit#submit\">\n * <input type=\"search\" name=\"q\">\n * </form>\n *\n * <!-- Or with the form as a target nested under the controller element: -->\n * <div data-controller=\"stimeo--auto-submit\">\n * <form data-stimeo--auto-submit-target=\"form\"> … </form>\n * </div>\n *\n * @remarks\n * Behavior only — it owns *triggering* the submit (debounce + `requestSubmit`),\n * never the submit itself (Turbo / native form submission) or validation. It\n * **never moves focus** (WCAG 2.2 3.2.2 / 4.1.3): auto-submitting must not yank the\n * caret out of the field. While a result swap is silent for screen-reader users,\n * setting `announce` bridges the completion to the shared `stimeo--announcer`\n * as a safety net; apps can also listen for `stimeo--auto-submit:done`\n * and announce richer text themselves. `aria-busy` marks the in-flight window and\n * `data-auto-submit-pending` the debounce window, for consumer CSS. During IME\n * composition it holds the submit until `compositionend` (the confirmed\n * conversion) so it does not fire on each intermediate keystroke. The debounce\n * timer and the `turbo:submit-end`/composition listeners are torn down on\n * `disconnect()`.\n */\nexport class AutoSubmitController extends Controller<HTMLElement> {\n static override targets = [\"form\"];\n static override values = {\n debounce: { type: Number, default: 300 },\n on: { type: String, default: \"input change\" },\n announce: { type: Boolean, default: false },\n message: { type: String, default: \"\" },\n };\n static actions = [\"submit\"] as const;\n static events = [\"submit\", \"done\"] as const;\n\n declare readonly formTarget: HTMLFormElement;\n declare readonly hasFormTarget: boolean;\n\n declare debounceValue: number;\n declare onValue: string;\n declare announceValue: boolean;\n declare messageValue: string;\n\n /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */\n readonly #timers = new SafeTimeout();\n /** Id of the pending debounce timer, so a new keystroke can reset it. */\n #pendingId = 0;\n\n /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */\n readonly #onSubmitEnd = (): void => {\n this.#form.removeAttribute(\"aria-busy\");\n const message = this.messageValue;\n this.dispatch(\"done\", { detail: { message: message || undefined } });\n // Bridge the silent result swap to the shared Announcer so SR users hear it.\n if (this.announceValue && message) {\n window.dispatchEvent(new CustomEvent(\"stimeo--announcer:announce\", { detail: { message } }));\n }\n };\n\n /** True while an IME composition is in progress on one of the form's fields. */\n #composing = false;\n\n /** Marks composition active so `input` events mid-conversion don't submit. */\n readonly #onCompositionStart = (): void => {\n this.#composing = true;\n };\n\n /**\n * Composition finished (the IME conversion is confirmed): clear the flag and\n * schedule a submit as if `input` fired, so the settled text triggers a submit\n * even on browsers whose post-composition `input` still reads `isComposing`.\n */\n readonly #onCompositionEnd = (event: Event): void => {\n this.#composing = false;\n if (this.#triggers(\"input\")) this.#schedule((event.target as HTMLElement | null) ?? null);\n };\n\n override connect(): void {\n this.#form.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#form.addEventListener(\"compositionstart\", this.#onCompositionStart);\n this.#form.addEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n\n override disconnect(): void {\n this.#timers.clearAll();\n this.#pendingId = 0;\n // Reset so a same-instance reconnect (e.g. Turbo cache restore mid-composition)\n // never starts with submits suppressed.\n this.#composing = false;\n this.#form.removeAttribute(\"data-auto-submit-pending\");\n this.#form.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#form.removeEventListener(\"compositionstart\", this.#onCompositionStart);\n this.#form.removeEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n\n /**\n * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an\n * allowlist so a configured subset (e.g. only `change`) is honored even when both\n * are bound in markup. Coalesces rapid events into a single `requestSubmit`.\n */\n submit(event: Event): void {\n if (!this.#triggers(event.type)) return;\n // Ignore `input` events fired mid-IME-composition (e.g. typing kana before the\n // Japanese conversion is confirmed); the confirmed text submits on\n // `compositionend` and the browser's final post-composition `input`.\n if (event.type === \"input\" && (this.#composing || (event as InputEvent).isComposing)) return;\n this.#schedule((event.target as HTMLElement | null) ?? null);\n }\n\n /** Schedules (and coalesces) the debounced submit for the given trigger. */\n #schedule(trigger: HTMLElement | null): void {\n this.#form.setAttribute(\"data-auto-submit-pending\", \"true\");\n if (this.#pendingId) this.#timers.clear(this.#pendingId);\n\n this.#pendingId = this.#timers.set(() => {\n this.#pendingId = 0;\n this.#form.removeAttribute(\"data-auto-submit-pending\");\n this.dispatch(\"submit\", { detail: { trigger } });\n // `requestSubmit()` runs native constraint validation. If the form is\n // invalid the actual submit never happens — the browser blocks it (or, when\n // a `stimeo--form-validation` set `novalidate`, that controller cancels the\n // submit) — so no `turbo:submit-end` arrives to clear `aria-busy`. Only mark\n // the form busy when the submit will really proceed; still call\n // `requestSubmit()` either way so the validation surfaces to the user.\n if (this.#form.checkValidity()) {\n this.#form.setAttribute(\"aria-busy\", \"true\");\n }\n this.#form.requestSubmit();\n }, this.debounceValue);\n }\n\n /** Resolves the form element (explicit `form` target, else the controller root). */\n get #form(): HTMLFormElement {\n return this.hasFormTarget ? this.formTarget : (this.element as HTMLFormElement);\n }\n\n /** Whether `type` is one of the whitespace-separated event types in `on`. */\n #triggers(type: string): boolean {\n return this.onValue.split(/\\s+/).filter(Boolean).includes(type);\n }\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless **Avatar** behavior with image-load fallback. No dedicated APG
|
|
5
|
+
* pattern; it follows the non-text-content practice (WCAG 1.1.1).
|
|
6
|
+
*
|
|
7
|
+
* Markup contract (identifier: `stimeo--avatar`):
|
|
8
|
+
* <span data-controller="stimeo--avatar" role="img" aria-label="Jane Doe"
|
|
9
|
+
* data-stimeo--avatar-src-value="/u/123.jpg">
|
|
10
|
+
* <img alt="" aria-hidden="true"
|
|
11
|
+
* data-stimeo--avatar-target="image"
|
|
12
|
+
* data-action="load->stimeo--avatar#onLoad error->stimeo--avatar#onError" />
|
|
13
|
+
* <span aria-hidden="true" hidden data-stimeo--avatar-target="fallback">JD</span>
|
|
14
|
+
* </span>
|
|
15
|
+
*
|
|
16
|
+
* Watches the `<img>` `load`/`error` events and swaps to the author-provided
|
|
17
|
+
* fallback when the image fails or no `src` is given. The accessible name lives
|
|
18
|
+
* on the container (`role="img"` + `aria-label`); the inner `<img>` and the
|
|
19
|
+
* fallback are `aria-hidden` so assistive tech reads the name once, regardless of
|
|
20
|
+
* which side is visible.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* Behavior only — shape, size, and colour are the consumer's CSS. Initials or
|
|
24
|
+
* colour generation are out of scope: the fallback content comes from markup. The
|
|
25
|
+
* loading/loaded/error phase is exposed on `data-state` so CSS can style each.
|
|
26
|
+
*/
|
|
27
|
+
declare class AvatarController extends Controller<HTMLElement> {
|
|
28
|
+
#private;
|
|
29
|
+
static targets: string[];
|
|
30
|
+
static values: {
|
|
31
|
+
src: {
|
|
32
|
+
type: StringConstructor;
|
|
33
|
+
default: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
static actions: readonly ["onError", "onLoad"];
|
|
37
|
+
static events: readonly ["error"];
|
|
38
|
+
readonly imageTarget: HTMLImageElement;
|
|
39
|
+
readonly fallbackTarget: HTMLElement;
|
|
40
|
+
readonly hasImageTarget: boolean;
|
|
41
|
+
readonly hasFallbackTarget: boolean;
|
|
42
|
+
srcValue: string;
|
|
43
|
+
connect(): void;
|
|
44
|
+
/** Reveals the image once it has loaded successfully. */
|
|
45
|
+
onLoad(): void;
|
|
46
|
+
/** Swaps to the fallback when the image fails and emits `error`. */
|
|
47
|
+
onError(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { AvatarController };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/avatar_controller.ts
|
|
4
|
+
var AvatarController = class extends Controller {
|
|
5
|
+
static targets = ["image", "fallback"];
|
|
6
|
+
static values = {
|
|
7
|
+
src: { type: String, default: "" }
|
|
8
|
+
};
|
|
9
|
+
static actions = ["onError", "onLoad"];
|
|
10
|
+
static events = ["error"];
|
|
11
|
+
connect() {
|
|
12
|
+
if (!this.hasImageTarget) {
|
|
13
|
+
this.#showFallback();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (this.srcValue) {
|
|
17
|
+
this.imageTarget.src = this.srcValue;
|
|
18
|
+
}
|
|
19
|
+
const src = this.imageTarget.getAttribute("src");
|
|
20
|
+
if (!src) {
|
|
21
|
+
this.#showFallback();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {
|
|
25
|
+
this.#showImage();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (this.imageTarget.complete && this.imageTarget.naturalWidth === 0 && src) {
|
|
29
|
+
this.onError();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
this.#enterLoading();
|
|
33
|
+
}
|
|
34
|
+
/** Reveals the image once it has loaded successfully. */
|
|
35
|
+
onLoad() {
|
|
36
|
+
this.#showImage();
|
|
37
|
+
}
|
|
38
|
+
/** Swaps to the fallback when the image fails and emits `error`. */
|
|
39
|
+
onError() {
|
|
40
|
+
const src = this.hasImageTarget ? this.imageTarget.getAttribute("src") ?? "" : "";
|
|
41
|
+
this.#showFallback();
|
|
42
|
+
this.dispatch("error", { detail: { src } });
|
|
43
|
+
}
|
|
44
|
+
/** Loading phase: keep the image visible (per markup) while it fetches. */
|
|
45
|
+
#enterLoading() {
|
|
46
|
+
if (this.hasImageTarget) this.imageTarget.hidden = false;
|
|
47
|
+
if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;
|
|
48
|
+
this.element.setAttribute("data-state", "loading");
|
|
49
|
+
}
|
|
50
|
+
/** Loaded phase: image visible, fallback hidden. */
|
|
51
|
+
#showImage() {
|
|
52
|
+
if (this.hasImageTarget) this.imageTarget.hidden = false;
|
|
53
|
+
if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;
|
|
54
|
+
this.element.setAttribute("data-state", "loaded");
|
|
55
|
+
}
|
|
56
|
+
/** Error / no-src phase: fallback visible, image hidden. */
|
|
57
|
+
#showFallback() {
|
|
58
|
+
if (this.hasImageTarget) this.imageTarget.hidden = true;
|
|
59
|
+
if (this.hasFallbackTarget) this.fallbackTarget.hidden = false;
|
|
60
|
+
this.element.setAttribute("data-state", "error");
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export { AvatarController };
|
|
65
|
+
//# sourceMappingURL=avatar_controller.js.map
|
|
66
|
+
//# sourceMappingURL=avatar_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/avatar_controller.ts"],"names":[],"mappings":";;;AA0BO,IAAM,gBAAA,GAAN,cAA+B,UAAA,CAAwB;AAAA,EAC5D,OAAgB,OAAA,GAAU,CAAC,OAAA,EAAS,UAAU,CAAA;AAAA,EAC9C,OAAgB,MAAA,GAAS;AAAA,IACvB,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GACnC;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,SAAA,EAAW,QAAQ,CAAA;AAAA,EACrC,OAAO,MAAA,GAAS,CAAC,OAAO,CAAA;AAAA,EASf,OAAA,GAAgB;AACvB,IAAA,IAAI,CAAC,KAAK,cAAA,EAAgB;AAExB,MAAA,IAAA,CAAK,aAAA,EAAc;AACnB,MAAA;AAAA,IACF;AAIA,IAAA,IAAI,KAAK,QAAA,EAAU;AACjB,MAAA,IAAA,CAAK,WAAA,CAAY,MAAM,IAAA,CAAK,QAAA;AAAA,IAC9B;AAEA,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,WAAA,CAAY,YAAA,CAAa,KAAK,CAAA;AAC/C,IAAA,IAAI,CAAC,GAAA,EAAK;AAGR,MAAA,IAAA,CAAK,aAAA,EAAc;AACnB,MAAA;AAAA,IACF;AAIA,IAAA,IAAI,KAAK,WAAA,CAAY,QAAA,IAAY,IAAA,CAAK,WAAA,CAAY,eAAe,CAAA,EAAG;AAClE,MAAA,IAAA,CAAK,UAAA,EAAW;AAChB,MAAA;AAAA,IACF;AACA,IAAA,IAAI,KAAK,WAAA,CAAY,QAAA,IAAY,KAAK,WAAA,CAAY,YAAA,KAAiB,KAAK,GAAA,EAAK;AAE3E,MAAA,IAAA,CAAK,OAAA,EAAQ;AACb,MAAA;AAAA,IACF;AAEA,IAAA,IAAA,CAAK,aAAA,EAAc;AAAA,EACrB;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAA,CAAK,UAAA,EAAW;AAAA,EAClB;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,MAAM,GAAA,GAAM,KAAK,cAAA,GAAkB,IAAA,CAAK,YAAY,YAAA,CAAa,KAAK,KAAK,EAAA,GAAM,EAAA;AACjF,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,IAAA,CAAK,SAAS,OAAA,EAAS,EAAE,QAAQ,EAAE,GAAA,IAAO,CAAA;AAAA,EAC5C;AAAA;AAAA,EAGA,aAAA,GAAsB;AACpB,IAAA,IAAI,IAAA,CAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,KAAA;AACnD,IAAA,IAAI,IAAA,CAAK,iBAAA,EAAmB,IAAA,CAAK,cAAA,CAAe,MAAA,GAAS,IAAA;AACzD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,SAAS,CAAA;AAAA,EACnD;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,IAAI,IAAA,CAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,KAAA;AACnD,IAAA,IAAI,IAAA,CAAK,iBAAA,EAAmB,IAAA,CAAK,cAAA,CAAe,MAAA,GAAS,IAAA;AACzD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,QAAQ,CAAA;AAAA,EAClD;AAAA;AAAA,EAGA,aAAA,GAAsB;AACpB,IAAA,IAAI,IAAA,CAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,IAAA;AACnD,IAAA,IAAI,IAAA,CAAK,iBAAA,EAAmB,IAAA,CAAK,cAAA,CAAe,MAAA,GAAS,KAAA;AACzD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,OAAO,CAAA;AAAA,EACjD;AACF","file":"avatar_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **Avatar** behavior with image-load fallback. No dedicated APG\n * pattern; it follows the non-text-content practice (WCAG 1.1.1).\n *\n * Markup contract (identifier: `stimeo--avatar`):\n * <span data-controller=\"stimeo--avatar\" role=\"img\" aria-label=\"Jane Doe\"\n * data-stimeo--avatar-src-value=\"/u/123.jpg\">\n * <img alt=\"\" aria-hidden=\"true\"\n * data-stimeo--avatar-target=\"image\"\n * data-action=\"load->stimeo--avatar#onLoad error->stimeo--avatar#onError\" />\n * <span aria-hidden=\"true\" hidden data-stimeo--avatar-target=\"fallback\">JD</span>\n * </span>\n *\n * Watches the `<img>` `load`/`error` events and swaps to the author-provided\n * fallback when the image fails or no `src` is given. The accessible name lives\n * on the container (`role=\"img\"` + `aria-label`); the inner `<img>` and the\n * fallback are `aria-hidden` so assistive tech reads the name once, regardless of\n * which side is visible.\n *\n * @remarks\n * Behavior only — shape, size, and colour are the consumer's CSS. Initials or\n * colour generation are out of scope: the fallback content comes from markup. The\n * loading/loaded/error phase is exposed on `data-state` so CSS can style each.\n */\nexport class AvatarController extends Controller<HTMLElement> {\n static override targets = [\"image\", \"fallback\"];\n static override values = {\n src: { type: String, default: \"\" },\n };\n static actions = [\"onError\", \"onLoad\"] as const;\n static events = [\"error\"] as const;\n\n declare readonly imageTarget: HTMLImageElement;\n declare readonly fallbackTarget: HTMLElement;\n declare readonly hasImageTarget: boolean;\n declare readonly hasFallbackTarget: boolean;\n\n declare srcValue: string;\n\n override connect(): void {\n if (!this.hasImageTarget) {\n // Nothing to monitor: surface the fallback so the container is not empty.\n this.#showFallback();\n return;\n }\n\n // A `src` Value, when present, is the source of truth and is applied to the\n // image; otherwise the markup's own `src` attribute (if any) is honored.\n if (this.srcValue) {\n this.imageTarget.src = this.srcValue;\n }\n\n const src = this.imageTarget.getAttribute(\"src\");\n if (!src) {\n // No image to load at all — go straight to the fallback without emitting an\n // error event (there was no failed load attempt).\n this.#showFallback();\n return;\n }\n\n // A cached image may already be complete by the time the controller connects,\n // in which case the `load` event has fired and won't fire again.\n if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {\n this.#showImage();\n return;\n }\n if (this.imageTarget.complete && this.imageTarget.naturalWidth === 0 && src) {\n // Complete but with no intrinsic size means the load already failed.\n this.onError();\n return;\n }\n\n this.#enterLoading();\n }\n\n /** Reveals the image once it has loaded successfully. */\n onLoad(): void {\n this.#showImage();\n }\n\n /** Swaps to the fallback when the image fails and emits `error`. */\n onError(): void {\n const src = this.hasImageTarget ? (this.imageTarget.getAttribute(\"src\") ?? \"\") : \"\";\n this.#showFallback();\n this.dispatch(\"error\", { detail: { src } });\n }\n\n /** Loading phase: keep the image visible (per markup) while it fetches. */\n #enterLoading(): void {\n if (this.hasImageTarget) this.imageTarget.hidden = false;\n if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;\n this.element.setAttribute(\"data-state\", \"loading\");\n }\n\n /** Loaded phase: image visible, fallback hidden. */\n #showImage(): void {\n if (this.hasImageTarget) this.imageTarget.hidden = false;\n if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;\n this.element.setAttribute(\"data-state\", \"loaded\");\n }\n\n /** Error / no-src phase: fallback visible, image hidden. */\n #showFallback(): void {\n if (this.hasImageTarget) this.imageTarget.hidden = true;\n if (this.hasFallbackTarget) this.fallbackTarget.hidden = false;\n this.element.setAttribute(\"data-state\", \"error\");\n }\n}\n"]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible responsive breadcrumb behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--breadcrumb`):
|
|
7
|
+
* <nav data-controller="stimeo--breadcrumb" aria-label="Breadcrumb">
|
|
8
|
+
* <ol data-stimeo--breadcrumb-target="list">
|
|
9
|
+
* <li><a href="/">Home</a></li>
|
|
10
|
+
* <li data-stimeo--breadcrumb-target="ellipsis" hidden>
|
|
11
|
+
* <button type="button" aria-expanded="false" aria-controls="bc-collapsed"
|
|
12
|
+
* aria-label="Show full path"
|
|
13
|
+
* data-stimeo--breadcrumb-target="trigger"
|
|
14
|
+
* data-action="stimeo--breadcrumb#toggle">…</button>
|
|
15
|
+
* </li>
|
|
16
|
+
* <li id="bc-collapsed" data-stimeo--breadcrumb-target="collapsible"><a href="/a">Section A</a></li>
|
|
17
|
+
* <li data-stimeo--breadcrumb-target="collapsible"><a href="/a/b">Sub B</a></li>
|
|
18
|
+
* <li><a href="/a/b/c" aria-current="page">Item C</a></li>
|
|
19
|
+
* </ol>
|
|
20
|
+
* </nav>
|
|
21
|
+
*
|
|
22
|
+
* Implements the WAI-ARIA APG **Breadcrumb** pattern. The base structure (`nav`
|
|
23
|
+
* + `ol` + `aria-current="page"`) lives in the markup; this controller adds the
|
|
24
|
+
* responsive behavior: when the trail overflows its container it collapses the
|
|
25
|
+
* author-marked middle items behind a disclosure (`…`) button, which expands
|
|
26
|
+
* them back on demand.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Behavior only — the consumer owns separators (CSS) and the look. The items to
|
|
30
|
+
* collapse are the author-marked `collapsible` targets (the source of truth),
|
|
31
|
+
* placed between the leading and trailing items that must always stay visible.
|
|
32
|
+
*
|
|
33
|
+
* Behavior provided:
|
|
34
|
+
* - Detects overflow via {@link LayoutObserver} (element + viewport resize).
|
|
35
|
+
* - While overflowing and not expanded, hides the `collapsible` items and shows
|
|
36
|
+
* the `ellipsis` item; when it fits, shows everything and hides the ellipsis.
|
|
37
|
+
* - The disclosure `trigger` toggles `aria-expanded` and the collapsed items,
|
|
38
|
+
* dispatching `stimeo--breadcrumb:toggle`.
|
|
39
|
+
*/
|
|
40
|
+
declare class BreadcrumbController extends Controller<HTMLElement> {
|
|
41
|
+
#private;
|
|
42
|
+
static targets: string[];
|
|
43
|
+
static actions: readonly ["toggle"];
|
|
44
|
+
static events: readonly ["toggle"];
|
|
45
|
+
readonly listTarget: HTMLElement;
|
|
46
|
+
readonly collapsibleTargets: HTMLElement[];
|
|
47
|
+
readonly ellipsisTarget: HTMLElement;
|
|
48
|
+
readonly triggerTarget: HTMLElement;
|
|
49
|
+
readonly hasListTarget: boolean;
|
|
50
|
+
readonly hasEllipsisTarget: boolean;
|
|
51
|
+
readonly hasTriggerTarget: boolean;
|
|
52
|
+
/** Starts observing for overflow and renders the initial state. */
|
|
53
|
+
connect(): void;
|
|
54
|
+
/** Releases the resize observation (Turbo navigation included). */
|
|
55
|
+
disconnect(): void;
|
|
56
|
+
/** Expands or re-collapses the trail and dispatches `toggle`. */
|
|
57
|
+
toggle(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { BreadcrumbController };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/breadcrumb_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/layout_observer.ts
|
|
6
|
+
var LayoutObserver = class {
|
|
7
|
+
#callback;
|
|
8
|
+
#resizeObserverFactory;
|
|
9
|
+
#resizeObserver = null;
|
|
10
|
+
#observingViewport = false;
|
|
11
|
+
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
12
|
+
#handleViewportResize = () => {
|
|
13
|
+
this.#callback();
|
|
14
|
+
};
|
|
15
|
+
constructor(callback, options = {}) {
|
|
16
|
+
this.#callback = callback;
|
|
17
|
+
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Starts observing an element's size. Repeated calls observe additional
|
|
21
|
+
* elements through the same shared observer. No-ops when no
|
|
22
|
+
* `ResizeObserver` implementation is available.
|
|
23
|
+
*/
|
|
24
|
+
observe(element) {
|
|
25
|
+
if (!this.#resizeObserverFactory) return;
|
|
26
|
+
if (!this.#resizeObserver) {
|
|
27
|
+
this.#resizeObserver = this.#resizeObserverFactory(() => {
|
|
28
|
+
this.#callback();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
this.#resizeObserver.observe(element);
|
|
32
|
+
}
|
|
33
|
+
/** Stops observing a single element while leaving any others in place. */
|
|
34
|
+
unobserve(element) {
|
|
35
|
+
this.#resizeObserver?.unobserve(element);
|
|
36
|
+
}
|
|
37
|
+
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
38
|
+
observeViewport() {
|
|
39
|
+
if (this.#observingViewport) return;
|
|
40
|
+
this.#observingViewport = true;
|
|
41
|
+
window.addEventListener("resize", this.#handleViewportResize);
|
|
42
|
+
}
|
|
43
|
+
/** Stops observing viewport resizes without affecting element observation. */
|
|
44
|
+
unobserveViewport() {
|
|
45
|
+
if (!this.#observingViewport) return;
|
|
46
|
+
this.#observingViewport = false;
|
|
47
|
+
window.removeEventListener("resize", this.#handleViewportResize);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and
|
|
51
|
+
* removes the viewport listener. Safe to call multiple times. Call this from a
|
|
52
|
+
* controller's `disconnect()`.
|
|
53
|
+
*/
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#resizeObserver?.disconnect();
|
|
56
|
+
this.#resizeObserver = null;
|
|
57
|
+
this.unobserveViewport();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/controllers/breadcrumb_controller.ts
|
|
62
|
+
var BreadcrumbController = class extends Controller {
|
|
63
|
+
static targets = ["list", "collapsible", "ellipsis", "trigger"];
|
|
64
|
+
static actions = ["toggle"];
|
|
65
|
+
static events = ["toggle"];
|
|
66
|
+
/** Whether the trail currently overflows its container. */
|
|
67
|
+
#overflowing = false;
|
|
68
|
+
/** Whether the user has expanded the collapsed items via the disclosure. */
|
|
69
|
+
#expanded = false;
|
|
70
|
+
#layout = new LayoutObserver(() => this.#update());
|
|
71
|
+
/** Starts observing for overflow and renders the initial state. */
|
|
72
|
+
connect() {
|
|
73
|
+
if (this.hasListTarget) this.#layout.observe(this.listTarget);
|
|
74
|
+
this.#layout.observeViewport();
|
|
75
|
+
this.#update();
|
|
76
|
+
}
|
|
77
|
+
/** Releases the resize observation (Turbo navigation included). */
|
|
78
|
+
disconnect() {
|
|
79
|
+
this.#layout.disconnect();
|
|
80
|
+
}
|
|
81
|
+
/** Expands or re-collapses the trail and dispatches `toggle`. */
|
|
82
|
+
toggle() {
|
|
83
|
+
this.#expanded = !this.#expanded;
|
|
84
|
+
this.#render();
|
|
85
|
+
this.dispatch("toggle", { detail: { expanded: this.#expanded } });
|
|
86
|
+
}
|
|
87
|
+
/** Re-measures overflow and re-renders (e.g. on resize). */
|
|
88
|
+
#update() {
|
|
89
|
+
this.#overflowing = this.#measureOverflow();
|
|
90
|
+
if (!this.#overflowing) this.#expanded = false;
|
|
91
|
+
this.#render();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Measures overflow against the **fully expanded** layout so hiding items does
|
|
95
|
+
* not make the condition oscillate. Reveals every item, then compares the list's
|
|
96
|
+
* own scroll width to its own client width; `#render` immediately re-applies
|
|
97
|
+
* the correct hidden state afterward.
|
|
98
|
+
*
|
|
99
|
+
* Both widths are read from the list element itself (not the host `nav`) so the
|
|
100
|
+
* check is independent of any padding/border on the host — comparing against the
|
|
101
|
+
* host's `clientWidth` would over-report the available space by its padding and
|
|
102
|
+
* miss real overflow in a padded container.
|
|
103
|
+
*/
|
|
104
|
+
#measureOverflow() {
|
|
105
|
+
if (!this.hasListTarget) return false;
|
|
106
|
+
for (const item of this.collapsibleTargets) item.hidden = false;
|
|
107
|
+
if (this.hasEllipsisTarget) this.ellipsisTarget.hidden = true;
|
|
108
|
+
return this.listTarget.scrollWidth > this.listTarget.clientWidth;
|
|
109
|
+
}
|
|
110
|
+
/** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */
|
|
111
|
+
#render() {
|
|
112
|
+
const collapsed = this.#overflowing && !this.#expanded;
|
|
113
|
+
for (const item of this.collapsibleTargets) item.hidden = collapsed;
|
|
114
|
+
if (this.hasEllipsisTarget) this.ellipsisTarget.hidden = !this.#overflowing;
|
|
115
|
+
if (this.hasTriggerTarget) {
|
|
116
|
+
this.triggerTarget.setAttribute("aria-expanded", this.#expanded ? "true" : "false");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export { BreadcrumbController };
|
|
122
|
+
//# sourceMappingURL=breadcrumb_controller.js.map
|
|
123
|
+
//# sourceMappingURL=breadcrumb_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/breadcrumb_controller.ts"],"names":[],"mappings":"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","file":"breadcrumb_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Layout-sensitive widgets (sliders, resizable panes, scroll spies, popovers)\n * need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\n\n/**\n * Headless, accessible responsive breadcrumb behavior.\n *\n * Markup contract (identifier: `stimeo--breadcrumb`):\n * <nav data-controller=\"stimeo--breadcrumb\" aria-label=\"Breadcrumb\">\n * <ol data-stimeo--breadcrumb-target=\"list\">\n * <li><a href=\"/\">Home</a></li>\n * <li data-stimeo--breadcrumb-target=\"ellipsis\" hidden>\n * <button type=\"button\" aria-expanded=\"false\" aria-controls=\"bc-collapsed\"\n * aria-label=\"Show full path\"\n * data-stimeo--breadcrumb-target=\"trigger\"\n * data-action=\"stimeo--breadcrumb#toggle\">…</button>\n * </li>\n * <li id=\"bc-collapsed\" data-stimeo--breadcrumb-target=\"collapsible\"><a href=\"/a\">Section A</a></li>\n * <li data-stimeo--breadcrumb-target=\"collapsible\"><a href=\"/a/b\">Sub B</a></li>\n * <li><a href=\"/a/b/c\" aria-current=\"page\">Item C</a></li>\n * </ol>\n * </nav>\n *\n * Implements the WAI-ARIA APG **Breadcrumb** pattern. The base structure (`nav`\n * + `ol` + `aria-current=\"page\"`) lives in the markup; this controller adds the\n * responsive behavior: when the trail overflows its container it collapses the\n * author-marked middle items behind a disclosure (`…`) button, which expands\n * them back on demand.\n *\n * @remarks\n * Behavior only — the consumer owns separators (CSS) and the look. The items to\n * collapse are the author-marked `collapsible` targets (the source of truth),\n * placed between the leading and trailing items that must always stay visible.\n *\n * Behavior provided:\n * - Detects overflow via {@link LayoutObserver} (element + viewport resize).\n * - While overflowing and not expanded, hides the `collapsible` items and shows\n * the `ellipsis` item; when it fits, shows everything and hides the ellipsis.\n * - The disclosure `trigger` toggles `aria-expanded` and the collapsed items,\n * dispatching `stimeo--breadcrumb:toggle`.\n */\nexport class BreadcrumbController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"collapsible\", \"ellipsis\", \"trigger\"];\n static actions = [\"toggle\"] as const;\n static events = [\"toggle\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly collapsibleTargets: HTMLElement[];\n declare readonly ellipsisTarget: HTMLElement;\n declare readonly triggerTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasEllipsisTarget: boolean;\n declare readonly hasTriggerTarget: boolean;\n\n /** Whether the trail currently overflows its container. */\n #overflowing = false;\n /** Whether the user has expanded the collapsed items via the disclosure. */\n #expanded = false;\n\n readonly #layout = new LayoutObserver(() => this.#update());\n\n /** Starts observing for overflow and renders the initial state. */\n override connect(): void {\n if (this.hasListTarget) this.#layout.observe(this.listTarget);\n this.#layout.observeViewport();\n this.#update();\n }\n\n /** Releases the resize observation (Turbo navigation included). */\n override disconnect(): void {\n this.#layout.disconnect();\n }\n\n /** Expands or re-collapses the trail and dispatches `toggle`. */\n toggle(): void {\n this.#expanded = !this.#expanded;\n this.#render();\n this.dispatch(\"toggle\", { detail: { expanded: this.#expanded } });\n }\n\n /** Re-measures overflow and re-renders (e.g. on resize). */\n #update(): void {\n this.#overflowing = this.#measureOverflow();\n if (!this.#overflowing) this.#expanded = false;\n this.#render();\n }\n\n /**\n * Measures overflow against the **fully expanded** layout so hiding items does\n * not make the condition oscillate. Reveals every item, then compares the list's\n * own scroll width to its own client width; `#render` immediately re-applies\n * the correct hidden state afterward.\n *\n * Both widths are read from the list element itself (not the host `nav`) so the\n * check is independent of any padding/border on the host — comparing against the\n * host's `clientWidth` would over-report the available space by its padding and\n * miss real overflow in a padded container.\n */\n #measureOverflow(): boolean {\n if (!this.hasListTarget) return false;\n for (const item of this.collapsibleTargets) item.hidden = false;\n if (this.hasEllipsisTarget) this.ellipsisTarget.hidden = true;\n return this.listTarget.scrollWidth > this.listTarget.clientWidth;\n }\n\n /** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */\n #render(): void {\n const collapsed = this.#overflowing && !this.#expanded;\n for (const item of this.collapsibleTargets) item.hidden = collapsed;\n if (this.hasEllipsisTarget) this.ellipsisTarget.hidden = !this.#overflowing;\n if (this.hasTriggerTarget) {\n this.triggerTarget.setAttribute(\"aria-expanded\", this.#expanded ? \"true\" : \"false\");\n }\n }\n}\n"]}
|