stimeo-ui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +97 -10
- package/dist/controllers/breadcrumb_controller.js +225 -13
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +11 -1
- package/dist/controllers/calendar_controller.js +89 -22
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +115 -0
- package/dist/controllers/carousel_controller.js +313 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +87 -0
- package/dist/controllers/color_picker_controller.js +252 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +14 -1
- package/dist/controllers/combobox_controller.js +162 -23
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +2 -0
- package/dist/controllers/command_palette_controller.js +194 -17
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +7 -6
- package/dist/controllers/context_menu_controller.js +32 -10
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +89 -0
- package/dist/controllers/data_grid_controller.js +246 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +88 -0
- package/dist/controllers/date_range_picker_controller.js +441 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +169 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +1 -1
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +1 -1
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +1 -1
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +67 -19
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +54 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +9 -2
- package/dist/controllers/listbox_controller.js +257 -53
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.js +2 -2
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +29 -11
- package/dist/controllers/menu_controller.js +104 -17
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +142 -0
- package/dist/controllers/menubar_controller.js +785 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +141 -0
- package/dist/controllers/multi_select_controller.js +755 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +146 -0
- package/dist/controllers/navigation_menu_controller.js +511 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +4 -5
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +1 -1
- package/dist/controllers/number_input_controller.js +7 -0
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +18 -1
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +15 -6
- package/dist/controllers/overflow_indicator_controller.js +246 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +78 -19
- package/dist/controllers/overflow_menu_controller.js +381 -57
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +36 -5
- package/dist/controllers/pagination_controller.js +163 -32
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/persist_controller.js +6 -6
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +2 -3
- package/dist/controllers/pointer_drag_controller.js +9 -1
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +1 -2
- package/dist/controllers/popover_controller.js +2 -2
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +4 -4
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.js +22 -3
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +97 -0
- package/dist/controllers/range_slider_controller.js +192 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/rating_controller.js +16 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +238 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +3 -1
- package/dist/controllers/resizable_controller.js +65 -1
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +17 -2
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +101 -14
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +40 -6
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +64 -15
- package/dist/controllers/scrollspy_controller.js +369 -74
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +69 -0
- package/dist/controllers/separator_controller.js +96 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.js +1 -1
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +11 -1
- package/dist/controllers/slider_controller.js +32 -6
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +3 -1
- package/dist/controllers/sortable_controller.js +34 -3
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.js +1 -1
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
- package/dist/controllers/stick_to_bottom_controller.js +9 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +109 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +2 -2
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.js +1 -0
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +13 -4
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +295 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +42 -13
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +231 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +5 -14
- package/dist/controllers/toast_controller.js +40 -14
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js +23 -2
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.d.ts +53 -13
- package/dist/controllers/toolbar_controller.js +230 -31
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +1 -1
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +135 -0
- package/dist/controllers/tree_view_controller.js +691 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1818
- package/dist/index.js +4256 -915
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +375 -39
- package/dist/inspector/cli.js +228 -14
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +419 -48
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +26 -26
- package/dist/inspector/manifest.json +1054 -127
- package/package.json +5 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/accordion_controller.ts"],"names":[],"mappings":"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","file":"accordion_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible accordion behavior.\n *\n * Markup contract (identifier: `stimeo--accordion`):\n * <div data-controller=\"stimeo--accordion\">\n * <h3>\n * <button data-stimeo--accordion-target=\"trigger\"\n * data-action=\"stimeo--accordion#toggle\n * keydown->stimeo--accordion#onKeydown\"\n * aria-expanded=\"false\" aria-controls=\"panel-1\">Section 1</button>\n * </h3>\n * <div id=\"panel-1\" data-stimeo--accordion-target=\"panel\"\n * role=\"region\" hidden>…</div>\n * <!-- repeat header/panel pairs -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Accordion** pattern. Each header button is\n * associated with its panel through `aria-controls`; the panel's `id` is the\n * single source of truth for the pairing, so headers and panels need not be\n * adjacent siblings.\n *\n * @remarks\n * Multiple panels may be open at once (this is the APG-allowed default). State\n * is reflected through `aria-expanded` on the header and the `hidden` attribute\n * on the panel — never through visual styling, which the consumer owns.\n *\n * Behavior provided:\n * - Click a header to toggle its panel (`aria-expanded` + `hidden` reflect state).\n * - `ArrowDown`/`ArrowUp` move focus between headers; `Home`/`End` jump to the\n * first/last header. Hidden headers (including those inside a hidden subtree,\n * e.g. a section a filter collapsed) are skipped so focus stays perceivable.\n * - {@link expandAll} / {@link collapseAll} open or close every panel at once,\n * for an optional \"expand all / collapse all\" control pair anywhere in scope.\n */\nexport class AccordionController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static actions = [\"collapseAll\", \"expandAll\", \"onKeydown\", \"toggle\"] as const;\n\n declare readonly triggerTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Toggles the panel controlled by the activated header. */\n toggle(event: Event): void {\n const trigger = event.currentTarget as HTMLButtonElement;\n const panel = this.#panelFor(trigger);\n if (!panel) return;\n\n this.#setExpanded(trigger, panel, trigger.getAttribute(\"aria-expanded\") !== \"true\");\n }\n\n /** Opens every panel. Bound via `data-action` on an \"expand all\" control. */\n expandAll(): void {\n this.#setAll(true);\n }\n\n /** Closes every panel. Bound via `data-action` on a \"collapse all\" control. */\n collapseAll(): void {\n this.#setAll(false);\n }\n\n /** Drives every header/panel pair to the same expanded state. */\n #setAll(open: boolean): void {\n for (const trigger of this.triggerTargets) {\n const panel = this.#panelFor(trigger);\n if (panel) this.#setExpanded(trigger, panel, open);\n }\n }\n\n /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */\n #setExpanded(trigger: HTMLButtonElement, panel: HTMLElement, open: boolean): void {\n trigger.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n panel.hidden = !open;\n }\n\n /**\n * Moves focus between headers per the APG keyboard model, skipping any header\n * that is hidden or nested in a hidden subtree. A consumer may hide whole\n * sections (e.g. an `stimeo--filter` that collapses empty groups), and an\n * unperceivable header must never become an arrow-key target — otherwise\n * `.focus()` lands on nothing and navigation appears to stall.\n */\n onKeydown(event: KeyboardEvent): void {\n const current = event.currentTarget as HTMLButtonElement;\n if (this.triggerTargets.indexOf(current) === -1) return;\n\n // `closest(\"[hidden]\")` catches both a directly-hidden header and one inside a\n // hidden ancestor (the filter-group case); navigate over the visible set only.\n const navigable = this.triggerTargets.filter((trigger) => trigger.closest(\"[hidden]\") === null);\n const here = navigable.indexOf(current);\n if (here === -1) return;\n\n let next: HTMLButtonElement | undefined;\n switch (event.key) {\n case \"ArrowDown\":\n next = navigable[(here + 1) % navigable.length];\n break;\n case \"ArrowUp\":\n next = navigable[(here - 1 + navigable.length) % navigable.length];\n break;\n case \"Home\":\n next = navigable[0];\n break;\n case \"End\":\n next = navigable[navigable.length - 1];\n break;\n default:\n return;\n }\n\n event.preventDefault();\n next?.focus();\n }\n\n /** Resolves the panel a header controls via its `aria-controls` reference. */\n #panelFor(trigger: HTMLButtonElement): HTMLElement | null {\n const id = trigger.getAttribute(\"aria-controls\");\n return id ? (this.panelTargets.find((panel) => panel.id === id) ?? null) : null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/controllers/accordion_controller.ts"],"names":[],"mappings":"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","file":"accordion_controller.js","sourcesContent":["import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\n\n/**\n * Headless, accessible accordion behavior.\n *\n * Markup contract (identifier: `stimeo--accordion`):\n * <div data-controller=\"stimeo--accordion\">\n * <h3>\n * <button id=\"trigger-1\"\n * data-stimeo--accordion-target=\"trigger\"\n * data-action=\"stimeo--accordion#toggle\n * keydown->stimeo--accordion#onKeydown\"\n * aria-expanded=\"false\" aria-controls=\"panel-1\">Section 1</button>\n * </h3>\n * <div id=\"panel-1\" data-stimeo--accordion-target=\"panel\"\n * role=\"region\" aria-labelledby=\"trigger-1\" hidden>…</div>\n * <!-- repeat header/panel pairs -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Accordion** pattern. Each header button is\n * associated with its panel through `aria-controls`; the panel's `id` is the\n * single source of truth for the pairing, so headers and panels need not be\n * adjacent siblings.\n *\n * @remarks\n * Multiple panels may be open at once (this is the APG-allowed default). State\n * is reflected through `aria-expanded` on the header and the `hidden` attribute\n * on the panel — never through visual styling, which the consumer owns.\n *\n * Behavior provided:\n * - Click a header to toggle its panel (`aria-expanded` + `hidden` reflect state).\n * - `ArrowDown`/`ArrowUp` move focus between headers; `Home`/`End` jump to the\n * first/last header. Hidden headers (including those inside a hidden subtree,\n * e.g. a section a filter collapsed) are skipped so focus stays perceivable.\n * - {@link expandAll} / {@link collapseAll} open or close every panel at once,\n * for an optional \"expand all / collapse all\" control pair anywhere in scope.\n */\nexport class AccordionController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static actions = [\"collapseAll\", \"expandAll\", \"onKeydown\", \"toggle\"] as const;\n\n declare readonly triggerTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Toggles the panel controlled by the activated header. */\n toggle(event: Event): void {\n const trigger = event.currentTarget as HTMLButtonElement;\n const panel = this.#panelFor(trigger);\n if (!panel) return;\n\n this.#setExpanded(trigger, panel, trigger.getAttribute(\"aria-expanded\") !== \"true\");\n }\n\n /** Opens every panel. Bound via `data-action` on an \"expand all\" control. */\n expandAll(): void {\n this.#setAll(true);\n }\n\n /** Closes every panel. Bound via `data-action` on a \"collapse all\" control. */\n collapseAll(): void {\n this.#setAll(false);\n }\n\n /** Drives every header/panel pair to the same expanded state. */\n #setAll(open: boolean): void {\n for (const trigger of this.triggerTargets) {\n const panel = this.#panelFor(trigger);\n if (panel) this.#setExpanded(trigger, panel, open);\n }\n }\n\n /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */\n #setExpanded(trigger: HTMLButtonElement, panel: HTMLElement, open: boolean): void {\n trigger.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n panel.hidden = !open;\n }\n\n /**\n * Moves focus between headers per the APG keyboard model, skipping any header\n * that is hidden or nested in a hidden subtree. A consumer may hide whole\n * sections (e.g. an `stimeo--filter` that collapses empty groups), and an\n * unperceivable header must never become an arrow-key target — otherwise\n * `.focus()` lands on nothing and navigation appears to stall.\n */\n onKeydown(event: KeyboardEvent): void {\n // A descendant widget that already claimed the key (a grabbed drag handle, a\n // nested menu) must not ALSO act on it — composition depends on this yield.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n const current = event.currentTarget as HTMLButtonElement;\n if (this.triggerTargets.indexOf(current) === -1) return;\n\n // `closest(\"[hidden]\")` catches both a directly-hidden header and one inside a\n // hidden ancestor (the filter-group case); navigate over the visible set only.\n const navigable = this.triggerTargets.filter((trigger) => trigger.closest(\"[hidden]\") === null);\n const here = navigable.indexOf(current);\n if (here === -1) return;\n\n let next: HTMLButtonElement | undefined;\n switch (event.key) {\n case \"ArrowDown\":\n next = navigable[(here + 1) % navigable.length];\n break;\n case \"ArrowUp\":\n next = navigable[(here - 1 + navigable.length) % navigable.length];\n break;\n case \"Home\":\n next = navigable[0];\n break;\n case \"End\":\n next = navigable[navigable.length - 1];\n break;\n default:\n return;\n }\n\n event.preventDefault();\n next?.focus();\n }\n\n /** Resolves the panel a header controls via its `aria-controls` reference. */\n #panelFor(trigger: HTMLButtonElement): HTMLElement | null {\n const id = trigger.getAttribute(\"aria-controls\");\n return id ? (this.panelTargets.find((panel) => panel.id === id) ?? null) : null;\n }\n}\n"]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible **alert dialog** behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--alert-dialog`):
|
|
7
|
+
* <div data-controller="stimeo--alert-dialog">
|
|
8
|
+
* <button data-stimeo--alert-dialog-target="trigger"
|
|
9
|
+
* data-action="click->stimeo--alert-dialog#open">Delete…</button>
|
|
10
|
+
* <div data-stimeo--alert-dialog-target="dialog" role="alertdialog"
|
|
11
|
+
* aria-modal="true" aria-labelledby="t" aria-describedby="d" hidden>
|
|
12
|
+
* <h2 id="t">…</h2><p id="d">…</p>
|
|
13
|
+
* <button data-stimeo--alert-dialog-target="initialFocus"
|
|
14
|
+
* data-action="click->stimeo--alert-dialog#cancel">Cancel</button>
|
|
15
|
+
* <button data-action="click->stimeo--alert-dialog#confirm">Delete</button>
|
|
16
|
+
* </div>
|
|
17
|
+
* </div>
|
|
18
|
+
*
|
|
19
|
+
* Implements the WAI-ARIA APG **Alert Dialog** pattern. It is the same modal as
|
|
20
|
+
* `stimeo--dialog` with two deliberate differences that suit an *interrupting
|
|
21
|
+
* confirmation*: it never closes on a backdrop click (an accidental dismissal
|
|
22
|
+
* would be dangerous), and it exposes explicit {@link confirm}/{@link cancel}
|
|
23
|
+
* actions that emit events so the consumer only writes the message and "what to
|
|
24
|
+
* do on confirm".
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Behavior only. The modal lifecycle (focus trap, scroll lock, background
|
|
28
|
+
* `inert`, focus restore, teardown reversal) is delegated to the shared
|
|
29
|
+
* {@link FocusTrap}. The only closing affordances are the confirm/cancel actions
|
|
30
|
+
* and `Escape` (which cancels) — there is intentionally no backdrop close.
|
|
31
|
+
*
|
|
32
|
+
* Behavior provided:
|
|
33
|
+
* - {@link open} shows the dialog and moves focus to the `initialFocus` target
|
|
34
|
+
* (the least destructive action, by convention), else the first focusable
|
|
35
|
+
* element.
|
|
36
|
+
* - `Tab`/`Shift+Tab` cycle focus within the dialog (focus trap).
|
|
37
|
+
* - {@link confirm} closes and dispatches `stimeo--alert-dialog:confirm`.
|
|
38
|
+
* - {@link cancel} and `Escape` close and dispatch `stimeo--alert-dialog:cancel`
|
|
39
|
+
* with a `reason` of `"user"` / `"escape"`. Focus returns to the opener.
|
|
40
|
+
*/
|
|
41
|
+
declare class AlertDialogController extends Controller<HTMLElement> {
|
|
42
|
+
#private;
|
|
43
|
+
static targets: string[];
|
|
44
|
+
static actions: readonly ["cancel", "confirm", "open"];
|
|
45
|
+
static events: readonly ["cancel", "confirm"];
|
|
46
|
+
readonly triggerTarget: HTMLElement;
|
|
47
|
+
readonly dialogTarget: HTMLElement;
|
|
48
|
+
readonly initialFocusTarget: HTMLElement;
|
|
49
|
+
readonly hasTriggerTarget: boolean;
|
|
50
|
+
readonly hasDialogTarget: boolean;
|
|
51
|
+
readonly hasInitialFocusTarget: boolean;
|
|
52
|
+
/** Starts closed (idempotently reflects the closed state on the markup). */
|
|
53
|
+
connect(): void;
|
|
54
|
+
/** Reverts the modal side effects if torn down while open (Turbo navigation). */
|
|
55
|
+
disconnect(): void;
|
|
56
|
+
/** Opens the dialog, traps focus, and locks background scroll. */
|
|
57
|
+
open(): void;
|
|
58
|
+
/** Confirms: closes and dispatches `confirm`. Bound via `data-action`. */
|
|
59
|
+
confirm(): void;
|
|
60
|
+
/** Cancels (user action): closes and dispatches `cancel` with `reason: "user"`. */
|
|
61
|
+
cancel(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { AlertDialogController };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/alert_dialog_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
var EscapeLayer = class _EscapeLayer {
|
|
7
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
#ownerDocument = null;
|
|
9
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
10
|
+
#onDismiss = null;
|
|
11
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
12
|
+
#claims = null;
|
|
13
|
+
/**
|
|
14
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
15
|
+
* the document's shared resolver listener if this is its first layer.
|
|
16
|
+
* Re-activating an already-active layer moves it to the top.
|
|
17
|
+
*/
|
|
18
|
+
activate(ownerDocument = document, options) {
|
|
19
|
+
this.deactivate();
|
|
20
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
21
|
+
if (!registry) {
|
|
22
|
+
registry = _EscapeLayer.#createRegistry();
|
|
23
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
24
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
25
|
+
}
|
|
26
|
+
registry.stack.push(this);
|
|
27
|
+
this.#ownerDocument = ownerDocument;
|
|
28
|
+
this.#onDismiss = options.onDismiss;
|
|
29
|
+
this.#claims = options.claims ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
33
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
34
|
+
*/
|
|
35
|
+
deactivate() {
|
|
36
|
+
const ownerDocument = this.#ownerDocument;
|
|
37
|
+
if (!ownerDocument) return;
|
|
38
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
39
|
+
if (registry) {
|
|
40
|
+
const index = registry.stack.lastIndexOf(this);
|
|
41
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
42
|
+
if (registry.stack.length === 0) {
|
|
43
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
44
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.#ownerDocument = null;
|
|
48
|
+
this.#onDismiss = null;
|
|
49
|
+
this.#claims = null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
53
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
54
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
55
|
+
*/
|
|
56
|
+
get ownsEscape() {
|
|
57
|
+
const ownerDocument = this.#ownerDocument;
|
|
58
|
+
if (!ownerDocument) return false;
|
|
59
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
60
|
+
if (!registry) return false;
|
|
61
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
62
|
+
}
|
|
63
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
64
|
+
static #createRegistry() {
|
|
65
|
+
const registry = {
|
|
66
|
+
stack: [],
|
|
67
|
+
onKeydown: (event) => {
|
|
68
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
69
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
70
|
+
if (!owner) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
owner.#onDismiss?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
78
|
+
static #resolveOwner(stack) {
|
|
79
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
80
|
+
const layer = stack[index];
|
|
81
|
+
if (!layer) continue;
|
|
82
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
83
|
+
return layer;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/utils/focus_trap.ts
|
|
90
|
+
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
91
|
+
var FocusTrap = class {
|
|
92
|
+
/** The element focused before activation, restored on deactivation. */
|
|
93
|
+
#previouslyFocused = null;
|
|
94
|
+
/** The body's inline `overflow` before locking, restored on deactivation. */
|
|
95
|
+
#previousBodyOverflow = "";
|
|
96
|
+
/** Whether scroll was locked this activation (so it is only restored if applied). */
|
|
97
|
+
#scrollLocked = false;
|
|
98
|
+
/** Background siblings made `inert` while active, restored on deactivation. */
|
|
99
|
+
#inertedSiblings = [];
|
|
100
|
+
/** Whether the modal side effects are currently applied. */
|
|
101
|
+
#activeState = false;
|
|
102
|
+
/** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
|
|
103
|
+
#escapeLayer = new EscapeLayer();
|
|
104
|
+
/** Returns the trapped element; called on every operation for the live target. */
|
|
105
|
+
#getContainer;
|
|
106
|
+
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
107
|
+
#options;
|
|
108
|
+
/**
|
|
109
|
+
* @param getContainer - Returns the trapped element. Called on every operation
|
|
110
|
+
* so the live target is always used.
|
|
111
|
+
* @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
|
|
112
|
+
*/
|
|
113
|
+
constructor(getContainer, options = {}) {
|
|
114
|
+
this.#getContainer = getContainer;
|
|
115
|
+
this.#options = options;
|
|
116
|
+
}
|
|
117
|
+
/** Whether the trap is currently active. */
|
|
118
|
+
get active() {
|
|
119
|
+
return this.#activeState;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Applies the trap: records the current focus, optionally locks background scroll
|
|
123
|
+
* and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
|
|
124
|
+
* `autoFocus` is off) moves focus inside. No-ops if already active.
|
|
125
|
+
*/
|
|
126
|
+
activate() {
|
|
127
|
+
if (this.#activeState) return;
|
|
128
|
+
this.#activeState = true;
|
|
129
|
+
const active = document.activeElement;
|
|
130
|
+
this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
131
|
+
if (this.#flag(this.#options.lockScroll, true)) {
|
|
132
|
+
this.#previousBodyOverflow = document.body.style.overflow;
|
|
133
|
+
document.body.style.overflow = "hidden";
|
|
134
|
+
this.#scrollLocked = true;
|
|
135
|
+
}
|
|
136
|
+
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
|
+
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
+
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
139
|
+
const onEscape = this.#options.onEscape;
|
|
140
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
|
+
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Reverts every side effect applied by {@link activate}. No-ops if inactive, so
|
|
145
|
+
* a controller can call it defensively from both `close()` and `disconnect()`.
|
|
146
|
+
*
|
|
147
|
+
* @param restoreFocus - Move focus back to the opener (default `true`). Pass
|
|
148
|
+
* `false` on teardown (`disconnect`), where yanking focus is undesirable.
|
|
149
|
+
*/
|
|
150
|
+
deactivate({ restoreFocus = true } = {}) {
|
|
151
|
+
if (!this.#activeState) return;
|
|
152
|
+
this.#activeState = false;
|
|
153
|
+
this.#escapeLayer.deactivate();
|
|
154
|
+
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
+
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
156
|
+
if (this.#scrollLocked) {
|
|
157
|
+
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
|
+
this.#scrollLocked = false;
|
|
159
|
+
}
|
|
160
|
+
this.#releaseBackground();
|
|
161
|
+
if (restoreFocus) {
|
|
162
|
+
const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
|
|
163
|
+
target?.focus();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Resolves a boolean-or-getter option, defaulting when it was not provided. */
|
|
167
|
+
#flag(option, fallback) {
|
|
168
|
+
if (option === void 0) return fallback;
|
|
169
|
+
return typeof option === "function" ? option() : option;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reverts the side effects just before Turbo caches the page snapshot, so an
|
|
173
|
+
* overlay left open does not bake the scroll lock into `body[style]` — a
|
|
174
|
+
* restored page would feed that locked value back into {@link activate} as the
|
|
175
|
+
* baseline, and closing would then never unlock the page. Markup state stays
|
|
176
|
+
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
|
+
* is left alone mid-navigation. The listener lives only while active.
|
|
178
|
+
*/
|
|
179
|
+
#onBeforeCache = () => {
|
|
180
|
+
this.deactivate({ restoreFocus: false });
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
|
+
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
185
|
+
* which layer currently owns Escape.
|
|
186
|
+
*/
|
|
187
|
+
#onKeydown = (event) => {
|
|
188
|
+
if (event.key === "Tab") this.#trapTab(event);
|
|
189
|
+
};
|
|
190
|
+
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
191
|
+
#trapTab(event) {
|
|
192
|
+
const focusable = this.#focusableElements();
|
|
193
|
+
if (focusable.length === 0) {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const first = focusable[0];
|
|
198
|
+
const last = focusable[focusable.length - 1];
|
|
199
|
+
const active = document.activeElement;
|
|
200
|
+
if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
first?.focus();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (event.shiftKey && active === first) {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
last?.focus();
|
|
208
|
+
} else if (!event.shiftKey && active === last) {
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
first?.focus();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Marks every element outside the container's subtree as `inert` so background
|
|
215
|
+
* content cannot be focused or reached by assistive technology, honoring the
|
|
216
|
+
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
217
|
+
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
218
|
+
*/
|
|
219
|
+
#isolateBackground() {
|
|
220
|
+
const container = this.#getContainer();
|
|
221
|
+
this.#inertedSiblings = [];
|
|
222
|
+
for (const sibling of Array.from(document.body.children)) {
|
|
223
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
224
|
+
if (sibling.contains(container) || sibling.inert) continue;
|
|
225
|
+
sibling.inert = true;
|
|
226
|
+
this.#inertedSiblings.push(sibling);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
230
|
+
#releaseBackground() {
|
|
231
|
+
for (const sibling of this.#inertedSiblings) {
|
|
232
|
+
sibling.inert = false;
|
|
233
|
+
}
|
|
234
|
+
this.#inertedSiblings = [];
|
|
235
|
+
}
|
|
236
|
+
/** Moves focus to the initial target, the first focusable, or the container. */
|
|
237
|
+
#focusInitial() {
|
|
238
|
+
const preferred = this.#options.initialFocus?.();
|
|
239
|
+
if (preferred) {
|
|
240
|
+
preferred.focus();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const focusable = this.#focusableElements();
|
|
244
|
+
if (focusable[0]) {
|
|
245
|
+
focusable[0].focus();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const container = this.#getContainer();
|
|
249
|
+
container.tabIndex = -1;
|
|
250
|
+
container.focus();
|
|
251
|
+
}
|
|
252
|
+
/** Collects the container's currently focusable descendants in DOM order. */
|
|
253
|
+
#focusableElements() {
|
|
254
|
+
return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
|
|
255
|
+
(el) => !el.hidden
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// src/controllers/alert_dialog_controller.ts
|
|
261
|
+
var AlertDialogController = class extends Controller {
|
|
262
|
+
static targets = ["trigger", "dialog", "initialFocus"];
|
|
263
|
+
static actions = ["cancel", "confirm", "open"];
|
|
264
|
+
static events = ["cancel", "confirm"];
|
|
265
|
+
/**
|
|
266
|
+
* Owns the modal side effects. Escape is routed through {@link cancel} so it
|
|
267
|
+
* emits the same event as the cancel button (tagged `"escape"`); focus falls
|
|
268
|
+
* back to the trigger when nothing was focused before opening.
|
|
269
|
+
*/
|
|
270
|
+
#trap = new FocusTrap(() => this.dialogTarget, {
|
|
271
|
+
onEscape: () => this.#requestCancel("escape"),
|
|
272
|
+
initialFocus: () => this.hasInitialFocusTarget ? this.initialFocusTarget : null,
|
|
273
|
+
fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
|
|
274
|
+
});
|
|
275
|
+
/** Starts closed (idempotently reflects the closed state on the markup). */
|
|
276
|
+
connect() {
|
|
277
|
+
if (this.hasDialogTarget) this.dialogTarget.hidden = true;
|
|
278
|
+
}
|
|
279
|
+
/** Reverts the modal side effects if torn down while open (Turbo navigation). */
|
|
280
|
+
disconnect() {
|
|
281
|
+
this.#trap.deactivate({ restoreFocus: false });
|
|
282
|
+
}
|
|
283
|
+
/** Opens the dialog, traps focus, and locks background scroll. */
|
|
284
|
+
open() {
|
|
285
|
+
if (!this.hasDialogTarget || this.#isOpen) return;
|
|
286
|
+
this.dialogTarget.hidden = false;
|
|
287
|
+
this.#trap.activate();
|
|
288
|
+
}
|
|
289
|
+
/** Confirms: closes and dispatches `confirm`. Bound via `data-action`. */
|
|
290
|
+
confirm() {
|
|
291
|
+
if (!this.#isOpen) return;
|
|
292
|
+
this.dispatch("confirm");
|
|
293
|
+
this.#closeDialog();
|
|
294
|
+
}
|
|
295
|
+
/** Cancels (user action): closes and dispatches `cancel` with `reason: "user"`. */
|
|
296
|
+
cancel() {
|
|
297
|
+
this.#requestCancel("user");
|
|
298
|
+
}
|
|
299
|
+
/** Shared cancel path used by both the cancel action and the Escape key. */
|
|
300
|
+
#requestCancel(reason) {
|
|
301
|
+
if (!this.#isOpen) return;
|
|
302
|
+
this.dispatch("cancel", { detail: { reason } });
|
|
303
|
+
this.#closeDialog();
|
|
304
|
+
}
|
|
305
|
+
/** Hides the dialog and reverts the modal side effects (restoring focus). */
|
|
306
|
+
#closeDialog() {
|
|
307
|
+
this.dialogTarget.hidden = true;
|
|
308
|
+
this.#trap.deactivate();
|
|
309
|
+
}
|
|
310
|
+
/** Whether the dialog is currently visible. */
|
|
311
|
+
get #isOpen() {
|
|
312
|
+
return this.hasDialogTarget && !this.dialogTarget.hidden;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
export { AlertDialogController };
|
|
317
|
+
//# sourceMappingURL=alert_dialog_controller.js.map
|
|
318
|
+
//# sourceMappingURL=alert_dialog_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/focus_trap.ts","../../src/controllers/alert_dialog_controller.ts"],"names":[],"mappings":"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","file":"alert_dialog_controller.js","sourcesContent":["/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest handler resolves the\n * press. A keydown that is part of an IME composition (`event.isComposing`)\n * cancels the composition, never a layer, and is ignored here for every layer\n * at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","import { EscapeLayer } from \"./escape_layer\";\n\n/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / confirm / drawer / command-palette / sidebar); the\n * non-modal focus scope (focus) reuses it with the modal side effects opted out.\n *\n * The WAI-ARIA APG modal pattern is more than \"cycle Tab inside a box\": a modal\n * also locks background scroll, makes the rest of the page `inert` (so assistive\n * technology and pointer/Tab cannot reach it, honoring `aria-modal=\"true\"`),\n * sends focus inside on open, and restores it to the opener on close — and every\n * one of those side effects must be reverted if the element is torn down while\n * open (a Turbo navigation mid-dialog) and kept out of the snapshot Turbo\n * caches (`turbo:before-cache`). {@link FocusTrap} owns that whole modal\n * lifecycle so each controller only decides *when* to open/close and *what*\n * \"close\" means.\n *\n * It is intentionally **policy-free about closing**. Escape semantics differ per\n * widget (a plain dialog just closes; an alert-dialog closes *as a cancel* with a\n * reason; a drawer runs an exit transition), so the trap merely forwards Escape\n * to an {@link FocusTrapOptions.onEscape | onEscape} callback and never decides on\n * its own what closing entails.\n *\n * @remarks\n * The container is read through a getter so a controller can hand over a Stimulus\n * target without worrying about when the trap instance is constructed relative to\n * `connect()`.\n */\n\n/**\n * Selector matching the elements considered focusable. Shared by the trap's Tab\n * cycling and by form-validation's invalid-focus delegation.\n */\nexport const FOCUSABLE =\n 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n/** Behavior hooks a controller supplies when constructing a {@link FocusTrap}. */\nexport interface FocusTrapOptions {\n /**\n * Called when `Escape` is pressed while the trap is active. When omitted,\n * `Escape` is left alone (the trap never joins the Escape stack). Dismissal\n * is resolved by the shared {@link EscapeLayer}: an Escape already consumed\n * by an inner handler is ignored, and among active layers the most recently\n * activated claiming one owns the press. The resolver consumes the event\n * before invoking the callback.\n */\n onEscape?: () => void;\n /**\n * Returns the element to focus when the trap activates. When it returns `null`\n * (or is omitted), the first focusable descendant is used, falling back to the\n * container itself (made programmatically focusable with `tabindex=-1`).\n */\n initialFocus?: () => HTMLElement | null;\n /**\n * Returns the element to focus on deactivation when nothing was focused before\n * the trap opened (e.g. the trigger). The element focused *before* opening\n * always takes precedence.\n */\n fallbackFocus?: () => HTMLElement | null;\n /**\n * Lock background scroll (`body` overflow) while active. Defaults to `true` for\n * the modal overlays; a lighter focus scope passes `false`. Read on `activate`.\n */\n lockScroll?: boolean | (() => boolean);\n /**\n * Make background siblings `inert` while active (the `aria-modal` isolation).\n * Defaults to `true` for the modal overlays; a soft focus scope can opt out so\n * the background stays reachable while `Tab` still cycles inside. Read on `activate`.\n */\n isolate?: boolean | (() => boolean);\n /**\n * Move focus inside on `activate`. Defaults to `true`; a focus scope that only\n * wants the `Tab` boundary (no focus move) passes `false`. Read on `activate`.\n */\n autoFocus?: boolean | (() => boolean);\n}\n\n/**\n * Owns the modal side effects (scroll lock, background `inert`, focus trap, focus\n * restore) for a single container, applied on {@link activate} and reverted on\n * {@link deactivate}.\n */\nexport class FocusTrap {\n /** The element focused before activation, restored on deactivation. */\n #previouslyFocused: HTMLElement | null = null;\n /** The body's inline `overflow` before locking, restored on deactivation. */\n #previousBodyOverflow = \"\";\n /** Whether scroll was locked this activation (so it is only restored if applied). */\n #scrollLocked = false;\n /** Background siblings made `inert` while active, restored on deactivation. */\n #inertedSiblings: HTMLElement[] = [];\n /** Whether the modal side effects are currently applied. */\n #activeState = false;\n /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Returns the trapped element; called on every operation for the live target. */\n readonly #getContainer: () => HTMLElement;\n /** Closing/focus hooks; see {@link FocusTrapOptions}. */\n readonly #options: FocusTrapOptions;\n\n /**\n * @param getContainer - Returns the trapped element. Called on every operation\n * so the live target is always used.\n * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.\n */\n constructor(getContainer: () => HTMLElement, options: FocusTrapOptions = {}) {\n this.#getContainer = getContainer;\n this.#options = options;\n }\n\n /** Whether the trap is currently active. */\n get active(): boolean {\n return this.#activeState;\n }\n\n /**\n * Applies the trap: records the current focus, optionally locks background scroll\n * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless\n * `autoFocus` is off) moves focus inside. No-ops if already active.\n */\n activate(): void {\n if (this.#activeState) return;\n this.#activeState = true;\n // Record the opener so it can be refocused on close. `<body>` (the default\n // active element when nothing is focused) is treated as \"nothing\", so the\n // fallback target — typically the trigger — wins in that case.\n const active = document.activeElement;\n this.#previouslyFocused =\n active instanceof HTMLElement && active !== document.body ? active : null;\n if (this.#flag(this.#options.lockScroll, true)) {\n this.#previousBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n this.#scrollLocked = true;\n }\n if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();\n document.addEventListener(\"keydown\", this.#onKeydown);\n document.addEventListener(\"turbo:before-cache\", this.#onBeforeCache);\n const onEscape = this.#options.onEscape;\n if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });\n if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();\n }\n\n /**\n * Reverts every side effect applied by {@link activate}. No-ops if inactive, so\n * a controller can call it defensively from both `close()` and `disconnect()`.\n *\n * @param restoreFocus - Move focus back to the opener (default `true`). Pass\n * `false` on teardown (`disconnect`), where yanking focus is undesirable.\n */\n deactivate({ restoreFocus = true }: { restoreFocus?: boolean } = {}): void {\n if (!this.#activeState) return;\n this.#activeState = false;\n this.#escapeLayer.deactivate();\n document.removeEventListener(\"keydown\", this.#onKeydown);\n document.removeEventListener(\"turbo:before-cache\", this.#onBeforeCache);\n if (this.#scrollLocked) {\n document.body.style.overflow = this.#previousBodyOverflow;\n this.#scrollLocked = false;\n }\n this.#releaseBackground();\n if (restoreFocus) {\n const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;\n target?.focus();\n }\n }\n\n /** Resolves a boolean-or-getter option, defaulting when it was not provided. */\n #flag(option: boolean | (() => boolean) | undefined, fallback: boolean): boolean {\n if (option === undefined) return fallback;\n return typeof option === \"function\" ? option() : option;\n }\n\n /**\n * Reverts the side effects just before Turbo caches the page snapshot, so an\n * overlay left open does not bake the scroll lock into `body[style]` — a\n * restored page would feed that locked value back into {@link activate} as the\n * baseline, and closing would then never unlock the page. Markup state stays\n * untouched (restore-open designs reopen against a clean baseline), and focus\n * is left alone mid-navigation. The listener lives only while active.\n */\n readonly #onBeforeCache = (): void => {\n this.deactivate({ restoreFocus: false });\n };\n\n /**\n * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the\n * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of\n * which layer currently owns Escape.\n */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Tab\") this.#trapTab(event);\n };\n\n /** Keeps `Tab` focus cycling within the container's focusable elements. */\n #trapTab(event: KeyboardEvent): void {\n const focusable = this.#focusableElements();\n if (focusable.length === 0) {\n event.preventDefault();\n return;\n }\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n const active = document.activeElement;\n\n // If focus has somehow escaped the container, pull it back to the first item.\n if (!(active instanceof Node) || !this.#getContainer().contains(active)) {\n event.preventDefault();\n first?.focus();\n return;\n }\n\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first?.focus();\n }\n }\n\n /**\n * Marks every element outside the container's subtree as `inert` so background\n * content cannot be focused or reached by assistive technology, honoring the\n * `aria-modal=\"true\"` contract. An element that was *already* `inert` is left\n * untracked so `#releaseBackground` does not wrongly clear it.\n */\n #isolateBackground(): void {\n const container = this.#getContainer();\n this.#inertedSiblings = [];\n for (const sibling of Array.from(document.body.children)) {\n if (!(sibling instanceof HTMLElement)) continue;\n if (sibling.contains(container) || sibling.inert) continue;\n sibling.inert = true;\n this.#inertedSiblings.push(sibling);\n }\n }\n\n /** Reverts the `inert` flags applied by `#isolateBackground`. */\n #releaseBackground(): void {\n for (const sibling of this.#inertedSiblings) {\n sibling.inert = false;\n }\n this.#inertedSiblings = [];\n }\n\n /** Moves focus to the initial target, the first focusable, or the container. */\n #focusInitial(): void {\n const preferred = this.#options.initialFocus?.();\n if (preferred) {\n preferred.focus();\n return;\n }\n const focusable = this.#focusableElements();\n if (focusable[0]) {\n focusable[0].focus();\n return;\n }\n const container = this.#getContainer();\n container.tabIndex = -1;\n container.focus();\n }\n\n /** Collects the container's currently focusable descendants in DOM order. */\n #focusableElements(): HTMLElement[] {\n return Array.from(this.#getContainer().querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n (el) => !el.hidden,\n );\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { FocusTrap } from \"../utils/focus_trap\";\n\n/** Reason carried by the `cancel` event: an explicit cancel vs. the Escape key. */\ntype CancelReason = \"user\" | \"escape\";\n\n/**\n * Headless, accessible **alert dialog** behavior.\n *\n * Markup contract (identifier: `stimeo--alert-dialog`):\n * <div data-controller=\"stimeo--alert-dialog\">\n * <button data-stimeo--alert-dialog-target=\"trigger\"\n * data-action=\"click->stimeo--alert-dialog#open\">Delete…</button>\n * <div data-stimeo--alert-dialog-target=\"dialog\" role=\"alertdialog\"\n * aria-modal=\"true\" aria-labelledby=\"t\" aria-describedby=\"d\" hidden>\n * <h2 id=\"t\">…</h2><p id=\"d\">…</p>\n * <button data-stimeo--alert-dialog-target=\"initialFocus\"\n * data-action=\"click->stimeo--alert-dialog#cancel\">Cancel</button>\n * <button data-action=\"click->stimeo--alert-dialog#confirm\">Delete</button>\n * </div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Alert Dialog** pattern. It is the same modal as\n * `stimeo--dialog` with two deliberate differences that suit an *interrupting\n * confirmation*: it never closes on a backdrop click (an accidental dismissal\n * would be dangerous), and it exposes explicit {@link confirm}/{@link cancel}\n * actions that emit events so the consumer only writes the message and \"what to\n * do on confirm\".\n *\n * @remarks\n * Behavior only. The modal lifecycle (focus trap, scroll lock, background\n * `inert`, focus restore, teardown reversal) is delegated to the shared\n * {@link FocusTrap}. The only closing affordances are the confirm/cancel actions\n * and `Escape` (which cancels) — there is intentionally no backdrop close.\n *\n * Behavior provided:\n * - {@link open} shows the dialog and moves focus to the `initialFocus` target\n * (the least destructive action, by convention), else the first focusable\n * element.\n * - `Tab`/`Shift+Tab` cycle focus within the dialog (focus trap).\n * - {@link confirm} closes and dispatches `stimeo--alert-dialog:confirm`.\n * - {@link cancel} and `Escape` close and dispatch `stimeo--alert-dialog:cancel`\n * with a `reason` of `\"user\"` / `\"escape\"`. Focus returns to the opener.\n */\nexport class AlertDialogController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"dialog\", \"initialFocus\"];\n static actions = [\"cancel\", \"confirm\", \"open\"] as const;\n static events = [\"cancel\", \"confirm\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly dialogTarget: HTMLElement;\n declare readonly initialFocusTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasDialogTarget: boolean;\n declare readonly hasInitialFocusTarget: boolean;\n\n /**\n * Owns the modal side effects. Escape is routed through {@link cancel} so it\n * emits the same event as the cancel button (tagged `\"escape\"`); focus falls\n * back to the trigger when nothing was focused before opening.\n */\n readonly #trap = new FocusTrap(() => this.dialogTarget, {\n onEscape: () => this.#requestCancel(\"escape\"),\n initialFocus: () => (this.hasInitialFocusTarget ? this.initialFocusTarget : null),\n fallbackFocus: () => (this.hasTriggerTarget ? this.triggerTarget : null),\n });\n\n /** Starts closed (idempotently reflects the closed state on the markup). */\n override connect(): void {\n if (this.hasDialogTarget) this.dialogTarget.hidden = true;\n }\n\n /** Reverts the modal side effects if torn down while open (Turbo navigation). */\n override disconnect(): void {\n this.#trap.deactivate({ restoreFocus: false });\n }\n\n /** Opens the dialog, traps focus, and locks background scroll. */\n open(): void {\n if (!this.hasDialogTarget || this.#isOpen) return;\n this.dialogTarget.hidden = false;\n this.#trap.activate();\n }\n\n /** Confirms: closes and dispatches `confirm`. Bound via `data-action`. */\n confirm(): void {\n if (!this.#isOpen) return;\n this.dispatch(\"confirm\");\n this.#closeDialog();\n }\n\n /** Cancels (user action): closes and dispatches `cancel` with `reason: \"user\"`. */\n cancel(): void {\n this.#requestCancel(\"user\");\n }\n\n /** Shared cancel path used by both the cancel action and the Escape key. */\n #requestCancel(reason: CancelReason): void {\n if (!this.#isOpen) return;\n this.dispatch(\"cancel\", { detail: { reason } });\n this.#closeDialog();\n }\n\n /** Hides the dialog and reverts the modal side effects (restoring focus). */\n #closeDialog(): void {\n this.dialogTarget.hidden = true;\n this.#trap.deactivate();\n }\n\n /** Whether the dialog is currently visible. */\n get #isOpen(): boolean {\n return this.hasDialogTarget && !this.dialogTarget.hidden;\n }\n}\n"]}
|
|
@@ -8,13 +8,13 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
8
8
|
* <ol data-stimeo--breadcrumb-target="list">
|
|
9
9
|
* <li><a href="/">Home</a></li>
|
|
10
10
|
* <li data-stimeo--breadcrumb-target="ellipsis" hidden>
|
|
11
|
-
* <button type="button" aria-expanded="false" aria-controls="bc-
|
|
11
|
+
* <button type="button" aria-expanded="false" aria-controls="bc-a bc-b"
|
|
12
12
|
* aria-label="Show full path"
|
|
13
13
|
* data-stimeo--breadcrumb-target="trigger"
|
|
14
|
-
* data-action="stimeo--breadcrumb#toggle">…</button>
|
|
14
|
+
* data-action="click->stimeo--breadcrumb#toggle">…</button>
|
|
15
15
|
* </li>
|
|
16
|
-
* <li id="bc-
|
|
17
|
-
* <li data-stimeo--breadcrumb-target="collapsible"><a href="/a/b">Sub B</a></li>
|
|
16
|
+
* <li id="bc-a" data-stimeo--breadcrumb-target="collapsible"><a href="/a">Section A</a></li>
|
|
17
|
+
* <li id="bc-b" data-stimeo--breadcrumb-target="collapsible"><a href="/a/b">Sub B</a></li>
|
|
18
18
|
* <li><a href="/a/b/c" aria-current="page">Item C</a></li>
|
|
19
19
|
* </ol>
|
|
20
20
|
* </nav>
|
|
@@ -31,16 +31,59 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
31
31
|
* placed between the leading and trailing items that must always stay visible.
|
|
32
32
|
*
|
|
33
33
|
* Behavior provided:
|
|
34
|
-
* - Detects overflow via {@link LayoutObserver} (element + viewport resize)
|
|
34
|
+
* - Detects overflow via {@link LayoutObserver} (element + viewport resize) and a
|
|
35
|
+
* `MutationObserver` on the list (content edits, Turbo Stream / morph item
|
|
36
|
+
* swaps); the public `update` action re-measures on demand.
|
|
35
37
|
* - While overflowing and not expanded, hides the `collapsible` items and shows
|
|
36
|
-
* the `ellipsis` item; when it fits, shows everything
|
|
38
|
+
* the `ellipsis` item; when it fits, shows everything, hides the ellipsis and
|
|
39
|
+
* resets the expanded state (so a later collapse starts collapsed).
|
|
37
40
|
* - The disclosure `trigger` toggles `aria-expanded` and the collapsed items,
|
|
38
|
-
* dispatching `stimeo--breadcrumb:toggle`.
|
|
41
|
+
* dispatching `stimeo--breadcrumb:toggle`. `toggle` is a no-op (no state
|
|
42
|
+
* change, no event) while the trail fits, so a hidden trigger can never be
|
|
43
|
+
* left reporting `aria-expanded="true"`.
|
|
44
|
+
*
|
|
45
|
+
* Layout requirements the behavior depends on:
|
|
46
|
+
* - **The list must not wrap** (`white-space: nowrap` / `flex-wrap: nowrap`). A
|
|
47
|
+
* wrapping list grows in height instead of scroll width, so
|
|
48
|
+
* `scrollWidth === clientWidth` always holds and overflow is never detected.
|
|
49
|
+
* - **The list width must follow its container** (no shrink-to-fit / intrinsic
|
|
50
|
+
* width). Overflow is re-measured synchronously inside the resize
|
|
51
|
+
* notification, so a list that widens to its content would never report
|
|
52
|
+
* overflow. There is deliberately no debounce: the pass is one write → one
|
|
53
|
+
* forced read → one write on a single element.
|
|
54
|
+
* - Consumers must not neutralize `[hidden]` (e.g. `display: inline-flex` on the
|
|
55
|
+
* items wins over the UA's `[hidden] { display: none }`); collapsing is
|
|
56
|
+
* expressed exclusively through the `hidden` attribute.
|
|
57
|
+
*
|
|
58
|
+
* Ownership of `hidden`: this controller owns the attribute on an element **for as
|
|
59
|
+
* long as that element is a `collapsible` target and the controller is connected**.
|
|
60
|
+
* Outside that window it does not write it:
|
|
61
|
+
* - lose the marker while connected → the element becomes an always-visible item
|
|
62
|
+
* and the controller clears the `hidden` it owned;
|
|
63
|
+
* - controller `disconnect()` → the collapsed state is left in the DOM on purpose,
|
|
64
|
+
* because that DOM is what Turbo caches and restores.
|
|
65
|
+
*
|
|
66
|
+
* A consumer that also wants to own `hidden` on the same element (for a reason of
|
|
67
|
+
* its own) is not supported: `#measureOverflow` clears it on every measurement to
|
|
68
|
+
* read the expanded width.
|
|
69
|
+
*
|
|
70
|
+
* Focus safety: hiding the element the user is standing on would drop focus to
|
|
71
|
+
* `<body>`. When a collapse would hide the focused item, focus moves to the
|
|
72
|
+
* disclosure `trigger` first; when the ellipsis would be hidden while it holds
|
|
73
|
+
* focus, the hide waits for `blur`.
|
|
74
|
+
*
|
|
75
|
+
* **Fail-safe when the disclosure is incomplete.** Collapsing needs all of
|
|
76
|
+
* `list`, one `collapsible`, `ellipsis`, and `trigger`; miss any and the trail
|
|
77
|
+
* degrades to a plain APG breadcrumb (every item visible, ellipsis hidden,
|
|
78
|
+
* `aria-expanded="false"`, `toggle` a silent no-op). Hiding the middle items
|
|
79
|
+
* with no control that can reveal them puts the path out of reach for good — a
|
|
80
|
+
* too-wide trail is the cheaper failure. Watched at runtime, so a swap that
|
|
81
|
+
* breaks the set releases the items and one that completes it starts collapsing.
|
|
39
82
|
*/
|
|
40
83
|
declare class BreadcrumbController extends Controller<HTMLElement> {
|
|
41
84
|
#private;
|
|
42
85
|
static targets: string[];
|
|
43
|
-
static actions: readonly ["toggle"];
|
|
86
|
+
static actions: readonly ["toggle", "update"];
|
|
44
87
|
static events: readonly ["toggle"];
|
|
45
88
|
readonly listTarget: HTMLElement;
|
|
46
89
|
readonly collapsibleTargets: HTMLElement[];
|
|
@@ -51,10 +94,54 @@ declare class BreadcrumbController extends Controller<HTMLElement> {
|
|
|
51
94
|
readonly hasTriggerTarget: boolean;
|
|
52
95
|
/** Starts observing for overflow and renders the initial state. */
|
|
53
96
|
connect(): void;
|
|
54
|
-
/** Releases the resize observation (Turbo navigation included). */
|
|
97
|
+
/** Releases the resize/mutation observation (Turbo navigation included). */
|
|
55
98
|
disconnect(): void;
|
|
56
|
-
|
|
99
|
+
listTargetConnected(): void;
|
|
100
|
+
listTargetDisconnected(): void;
|
|
101
|
+
collapsibleTargetConnected(): void;
|
|
102
|
+
/**
|
|
103
|
+
* Re-measures when the disclosure set gains or loses a member. The set is a
|
|
104
|
+
* precondition for collapsing at all, so it needs the same watching the list
|
|
105
|
+
* and items get: without these callbacks a swap that breaks or completes the
|
|
106
|
+
* set would only take effect at the next resize or list mutation, which may
|
|
107
|
+
* never come.
|
|
108
|
+
*/
|
|
109
|
+
ellipsisTargetConnected(): void;
|
|
110
|
+
ellipsisTargetDisconnected(): void;
|
|
111
|
+
triggerTargetConnected(): void;
|
|
112
|
+
triggerTargetDisconnected(): void;
|
|
113
|
+
/**
|
|
114
|
+
* Hands `hidden` back when an element stops being a `collapsible` target.
|
|
115
|
+
*
|
|
116
|
+
* The marker is the source of truth for what may be collapsed, so an element
|
|
117
|
+
* that loses it while the controller is live is an always-visible item again —
|
|
118
|
+
* and `#render` only walks the *current* targets, so nothing else would ever
|
|
119
|
+
* clear the `hidden` this controller put there.
|
|
120
|
+
*
|
|
121
|
+
* The `#connected` guard is load-bearing, not defensive. Stimulus fires this
|
|
122
|
+
* callback for **every** target during teardown as well (`Context.disconnect()`
|
|
123
|
+
* stops the target observer after `disconnect()` has run), and there the
|
|
124
|
+
* collapsed DOM must survive untouched so Turbo's cache restores the trail in
|
|
125
|
+
* the state the user left it (see the ownership note in {@link BreadcrumbController}).
|
|
126
|
+
*
|
|
127
|
+
* @param element - the element that just left the `collapsible` target set
|
|
128
|
+
*/
|
|
129
|
+
collapsibleTargetDisconnected(element: HTMLElement): void;
|
|
130
|
+
/**
|
|
131
|
+
* Expands or re-collapses the trail and dispatches `toggle`.
|
|
132
|
+
*
|
|
133
|
+
* No-op while the trail fits: there is nothing collapsed to reveal, and the
|
|
134
|
+
* (hidden) trigger would otherwise keep a stale `aria-expanded="true"` that
|
|
135
|
+
* surfaces pre-expanded the next time the trail overflows. No event is
|
|
136
|
+
* dispatched in that case.
|
|
137
|
+
*/
|
|
57
138
|
toggle(): void;
|
|
139
|
+
/**
|
|
140
|
+
* Re-measures overflow and re-renders. Wired to resizes and list mutations;
|
|
141
|
+
* exposed as an action so consumers can re-measure after a change the
|
|
142
|
+
* observers cannot see (e.g. a web font swap that only alters text width).
|
|
143
|
+
*/
|
|
144
|
+
update(): void;
|
|
58
145
|
}
|
|
59
146
|
|
|
60
147
|
export { BreadcrumbController };
|