stimeo-ui 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/controllers/accordion_controller.d.ts +3 -2
  3. package/dist/controllers/accordion_controller.js.map +1 -1
  4. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  5. package/dist/controllers/alert_dialog_controller.js +318 -0
  6. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  7. package/dist/controllers/carousel_controller.d.ts +100 -0
  8. package/dist/controllers/carousel_controller.js +272 -0
  9. package/dist/controllers/carousel_controller.js.map +1 -0
  10. package/dist/controllers/clipboard_controller.d.ts +72 -0
  11. package/dist/controllers/clipboard_controller.js +144 -0
  12. package/dist/controllers/clipboard_controller.js.map +1 -0
  13. package/dist/controllers/collapsible_controller.d.ts +70 -0
  14. package/dist/controllers/collapsible_controller.js +327 -0
  15. package/dist/controllers/collapsible_controller.js.map +1 -0
  16. package/dist/controllers/color_picker_controller.d.ts +76 -0
  17. package/dist/controllers/color_picker_controller.js +213 -0
  18. package/dist/controllers/color_picker_controller.js.map +1 -0
  19. package/dist/controllers/count_up_controller.js +8 -1
  20. package/dist/controllers/count_up_controller.js.map +1 -1
  21. package/dist/controllers/currency_input_controller.d.ts +72 -0
  22. package/dist/controllers/currency_input_controller.js +147 -0
  23. package/dist/controllers/currency_input_controller.js.map +1 -0
  24. package/dist/controllers/data_grid_controller.d.ts +71 -0
  25. package/dist/controllers/data_grid_controller.js +168 -0
  26. package/dist/controllers/data_grid_controller.js.map +1 -0
  27. package/dist/controllers/date_range_picker_controller.d.ts +87 -0
  28. package/dist/controllers/date_range_picker_controller.js +417 -0
  29. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  30. package/dist/controllers/dismissible_controller.d.ts +57 -0
  31. package/dist/controllers/dismissible_controller.js +117 -0
  32. package/dist/controllers/dismissible_controller.js.map +1 -0
  33. package/dist/controllers/drawer_controller.d.ts +96 -0
  34. package/dist/controllers/drawer_controller.js +630 -0
  35. package/dist/controllers/drawer_controller.js.map +1 -0
  36. package/dist/controllers/editable_controller.d.ts +73 -0
  37. package/dist/controllers/editable_controller.js +168 -0
  38. package/dist/controllers/editable_controller.js.map +1 -0
  39. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  40. package/dist/controllers/file_dropzone_controller.js +165 -0
  41. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  42. package/dist/controllers/filter_controller.d.ts +60 -0
  43. package/dist/controllers/filter_controller.js +86 -0
  44. package/dist/controllers/filter_controller.js.map +1 -0
  45. package/dist/controllers/flash_controller.js +36 -5
  46. package/dist/controllers/flash_controller.js.map +1 -1
  47. package/dist/controllers/highlight_controller.js +6 -4
  48. package/dist/controllers/highlight_controller.js.map +1 -1
  49. package/dist/controllers/intersection_controller.js +41 -18
  50. package/dist/controllers/intersection_controller.js.map +1 -1
  51. package/dist/controllers/lazy_frame_controller.js +33 -11
  52. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  53. package/dist/controllers/masonry_controller.d.ts +54 -0
  54. package/dist/controllers/masonry_controller.js +142 -0
  55. package/dist/controllers/masonry_controller.js.map +1 -0
  56. package/dist/controllers/menubar_controller.d.ts +62 -0
  57. package/dist/controllers/menubar_controller.js +433 -0
  58. package/dist/controllers/menubar_controller.js.map +1 -0
  59. package/dist/controllers/multi_select_controller.d.ts +115 -0
  60. package/dist/controllers/multi_select_controller.js +472 -0
  61. package/dist/controllers/multi_select_controller.js.map +1 -0
  62. package/dist/controllers/navigation_menu_controller.d.ts +99 -0
  63. package/dist/controllers/navigation_menu_controller.js +384 -0
  64. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  65. package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
  66. package/dist/controllers/overflow_indicator_controller.js +178 -27
  67. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  68. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  69. package/dist/controllers/password_reveal_controller.js +117 -0
  70. package/dist/controllers/password_reveal_controller.js.map +1 -0
  71. package/dist/controllers/range_slider_controller.d.ts +87 -0
  72. package/dist/controllers/range_slider_controller.js +166 -0
  73. package/dist/controllers/range_slider_controller.js.map +1 -0
  74. package/dist/controllers/read_more_controller.d.ts +52 -0
  75. package/dist/controllers/read_more_controller.js +194 -0
  76. package/dist/controllers/read_more_controller.js.map +1 -0
  77. package/dist/controllers/scroll_area_controller.js +15 -2
  78. package/dist/controllers/scroll_area_controller.js.map +1 -1
  79. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  80. package/dist/controllers/scroll_restore_controller.js +93 -0
  81. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  82. package/dist/controllers/scroll_visibility_controller.js +8 -4
  83. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  84. package/dist/controllers/scrollspy_controller.js +33 -11
  85. package/dist/controllers/scrollspy_controller.js.map +1 -1
  86. package/dist/controllers/separator_controller.d.ts +54 -0
  87. package/dist/controllers/separator_controller.js +87 -0
  88. package/dist/controllers/separator_controller.js.map +1 -0
  89. package/dist/controllers/sidebar_controller.d.ts +107 -0
  90. package/dist/controllers/sidebar_controller.js +761 -0
  91. package/dist/controllers/sidebar_controller.js.map +1 -0
  92. package/dist/controllers/stepper_controller.d.ts +4 -0
  93. package/dist/controllers/stepper_controller.js +28 -12
  94. package/dist/controllers/stepper_controller.js.map +1 -1
  95. package/dist/controllers/stick_to_bottom_controller.js +8 -4
  96. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  97. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  98. package/dist/controllers/sticky_observer_controller.js +88 -20
  99. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  100. package/dist/controllers/tabs_controller.d.ts +3 -2
  101. package/dist/controllers/tabs_controller.js.map +1 -1
  102. package/dist/controllers/tags_input_controller.d.ts +91 -0
  103. package/dist/controllers/tags_input_controller.js +275 -0
  104. package/dist/controllers/tags_input_controller.js.map +1 -0
  105. package/dist/controllers/theme_controller.js +20 -10
  106. package/dist/controllers/theme_controller.js.map +1 -1
  107. package/dist/controllers/time_picker_controller.d.ts +67 -0
  108. package/dist/controllers/time_picker_controller.js +212 -0
  109. package/dist/controllers/time_picker_controller.js.map +1 -0
  110. package/dist/controllers/toast_controller.d.ts +1 -9
  111. package/dist/controllers/toast_controller.js +36 -9
  112. package/dist/controllers/toast_controller.js.map +1 -1
  113. package/dist/controllers/transition_controller.d.ts +14 -8
  114. package/dist/controllers/transition_controller.js +153 -38
  115. package/dist/controllers/transition_controller.js.map +1 -1
  116. package/dist/controllers/tree_view_controller.d.ts +54 -0
  117. package/dist/controllers/tree_view_controller.js +275 -0
  118. package/dist/controllers/tree_view_controller.js.map +1 -0
  119. package/dist/index.d.ts +27 -1818
  120. package/dist/index.js +811 -295
  121. package/dist/index.js.map +1 -1
  122. package/dist/inspector/examples.json +2 -2
  123. package/dist/inspector/manifest.json +15 -3
  124. package/package.json +1 -1
