stimeo-ui 0.2.1 → 0.4.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 +160 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.js +32 -5
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +32 -6
- package/dist/controllers/announcer_controller.js +255 -20
- package/dist/controllers/announcer_controller.js.map +1 -1
- 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 +16 -1
- package/dist/controllers/carousel_controller.js +47 -6
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.js +2 -2
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +14 -2
- package/dist/controllers/color_picker_controller.js +92 -7
- package/dist/controllers/color_picker_controller.js.map +1 -1
- 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 +226 -22
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +32 -5
- 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.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +19 -5
- package/dist/controllers/countdown_controller.js +112 -12
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +19 -1
- package/dist/controllers/data_grid_controller.js +82 -4
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +5 -1
- package/dist/controllers/date_range_picker_controller.js +77 -3
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +32 -5
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.js +32 -5
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.js +1 -0
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.d.ts +9 -4
- package/dist/controllers/empty_state_controller.js +24 -10
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +1 -1
- package/dist/controllers/focus_controller.js +32 -5
- 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/frame_loading_controller.d.ts +14 -3
- package/dist/controllers/frame_loading_controller.js +177 -15
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- 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 +36 -11
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +31 -10
- 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.d.ts +19 -3
- package/dist/controllers/local_time_controller.js +102 -8
- package/dist/controllers/local_time_controller.js.map +1 -1
- 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 +91 -11
- package/dist/controllers/menubar_controller.js +415 -63
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +22 -2
- package/dist/controllers/meter_controller.js +145 -26
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +31 -5
- package/dist/controllers/multi_select_controller.js +312 -29
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +65 -18
- package/dist/controllers/navigation_menu_controller.js +154 -27
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +4 -5
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +13 -1
- package/dist/controllers/network_status_controller.js +28 -8
- package/dist/controllers/network_status_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 +3 -2
- package/dist/controllers/overflow_indicator_controller.js +81 -13
- 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 +408 -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/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.d.ts +16 -1
- package/dist/controllers/progress_controller.js +116 -9
- 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 +18 -5
- package/dist/controllers/range_slider_controller.js +100 -9
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +4 -1
- package/dist/controllers/rating_controller.js +69 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.js +63 -19
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.d.ts +13 -0
- package/dist/controllers/relative_time_controller.js +133 -12
- package/dist/controllers/relative_time_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 +86 -12
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +1 -1
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +33 -3
- 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 +346 -73
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +20 -5
- package/dist/controllers/separator_controller.js +9 -0
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +2 -12
- package/dist/controllers/sidebar_controller.js +37 -8
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +7 -2
- package/dist/controllers/skeleton_controller.js +73 -20
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +11 -1
- package/dist/controllers/slider_controller.js +49 -8
- 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.d.ts +37 -4
- package/dist/controllers/spinner_controller.js +228 -27
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +12 -1
- package/dist/controllers/step_indicator_controller.js +82 -5
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +31 -8
- package/dist/controllers/stick_to_bottom_controller.js +61 -10
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +32 -11
- 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 +11 -3
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.js +22 -2
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +22 -3
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.js +20 -1
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +4 -5
- package/dist/controllers/toast_controller.js +4 -5
- 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.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +87 -6
- package/dist/controllers/tree_view_controller.js +467 -51
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +4507 -907
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +375 -39
- package/dist/inspector/cli.js +1009 -86
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +1200 -120
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +28 -28
- package/dist/inspector/manifest.json +1060 -132
- package/package.json +5 -6
|
@@ -107,6 +107,7 @@ var EditableController = class extends Controller {
|
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
if (event.key === "Enter") {
|
|
110
|
+
if (event.defaultPrevented) return;
|
|
110
111
|
if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
|
|
111
112
|
event.preventDefault();
|
|
112
113
|
this.#save(true);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/controllers/editable_controller.ts"],"names":[],"mappings":"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","file":"editable_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\n\n/**\n * Headless, accessible inline-editing behavior.\n *\n * Markup contract (identifier: `stimeo--editable`):\n * <div data-controller=\"stimeo--editable\"\n * data-stimeo--editable-submit-on-blur-value=\"true\">\n * <button type=\"button\" aria-label=\"Edit title\"\n * data-stimeo--editable-target=\"display\"\n * data-action=\"click->stimeo--editable#edit\n * keydown->stimeo--editable#onDisplayKeydown\">Current title</button>\n * <input type=\"text\" aria-label=\"Title\" hidden\n * data-stimeo--editable-target=\"input\"\n * data-action=\"keydown->stimeo--editable#onKeydown\n * blur->stimeo--editable#onBlur\" />\n * </div>\n *\n * There is no dedicated APG pattern; this implements a display ⇄ edit toggle with\n * focus management and keyboard commit/cancel, leaning on native form-control\n * labeling (WCAG 1.3.1 / 4.1.2). The look is the consumer's CSS, keyed off\n * `data-mode`.\n *\n * Behavior provided:\n * - Activating the display element (`Enter`/`Space` via the `<button>`, or `F2`)\n * enters edit mode, focuses the input, and selects its text.\n * - `Enter` (single-line) or `Ctrl+Enter` / `Cmd+Enter` (multiline `<textarea>`)\n * saves; `Escape` cancels. Both return focus to the display element. Keys\n * pressed during an IME composition act on the composition, never the edit:\n * a conversion-cancelling `Escape` keeps editing and a conversion-confirming\n * `Enter` never saves (lifecycle-tracked, covering events that omit\n * `isComposing`).\n * - Losing focus while editing saves when `submitOnBlur` is true (the default),\n * honoring wherever focus moved; when false, editing is kept.\n * - Saving dispatches `stimeo--editable:change` with `{ value, previous }` only\n * when the value actually changed; cancelling dispatches\n * `stimeo--editable:cancel`.\n */\nexport class EditableController extends Controller<HTMLElement> {\n static override targets = [\"display\", \"input\"];\n static override values = {\n submitOnBlur: { type: Boolean, default: true },\n };\n static actions = [\"edit\", \"onBlur\", \"onDisplayKeydown\", \"onKeydown\"] as const;\n static events = [\"cancel\", \"change\"] as const;\n\n declare readonly displayTarget: HTMLElement;\n declare readonly inputTarget: HTMLInputElement | HTMLTextAreaElement;\n declare readonly hasDisplayTarget: boolean;\n declare readonly hasInputTarget: boolean;\n\n declare submitOnBlurValue: boolean;\n\n /** The value captured when edit mode began, used to detect real changes. */\n #previousValue = \"\";\n\n /**\n * Owns IME lifecycle state for the edit surface, so a keydown that belongs to\n * a composition (cancel or confirm) is never treated as an edit command.\n */\n readonly #composition = new CompositionTracker();\n\n /** Establishes the initial display mode (display shown, input hidden). */\n override connect(): void {\n if (this.hasInputTarget) this.#composition.observe(this.inputTarget);\n this.#setMode(\"display\");\n }\n\n /** Releases the composition listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#composition.disconnect();\n }\n\n /** Tracks an input added initially or after connect (e.g. a Turbo swap). */\n inputTargetConnected(input: HTMLElement): void {\n this.#composition.observe(input);\n }\n\n /** Removes composition listeners when the active input is replaced or removed. */\n inputTargetDisconnected(input: HTMLElement): void {\n this.#composition.unobserve(input);\n }\n\n /** Enters edit mode: seeds the input from the display text, focuses, selects. */\n edit(): void {\n if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;\n this.#previousValue = this.#currentValue;\n this.inputTarget.value = this.#previousValue;\n this.#setMode(\"editing\");\n this.inputTarget.focus();\n this.inputTarget.select();\n }\n\n /** Adds `F2` as an editing entry point alongside the button's native activation. */\n onDisplayKeydown(event: KeyboardEvent): void {\n if (event.key === \"F2\") {\n event.preventDefault();\n this.edit();\n }\n }\n\n /** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */\n onKeydown(event: KeyboardEvent): void {\n // Keys fired during IME composition steer the composition (cancel a\n // conversion, confirm a candidate) and must never cancel or save the edit.\n if (this.#composition.isComposing(event)) return;\n if (event.key === \"Escape\") {\n // Layered-Escape rule 1: leave a press an inner handler already owned.\n if (event.defaultPrevented) return;\n event.preventDefault();\n this.#cancel();\n return;\n }\n if (event.key === \"Enter\") {\n // A bare Enter inserts a newline in a textarea; only Ctrl+Enter (or Cmd+Enter on\n // macOS, the platform-conventional commit chord) saves there.\n if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;\n event.preventDefault();\n this.#save(true);\n }\n }\n\n /** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */\n onBlur(): void {\n if (!this.#isEditing) return;\n if (this.submitOnBlurValue) this.#save(false);\n }\n\n /**\n * Returns to display mode, reflecting the input into the display text and\n * dispatching `change` when the value differs from where editing began.\n *\n * @param restoreFocus - Move focus back to the display element (explicit\n * keyboard commit) rather than honoring the user's new focus target (blur).\n */\n #save(restoreFocus: boolean): void {\n if (!this.#isEditing) return;\n const value = this.inputTarget.value;\n const previous = this.#previousValue;\n this.displayTarget.textContent = value;\n this.#setMode(\"display\");\n if (restoreFocus) this.displayTarget.focus();\n if (value !== previous) {\n this.dispatch(\"change\", { detail: { value, previous } });\n }\n }\n\n /** Discards edits, returns to display mode, and dispatches `cancel`. */\n #cancel(): void {\n if (!this.#isEditing) return;\n this.#setMode(\"display\");\n this.displayTarget.focus();\n this.dispatch(\"cancel\", { detail: {} });\n }\n\n /** Toggles the `data-mode` flag and the `hidden` state of both elements. */\n #setMode(mode: \"display\" | \"editing\"): void {\n this.element.dataset.mode = mode;\n const editing = mode === \"editing\";\n if (this.hasDisplayTarget) this.displayTarget.hidden = editing;\n if (this.hasInputTarget) this.inputTarget.hidden = !editing;\n }\n\n /** Current display text, trimmed — the value shown when not editing. */\n get #currentValue(): string {\n return (this.displayTarget.textContent ?? \"\").trim();\n }\n\n /** Whether the editing control is a multi-line `<textarea>`. */\n get #isMultiline(): boolean {\n return this.inputTarget.tagName === \"TEXTAREA\";\n }\n\n /** Whether the controller is currently in edit mode. */\n get #isEditing(): boolean {\n return this.element.dataset.mode === \"editing\";\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/controllers/editable_controller.ts"],"names":[],"mappings":"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","file":"editable_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\n\n/**\n * Headless, accessible inline-editing behavior.\n *\n * Markup contract (identifier: `stimeo--editable`):\n * <div data-controller=\"stimeo--editable\"\n * data-stimeo--editable-submit-on-blur-value=\"true\">\n * <button type=\"button\" aria-label=\"Edit title\"\n * data-stimeo--editable-target=\"display\"\n * data-action=\"click->stimeo--editable#edit\n * keydown->stimeo--editable#onDisplayKeydown\">Current title</button>\n * <input type=\"text\" aria-label=\"Title\" hidden\n * data-stimeo--editable-target=\"input\"\n * data-action=\"keydown->stimeo--editable#onKeydown\n * blur->stimeo--editable#onBlur\" />\n * </div>\n *\n * There is no dedicated APG pattern; this implements a display ⇄ edit toggle with\n * focus management and keyboard commit/cancel, leaning on native form-control\n * labeling (WCAG 1.3.1 / 4.1.2). The look is the consumer's CSS, keyed off\n * `data-mode`.\n *\n * Behavior provided:\n * - Activating the display element (`Enter`/`Space` via the `<button>`, or `F2`)\n * enters edit mode, focuses the input, and selects its text.\n * - `Enter` (single-line) or `Ctrl+Enter` / `Cmd+Enter` (multiline `<textarea>`)\n * saves; `Escape` cancels. Both return focus to the display element. Keys\n * pressed during an IME composition act on the composition, never the edit:\n * a conversion-cancelling `Escape` keeps editing and a conversion-confirming\n * `Enter` never saves (lifecycle-tracked, covering events that omit\n * `isComposing`).\n * - Losing focus while editing saves when `submitOnBlur` is true (the default),\n * honoring wherever focus moved; when false, editing is kept.\n * - Saving dispatches `stimeo--editable:change` with `{ value, previous }` only\n * when the value actually changed; cancelling dispatches\n * `stimeo--editable:cancel`.\n */\nexport class EditableController extends Controller<HTMLElement> {\n static override targets = [\"display\", \"input\"];\n static override values = {\n submitOnBlur: { type: Boolean, default: true },\n };\n static actions = [\"edit\", \"onBlur\", \"onDisplayKeydown\", \"onKeydown\"] as const;\n static events = [\"cancel\", \"change\"] as const;\n\n declare readonly displayTarget: HTMLElement;\n declare readonly inputTarget: HTMLInputElement | HTMLTextAreaElement;\n declare readonly hasDisplayTarget: boolean;\n declare readonly hasInputTarget: boolean;\n\n declare submitOnBlurValue: boolean;\n\n /** The value captured when edit mode began, used to detect real changes. */\n #previousValue = \"\";\n\n /**\n * Owns IME lifecycle state for the edit surface, so a keydown that belongs to\n * a composition (cancel or confirm) is never treated as an edit command.\n */\n readonly #composition = new CompositionTracker();\n\n /** Establishes the initial display mode (display shown, input hidden). */\n override connect(): void {\n if (this.hasInputTarget) this.#composition.observe(this.inputTarget);\n this.#setMode(\"display\");\n }\n\n /** Releases the composition listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#composition.disconnect();\n }\n\n /** Tracks an input added initially or after connect (e.g. a Turbo swap). */\n inputTargetConnected(input: HTMLElement): void {\n this.#composition.observe(input);\n }\n\n /** Removes composition listeners when the active input is replaced or removed. */\n inputTargetDisconnected(input: HTMLElement): void {\n this.#composition.unobserve(input);\n }\n\n /** Enters edit mode: seeds the input from the display text, focuses, selects. */\n edit(): void {\n if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;\n this.#previousValue = this.#currentValue;\n this.inputTarget.value = this.#previousValue;\n this.#setMode(\"editing\");\n this.inputTarget.focus();\n this.inputTarget.select();\n }\n\n /** Adds `F2` as an editing entry point alongside the button's native activation. */\n onDisplayKeydown(event: KeyboardEvent): void {\n if (event.key === \"F2\") {\n event.preventDefault();\n this.edit();\n }\n }\n\n /** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */\n onKeydown(event: KeyboardEvent): void {\n // Keys fired during IME composition steer the composition (cancel a\n // conversion, confirm a candidate) and must never cancel or save the edit.\n if (this.#composition.isComposing(event)) return;\n if (event.key === \"Escape\") {\n // Leave a press an inner handler already owned.\n if (event.defaultPrevented) return;\n event.preventDefault();\n this.#cancel();\n return;\n }\n if (event.key === \"Enter\") {\n // A descendant widget that already claimed the key (a completion popup\n // confirming its candidate) must not ALSO commit the edit — composition\n // depends on this yield. Kept separate from the Escape guard above, which\n // answers a different question: who owns an Escape press.\n if (event.defaultPrevented) return;\n // A bare Enter inserts a newline in a textarea; only Ctrl+Enter (or Cmd+Enter on\n // macOS, the platform-conventional commit chord) saves there.\n if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;\n event.preventDefault();\n this.#save(true);\n }\n }\n\n /** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */\n onBlur(): void {\n if (!this.#isEditing) return;\n if (this.submitOnBlurValue) this.#save(false);\n }\n\n /**\n * Returns to display mode, reflecting the input into the display text and\n * dispatching `change` when the value differs from where editing began.\n *\n * @param restoreFocus - Move focus back to the display element (explicit\n * keyboard commit) rather than honoring the user's new focus target (blur).\n */\n #save(restoreFocus: boolean): void {\n if (!this.#isEditing) return;\n const value = this.inputTarget.value;\n const previous = this.#previousValue;\n this.displayTarget.textContent = value;\n this.#setMode(\"display\");\n if (restoreFocus) this.displayTarget.focus();\n if (value !== previous) {\n this.dispatch(\"change\", { detail: { value, previous } });\n }\n }\n\n /** Discards edits, returns to display mode, and dispatches `cancel`. */\n #cancel(): void {\n if (!this.#isEditing) return;\n this.#setMode(\"display\");\n this.displayTarget.focus();\n this.dispatch(\"cancel\", { detail: {} });\n }\n\n /** Toggles the `data-mode` flag and the `hidden` state of both elements. */\n #setMode(mode: \"display\" | \"editing\"): void {\n this.element.dataset.mode = mode;\n const editing = mode === \"editing\";\n if (this.hasDisplayTarget) this.displayTarget.hidden = editing;\n if (this.hasInputTarget) this.inputTarget.hidden = !editing;\n }\n\n /** Current display text, trimmed — the value shown when not editing. */\n get #currentValue(): string {\n return (this.displayTarget.textContent ?? \"\").trim();\n }\n\n /** Whether the editing control is a multi-line `<textarea>`. */\n get #isMultiline(): boolean {\n return this.inputTarget.tagName === \"TEXTAREA\";\n }\n\n /** Whether the controller is currently in edit mode. */\n get #isEditing(): boolean {\n return this.element.dataset.mode === \"editing\";\n }\n}\n"]}
|
|
@@ -41,9 +41,13 @@ declare class EmptyStateController extends Controller<HTMLElement> {
|
|
|
41
41
|
type: StringConstructor;
|
|
42
42
|
default: string;
|
|
43
43
|
};
|
|
44
|
-
|
|
45
|
-
type:
|
|
46
|
-
default:
|
|
44
|
+
announceText: {
|
|
45
|
+
type: StringConstructor;
|
|
46
|
+
default: string;
|
|
47
|
+
};
|
|
48
|
+
announceFilledText: {
|
|
49
|
+
type: StringConstructor;
|
|
50
|
+
default: string;
|
|
47
51
|
};
|
|
48
52
|
};
|
|
49
53
|
static events: readonly ["change"];
|
|
@@ -52,7 +56,8 @@ declare class EmptyStateController extends Controller<HTMLElement> {
|
|
|
52
56
|
readonly hasListTarget: boolean;
|
|
53
57
|
readonly hasEmptyTarget: boolean;
|
|
54
58
|
itemSelectorValue: string;
|
|
55
|
-
|
|
59
|
+
announceTextValue: string;
|
|
60
|
+
announceFilledTextValue: string;
|
|
56
61
|
connect(): void;
|
|
57
62
|
disconnect(): void;
|
|
58
63
|
}
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/empty_state_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
// src/controllers/empty_state_controller.ts
|
|
4
23
|
var EmptyStateController = class extends Controller {
|
|
5
24
|
static targets = ["list", "empty"];
|
|
6
25
|
static values = {
|
|
7
26
|
itemSelector: { type: String, default: "" },
|
|
8
|
-
|
|
27
|
+
announceText: { type: String, default: "" },
|
|
28
|
+
announceFilledText: { type: String, default: "" }
|
|
9
29
|
};
|
|
10
30
|
static events = ["change"];
|
|
11
31
|
#observer = null;
|
|
@@ -13,10 +33,6 @@ var EmptyStateController = class extends Controller {
|
|
|
13
33
|
#empty = null;
|
|
14
34
|
connect() {
|
|
15
35
|
if (!this.hasListTarget) return;
|
|
16
|
-
if (this.announceValue && this.hasEmptyTarget && !this.#isLiveRegion(this.emptyTarget)) {
|
|
17
|
-
this.emptyTarget.setAttribute("role", "status");
|
|
18
|
-
this.emptyTarget.setAttribute("aria-live", "polite");
|
|
19
|
-
}
|
|
20
36
|
if (typeof MutationObserver !== "undefined") {
|
|
21
37
|
this.#observer = new MutationObserver(() => this.#apply());
|
|
22
38
|
this.#observer.observe(this.listTarget, { childList: true });
|
|
@@ -42,6 +58,9 @@ var EmptyStateController = class extends Controller {
|
|
|
42
58
|
if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
|
|
43
59
|
if (this.#empty !== null && empty !== this.#empty) {
|
|
44
60
|
this.dispatch("change", { detail: { count, empty } });
|
|
61
|
+
announce(
|
|
62
|
+
fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
|
|
63
|
+
);
|
|
45
64
|
}
|
|
46
65
|
this.#empty = empty;
|
|
47
66
|
}
|
|
@@ -55,11 +74,6 @@ var EmptyStateController = class extends Controller {
|
|
|
55
74
|
return this.listTarget.childElementCount;
|
|
56
75
|
}
|
|
57
76
|
}
|
|
58
|
-
#isLiveRegion(el) {
|
|
59
|
-
if (el.hasAttribute("aria-live")) return true;
|
|
60
|
-
const role = el.getAttribute("role");
|
|
61
|
-
return role === "status" || role === "alert";
|
|
62
|
-
}
|
|
63
77
|
};
|
|
64
78
|
|
|
65
79
|
export { EmptyStateController };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/empty_state_controller.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"sources":["../../src/utils/announce.ts","../../src/controllers/empty_state_controller.ts"],"names":[],"mappings":"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","file":"empty_state_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\n\n/**\n * Headless empty-state behavior: shows an \"empty\" placeholder when a list has no\n * items and hides it once one arrives (and vice-versa), watching the list with a\n * `MutationObserver` (no dedicated APG pattern; follows the WCAG \"status messages\"\n * practice when announcing).\n *\n * Markup contract (identifier: `stimeo--empty-state`):\n * <div data-controller=\"stimeo--empty-state\">\n * <ul data-stimeo--empty-state-target=\"list\"><!-- Turbo Stream rows --></ul>\n * <p data-stimeo--empty-state-target=\"empty\" hidden>No items</p>\n * </div>\n *\n * Counts `list`'s child items (all element children, or those matching\n * `itemSelector`) on connect and on every childList mutation, toggles `hidden` on\n * `list` / `empty` at the 0 ↔ 1+ boundary, and reflects `data-empty` / `data-count`\n * on the controller element. Crossing the boundary dispatches `change`.\n *\n * @remarks\n * Behavior only — the placeholder's look/copy is the consumer's. State is derived\n * from the DOM (no module-scope state), so `connect()` re-syncs after a Turbo\n * Stream insertion. The `MutationObserver` is severed on `disconnect()` (Turbo\n * navigation included). With `announce`, the `empty` target is made a polite live\n * region (only if the author hasn't already), so showing it is announced — SR\n * support for unhiding a live region varies; pair with Announcer for a guarantee.\n *\n * Ownership note: this controller deliberately owns `hidden` on the `list` / `empty`\n * targets as its single source of truth for which one is shown, rather than only\n * emitting `data-empty` and delegating visibility to consumer CSS. The toggle is\n * unconditional (set every sync), so there is nothing to save/restore and no\n * authored `hidden` to preserve — the displayed half is always a pure function of\n * the item count. Consumers wanting CSS-driven visibility should not also set\n * `hidden` on these targets themselves.\n */\nexport class EmptyStateController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"empty\"];\n static override values = {\n itemSelector: { type: String, default: \"\" },\n announceText: { type: String, default: \"\" },\n announceFilledText: { type: String, default: \"\" },\n };\n static events = [\"change\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly emptyTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasEmptyTarget: boolean;\n\n declare itemSelectorValue: string;\n declare announceTextValue: string;\n declare announceFilledTextValue: string;\n\n #observer: MutationObserver | null = null;\n /** Last applied empty state; `null` until the first sync so connect emits nothing. */\n #empty: boolean | null = null;\n\n override connect(): void {\n if (!this.hasListTarget) return;\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#apply());\n this.#observer.observe(this.listTarget, { childList: true });\n }\n this.#apply();\n }\n\n override disconnect(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /** Recomputes the count and syncs visibility, hooks, and the change event. */\n #apply(): void {\n if (!this.hasListTarget) return;\n const count = this.#count();\n const empty = count === 0;\n\n this.element.setAttribute(\"data-count\", String(count));\n if (empty) {\n this.element.setAttribute(\"data-empty\", \"true\");\n } else {\n this.element.removeAttribute(\"data-empty\");\n }\n this.listTarget.hidden = empty;\n if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;\n\n // Emit only when the 0 ↔ 1+ boundary is crossed (not on the initial sync, and\n // not for count changes that stay non-empty, e.g. 2 → 3).\n if (this.#empty !== null && empty !== this.#empty) {\n this.dispatch(\"change\", { detail: { count, empty } });\n // The crossing is the news, and the page's announcer reads it: a region that\n // only becomes live when the empty state appears is not reliably announced.\n announce(\n fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count }),\n );\n }\n this.#empty = empty;\n }\n\n /** Item count: element children matching `itemSelector`, or all element children. */\n #count(): number {\n const selector = this.itemSelectorValue;\n if (selector.length === 0) return this.listTarget.childElementCount;\n try {\n return Array.from(this.listTarget.children).filter((child) => child.matches(selector)).length;\n } catch {\n // An invalid selector (author typo) must not crash the controller — count all.\n return this.listTarget.childElementCount;\n }\n }\n}\n"]}
|
|
@@ -22,7 +22,7 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
22
22
|
*
|
|
23
23
|
* @remarks
|
|
24
24
|
* Behavior only — it does not open/close or render an overlay (pair with Dialog) and
|
|
25
|
-
* does not move DOM (pair with Portal). It reuses
|
|
25
|
+
* does not move DOM (pair with Portal). It reuses the shared {@link FocusTrap}, so it never
|
|
26
26
|
* scroll-locks the page (unlike the modal overlays) and tracks live focusable children
|
|
27
27
|
* (dynamic additions are picked up on the next `Tab`). The opener is recorded on
|
|
28
28
|
* activate and refocused on release if still present. Everything is torn down on
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/focus_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/focus_trap.ts","../../src/controllers/focus_controller.ts"],"names":[],"mappings":"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","file":"focus_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-first half of the\n * shared layered-Escape contract. A keydown that is part of an IME composition\n * (`event.isComposing`) cancels the composition, never a layer, and is ignored\n * here for every layer 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 (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 { FocusTrap } from \"../utils/focus_trap\";\n\n/**\n * Headless **focus scope**: exposes the shared {@link FocusTrap} as a standalone,\n * declarative focus boundary — `Tab` cycling, initial focus, and restore-on-release —\n * for any region, without building a full modal (counterpart to Alpine `focus` /\n * Headless UI's trap). No dedicated APG pattern; it is the primitive the overlay\n * patterns use, surfaced for direct use.\n *\n * Markup contract (identifier: `stimeo--focus`):\n * <div data-controller=\"stimeo--focus\" data-stimeo--focus-trap-value=\"true\">\n * <input data-stimeo--focus-target=\"initial\" />\n * …\n * </div>\n *\n * While `trap` is on, `Tab` / `Shift+Tab` cycle within the element, focus moves to the\n * `initial` target (or the first focusable) when `auto`, `Escape` releases it, and on\n * release focus returns to the opener when `restore`. With `inert` the rest of the page\n * is made `inert` (a hard, modal-style isolation); left off it is a soft boundary —\n * `Tab` still cycles but the background stays reachable. The element carries\n * `data-focus-trapped` while active and emits `activate` / `deactivate`.\n *\n * @remarks\n * Behavior only — it does not open/close or render an overlay (pair with Dialog) and\n * does not move DOM (pair with Portal). It reuses `src/utils/focus_trap.ts`, so it never\n * scroll-locks the page (unlike the modal overlays) and tracks live focusable children\n * (dynamic additions are picked up on the next `Tab`). The opener is recorded on\n * activate and refocused on release if still present. Everything is torn down on\n * `disconnect()` (Turbo navigation included) without yanking focus.\n */\nexport class FocusController extends Controller<HTMLElement> {\n static override targets = [\"initial\"];\n static override values = {\n trap: { type: Boolean, default: false },\n auto: { type: Boolean, default: true },\n restore: { type: Boolean, default: true },\n inert: { type: Boolean, default: false },\n };\n static actions = [\"activate\", \"deactivate\"] as const;\n static events = [\"activate\", \"deactivate\"] as const;\n\n declare readonly initialTarget: HTMLElement;\n declare readonly hasInitialTarget: boolean;\n\n declare trapValue: boolean;\n declare autoValue: boolean;\n declare restoreValue: boolean;\n declare inertValue: boolean;\n\n readonly #trap = new FocusTrap(() => this.element, {\n // A focus scope is not a modal: never lock scroll, and only isolate the background\n // when `inert` is requested. `auto` gates the initial focus move; Escape releases.\n lockScroll: false,\n isolate: () => this.inertValue,\n autoFocus: () => this.autoValue,\n initialFocus: () => (this.hasInitialTarget ? this.initialTarget : null),\n onEscape: () => this.deactivate(),\n });\n\n /** Stimulus drives activation from the `trap` value (also fires on connect). */\n trapValueChanged(): void {\n if (this.trapValue) this.#activate();\n else this.#deactivate();\n }\n\n override disconnect(): void {\n // Release without restoring focus — the element is leaving the DOM. This is a\n // teardown, not a user-driven close, so it intentionally does NOT emit\n // `deactivate` or reset `trapValue` (which would resurrect on a Turbo cache\n // restore); only the public `deactivate()` action fires the event.\n this.#trap.deactivate({ restoreFocus: false });\n this.element.removeAttribute(\"data-focus-trapped\");\n }\n\n /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */\n activate(): void {\n this.trapValue = true;\n this.#activate();\n }\n\n /** Turns the trap off (also wired to Escape). */\n deactivate(): void {\n this.trapValue = false;\n this.#deactivate();\n }\n\n #activate(): void {\n if (this.#trap.active) return;\n this.#trap.activate();\n this.element.setAttribute(\"data-focus-trapped\", \"true\");\n this.dispatch(\"activate\", { detail: {} });\n }\n\n #deactivate(): void {\n if (!this.#trap.active) return;\n this.#trap.deactivate({ restoreFocus: this.restoreValue });\n this.element.removeAttribute(\"data-focus-trapped\");\n this.dispatch(\"deactivate\", { detail: {} });\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/escape_layer.ts","../../src/utils/focus_trap.ts","../../src/controllers/focus_controller.ts"],"names":[],"mappings":"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","file":"focus_controller.js","sourcesContent":["/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way (the `MicrotaskCoalescer` split).\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","/**\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 { BeforeCacheReset } from \"./before_cache_reset\";\nimport { 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 this.#beforeCache.activate();\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 this.#beforeCache.deactivate();\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 #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));\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/**\n * Headless **focus scope**: exposes the shared {@link FocusTrap} as a standalone,\n * declarative focus boundary — `Tab` cycling, initial focus, and restore-on-release —\n * for any region, without building a full modal (counterpart to Alpine `focus` /\n * Headless UI's trap). No dedicated APG pattern; it is the primitive the overlay\n * patterns use, surfaced for direct use.\n *\n * Markup contract (identifier: `stimeo--focus`):\n * <div data-controller=\"stimeo--focus\" data-stimeo--focus-trap-value=\"true\">\n * <input data-stimeo--focus-target=\"initial\" />\n * …\n * </div>\n *\n * While `trap` is on, `Tab` / `Shift+Tab` cycle within the element, focus moves to the\n * `initial` target (or the first focusable) when `auto`, `Escape` releases it, and on\n * release focus returns to the opener when `restore`. With `inert` the rest of the page\n * is made `inert` (a hard, modal-style isolation); left off it is a soft boundary —\n * `Tab` still cycles but the background stays reachable. The element carries\n * `data-focus-trapped` while active and emits `activate` / `deactivate`.\n *\n * @remarks\n * Behavior only — it does not open/close or render an overlay (pair with Dialog) and\n * does not move DOM (pair with Portal). It reuses the shared {@link FocusTrap}, so it never\n * scroll-locks the page (unlike the modal overlays) and tracks live focusable children\n * (dynamic additions are picked up on the next `Tab`). The opener is recorded on\n * activate and refocused on release if still present. Everything is torn down on\n * `disconnect()` (Turbo navigation included) without yanking focus.\n */\nexport class FocusController extends Controller<HTMLElement> {\n static override targets = [\"initial\"];\n static override values = {\n trap: { type: Boolean, default: false },\n auto: { type: Boolean, default: true },\n restore: { type: Boolean, default: true },\n inert: { type: Boolean, default: false },\n };\n static actions = [\"activate\", \"deactivate\"] as const;\n static events = [\"activate\", \"deactivate\"] as const;\n\n declare readonly initialTarget: HTMLElement;\n declare readonly hasInitialTarget: boolean;\n\n declare trapValue: boolean;\n declare autoValue: boolean;\n declare restoreValue: boolean;\n declare inertValue: boolean;\n\n readonly #trap = new FocusTrap(() => this.element, {\n // A focus scope is not a modal: never lock scroll, and only isolate the background\n // when `inert` is requested. `auto` gates the initial focus move; Escape releases.\n lockScroll: false,\n isolate: () => this.inertValue,\n autoFocus: () => this.autoValue,\n initialFocus: () => (this.hasInitialTarget ? this.initialTarget : null),\n onEscape: () => this.deactivate(),\n });\n\n /** Stimulus drives activation from the `trap` value (also fires on connect). */\n trapValueChanged(): void {\n if (this.trapValue) this.#activate();\n else this.#deactivate();\n }\n\n override disconnect(): void {\n // Release without restoring focus — the element is leaving the DOM. This is a\n // teardown, not a user-driven close, so it intentionally does NOT emit\n // `deactivate` or reset `trapValue` (which would resurrect on a Turbo cache\n // restore); only the public `deactivate()` action fires the event.\n this.#trap.deactivate({ restoreFocus: false });\n this.element.removeAttribute(\"data-focus-trapped\");\n }\n\n /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */\n activate(): void {\n this.trapValue = true;\n this.#activate();\n }\n\n /** Turns the trap off (also wired to Escape). */\n deactivate(): void {\n this.trapValue = false;\n this.#deactivate();\n }\n\n #activate(): void {\n if (this.#trap.active) return;\n this.#trap.activate();\n this.element.setAttribute(\"data-focus-trapped\", \"true\");\n this.dispatch(\"activate\", { detail: {} });\n }\n\n #deactivate(): void {\n if (!this.#trap.active) return;\n this.#trap.deactivate({ restoreFocus: this.restoreValue });\n this.element.removeAttribute(\"data-focus-trapped\");\n this.dispatch(\"deactivate\", { detail: {} });\n }\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/aria_ids.ts","../../src/controllers/form_field_controller.ts"],"names":[],"mappings":"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","file":"form_field_controller.js","sourcesContent":["/**\n * Minimal id primitives for wiring ARIA relationships.\n *\n * Accessible relationships such as `aria-describedby`, `aria-errormessage`,\n * `aria-labelledby`, and `aria-activedescendant` reference their targets by DOM\n * `id`. When a controller must establish such a relationship but the consumer's\n * markup left the target without an `id`, it needs to mint one that is stable for\n * the page's lifetime and guaranteed not to collide with another generated id.\n *\n * These helpers own *only* id generation/assignment. The linking policy (which\n * attribute, what order, when to add or remove it) stays in each controller —\n * this deliberately avoids a premature generic \"ARIA linker\" abstraction.\n */\n\n/**\n * Monotonic counter backing {@link uniqueId}. Module-scoped so every generated\n * id is unique across all controller instances sharing this module in a document.\n */\nlet counter = 0;\n\n/**\n * Returns a unique, DOM-id-safe string of the form `` `${prefix}-${n}` `` where\n * `n` increases on each call.\n *\n * The counter alone guarantees uniqueness against other *generated* ids, but a\n * consumer-authored element could already own `` `${prefix}-${n}` ``. When a\n * `document` is available the candidate is therefore advanced until no element\n * with that id exists, so a generated id never collides with author markup\n * either — the minimal \"id registry\" guarantee these helpers exist to provide.\n *\n * @param prefix - Leading segment of the id (e.g. a controller's identifier).\n * Defaults to `\"stimeo\"`.\n */\nexport function uniqueId(prefix = \"stimeo\"): string {\n let candidate: string;\n do {\n counter += 1;\n candidate = `${prefix}-${counter}`;\n } while (typeof document !== \"undefined\" && document.getElementById(candidate) !== null);\n return candidate;\n}\n\n/**\n * Returns `element`'s existing `id`, or assigns it a freshly generated\n * {@link uniqueId} (with the given `prefix`) and returns that. Idempotent: an\n * element that already has an id keeps it, so consumer-authored ids are never\n * overwritten.\n *\n * @param element - The element to read or stamp an `id` onto.\n * @param prefix - Prefix forwarded to {@link uniqueId} when one must be generated.\n * @returns The element's id (existing or newly assigned).\n */\nexport function ensureId(element: Element, prefix = \"stimeo\"): string {\n if (element.id) return element.id;\n const id = uniqueId(prefix);\n element.id = id;\n return id;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { ensureId } from \"../utils/aria_ids\";\n\n/** Stimulus action params understood by {@link FormFieldController.setError}. */\ninterface SetErrorParams {\n /** The error message to display. */\n message?: string;\n}\n\n/** An action event carrying Stimulus `data-*-param` values. */\ntype ActionEvent = Event & { params?: SetErrorParams };\n\n/**\n * Headless, accessible form-field association behavior.\n *\n * Markup contract (identifier: `stimeo--form-field`):\n * <div data-controller=\"stimeo--form-field\">\n * <label for=\"email\">Email</label>\n * <input id=\"email\" type=\"email\" aria-invalid=\"false\"\n * data-stimeo--form-field-target=\"control\" />\n * <p data-stimeo--form-field-target=\"description\">We'll send a confirmation.</p>\n * <p role=\"alert\" hidden data-stimeo--form-field-target=\"error\"></p>\n * </div>\n *\n * Not an APG widget pattern — this is the wiring substrate behind form controls:\n * it supports **Name, Role, Value** (WCAG 4.1.2) and **error identification**\n * (3.3.1 / 4.1.3) by composing the control's `aria-describedby`, toggling\n * `aria-invalid`, and pointing `aria-errormessage` at the live error region.\n *\n * @remarks\n * Behavior only — it sets semantic attributes and the `hidden` state of the\n * error region; it never validates input (the consumer / server decides) and\n * never styles. The error target should carry `role=\"alert\"` (or an\n * `aria-live` region) so a newly shown message is announced without moving focus.\n *\n * Behavior provided:\n * - On connect, assigns ids to description/error targets and composes the\n * control's `aria-describedby` from them (preserving any pre-existing tokens).\n * - Reflects server-rendered errors: an error target that is already visible and\n * non-empty at connect puts the field into the invalid state (progressive\n * enhancement).\n * - {@link setError} / {@link clearError} drive the invalid state at runtime and\n * dispatch `stimeo--form-field:validate`.\n */\nexport class FormFieldController extends Controller<HTMLElement> {\n static override targets = [\"control\", \"description\", \"error\"];\n static override values = {\n focusOnError: { type: Boolean, default: false },\n };\n static actions = [\"clearError\", \"setError\"] as const;\n static events = [\"validate\"] as const;\n\n declare readonly controlTarget: HTMLElement;\n declare readonly hasControlTarget: boolean;\n declare readonly descriptionTargets: HTMLElement[];\n declare readonly errorTargets: HTMLElement[];\n declare readonly hasErrorTarget: boolean;\n declare focusOnErrorValue: boolean;\n\n /** Root attribute (CSS hook) reflecting the invalid state. */\n static readonly #INVALID_ATTR = \"data-stimeo--form-field-invalid\";\n\n /**\n * `aria-describedby` tokens the consumer set on the control that the\n * controller does not own. Captured once so composition never clobbers them.\n */\n #baseDescribedBy: string[] = [];\n\n /** Wires ids, captures consumer tokens, and reflects any initial error state. */\n override connect(): void {\n for (const description of this.descriptionTargets) {\n ensureId(description, \"stimeo--form-field-desc\");\n }\n for (const error of this.errorTargets) {\n ensureId(error, \"stimeo--form-field-error\");\n }\n this.#baseDescribedBy = this.#externalDescribedByTokens();\n this.#reflect();\n }\n\n /**\n * Marks the field invalid and shows the error message. Bound via `data-action`\n * (`#setError`) or callable directly.\n *\n * @param arg - Either the message string, or the action event whose\n * `data-stimeo--form-field-message-param` supplies it. When no message is\n * resolvable, any already-populated error targets are simply (re)shown.\n */\n setError(arg?: string | ActionEvent): void {\n const message = this.#resolveMessage(arg);\n if (message !== null && this.hasErrorTarget) {\n this.errorTargets[0]?.replaceChildren(document.createTextNode(message));\n }\n for (const error of this.errorTargets) {\n error.hidden = (error.textContent ?? \"\").trim() === \"\";\n }\n // setError is an explicit invalid request: mark invalid even when the field\n // has no error region (structure rules require only `control`), so the DOM\n // invalid state never disagrees with the dispatched `valid: false`.\n this.#reflect(true);\n this.dispatch(\"validate\", { detail: { valid: false, message: this.#shownMessage() } });\n if (this.focusOnErrorValue && this.hasControlTarget) {\n this.controlTarget.focus();\n }\n }\n\n /**\n * Clears the error: empties and hides every error target and marks the field\n * valid. Bound via `data-action` (`#clearError`) or callable directly.\n */\n clearError(): void {\n for (const error of this.errorTargets) {\n error.replaceChildren();\n error.hidden = true;\n }\n this.#reflect();\n this.dispatch(\"validate\", { detail: { valid: true, message: \"\" } });\n }\n\n /**\n * Synchronizes the control's ARIA wiring and the root CSS hook from the current\n * error targets. Idempotent, so it is safe to call on connect and after any\n * change (and survives Turbo morphing).\n *\n * @param force - When `true`, the field is marked invalid regardless of whether\n * a (visible, non-empty) error region exists. {@link setError} passes this so\n * the invalid state holds even with no error target; derivation from the DOM\n * (connect / {@link clearError}) leaves it `false`.\n */\n #reflect(force = false): void {\n const shown = this.#shownErrors();\n const invalid = force || shown.length > 0;\n\n if (invalid) {\n this.element.setAttribute(FormFieldController.#INVALID_ATTR, \"\");\n } else {\n this.element.removeAttribute(FormFieldController.#INVALID_ATTR);\n }\n\n if (!this.hasControlTarget) return;\n const control = this.controlTarget;\n control.setAttribute(\"aria-invalid\", invalid ? \"true\" : \"false\");\n\n const errorIds = shown.map((error) => error.id);\n // aria-errormessage references a single error element (the widely-supported\n // IDREF form); any additional errors stay in aria-describedby below.\n const primaryErrorId = errorIds[0];\n if (primaryErrorId) {\n control.setAttribute(\"aria-errormessage\", primaryErrorId);\n } else {\n control.removeAttribute(\"aria-errormessage\");\n }\n\n // Compose describedby: consumer tokens, then descriptions, then shown errors\n // (so legacy AT that ignore aria-errormessage still read the error).\n const describedBy = [\n ...this.#baseDescribedBy,\n ...this.descriptionTargets.map((description) => description.id),\n ...errorIds,\n ];\n if (describedBy.length > 0) {\n control.setAttribute(\"aria-describedby\", describedBy.join(\" \"));\n } else {\n control.removeAttribute(\"aria-describedby\");\n }\n }\n\n /** Error targets currently visible and non-empty. */\n #shownErrors(): HTMLElement[] {\n return this.errorTargets.filter(\n (error) => !error.hidden && (error.textContent ?? \"\").trim() !== \"\",\n );\n }\n\n /** Text of the first shown error, for the `validate` event detail. */\n #shownMessage(): string {\n return (this.#shownErrors()[0]?.textContent ?? \"\").trim();\n }\n\n /** Resolves a message from a string argument or an action event's params. */\n #resolveMessage(arg?: string | ActionEvent): string | null {\n if (typeof arg === \"string\") return arg;\n const message = arg?.params?.message;\n return typeof message === \"string\" ? message : null;\n }\n\n /**\n * Tokens already in the control's `aria-describedby` that are not ids of this\n * controller's own description/error targets.\n */\n #externalDescribedByTokens(): string[] {\n if (!this.hasControlTarget) return [];\n const owned = new Set([\n ...this.descriptionTargets.map((description) => description.id),\n ...this.errorTargets.map((error) => error.id),\n ]);\n const existing = this.controlTarget.getAttribute(\"aria-describedby\") ?? \"\";\n return existing.split(/\\s+/).filter((token) => token.length > 0 && !owned.has(token));\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/aria_ids.ts","../../src/controllers/form_field_controller.ts"],"names":[],"mappings":"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","file":"form_field_controller.js","sourcesContent":["/**\n * Minimal id primitives for wiring ARIA relationships.\n *\n * Accessible relationships such as `aria-describedby`, `aria-errormessage`,\n * `aria-labelledby`, and `aria-activedescendant` reference their targets by DOM\n * `id`. When a controller must establish such a relationship but the consumer's\n * markup left the target without an `id`, it needs to mint one that is stable for\n * the page's lifetime and guaranteed not to collide with another generated id.\n *\n * These helpers own *only* id generation/assignment. The linking policy (which\n * attribute, what order, when to add or remove it) stays in each controller —\n * this deliberately avoids a premature generic \"ARIA linker\" abstraction.\n */\n\n/**\n * Monotonic counter backing {@link uniqueId}. Module-scoped so every generated\n * id is unique across all controller instances sharing this module in a document.\n */\nlet counter = 0;\n\n/**\n * Returns a unique, DOM-id-safe string of the form `` `${prefix}-${n}` `` where\n * `n` increases on each call.\n *\n * The counter alone guarantees uniqueness against other *generated* ids, but a\n * consumer-authored element could already own `` `${prefix}-${n}` ``. When a\n * `document` is available the candidate is therefore advanced until no element\n * with that id exists, so a generated id never collides with author markup\n * either — the minimal \"id registry\" guarantee these helpers exist to provide.\n *\n * @param prefix - Leading segment of the id (e.g. a controller's identifier).\n * Defaults to `\"stimeo\"`.\n */\nexport function uniqueId(prefix = \"stimeo\"): string {\n let candidate: string;\n do {\n counter += 1;\n candidate = `${prefix}-${counter}`;\n } while (typeof document !== \"undefined\" && document.getElementById(candidate) !== null);\n return candidate;\n}\n\n/**\n * Returns `element`'s existing `id`, or assigns it a freshly generated\n * {@link uniqueId} (with the given `prefix`) and returns that. Idempotent: an\n * element that already has an id keeps it, so consumer-authored ids are never\n * overwritten.\n *\n * @param element - The element to read or stamp an `id` onto.\n * @param prefix - Prefix forwarded to {@link uniqueId} when one must be generated.\n * @returns The element's id (existing or newly assigned).\n */\nexport function ensureId(element: Element, prefix = \"stimeo\"): string {\n if (element.id) return element.id;\n const id = uniqueId(prefix);\n element.id = id;\n return id;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { ensureId } from \"../utils/aria_ids\";\n\n/** Stimulus action params understood by {@link FormFieldController.setError}. */\ninterface SetErrorParams {\n /** The error message to display. */\n message?: string;\n}\n\n/** An action event carrying Stimulus `data-*-param` values. */\ntype ActionEvent = Event & { params?: SetErrorParams };\n\n/**\n * Headless, accessible form-field association behavior.\n *\n * Markup contract (identifier: `stimeo--form-field`):\n * <div data-controller=\"stimeo--form-field\">\n * <label for=\"email\">Email</label>\n * <input id=\"email\" type=\"email\" aria-invalid=\"false\"\n * data-stimeo--form-field-target=\"control\" />\n * <p data-stimeo--form-field-target=\"description\">We'll send a confirmation.</p>\n * <p role=\"alert\" hidden data-stimeo--form-field-target=\"error\"></p>\n * </div>\n *\n * Not an APG widget pattern — this is the wiring substrate behind form controls:\n * it supports **Name, Role, Value** (WCAG 4.1.2) and **error identification**\n * (3.3.1 / 4.1.3) by composing the control's `aria-describedby`, toggling\n * `aria-invalid`, and pointing `aria-errormessage` at the live error region.\n *\n * @remarks\n * Behavior only — it sets semantic attributes and the `hidden` state of the\n * error region; it never validates input (the consumer / server decides) and\n * never styles. The error target should carry `role=\"alert\"` (or an\n * `aria-live` region) so a newly shown message is announced without moving focus.\n *\n * Behavior provided:\n * - On connect, assigns ids to description/error targets and composes the\n * control's `aria-describedby` from them (preserving any pre-existing tokens).\n * - Reflects server-rendered errors: an error target that is already visible and\n * non-empty at connect puts the field into the invalid state (progressive\n * enhancement).\n * - {@link setError} / {@link clearError} drive the invalid state at runtime and\n * dispatch `stimeo--form-field:validate`.\n */\nexport class FormFieldController extends Controller<HTMLElement> {\n static override targets = [\"control\", \"description\", \"error\"];\n static override values = {\n focusOnError: { type: Boolean, default: false },\n };\n static actions = [\"clearError\", \"setError\"] as const;\n static events = [\"validate\"] as const;\n\n declare readonly controlTarget: HTMLElement;\n declare readonly hasControlTarget: boolean;\n declare readonly descriptionTargets: HTMLElement[];\n declare readonly errorTargets: HTMLElement[];\n declare readonly hasErrorTarget: boolean;\n declare focusOnErrorValue: boolean;\n\n /** Root attribute (CSS hook) reflecting the invalid state. */\n static readonly #INVALID_ATTR = \"data-stimeo--form-field-invalid\";\n\n /**\n * `aria-describedby` tokens the consumer set on the control that the\n * controller does not own. Captured once so composition never clobbers them.\n */\n #baseDescribedBy: string[] = [];\n\n /** Wires ids, captures consumer tokens, and reflects any initial error state. */\n override connect(): void {\n for (const description of this.descriptionTargets) {\n ensureId(description, \"stimeo--form-field-desc\");\n }\n for (const error of this.errorTargets) {\n ensureId(error, \"stimeo--form-field-error\");\n }\n this.#baseDescribedBy = this.#externalDescribedByTokens();\n this.#reflect();\n }\n\n /**\n * Marks the field invalid and shows the error message. Bound via `data-action`\n * (`#setError`) or callable directly.\n *\n * @param arg - Either the message string, or the action event whose\n * `data-stimeo--form-field-message-param` supplies it. When no message is\n * resolvable, any already-populated error targets are simply (re)shown.\n */\n setError(arg?: string | ActionEvent): void {\n const message = this.#resolveMessage(arg);\n if (message !== null && this.hasErrorTarget) {\n this.errorTargets[0]?.replaceChildren(document.createTextNode(message));\n }\n for (const error of this.errorTargets) {\n error.hidden = (error.textContent ?? \"\").trim() === \"\";\n }\n // setError is an explicit invalid request: mark invalid even when the field\n // has no error region (only `control` is required), so the DOM invalid state\n // never disagrees with the dispatched `valid: false`.\n this.#reflect(true);\n this.dispatch(\"validate\", { detail: { valid: false, message: this.#shownMessage() } });\n if (this.focusOnErrorValue && this.hasControlTarget) {\n this.controlTarget.focus();\n }\n }\n\n /**\n * Clears the error: empties and hides every error target and marks the field\n * valid. Bound via `data-action` (`#clearError`) or callable directly.\n */\n clearError(): void {\n for (const error of this.errorTargets) {\n error.replaceChildren();\n error.hidden = true;\n }\n this.#reflect();\n this.dispatch(\"validate\", { detail: { valid: true, message: \"\" } });\n }\n\n /**\n * Synchronizes the control's ARIA wiring and the root CSS hook from the current\n * error targets. Idempotent, so it is safe to call on connect and after any\n * change (and survives Turbo morphing).\n *\n * @param force - When `true`, the field is marked invalid regardless of whether\n * a (visible, non-empty) error region exists. {@link setError} passes this so\n * the invalid state holds even with no error target; derivation from the DOM\n * (connect / {@link clearError}) leaves it `false`.\n */\n #reflect(force = false): void {\n const shown = this.#shownErrors();\n const invalid = force || shown.length > 0;\n\n if (invalid) {\n this.element.setAttribute(FormFieldController.#INVALID_ATTR, \"\");\n } else {\n this.element.removeAttribute(FormFieldController.#INVALID_ATTR);\n }\n\n if (!this.hasControlTarget) return;\n const control = this.controlTarget;\n control.setAttribute(\"aria-invalid\", invalid ? \"true\" : \"false\");\n\n const errorIds = shown.map((error) => error.id);\n // aria-errormessage references a single error element (the widely-supported\n // IDREF form); any additional errors stay in aria-describedby below.\n const primaryErrorId = errorIds[0];\n if (primaryErrorId) {\n control.setAttribute(\"aria-errormessage\", primaryErrorId);\n } else {\n control.removeAttribute(\"aria-errormessage\");\n }\n\n // Compose describedby: consumer tokens, then descriptions, then shown errors\n // (so legacy AT that ignore aria-errormessage still read the error).\n const describedBy = [\n ...this.#baseDescribedBy,\n ...this.descriptionTargets.map((description) => description.id),\n ...errorIds,\n ];\n if (describedBy.length > 0) {\n control.setAttribute(\"aria-describedby\", describedBy.join(\" \"));\n } else {\n control.removeAttribute(\"aria-describedby\");\n }\n }\n\n /** Error targets currently visible and non-empty. */\n #shownErrors(): HTMLElement[] {\n return this.errorTargets.filter(\n (error) => !error.hidden && (error.textContent ?? \"\").trim() !== \"\",\n );\n }\n\n /** Text of the first shown error, for the `validate` event detail. */\n #shownMessage(): string {\n return (this.#shownErrors()[0]?.textContent ?? \"\").trim();\n }\n\n /** Resolves a message from a string argument or an action event's params. */\n #resolveMessage(arg?: string | ActionEvent): string | null {\n if (typeof arg === \"string\") return arg;\n const message = arg?.params?.message;\n return typeof message === \"string\" ? message : null;\n }\n\n /**\n * Tokens already in the control's `aria-describedby` that are not ids of this\n * controller's own description/error targets.\n */\n #externalDescribedByTokens(): string[] {\n if (!this.hasControlTarget) return [];\n const owned = new Set([\n ...this.descriptionTargets.map((description) => description.id),\n ...this.errorTargets.map((error) => error.id),\n ]);\n const existing = this.controlTarget.getAttribute(\"aria-describedby\") ?? \"\";\n return existing.split(/\\s+/).filter((token) => token.length > 0 && !owned.has(token));\n }\n}\n"]}
|
|
@@ -216,7 +216,7 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
218
218
|
* Where focus should land for an invalid control. A visible control is focused
|
|
219
|
-
* directly
|
|
219
|
+
* directly — the case for native fields and radios. A validatable mirror
|
|
220
220
|
* (the `hidden` attribute) cannot receive focus, so focus is delegated to the
|
|
221
221
|
* visible widget: the owning field's `control` target when it is itself
|
|
222
222
|
* focusable, else its first focusable descendant (e.g. a roving-tabindex
|