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 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/switch_controller.ts"],"names":[],"mappings":";;;AAyBO,IAAM,gBAAA,GAAN,cAA+B,UAAA,CAAwB;AAAA,EAC5D,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,QAAQ,CAAA;AAAA,EACvC,OAAO,MAAA,GAAS,CAAC,SAAS,CAAA;AAAA;AAAA,EAGjB,OAAA,GAAgB;AACvB,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,MAAM,CAAA,EAAG;AACtC,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,MAAA,EAAQ,QAAQ,CAAA;AAAA,IAC5C;AACA,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAc,CAAA,EAAG;AAC9C,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAA,EAAgB,OAAO,CAAA;AAAA,IACnD;AAIA,IAAA,IAAI,EAAE,KAAK,OAAA,YAAmB,iBAAA,CAAA,IAAsB,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,UAAU,CAAA,EAAG;AAC1F,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,UAAA,EAAY,GAAG,CAAA;AAAA,IAC3C;AAAA,EACF;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAA,CAAK,QAAA,GAAW,CAAC,IAAA,CAAK,QAAA;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,UAAU,KAAA,EAA4B;AACpC,IAAA,IAAI,IAAA,CAAK,mBAAmB,iBAAA,EAAmB;AAC/C,IAAA,IAAI,MAAM,MAAA,EAAQ;AAClB,IAAA,IAAI,KAAA,CAAM,GAAA,KAAQ,GAAA,IAAO,KAAA,CAAM,QAAQ,OAAA,EAAS;AAC9C,MAAA,KAAA,CAAM,cAAA,EAAe;AACrB,MAAA,IAAA,CAAK,MAAA,EAAO;AAAA,IACd;AAAA,EACF;AAAA;AAAA,EAGA,IAAI,QAAA,GAAoB;AACtB,IAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAc,CAAA,KAAM,MAAA;AAAA,EACvD;AAAA;AAAA,EAGA,IAAI,SAAS,KAAA,EAAgB;AAC3B,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAA,EAAgB,KAAA,GAAQ,SAAS,OAAO,CAAA;AAClE,IAAA,IAAA,CAAK,QAAA,CAAS,WAAW,EAAE,MAAA,EAAQ,EAAE,OAAA,EAAS,KAAA,IAAS,CAAA;AAAA,EACzD;AACF","file":"switch_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible switch (toggle) behavior.\n *\n * Markup contract (identifier: `stimeo--switch`):\n * <button data-controller=\"stimeo--switch\"\n * data-action=\"stimeo--switch#toggle keydown->stimeo--switch#onKeydown\"\n * role=\"switch\" aria-checked=\"false\">…</button>\n *\n * Implements the WAI-ARIA APG **Switch** pattern. The controller element is the\n * switch itself; its on/off state is reflected solely through `aria-checked`.\n *\n * @remarks\n * Behavior only — the consumer owns all styling (typically keyed off the\n * `[aria-checked=\"true\"]` attribute). On a native `<button>` host, the browser\n * already synthesizes a click for Space/Enter, so {@link onKeydown} deliberately\n * does nothing there (to avoid a double toggle) and only drives non-button hosts\n * such as `<div role=\"switch\" tabindex=\"0\">`.\n *\n * Behavior provided:\n * - Click (or Space/Enter) toggles `aria-checked` between `\"true\"` and `\"false\"`.\n * - A `stimeo--switch:changed` event is dispatched on every toggle so the\n * consumer can react (its `detail.checked` carries the new boolean state).\n */\nexport class SwitchController extends Controller<HTMLElement> {\n static actions = [\"onKeydown\", \"toggle\"] as const;\n static events = [\"changed\"] as const;\n\n /** Ensures the switch exposes a role and is keyboard-reachable. */\n override connect(): void {\n if (!this.element.hasAttribute(\"role\")) {\n this.element.setAttribute(\"role\", \"switch\");\n }\n if (!this.element.hasAttribute(\"aria-checked\")) {\n this.element.setAttribute(\"aria-checked\", \"false\");\n }\n // Native <button> hosts are focusable already; a non-button host (e.g.\n // <div role=\"switch\">) needs an explicit tabindex to be keyboard-reachable,\n // otherwise the keyboard support below would be unreachable.\n if (!(this.element instanceof HTMLButtonElement) && !this.element.hasAttribute(\"tabindex\")) {\n this.element.setAttribute(\"tabindex\", \"0\");\n }\n }\n\n /** Toggles the checked state. Bound via `data-action` (click). */\n toggle(): void {\n this.#checked = !this.#checked;\n }\n\n /**\n * Activates the switch on Space/Enter for non-native hosts and prevents the\n * default Space scroll. Bound via `data-action` (keydown). Native `<button>`\n * hosts are skipped because the browser already turns Space/Enter into a click,\n * which would otherwise toggle the switch twice.\n */\n onKeydown(event: KeyboardEvent): void {\n if (this.element instanceof HTMLButtonElement) return;\n if (event.repeat) return;\n if (event.key === \" \" || event.key === \"Enter\") {\n event.preventDefault();\n this.toggle();\n }\n }\n\n /** Whether the switch is currently on. */\n get #checked(): boolean {\n return this.element.getAttribute(\"aria-checked\") === \"true\";\n }\n\n /** Reflects the new state on `aria-checked` and notifies listeners. */\n set #checked(value: boolean) {\n this.element.setAttribute(\"aria-checked\", value ? \"true\" : \"false\");\n this.dispatch(\"changed\", { detail: { checked: value } });\n }\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible tabs behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--tabs`):
|
|
7
|
+
* <div data-controller="stimeo--tabs">
|
|
8
|
+
* <div role="tablist" aria-label="…">
|
|
9
|
+
* <button role="tab" id="tab-1" aria-controls="panel-1"
|
|
10
|
+
* data-stimeo--tabs-target="tab"
|
|
11
|
+
* data-action="stimeo--tabs#select
|
|
12
|
+
* keydown->stimeo--tabs#onKeydown">Tab 1</button>
|
|
13
|
+
* <!-- more tabs -->
|
|
14
|
+
* </div>
|
|
15
|
+
* <div role="tabpanel" id="panel-1" aria-labelledby="tab-1"
|
|
16
|
+
* data-stimeo--tabs-target="panel">…</div>
|
|
17
|
+
* <!-- more panels -->
|
|
18
|
+
* </div>
|
|
19
|
+
*
|
|
20
|
+
* Implements the WAI-ARIA APG **Tabs** pattern with **automatic activation**:
|
|
21
|
+
* moving focus with the arrow keys immediately selects the focused tab. Tabs are
|
|
22
|
+
* paired to panels by index (the Nth tab controls the Nth panel).
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* Behavior only. State is exposed through `aria-selected`, roving `tabindex`
|
|
26
|
+
* (`0` for the active tab, `-1` for the rest), and the panel `hidden` attribute;
|
|
27
|
+
* the consumer owns all styling.
|
|
28
|
+
*
|
|
29
|
+
* Behavior provided:
|
|
30
|
+
* - Click a tab to select it.
|
|
31
|
+
* - `ArrowRight`/`ArrowLeft` move to and select the next/previous tab (wrapping);
|
|
32
|
+
* `Home`/`End` select the first/last tab.
|
|
33
|
+
*/
|
|
34
|
+
declare class TabsController extends Controller<HTMLElement> {
|
|
35
|
+
#private;
|
|
36
|
+
static targets: string[];
|
|
37
|
+
static actions: readonly ["onKeydown", "select"];
|
|
38
|
+
readonly tabTargets: HTMLButtonElement[];
|
|
39
|
+
readonly panelTargets: HTMLElement[];
|
|
40
|
+
/** Selects the initially active tab (the pre-selected one, else the first). */
|
|
41
|
+
connect(): void;
|
|
42
|
+
/** Selects the clicked tab. Bound via `data-action` (click). */
|
|
43
|
+
select(event: Event): void;
|
|
44
|
+
/** Implements arrow/Home/End navigation with automatic activation. */
|
|
45
|
+
onKeydown(event: KeyboardEvent): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { TabsController };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/tabs_controller.ts
|
|
4
|
+
var TabsController = class extends Controller {
|
|
5
|
+
static targets = ["tab", "panel"];
|
|
6
|
+
static actions = ["onKeydown", "select"];
|
|
7
|
+
/** Selects the initially active tab (the pre-selected one, else the first). */
|
|
8
|
+
connect() {
|
|
9
|
+
const preselected = this.tabTargets.findIndex(
|
|
10
|
+
(tab) => tab.getAttribute("aria-selected") === "true"
|
|
11
|
+
);
|
|
12
|
+
this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });
|
|
13
|
+
}
|
|
14
|
+
/** Selects the clicked tab. Bound via `data-action` (click). */
|
|
15
|
+
select(event) {
|
|
16
|
+
const index = this.tabTargets.indexOf(event.currentTarget);
|
|
17
|
+
if (index !== -1) this.#selectIndex(index, { focus: false });
|
|
18
|
+
}
|
|
19
|
+
/** Implements arrow/Home/End navigation with automatic activation. */
|
|
20
|
+
onKeydown(event) {
|
|
21
|
+
const tabs = this.tabTargets;
|
|
22
|
+
const currentIndex = tabs.indexOf(event.currentTarget);
|
|
23
|
+
if (currentIndex === -1) return;
|
|
24
|
+
let nextIndex = null;
|
|
25
|
+
switch (event.key) {
|
|
26
|
+
case "ArrowRight":
|
|
27
|
+
nextIndex = (currentIndex + 1) % tabs.length;
|
|
28
|
+
break;
|
|
29
|
+
case "ArrowLeft":
|
|
30
|
+
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
31
|
+
break;
|
|
32
|
+
case "Home":
|
|
33
|
+
nextIndex = 0;
|
|
34
|
+
break;
|
|
35
|
+
case "End":
|
|
36
|
+
nextIndex = tabs.length - 1;
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
this.#selectIndex(nextIndex, { focus: true });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Activates the tab/panel pair at `index`: updates `aria-selected`, the roving
|
|
46
|
+
* `tabindex`, and panel visibility. Optionally moves focus to the new tab.
|
|
47
|
+
*/
|
|
48
|
+
#selectIndex(index, { focus }) {
|
|
49
|
+
this.tabTargets.forEach((tab, i) => {
|
|
50
|
+
const selected = i === index;
|
|
51
|
+
tab.setAttribute("aria-selected", selected ? "true" : "false");
|
|
52
|
+
tab.tabIndex = selected ? 0 : -1;
|
|
53
|
+
});
|
|
54
|
+
this.panelTargets.forEach((panel, i) => {
|
|
55
|
+
panel.hidden = i !== index;
|
|
56
|
+
});
|
|
57
|
+
if (focus) this.tabTargets[index]?.focus();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { TabsController };
|
|
62
|
+
//# sourceMappingURL=tabs_controller.js.map
|
|
63
|
+
//# sourceMappingURL=tabs_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/tabs_controller.ts"],"names":[],"mappings":";;;AAiCO,IAAM,cAAA,GAAN,cAA6B,UAAA,CAAwB;AAAA,EAC1D,OAAgB,OAAA,GAAU,CAAC,KAAA,EAAO,OAAO,CAAA;AAAA,EACzC,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,QAAQ,CAAA;AAAA;AAAA,EAM9B,OAAA,GAAgB;AACvB,IAAA,MAAM,WAAA,GAAc,KAAK,UAAA,CAAW,SAAA;AAAA,MAClC,CAAC,GAAA,KAAQ,GAAA,CAAI,YAAA,CAAa,eAAe,CAAA,KAAM;AAAA,KACjD;AACA,IAAA,IAAA,CAAK,YAAA,CAAa,gBAAgB,EAAA,GAAK,CAAA,GAAI,aAAa,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC1E;AAAA;AAAA,EAGA,OAAO,KAAA,EAAoB;AACzB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,UAAA,CAAW,OAAA,CAAQ,MAAM,aAAkC,CAAA;AAC9E,IAAA,IAAI,KAAA,KAAU,IAAI,IAAA,CAAK,YAAA,CAAa,OAAO,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC7D;AAAA;AAAA,EAGA,UAAU,KAAA,EAA4B;AACpC,IAAA,MAAM,OAAO,IAAA,CAAK,UAAA;AAClB,IAAA,MAAM,YAAA,GAAe,IAAA,CAAK,OAAA,CAAQ,KAAA,CAAM,aAAkC,CAAA;AAC1E,IAAA,IAAI,iBAAiB,EAAA,EAAI;AAEzB,IAAA,IAAI,SAAA,GAA2B,IAAA;AAC/B,IAAA,QAAQ,MAAM,GAAA;AAAK,MACjB,KAAK,YAAA;AACH,QAAA,SAAA,GAAA,CAAa,YAAA,GAAe,KAAK,IAAA,CAAK,MAAA;AACtC,QAAA;AAAA,MACF,KAAK,WAAA;AACH,QAAA,SAAA,GAAA,CAAa,YAAA,GAAe,CAAA,GAAI,IAAA,CAAK,MAAA,IAAU,IAAA,CAAK,MAAA;AACpD,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,SAAA,GAAY,CAAA;AACZ,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,SAAA,GAAY,KAAK,MAAA,GAAS,CAAA;AAC1B,QAAA;AAAA,MACF;AACE,QAAA;AAAA;AAGJ,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAA,CAAK,YAAA,CAAa,SAAA,EAAW,EAAE,KAAA,EAAO,MAAM,CAAA;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAA,CAAa,KAAA,EAAe,EAAE,KAAA,EAAM,EAA6B;AAC/D,IAAA,IAAA,CAAK,UAAA,CAAW,OAAA,CAAQ,CAAC,GAAA,EAAK,CAAA,KAAM;AAClC,MAAA,MAAM,WAAW,CAAA,KAAM,KAAA;AACvB,MAAA,GAAA,CAAI,YAAA,CAAa,eAAA,EAAiB,QAAA,GAAW,MAAA,GAAS,OAAO,CAAA;AAC7D,MAAA,GAAA,CAAI,QAAA,GAAW,WAAW,CAAA,GAAI,EAAA;AAAA,IAChC,CAAC,CAAA;AACD,IAAA,IAAA,CAAK,YAAA,CAAa,OAAA,CAAQ,CAAC,KAAA,EAAO,CAAA,KAAM;AACtC,MAAA,KAAA,CAAM,SAAS,CAAA,KAAM,KAAA;AAAA,IACvB,CAAC,CAAA;AACD,IAAA,IAAI,KAAA,EAAO,IAAA,CAAK,UAAA,CAAW,KAAK,GAAG,KAAA,EAAM;AAAA,EAC3C;AACF","file":"tabs_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible tabs behavior.\n *\n * Markup contract (identifier: `stimeo--tabs`):\n * <div data-controller=\"stimeo--tabs\">\n * <div role=\"tablist\" aria-label=\"…\">\n * <button role=\"tab\" id=\"tab-1\" aria-controls=\"panel-1\"\n * data-stimeo--tabs-target=\"tab\"\n * data-action=\"stimeo--tabs#select\n * keydown->stimeo--tabs#onKeydown\">Tab 1</button>\n * <!-- more tabs -->\n * </div>\n * <div role=\"tabpanel\" id=\"panel-1\" aria-labelledby=\"tab-1\"\n * data-stimeo--tabs-target=\"panel\">…</div>\n * <!-- more panels -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Tabs** pattern with **automatic activation**:\n * moving focus with the arrow keys immediately selects the focused tab. Tabs are\n * paired to panels by index (the Nth tab controls the Nth panel).\n *\n * @remarks\n * Behavior only. State is exposed through `aria-selected`, roving `tabindex`\n * (`0` for the active tab, `-1` for the rest), and the panel `hidden` attribute;\n * the consumer owns all styling.\n *\n * Behavior provided:\n * - Click a tab to select it.\n * - `ArrowRight`/`ArrowLeft` move to and select the next/previous tab (wrapping);\n * `Home`/`End` select the first/last tab.\n */\nexport class TabsController extends Controller<HTMLElement> {\n static override targets = [\"tab\", \"panel\"];\n static actions = [\"onKeydown\", \"select\"] as const;\n\n declare readonly tabTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Selects the initially active tab (the pre-selected one, else the first). */\n override connect(): void {\n const preselected = this.tabTargets.findIndex(\n (tab) => tab.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });\n }\n\n /** Selects the clicked tab. Bound via `data-action` (click). */\n select(event: Event): void {\n const index = this.tabTargets.indexOf(event.currentTarget as HTMLButtonElement);\n if (index !== -1) this.#selectIndex(index, { focus: false });\n }\n\n /** Implements arrow/Home/End navigation with automatic activation. */\n onKeydown(event: KeyboardEvent): void {\n const tabs = this.tabTargets;\n const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement);\n if (currentIndex === -1) return;\n\n let nextIndex: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n nextIndex = (currentIndex + 1) % tabs.length;\n break;\n case \"ArrowLeft\":\n nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;\n break;\n case \"Home\":\n nextIndex = 0;\n break;\n case \"End\":\n nextIndex = tabs.length - 1;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.#selectIndex(nextIndex, { focus: true });\n }\n\n /**\n * Activates the tab/panel pair at `index`: updates `aria-selected`, the roving\n * `tabindex`, and panel visibility. Optionally moves focus to the new tab.\n */\n #selectIndex(index: number, { focus }: { focus: boolean }): void {\n this.tabTargets.forEach((tab, i) => {\n const selected = i === index;\n tab.setAttribute(\"aria-selected\", selected ? \"true\" : \"false\");\n tab.tabIndex = selected ? 0 : -1;\n });\n this.panelTargets.forEach((panel, i) => {\n panel.hidden = i !== index;\n });\n if (focus) this.tabTargets[index]?.focus();\n }\n}\n"]}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless autosize behavior for a `<textarea>`: grows the element to fit its
|
|
5
|
+
* content (and clamps to `minRows` / `maxRows`, scrolling past the max). No APG
|
|
6
|
+
* pattern; supports WCAG 1.4.4 by following the text as it grows.
|
|
7
|
+
*
|
|
8
|
+
* Markup contract (identifier: `stimeo--textarea-autosize`):
|
|
9
|
+
* <textarea
|
|
10
|
+
* data-controller="stimeo--textarea-autosize"
|
|
11
|
+
* data-stimeo--textarea-autosize-max-rows-value="10"></textarea>
|
|
12
|
+
*
|
|
13
|
+
* On connect and on every input it collapses the element (`height:auto`), reads
|
|
14
|
+
* `scrollHeight`, and sets an explicit pixel height clamped between `minRows` and
|
|
15
|
+
* `maxRows` (in line-height units), toggling internal scrolling and the
|
|
16
|
+
* `data-at-max-rows` hook at the cap. Height-only changes preserve focus and caret.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Behavior only — the height is written to the element's own inline style, never a
|
|
20
|
+
* CSS class. State lives on the element (no module-scope state), so `connect()`
|
|
21
|
+
* re-measures after a Turbo navigation / morph. The input listener is removed on
|
|
22
|
+
* `disconnect()` (Turbo navigation included). Measurement is synchronous, so there
|
|
23
|
+
* is no pending animation frame to tear down.
|
|
24
|
+
*/
|
|
25
|
+
declare class TextareaAutosizeController extends Controller<HTMLTextAreaElement> {
|
|
26
|
+
#private;
|
|
27
|
+
static values: {
|
|
28
|
+
minRows: {
|
|
29
|
+
type: NumberConstructor;
|
|
30
|
+
default: number;
|
|
31
|
+
};
|
|
32
|
+
maxRows: {
|
|
33
|
+
type: NumberConstructor;
|
|
34
|
+
default: number;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
static actions: readonly ["resize"];
|
|
38
|
+
static events: readonly ["resize"];
|
|
39
|
+
minRowsValue: number;
|
|
40
|
+
maxRowsValue: number;
|
|
41
|
+
connect(): void;
|
|
42
|
+
disconnect(): void;
|
|
43
|
+
/** Re-measures the content and applies the clamped height. */
|
|
44
|
+
resize(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { TextareaAutosizeController };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/textarea_autosize_controller.ts
|
|
4
|
+
function px(value) {
|
|
5
|
+
const n = Number.parseFloat(value);
|
|
6
|
+
return Number.isNaN(n) ? 0 : n;
|
|
7
|
+
}
|
|
8
|
+
var TextareaAutosizeController = class extends Controller {
|
|
9
|
+
static values = {
|
|
10
|
+
minRows: { type: Number, default: 1 },
|
|
11
|
+
maxRows: { type: Number, default: 0 }
|
|
12
|
+
};
|
|
13
|
+
static actions = ["resize"];
|
|
14
|
+
static events = ["resize"];
|
|
15
|
+
#lastHeight = -1;
|
|
16
|
+
#onInput = () => {
|
|
17
|
+
this.resize();
|
|
18
|
+
};
|
|
19
|
+
connect() {
|
|
20
|
+
this.element.addEventListener("input", this.#onInput);
|
|
21
|
+
this.resize();
|
|
22
|
+
}
|
|
23
|
+
disconnect() {
|
|
24
|
+
this.element.removeEventListener("input", this.#onInput);
|
|
25
|
+
}
|
|
26
|
+
/** Re-measures the content and applies the clamped height. */
|
|
27
|
+
resize() {
|
|
28
|
+
const el = this.element;
|
|
29
|
+
const style = window.getComputedStyle(el);
|
|
30
|
+
const lineHeight = this.#lineHeight(style);
|
|
31
|
+
const paddingV = px(style.paddingTop) + px(style.paddingBottom);
|
|
32
|
+
const borderV = px(style.borderTopWidth) + px(style.borderBottomWidth);
|
|
33
|
+
const borderBox = style.boxSizing === "border-box";
|
|
34
|
+
el.style.height = "auto";
|
|
35
|
+
const contentHeight = Math.max(0, el.scrollHeight - paddingV);
|
|
36
|
+
const rows = Math.max(1, Math.round(contentHeight / lineHeight));
|
|
37
|
+
let targetContent = Math.max(contentHeight, this.minRowsValue * lineHeight);
|
|
38
|
+
let atMax = false;
|
|
39
|
+
if (this.maxRowsValue > 0) {
|
|
40
|
+
const maxContent = this.maxRowsValue * lineHeight;
|
|
41
|
+
if (targetContent > maxContent) {
|
|
42
|
+
targetContent = maxContent;
|
|
43
|
+
atMax = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const boxExtra = borderBox ? paddingV + borderV : 0;
|
|
47
|
+
const height = Math.round(targetContent + boxExtra);
|
|
48
|
+
el.style.height = `${height}px`;
|
|
49
|
+
el.style.overflowY = atMax ? "auto" : "hidden";
|
|
50
|
+
if (atMax) {
|
|
51
|
+
el.setAttribute("data-at-max-rows", "true");
|
|
52
|
+
} else {
|
|
53
|
+
el.removeAttribute("data-at-max-rows");
|
|
54
|
+
}
|
|
55
|
+
el.style.setProperty("--stimeo-textarea-rows", String(rows));
|
|
56
|
+
if (height !== this.#lastHeight) {
|
|
57
|
+
this.#lastHeight = height;
|
|
58
|
+
this.dispatch("resize", { detail: { height, rows } });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Resolved line height, falling back to ~1.2× font-size when `normal`. */
|
|
62
|
+
#lineHeight(style) {
|
|
63
|
+
const lh = px(style.lineHeight);
|
|
64
|
+
if (lh > 0) return lh;
|
|
65
|
+
const fontSize = px(style.fontSize);
|
|
66
|
+
return fontSize > 0 ? fontSize * 1.2 : 16;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export { TextareaAutosizeController };
|
|
71
|
+
//# sourceMappingURL=textarea_autosize_controller.js.map
|
|
72
|
+
//# sourceMappingURL=textarea_autosize_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/textarea_autosize_controller.ts"],"names":[],"mappings":"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","file":"textarea_autosize_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** Parses a CSS pixel length, defaulting to 0 for `auto` / empty / `normal`. */\nfunction px(value: string): number {\n const n = Number.parseFloat(value);\n return Number.isNaN(n) ? 0 : n;\n}\n\n/**\n * Headless autosize behavior for a `<textarea>`: grows the element to fit its\n * content (and clamps to `minRows` / `maxRows`, scrolling past the max). No APG\n * pattern; supports WCAG 1.4.4 by following the text as it grows.\n *\n * Markup contract (identifier: `stimeo--textarea-autosize`):\n * <textarea\n * data-controller=\"stimeo--textarea-autosize\"\n * data-stimeo--textarea-autosize-max-rows-value=\"10\"></textarea>\n *\n * On connect and on every input it collapses the element (`height:auto`), reads\n * `scrollHeight`, and sets an explicit pixel height clamped between `minRows` and\n * `maxRows` (in line-height units), toggling internal scrolling and the\n * `data-at-max-rows` hook at the cap. Height-only changes preserve focus and caret.\n *\n * @remarks\n * Behavior only — the height is written to the element's own inline style, never a\n * CSS class. State lives on the element (no module-scope state), so `connect()`\n * re-measures after a Turbo navigation / morph. The input listener is removed on\n * `disconnect()` (Turbo navigation included). Measurement is synchronous, so there\n * is no pending animation frame to tear down.\n */\nexport class TextareaAutosizeController extends Controller<HTMLTextAreaElement> {\n static override values = {\n minRows: { type: Number, default: 1 },\n maxRows: { type: Number, default: 0 },\n };\n static actions = [\"resize\"] as const;\n static events = [\"resize\"] as const;\n\n declare minRowsValue: number;\n declare maxRowsValue: number;\n\n #lastHeight = -1;\n\n readonly #onInput = (): void => {\n this.resize();\n };\n\n override connect(): void {\n this.element.addEventListener(\"input\", this.#onInput);\n this.resize();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"input\", this.#onInput);\n }\n\n /** Re-measures the content and applies the clamped height. */\n resize(): void {\n const el = this.element;\n const style = window.getComputedStyle(el);\n const lineHeight = this.#lineHeight(style);\n const paddingV = px(style.paddingTop) + px(style.paddingBottom);\n const borderV = px(style.borderTopWidth) + px(style.borderBottomWidth);\n const borderBox = style.boxSizing === \"border-box\";\n\n // Collapse first so scrollHeight reflects the content, not the prior height.\n el.style.height = \"auto\";\n const contentHeight = Math.max(0, el.scrollHeight - paddingV);\n const rows = Math.max(1, Math.round(contentHeight / lineHeight));\n\n let targetContent = Math.max(contentHeight, this.minRowsValue * lineHeight);\n let atMax = false;\n if (this.maxRowsValue > 0) {\n const maxContent = this.maxRowsValue * lineHeight;\n if (targetContent > maxContent) {\n targetContent = maxContent;\n atMax = true;\n }\n }\n\n const boxExtra = borderBox ? paddingV + borderV : 0;\n const height = Math.round(targetContent + boxExtra);\n el.style.height = `${height}px`;\n el.style.overflowY = atMax ? \"auto\" : \"hidden\";\n\n if (atMax) {\n el.setAttribute(\"data-at-max-rows\", \"true\");\n } else {\n el.removeAttribute(\"data-at-max-rows\");\n }\n el.style.setProperty(\"--stimeo-textarea-rows\", String(rows));\n\n if (height !== this.#lastHeight) {\n this.#lastHeight = height;\n this.dispatch(\"resize\", { detail: { height, rows } });\n }\n }\n\n /** Resolved line height, falling back to ~1.2× font-size when `normal`. */\n #lineHeight(style: CSSStyleDeclaration): number {\n const lh = px(style.lineHeight);\n if (lh > 0) return lh;\n const fontSize = px(style.fontSize);\n return fontSize > 0 ? fontSize * 1.2 : 16;\n }\n}\n"]}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless **theme / color-scheme toggle** — persists a light/dark/system choice to
|
|
5
|
+
* `localStorage`, follows the OS setting while in `system`, and reflects the
|
|
6
|
+
* effective theme onto the root for the consumer's CSS. Ships no colors; only state
|
|
7
|
+
* hooks.
|
|
8
|
+
*
|
|
9
|
+
* Two markup contracts (identifier: `stimeo--theme`):
|
|
10
|
+
*
|
|
11
|
+
* Canonical 3-value radiogroup (use this when `system` is offered):
|
|
12
|
+
* <div data-controller="stimeo--theme" data-stimeo--theme-mode-value="system"
|
|
13
|
+
* role="radiogroup" aria-label="Theme">
|
|
14
|
+
* <button data-stimeo--theme-target="option" role="radio"
|
|
15
|
+
* data-action="click->stimeo--theme#set"
|
|
16
|
+
* data-stimeo--theme-mode-param="light">Light</button>
|
|
17
|
+
* <button data-stimeo--theme-target="option" role="radio"
|
|
18
|
+
* data-action="click->stimeo--theme#set"
|
|
19
|
+
* data-stimeo--theme-mode-param="dark">Dark</button>
|
|
20
|
+
* <button data-stimeo--theme-target="option" role="radio"
|
|
21
|
+
* data-action="click->stimeo--theme#set"
|
|
22
|
+
* data-stimeo--theme-mode-param="system">System</button>
|
|
23
|
+
* </div>
|
|
24
|
+
*
|
|
25
|
+
* Auxiliary 2-value toggle (light↔dark only — `system` is not representable):
|
|
26
|
+
* <button data-controller="stimeo--theme" data-action="click->stimeo--theme#toggle"
|
|
27
|
+
* aria-pressed="false">Dark mode</button>
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* Behavior only — the actual palette is the consumer's CSS keyed off `data-theme`
|
|
31
|
+
* on the root. It applies `data-theme` (the *resolved* light/dark) and a matching
|
|
32
|
+
* `color-scheme` to the `target` element (`<html>` by default), keeps the radiogroup
|
|
33
|
+
* `aria-checked` + roving tabindex (APG radio) or the single button's `aria-pressed`
|
|
34
|
+
* in sync, and never moves focus. The `prefers-color-scheme` listener is attached on
|
|
35
|
+
* `connect()` and removed on `disconnect()` (Turbo included). FOUC avoidance for the
|
|
36
|
+
* very first paint is an inline `<head>` snippet (documented), not this controller.
|
|
37
|
+
*/
|
|
38
|
+
declare class ThemeController extends Controller<HTMLElement> {
|
|
39
|
+
#private;
|
|
40
|
+
static targets: string[];
|
|
41
|
+
static values: {
|
|
42
|
+
mode: {
|
|
43
|
+
type: StringConstructor;
|
|
44
|
+
default: string;
|
|
45
|
+
};
|
|
46
|
+
storageKey: {
|
|
47
|
+
type: StringConstructor;
|
|
48
|
+
default: string;
|
|
49
|
+
};
|
|
50
|
+
target: {
|
|
51
|
+
type: StringConstructor;
|
|
52
|
+
default: string;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
static actions: readonly ["set", "toggle"];
|
|
56
|
+
static events: readonly ["change"];
|
|
57
|
+
readonly optionTargets: HTMLElement[];
|
|
58
|
+
readonly hasOptionTarget: boolean;
|
|
59
|
+
modeValue: string;
|
|
60
|
+
storageKeyValue: string;
|
|
61
|
+
targetValue: string;
|
|
62
|
+
connect(): void;
|
|
63
|
+
disconnect(): void;
|
|
64
|
+
/** Selects an explicit mode from the `mode` action param (radiogroup option). */
|
|
65
|
+
set(event: Event): void;
|
|
66
|
+
/** Toggles light↔dark for the 2-value single-button contract. */
|
|
67
|
+
toggle(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { ThemeController };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/theme_controller.ts
|
|
4
|
+
var MODES = ["light", "dark", "system"];
|
|
5
|
+
var isMode = (value) => typeof value === "string" && MODES.includes(value);
|
|
6
|
+
var ThemeController = class extends Controller {
|
|
7
|
+
static targets = ["option"];
|
|
8
|
+
static values = {
|
|
9
|
+
mode: { type: String, default: "system" },
|
|
10
|
+
storageKey: { type: String, default: "stimeo-theme" },
|
|
11
|
+
target: { type: String, default: "html" }
|
|
12
|
+
};
|
|
13
|
+
static actions = ["set", "toggle"];
|
|
14
|
+
static events = ["change"];
|
|
15
|
+
/** The OS dark-mode query, watched so `system` tracks live changes. */
|
|
16
|
+
#media = null;
|
|
17
|
+
/** Re-resolves while in `system` mode when the OS preference flips. */
|
|
18
|
+
#onMediaChange = () => {
|
|
19
|
+
if (this.modeValue === "system") {
|
|
20
|
+
this.#applyTheme();
|
|
21
|
+
this.#syncControls();
|
|
22
|
+
this.#dispatchChange();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
|
|
26
|
+
#onKeydown = (event) => {
|
|
27
|
+
const options = this.optionTargets;
|
|
28
|
+
if (options.length === 0) return;
|
|
29
|
+
const target = event.target;
|
|
30
|
+
const current = options.indexOf(target);
|
|
31
|
+
if (current === -1) return;
|
|
32
|
+
const last = options.length - 1;
|
|
33
|
+
let next = current;
|
|
34
|
+
switch (event.key) {
|
|
35
|
+
case "ArrowDown":
|
|
36
|
+
case "ArrowRight":
|
|
37
|
+
next = current === last ? 0 : current + 1;
|
|
38
|
+
break;
|
|
39
|
+
case "ArrowUp":
|
|
40
|
+
case "ArrowLeft":
|
|
41
|
+
next = current === 0 ? last : current - 1;
|
|
42
|
+
break;
|
|
43
|
+
case "Home":
|
|
44
|
+
next = 0;
|
|
45
|
+
break;
|
|
46
|
+
case "End":
|
|
47
|
+
next = last;
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
const option = options[next];
|
|
54
|
+
if (!option) return;
|
|
55
|
+
option.focus();
|
|
56
|
+
this.#setMode(this.#optionMode(option));
|
|
57
|
+
};
|
|
58
|
+
connect() {
|
|
59
|
+
const stored = this.#readStored();
|
|
60
|
+
if (stored) this.modeValue = stored;
|
|
61
|
+
this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
|
62
|
+
this.#media?.addEventListener("change", this.#onMediaChange);
|
|
63
|
+
if (this.hasOptionTarget) this.element.addEventListener("keydown", this.#onKeydown);
|
|
64
|
+
this.#applyTheme();
|
|
65
|
+
this.#syncControls();
|
|
66
|
+
}
|
|
67
|
+
disconnect() {
|
|
68
|
+
this.#media?.removeEventListener("change", this.#onMediaChange);
|
|
69
|
+
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
70
|
+
}
|
|
71
|
+
/** Selects an explicit mode from the `mode` action param (radiogroup option). */
|
|
72
|
+
set(event) {
|
|
73
|
+
const mode = event.params?.mode;
|
|
74
|
+
if (isMode(mode)) this.#setMode(mode);
|
|
75
|
+
}
|
|
76
|
+
/** Toggles light↔dark for the 2-value single-button contract. */
|
|
77
|
+
toggle() {
|
|
78
|
+
this.#setMode(this.#resolved() === "dark" ? "light" : "dark");
|
|
79
|
+
}
|
|
80
|
+
/** Central mode change: persist, apply to the root, resync controls, announce. */
|
|
81
|
+
#setMode(mode) {
|
|
82
|
+
this.modeValue = mode;
|
|
83
|
+
this.#writeStored(mode);
|
|
84
|
+
this.#applyTheme();
|
|
85
|
+
this.#syncControls();
|
|
86
|
+
this.#dispatchChange();
|
|
87
|
+
}
|
|
88
|
+
/** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
|
|
89
|
+
#applyTheme() {
|
|
90
|
+
const root = this.#targetElement();
|
|
91
|
+
if (!root) return;
|
|
92
|
+
const resolved = this.#resolved();
|
|
93
|
+
root.setAttribute("data-theme", resolved);
|
|
94
|
+
root.style.setProperty("color-scheme", resolved);
|
|
95
|
+
}
|
|
96
|
+
/** Keeps the radiogroup (aria-checked + roving tabindex) or toggle (aria-pressed) in sync. */
|
|
97
|
+
#syncControls() {
|
|
98
|
+
const options = this.optionTargets;
|
|
99
|
+
if (options.length > 0) {
|
|
100
|
+
let hasTabbable = false;
|
|
101
|
+
for (const option of options) {
|
|
102
|
+
const selected = this.#optionMode(option) === this.modeValue;
|
|
103
|
+
option.setAttribute("aria-checked", String(selected));
|
|
104
|
+
option.tabIndex = selected ? 0 : -1;
|
|
105
|
+
hasTabbable ||= selected;
|
|
106
|
+
}
|
|
107
|
+
const first = options[0];
|
|
108
|
+
if (!hasTabbable && first) first.tabIndex = 0;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
|
|
112
|
+
}
|
|
113
|
+
/** Emits `change` with the selected mode and the resolved theme. */
|
|
114
|
+
#dispatchChange() {
|
|
115
|
+
this.dispatch("change", { detail: { mode: this.modeValue, resolved: this.#resolved() } });
|
|
116
|
+
}
|
|
117
|
+
/** The effective theme: the OS preference when `system`, else the mode itself. */
|
|
118
|
+
#resolved() {
|
|
119
|
+
if (this.modeValue === "dark") return "dark";
|
|
120
|
+
if (this.modeValue === "light") return "light";
|
|
121
|
+
return this.#media?.matches ? "dark" : "light";
|
|
122
|
+
}
|
|
123
|
+
/** Reads an option's mode from its action param attribute. */
|
|
124
|
+
#optionMode(option) {
|
|
125
|
+
const mode = option.getAttribute("data-stimeo--theme-mode-param");
|
|
126
|
+
return isMode(mode) ? mode : "system";
|
|
127
|
+
}
|
|
128
|
+
/** Resolves the state-hook target (`<html>` by default). */
|
|
129
|
+
#targetElement() {
|
|
130
|
+
if (this.targetValue === "html" || this.targetValue === ":root")
|
|
131
|
+
return document.documentElement;
|
|
132
|
+
return document.querySelector(this.targetValue);
|
|
133
|
+
}
|
|
134
|
+
/** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
|
|
135
|
+
#readStored() {
|
|
136
|
+
try {
|
|
137
|
+
const value = window.localStorage.getItem(this.storageKeyValue);
|
|
138
|
+
return isMode(value) ? value : null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Persists the mode, swallowing storage errors (private mode / quota). */
|
|
144
|
+
#writeStored(mode) {
|
|
145
|
+
try {
|
|
146
|
+
window.localStorage.setItem(this.storageKeyValue, mode);
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export { ThemeController };
|
|
153
|
+
//# sourceMappingURL=theme_controller.js.map
|
|
154
|
+
//# sourceMappingURL=theme_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/theme_controller.ts"],"names":[],"mappings":"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","file":"theme_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** The three selectable modes; `system` follows the OS `prefers-color-scheme`. */\ntype ThemeMode = \"light\" | \"dark\" | \"system\";\n/** The two effective (resolved) themes applied to the root. */\ntype ResolvedTheme = \"light\" | \"dark\";\n\nconst MODES: readonly ThemeMode[] = [\"light\", \"dark\", \"system\"];\nconst isMode = (value: unknown): value is ThemeMode =>\n typeof value === \"string\" && (MODES as readonly string[]).includes(value);\n\n/**\n * Headless **theme / color-scheme toggle** — persists a light/dark/system choice to\n * `localStorage`, follows the OS setting while in `system`, and reflects the\n * effective theme onto the root for the consumer's CSS. Ships no colors; only state\n * hooks.\n *\n * Two markup contracts (identifier: `stimeo--theme`):\n *\n * Canonical 3-value radiogroup (use this when `system` is offered):\n * <div data-controller=\"stimeo--theme\" data-stimeo--theme-mode-value=\"system\"\n * role=\"radiogroup\" aria-label=\"Theme\">\n * <button data-stimeo--theme-target=\"option\" role=\"radio\"\n * data-action=\"click->stimeo--theme#set\"\n * data-stimeo--theme-mode-param=\"light\">Light</button>\n * <button data-stimeo--theme-target=\"option\" role=\"radio\"\n * data-action=\"click->stimeo--theme#set\"\n * data-stimeo--theme-mode-param=\"dark\">Dark</button>\n * <button data-stimeo--theme-target=\"option\" role=\"radio\"\n * data-action=\"click->stimeo--theme#set\"\n * data-stimeo--theme-mode-param=\"system\">System</button>\n * </div>\n *\n * Auxiliary 2-value toggle (light↔dark only — `system` is not representable):\n * <button data-controller=\"stimeo--theme\" data-action=\"click->stimeo--theme#toggle\"\n * aria-pressed=\"false\">Dark mode</button>\n *\n * @remarks\n * Behavior only — the actual palette is the consumer's CSS keyed off `data-theme`\n * on the root. It applies `data-theme` (the *resolved* light/dark) and a matching\n * `color-scheme` to the `target` element (`<html>` by default), keeps the radiogroup\n * `aria-checked` + roving tabindex (APG radio) or the single button's `aria-pressed`\n * in sync, and never moves focus. The `prefers-color-scheme` listener is attached on\n * `connect()` and removed on `disconnect()` (Turbo included). FOUC avoidance for the\n * very first paint is an inline `<head>` snippet (documented), not this controller.\n */\nexport class ThemeController extends Controller<HTMLElement> {\n static override targets = [\"option\"];\n static override values = {\n mode: { type: String, default: \"system\" },\n storageKey: { type: String, default: \"stimeo-theme\" },\n target: { type: String, default: \"html\" },\n };\n static actions = [\"set\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly optionTargets: HTMLElement[];\n declare readonly hasOptionTarget: boolean;\n\n declare modeValue: string;\n declare storageKeyValue: string;\n declare targetValue: string;\n\n /** The OS dark-mode query, watched so `system` tracks live changes. */\n #media: MediaQueryList | null = null;\n\n /** Re-resolves while in `system` mode when the OS preference flips. */\n readonly #onMediaChange = (): void => {\n if (this.modeValue === \"system\") {\n this.#applyTheme();\n // Re-sync controls too: a 2-value toggle's `aria-pressed` reflects the\n // *resolved* theme, so it must follow the OS flip — not only `data-theme`.\n this.#syncControls();\n this.#dispatchChange();\n }\n };\n\n /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n // Resolved once per keydown: every `optionTargets` access re-queries the scope.\n const options = this.optionTargets;\n if (options.length === 0) return;\n const target = event.target as HTMLElement | null;\n const current = options.indexOf(target as HTMLElement);\n if (current === -1) return;\n\n const last = options.length - 1;\n let next = current;\n switch (event.key) {\n case \"ArrowDown\":\n case \"ArrowRight\":\n next = current === last ? 0 : current + 1;\n break;\n case \"ArrowUp\":\n case \"ArrowLeft\":\n next = current === 0 ? last : current - 1;\n break;\n case \"Home\":\n next = 0;\n break;\n case \"End\":\n next = last;\n break;\n default:\n return;\n }\n event.preventDefault();\n const option = options[next];\n if (!option) return;\n option.focus();\n this.#setMode(this.#optionMode(option));\n };\n\n override connect(): void {\n const stored = this.#readStored();\n if (stored) this.modeValue = stored;\n\n this.#media = window.matchMedia?.(\"(prefers-color-scheme: dark)\") ?? null;\n this.#media?.addEventListener(\"change\", this.#onMediaChange);\n if (this.hasOptionTarget) this.element.addEventListener(\"keydown\", this.#onKeydown);\n\n this.#applyTheme();\n this.#syncControls();\n }\n\n override disconnect(): void {\n this.#media?.removeEventListener(\"change\", this.#onMediaChange);\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n }\n\n /** Selects an explicit mode from the `mode` action param (radiogroup option). */\n set(event: Event): void {\n const mode = (event as { params?: Record<string, unknown> }).params?.mode;\n if (isMode(mode)) this.#setMode(mode);\n }\n\n /** Toggles light↔dark for the 2-value single-button contract. */\n toggle(): void {\n this.#setMode(this.#resolved() === \"dark\" ? \"light\" : \"dark\");\n }\n\n /** Central mode change: persist, apply to the root, resync controls, announce. */\n #setMode(mode: ThemeMode): void {\n this.modeValue = mode;\n this.#writeStored(mode);\n this.#applyTheme();\n this.#syncControls();\n this.#dispatchChange();\n }\n\n /** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */\n #applyTheme(): void {\n const root = this.#targetElement();\n if (!root) return;\n const resolved = this.#resolved();\n root.setAttribute(\"data-theme\", resolved);\n root.style.setProperty(\"color-scheme\", resolved);\n }\n\n /** Keeps the radiogroup (aria-checked + roving tabindex) or toggle (aria-pressed) in sync. */\n #syncControls(): void {\n const options = this.optionTargets;\n if (options.length > 0) {\n let hasTabbable = false;\n for (const option of options) {\n const selected = this.#optionMode(option) === this.modeValue;\n option.setAttribute(\"aria-checked\", String(selected));\n option.tabIndex = selected ? 0 : -1;\n hasTabbable ||= selected;\n }\n // APG: a radiogroup with no selection keeps its first radio tabbable.\n const first = options[0];\n if (!hasTabbable && first) first.tabIndex = 0;\n return;\n }\n this.element.setAttribute(\"aria-pressed\", String(this.#resolved() === \"dark\"));\n }\n\n /** Emits `change` with the selected mode and the resolved theme. */\n #dispatchChange(): void {\n this.dispatch(\"change\", { detail: { mode: this.modeValue, resolved: this.#resolved() } });\n }\n\n /** The effective theme: the OS preference when `system`, else the mode itself. */\n #resolved(): ResolvedTheme {\n if (this.modeValue === \"dark\") return \"dark\";\n if (this.modeValue === \"light\") return \"light\";\n return this.#media?.matches ? \"dark\" : \"light\";\n }\n\n /** Reads an option's mode from its action param attribute. */\n #optionMode(option: HTMLElement): ThemeMode {\n const mode = option.getAttribute(\"data-stimeo--theme-mode-param\");\n return isMode(mode) ? mode : \"system\";\n }\n\n /** Resolves the state-hook target (`<html>` by default). */\n #targetElement(): HTMLElement | null {\n if (this.targetValue === \"html\" || this.targetValue === \":root\")\n return document.documentElement;\n return document.querySelector<HTMLElement>(this.targetValue);\n }\n\n /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */\n #readStored(): ThemeMode | null {\n try {\n const value = window.localStorage.getItem(this.storageKeyValue);\n return isMode(value) ? value : null;\n } catch {\n return null;\n }\n }\n\n /** Persists the mode, swallowing storage errors (private mode / quota). */\n #writeStored(mode: ThemeMode): void {\n try {\n window.localStorage.setItem(this.storageKeyValue, mode);\n } catch {\n /* storage unavailable — the in-DOM state hooks still apply this session */\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, highly accessible toast notification behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--toast`):
|
|
7
|
+
* <div data-controller="stimeo--toast"
|
|
8
|
+
* data-stimeo--toast-duration-value="5000"
|
|
9
|
+
* data-stimeo--toast-max-value="3">
|
|
10
|
+
* <!-- Attribute-only trigger: no hand-written JS required. -->
|
|
11
|
+
* <button data-action="click->stimeo--toast#show"
|
|
12
|
+
* data-stimeo--toast-body-param="Saved"
|
|
13
|
+
* data-stimeo--toast-type-param="status">Show</button>
|
|
14
|
+
* <!-- The live region is a descendant; the controller element only needs
|
|
15
|
+
* to enclose both the trigger(s) and the list/template targets. -->
|
|
16
|
+
* <div role="region" aria-label="Notifications">
|
|
17
|
+
* <ol data-stimeo--toast-target="list"></ol>
|
|
18
|
+
* <template data-stimeo--toast-target="template">
|
|
19
|
+
* <li role="status" data-stimeo--toast-target="item"
|
|
20
|
+
* data-action="mouseenter->stimeo--toast#pause
|
|
21
|
+
* mouseleave->stimeo--toast#resume
|
|
22
|
+
* focusin->stimeo--toast#pause
|
|
23
|
+
* focusout->stimeo--toast#resume
|
|
24
|
+
* keydown->stimeo--toast#onKeydown"
|
|
25
|
+
* tabindex="0">
|
|
26
|
+
* <span data-toast-slot="body"></span>
|
|
27
|
+
* <button type="button" data-action="stimeo--toast#dismiss">Dismiss</button>
|
|
28
|
+
* </li>
|
|
29
|
+
* </template>
|
|
30
|
+
* </div>
|
|
31
|
+
* </div>
|
|
32
|
+
*
|
|
33
|
+
* Implements WAI-ARIA live region status/alert announcements, limits simultaneous
|
|
34
|
+
* elements, and pauses dismiss timeouts on hover or focus to comply with WCAG 2.2.1.
|
|
35
|
+
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* Behavior only. The controller handles state updates via `data-state` and lifecycle events
|
|
38
|
+
* while leaving visual styling completely to the client's CSS transitions.
|
|
39
|
+
*/
|
|
40
|
+
declare class ToastController extends Controller<HTMLElement> {
|
|
41
|
+
#private;
|
|
42
|
+
static targets: string[];
|
|
43
|
+
static values: {
|
|
44
|
+
duration: {
|
|
45
|
+
type: NumberConstructor;
|
|
46
|
+
default: number;
|
|
47
|
+
};
|
|
48
|
+
max: {
|
|
49
|
+
type: NumberConstructor;
|
|
50
|
+
default: number;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
static actions: readonly ["dismiss", "onKeydown", "pause", "resume", "show"];
|
|
54
|
+
static events: readonly ["dismiss", "show"];
|
|
55
|
+
readonly listTarget: HTMLElement;
|
|
56
|
+
readonly templateTarget: HTMLTemplateElement;
|
|
57
|
+
readonly itemTargets: HTMLElement[];
|
|
58
|
+
readonly hasListTarget: boolean;
|
|
59
|
+
readonly hasTemplateTarget: boolean;
|
|
60
|
+
durationValue: number;
|
|
61
|
+
maxValue: number;
|
|
62
|
+
connect(): void;
|
|
63
|
+
disconnect(): void;
|
|
64
|
+
durationValueChanged(): void;
|
|
65
|
+
maxValueChanged(): void;
|
|
66
|
+
/**
|
|
67
|
+
* Stimulus lifecycle callback triggered automatically when a new item target
|
|
68
|
+
* enters the DOM. Perfectly handles dynamic client-side injections and server-side
|
|
69
|
+
* Turbo Stream appends alike.
|
|
70
|
+
*/
|
|
71
|
+
itemTargetConnected(element: HTMLElement): void;
|
|
72
|
+
/** Clears any active timer when a toast is removed from the DOM. */
|
|
73
|
+
itemTargetDisconnected(element: HTMLElement): void;
|
|
74
|
+
/**
|
|
75
|
+
* Shows a new toast. Accepts its content from either a Stimulus action param
|
|
76
|
+
* (attribute-only trigger) or a programmatic `show` CustomEvent `detail`
|
|
77
|
+
* (remote / Turbo trigger); the action param wins when both are present.
|
|
78
|
+
*
|
|
79
|
+
* <button data-action="click->stimeo--toast#show"
|
|
80
|
+
* data-stimeo--toast-body-param="Saved"
|
|
81
|
+
* data-stimeo--toast-type-param="status">Show</button>
|
|
82
|
+
*
|
|
83
|
+
* element.dispatchEvent(new CustomEvent("show", { detail: { body, type } }))
|
|
84
|
+
*
|
|
85
|
+
* Clones the template slot, interpolates the body text, and appends to the list.
|
|
86
|
+
*/
|
|
87
|
+
show(event: Event): void;
|
|
88
|
+
/** Dismisses the toast that contained the trigger. */
|
|
89
|
+
dismiss(event: Event): void;
|
|
90
|
+
/** Dismisses the focused toast when Escape is pressed. */
|
|
91
|
+
onKeydown(event: KeyboardEvent): void;
|
|
92
|
+
/**
|
|
93
|
+
* Pauses the auto-dismiss timer on mouse entry or keyboard focus.
|
|
94
|
+
*
|
|
95
|
+
* Hover and focus are tracked as independent reasons: the timer is only
|
|
96
|
+
* snapshotted on the first active reason, and only resumed once *every*
|
|
97
|
+
* reason has been released (see {@link resume}). This keeps a toast paused
|
|
98
|
+
* while it is still hovered *or* focused, per WCAG 2.2.1.
|
|
99
|
+
*/
|
|
100
|
+
pause(event: Event): void;
|
|
101
|
+
/** Resumes the auto-dismiss timer once both hover and focus have been released. */
|
|
102
|
+
resume(event: Event): void;
|
|
103
|
+
/**
|
|
104
|
+
* Removes the oldest toasts when the list exceeds `maxValue`.
|
|
105
|
+
*
|
|
106
|
+
* Public (not `#private`) as a deterministic test seam: enforcement normally
|
|
107
|
+
* runs from `itemTargetConnected`, a Stimulus callback that fires via a
|
|
108
|
+
* MutationObserver — which happy-dom does not reliably deliver — so the unit
|
|
109
|
+
* tests invoke it directly. It is therefore listed in the contract guard's
|
|
110
|
+
* NON_ACTION_ALLOWLIST (it is not a user-wired action).
|
|
111
|
+
*/
|
|
112
|
+
enforceMaxLimit(): void;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Converts the first value of a CSS `transition-duration` string to milliseconds.
|
|
116
|
+
* Browsers normalize computed `<time>` values to seconds (e.g. `0.2s`), but parse
|
|
117
|
+
* `ms` defensively so a consumer's `ms` transition is not delayed 1000x.
|
|
118
|
+
*
|
|
119
|
+
* Pure (no `this`); exported so it can be unit-tested directly.
|
|
120
|
+
*/
|
|
121
|
+
declare function cssTimeToMs(value: string): number;
|
|
122
|
+
|
|
123
|
+
export { ToastController, cssTimeToMs };
|