@@ -0,0 +1,117 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/dismissible_controller.ts
4
+
5
+ // src/utils/focus_trap.ts
6
+ var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
+
8
+ // src/controllers/dismissible_controller.ts
9
+ var DismissibleController = class extends Controller {
10
+ static targets = ["root", "fallback"];
11
+ static values = {
12
+ mode: { type: String, default: "remove" },
13
+ closeOnEscape: { type: Boolean, default: false }
14
+ };
15
+ static actions = ["dismiss"];
16
+ static events = ["dismiss"];
17
+ connect() {
18
+ const root = this.#root;
19
+ if (!root.hasAttribute("data-state")) {
20
+ root.setAttribute("data-state", "open");
21
+ }
22
+ this.#syncEscapeListener();
23
+ }
24
+ disconnect() {
25
+ this.element.removeEventListener("keydown", this.#onKeydown);
26
+ }
27
+ /** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */
28
+ closeOnEscapeValueChanged() {
29
+ this.#syncEscapeListener();
30
+ }
31
+ /** Dismisses the element. Bound via `data-action` (click on the close button). */
32
+ dismiss() {
33
+ this.#performDismiss();
34
+ }
35
+ /** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */
36
+ #onKeydown = (event) => {
37
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
38
+ if (!this.closeOnEscapeValue) return;
39
+ const active = document.activeElement;
40
+ if (!active || !this.element.contains(active)) return;
41
+ event.preventDefault();
42
+ this.#performDismiss();
43
+ };
44
+ /** Adds or removes the one stable listener reference without duplicating it. */
45
+ #syncEscapeListener() {
46
+ this.element.removeEventListener("keydown", this.#onKeydown);
47
+ if (this.closeOnEscapeValue) {
48
+ this.element.addEventListener("keydown", this.#onKeydown);
49
+ }
50
+ }
51
+ /** The element to dismiss: the explicit `root` target, or the host element. */
52
+ get #root() {
53
+ return this.hasRootTarget ? this.rootTarget : this.element;
54
+ }
55
+ #performDismiss() {
56
+ const root = this.#root;
57
+ const mode = this.modeValue === "hide" ? "hide" : "remove";
58
+ this.#retreatFocus(root);
59
+ root.setAttribute("data-state", "closing");
60
+ this.dispatch("dismiss", { detail: { mode } });
61
+ if (mode === "hide") {
62
+ root.hidden = true;
63
+ } else {
64
+ root.remove();
65
+ }
66
+ }
67
+ /**
68
+ * Moves focus out of `root` *before* it is removed, but only when focus is
69
+ * actually inside it — otherwise the user's place elsewhere is left undisturbed.
70
+ */
71
+ #retreatFocus(root) {
72
+ const active = document.activeElement;
73
+ if (!(active instanceof HTMLElement) || !root.contains(active)) return;
74
+ for (const candidate of this.#focusFallbacks(root)) {
75
+ candidate.focus();
76
+ if (document.activeElement === candidate) return;
77
+ }
78
+ document.body.focus();
79
+ }
80
+ /** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */
81
+ *#focusFallbacks(root) {
82
+ const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;
83
+ if (explicitFallback && !root.contains(explicitFallback) && this.#isFocusAvailable(explicitFallback)) {
84
+ yield explicitFallback;
85
+ }
86
+ const candidates = document.querySelectorAll(FOCUSABLE);
87
+ for (const element of candidates) {
88
+ if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) {
89
+ continue;
90
+ }
91
+ if (this.#isFocusAvailable(element)) yield element;
92
+ }
93
+ for (let index = candidates.length - 1; index >= 0; index -= 1) {
94
+ const element = candidates.item(index);
95
+ if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)) {
96
+ continue;
97
+ }
98
+ if (this.#isFocusAvailable(element)) yield element;
99
+ }
100
+ }
101
+ /** Whether focus is allowed by the element and its semantic ancestors. */
102
+ #isFocusAvailable(element) {
103
+ if (!element.matches(FOCUSABLE) && !element.hasAttribute("tabindex") && !element.isContentEditable) {
104
+ return false;
105
+ }
106
+ if (element.matches(":disabled")) return false;
107
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
108
+ for (let current = element; current; current = current.parentElement) {
109
+ if (current.hidden || current.inert) return false;
110
+ }
111
+ return true;
112
+ }
113
+ };
114
+
115
+ export { DismissibleController };
116
+ //# sourceMappingURL=dismissible_controller.js.map
117
+ //# sourceMappingURL=dismissible_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/dismissible_controller.ts"],"names":[],"mappings":"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","file":"dismissible_controller.js","sourcesContent":["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 (the shared layered-Escape contract).\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 { FOCUSABLE } from \"../utils/focus_trap\";\n\n/**\n * Headless \"dismissible\" behavior for banners, notices, and inline alerts.\n *\n * Markup contract (identifier: `stimeo--dismissible`):\n * <div data-controller=\"stimeo--dismissible\"\n * data-stimeo--dismissible-mode-value=\"remove\">\n * <div data-stimeo--dismissible-target=\"root\" role=\"status\">\n * <p>Saved.</p>\n * <button type=\"button\" aria-label=\"Close\"\n * data-action=\"stimeo--dismissible#dismiss\">×</button>\n * </div>\n * </div>\n *\n * A general utility with no dedicated APG pattern. Its accessibility job is to\n * keep focus from being orphaned: if focus is inside the element being removed,\n * it is moved to a safe place first so the close button vanishing never strands\n * the user (WCAG 2.4.3).\n *\n * @remarks\n * Behavior only — the consumer owns any exit transition (use `hide` mode, which\n * adds `hidden`, and animate off `data-state`) and any semantics like\n * `role=\"alert\"`. Focus retreats to, in order: the `fallback` target → the next\n * focusable element after the root → the previous one → `document.body` as a\n * last resort (weak focus; prefer providing a `fallback`).\n */\nexport class DismissibleController extends Controller<HTMLElement> {\n static override targets = [\"root\", \"fallback\"];\n static override values = {\n mode: { type: String, default: \"remove\" },\n closeOnEscape: { type: Boolean, default: false },\n };\n static actions = [\"dismiss\"] as const;\n static events = [\"dismiss\"] as const;\n\n declare readonly rootTarget: HTMLElement;\n declare readonly fallbackTarget: HTMLElement;\n declare readonly hasRootTarget: boolean;\n declare readonly hasFallbackTarget: boolean;\n\n declare modeValue: string;\n declare closeOnEscapeValue: boolean;\n\n override connect(): void {\n const root = this.#root;\n if (!root.hasAttribute(\"data-state\")) {\n root.setAttribute(\"data-state\", \"open\");\n }\n this.#syncEscapeListener();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n }\n\n /** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */\n closeOnEscapeValueChanged(): void {\n this.#syncEscapeListener();\n }\n\n /** Dismisses the element. Bound via `data-action` (click on the close button). */\n dismiss(): void {\n this.#performDismiss();\n }\n\n /** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n // A press during IME composition cancels the conversion (e.g. in a text\n // field inside this element), never the element itself.\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n if (!this.closeOnEscapeValue) return;\n const active = document.activeElement;\n if (!active || !this.element.contains(active)) return;\n event.preventDefault();\n this.#performDismiss();\n };\n\n /** Adds or removes the one stable listener reference without duplicating it. */\n #syncEscapeListener(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.closeOnEscapeValue) {\n this.element.addEventListener(\"keydown\", this.#onKeydown);\n }\n }\n\n /** The element to dismiss: the explicit `root` target, or the host element. */\n get #root(): HTMLElement {\n return this.hasRootTarget ? this.rootTarget : this.element;\n }\n\n #performDismiss(): void {\n const root = this.#root;\n const mode = this.modeValue === \"hide\" ? \"hide\" : \"remove\";\n\n this.#retreatFocus(root);\n root.setAttribute(\"data-state\", \"closing\");\n // Dispatch before removal so a listener on the (about-to-leave) element still\n // runs; `dispatch` fires synchronously on `this.element`.\n this.dispatch(\"dismiss\", { detail: { mode } });\n\n if (mode === \"hide\") {\n root.hidden = true;\n } else {\n root.remove();\n }\n }\n\n /**\n * Moves focus out of `root` *before* it is removed, but only when focus is\n * actually inside it — otherwise the user's place elsewhere is left undisturbed.\n */\n #retreatFocus(root: HTMLElement): void {\n const active = document.activeElement;\n if (!(active instanceof HTMLElement) || !root.contains(active)) return;\n\n for (const candidate of this.#focusFallbacks(root)) {\n candidate.focus();\n if (document.activeElement === candidate) return;\n }\n document.body.focus();\n }\n\n /** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */\n *#focusFallbacks(root: HTMLElement): Generator<HTMLElement> {\n const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;\n if (\n explicitFallback &&\n !root.contains(explicitFallback) &&\n this.#isFocusAvailable(explicitFallback)\n ) {\n yield explicitFallback;\n }\n\n const candidates = document.querySelectorAll<HTMLElement>(FOCUSABLE);\n for (const element of candidates) {\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)\n ) {\n continue;\n }\n if (this.#isFocusAvailable(element)) yield element;\n }\n\n for (let index = candidates.length - 1; index >= 0; index -= 1) {\n const element = candidates.item(index);\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)\n ) {\n continue;\n }\n if (this.#isFocusAvailable(element)) yield element;\n }\n }\n\n /** Whether focus is allowed by the element and its semantic ancestors. */\n #isFocusAvailable(element: HTMLElement): boolean {\n if (\n !element.matches(FOCUSABLE) &&\n !element.hasAttribute(\"tabindex\") &&\n !element.isContentEditable\n ) {\n return false;\n }\n if (element.matches(\":disabled\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n\n for (let current: HTMLElement | null = element; current; current = current.parentElement) {\n if (current.hidden || current.inert) return false;\n }\n return true;\n }\n}\n"]}
@@ -0,0 +1,96 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible **drawer / slide-over** behavior.
5
+ *
6
+ * Markup contract (identifier: `stimeo--drawer`):
7
+ * <div data-controller="stimeo--drawer" data-stimeo--drawer-placement-value="right">
8
+ * <button data-stimeo--drawer-target="trigger"
9
+ * data-action="click->stimeo--drawer#open">Open panel</button>
10
+ * <div data-stimeo--drawer-target="overlay"
11
+ * data-action="click->stimeo--drawer#closeOnBackdrop" hidden>
12
+ * <div data-stimeo--drawer-target="panel" role="dialog" aria-modal="true"
13
+ * aria-labelledby="t" data-state="closed" hidden>
14
+ * <h2 id="t">…</h2>
15
+ * <button data-action="click->stimeo--drawer#close">Close</button>
16
+ * </div>
17
+ * </div>
18
+ * </div>
19
+ *
20
+ * Implements the WAI-ARIA APG **Dialog (Modal)** pattern. It is the same modal as
21
+ * `stimeo--dialog`; what it adds is the state plumbing an enter/exit *slide*
22
+ * needs: `data-state` (`open`/`closed`) is synced on the panel and overlay so CSS
23
+ * can animate, and `hidden` is applied only *after* the close transition finishes
24
+ * (so the exit animation can play). `placement` is reflected as `data-placement`
25
+ * for the CSS to read — the controller never computes coordinates.
26
+ *
27
+ * @remarks
28
+ * Behavior only. The modal lifecycle (focus trap, scroll lock, background
29
+ * `inert`, focus restore, teardown reversal) is delegated to the shared
30
+ * {@link FocusTrap}. Placement, slide direction, distance, and easing are all the
31
+ * consumer's CSS — `data-placement` is merely a flag.
32
+ *
33
+ * Behavior provided:
34
+ * - {@link open}/{@link close} toggle `data-state` and (deferred) `hidden`.
35
+ * - On open, focus moves to the first focusable element in the panel.
36
+ * - `Tab`/`Shift+Tab` cycle focus within the panel; `Escape` closes.
37
+ * - {@link closeOnBackdrop} closes only when the overlay *itself* is clicked.
38
+ */
39
+ declare class DrawerController extends Controller<HTMLElement> {
40
+ #private;
41
+ static targets: string[];
42
+ static values: {
43
+ placement: {
44
+ type: StringConstructor;
45
+ default: string;
46
+ };
47
+ open: {
48
+ type: BooleanConstructor;
49
+ default: boolean;
50
+ };
51
+ };
52
+ static actions: readonly ["close", "closeOnBackdrop", "open"];
53
+ readonly triggerTarget: HTMLElement;
54
+ readonly overlayTarget: HTMLElement;
55
+ readonly panelTarget: HTMLElement;
56
+ readonly hasTriggerTarget: boolean;
57
+ readonly hasOverlayTarget: boolean;
58
+ readonly hasPanelTarget: boolean;
59
+ placementValue: string;
60
+ openValue: boolean;
61
+ /**
62
+ * Reflects placement and establishes the initial open/closed state.
63
+ *
64
+ * The DOM is the source of truth on reconnect (Turbo cache restore / morph): a
65
+ * restored snapshot whose panel is already `data-state="open"` stays open
66
+ * rather than being re-derived from the declarative `open` Value (which would
67
+ * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
68
+ * render. We normalize to a clean closed baseline first so {@link open} runs its
69
+ * full reveal + trap activation — the {@link FocusTrap} is a fresh instance
70
+ * after a reconnect and must be re-activated.
71
+ */
72
+ connect(): void;
73
+ /** Reverts the modal side effects and pending hide if torn down while open. */
74
+ disconnect(): void;
75
+ /** Adopts a panel target added by a Turbo morph after the controller connected. */
76
+ panelTargetConnected(panel: HTMLElement): void;
77
+ /** Closes and releases modal side effects when the actively trapped panel disappears. */
78
+ panelTargetDisconnected(panel: HTMLElement): void;
79
+ /** Keeps `data-placement` in sync if the value changes at runtime. */
80
+ placementValueChanged(): void;
81
+ /** Opens the drawer: reveals it, syncs `data-state`, traps focus. */
82
+ open(): void;
83
+ /**
84
+ * Closes the drawer: syncs `data-state` to start the exit transition, then
85
+ * defers both `hidden` *and* the modal teardown (scroll lock / background
86
+ * `inert` / focus restore) until the transition finishes — see
87
+ * `#applyHidden`. This keeps the background inert and focus trapped while
88
+ * the drawer is still visually on screen, preserving the modal contract during
89
+ * the exit animation.
90
+ */
91
+ close(): void;
92
+ /** Closes only when the overlay itself (not its contents) is clicked. */
93
+ closeOnBackdrop(event: MouseEvent): void;
94
+ }
95
+
96
+ export { DrawerController };