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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/coerce.ts","../../src/controllers/meter_controller.ts"],"names":[],"mappings":"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","file":"meter_controller.js","sourcesContent":["/**\n * Numeric coercion shared by the value-bearing status controllers (progress,\n * meter).\n *\n * Stimulus already coerces numeric action params to numbers, but a value can also\n * arrive as a string — via a `*:set` CustomEvent `detail`, or an action param\n * whose attribute does not look numeric. Centralizing the parse keeps `setValue`\n * tolerant of either form while rejecting anything that is not a finite number.\n */\n\n/**\n * Coerces `raw` to a finite number, or returns `null` when it is absent, empty,\n * or not parseable. Empty strings are treated as \"no value\" rather than `0`, so a\n * stray blank param cannot silently reset the value.\n */\nexport function toFiniteNumber(raw: number | string | null | undefined): number | null {\n if (raw === null || raw === undefined || raw === \"\") return null;\n const value = typeof raw === \"number\" ? raw : Number(raw);\n return Number.isFinite(value) ? value : null;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { toFiniteNumber } from \"../utils/coerce\";\n\n/**\n * Event shape `setValue` accepts: an action param `amount` or a `detail.value`.\n * Both are typed `number | string` because, while Stimulus coerces numeric action\n * params to numbers, a `meter:set` CustomEvent (or a non-numeric-looking param)\n * may carry a string; {@link MeterController.setValue} normalizes either form.\n */\ntype SetValueEvent = Event & {\n params?: { amount?: number | string };\n detail?: { value?: number | string };\n};\n\n/** Threshold segment a value falls into, reflected on `data-state`. */\ntype MeterState = \"low\" | \"medium\" | \"high\";\n\n/**\n * Headless meter behavior backed by the WAI-ARIA `meter` role.\n *\n * Markup contract (identifier: `stimeo--meter`):\n * <div data-controller=\"stimeo--meter\" role=\"meter\" aria-label=\"Disk usage\"\n * aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"72\"\n * data-stimeo--meter-value-value=\"72\"\n * data-stimeo--meter-low-value=\"50\" data-stimeo--meter-high-value=\"80\">\n * <div data-stimeo--meter-target=\"bar\"></div>\n * </div>\n *\n * A `meter` is a *point-in-time* scalar within a known range (disk usage,\n * battery, score) — distinct from {@link ProgressController}'s task progress.\n * The controller syncs the ARIA value attributes and, when `low`/`high`\n * thresholds are present, classifies the value into a `low`/`medium`/`high`\n * segment on `data-state` so the consumer can color the bar.\n *\n * @remarks\n * Behavior only. Because state must not be conveyed by color alone (WCAG 1.4.1),\n * a consumer-provided `valueText` template feeds `aria-valuetext` so the segment\n * is also available as text. Threshold presence is read from the *attributes*\n * (an absent attribute means \"no threshold\"), not from a sentinel value.\n */\nexport class MeterController extends Controller<HTMLElement> {\n static override targets = [\"bar\"];\n static override values = {\n value: { type: Number, default: 0 },\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n low: { type: Number, default: 0 },\n high: { type: Number, default: 0 },\n optimum: { type: Number, default: 0 },\n valueText: { type: String, default: \"\" },\n };\n static actions = [\"setValue\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly barTarget: HTMLElement;\n declare readonly hasBarTarget: boolean;\n\n declare valueValue: number;\n declare minValue: number;\n declare maxValue: number;\n declare lowValue: number;\n declare highValue: number;\n declare optimumValue: number;\n declare valueTextValue: string;\n\n override connect(): void {\n this.#render();\n }\n\n /**\n * Updates the measured value from an action param (`amount`) or a\n * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches\n * `change` with the value, ratio, and computed segment.\n */\n setValue(event: SetValueEvent): void {\n const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);\n if (next === null) return;\n this.valueValue = this.#clamp(next);\n this.#render();\n this.dispatch(\"change\", {\n detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state },\n });\n }\n\n /** Clamps `raw` into the configured `[min, max]` range. */\n #clamp(raw: number): number {\n return Math.min(this.maxValue, Math.max(this.minValue, raw));\n }\n\n /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */\n get #ratio(): number {\n const span = this.maxValue - this.minValue;\n if (span <= 0) return 0;\n return (this.#clamp(this.valueValue) - this.minValue) / span;\n }\n\n /** Whether a threshold attribute is present (absent = no threshold). */\n #hasThreshold(name: \"low\" | \"high\"): boolean {\n return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);\n }\n\n /**\n * Classifies the value into a `low`/`medium`/`high` segment. Values at or\n * below `low` are `low`; at or above `high` are `high`; otherwise `medium`.\n * With neither threshold present, everything is `medium`.\n */\n get #state(): MeterState {\n const value = this.#clamp(this.valueValue);\n if (this.#hasThreshold(\"low\") && value <= this.lowValue) return \"low\";\n if (this.#hasThreshold(\"high\") && value >= this.highValue) return \"high\";\n return \"medium\";\n }\n\n /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */\n #render(): void {\n const value = this.#clamp(this.valueValue);\n this.element.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.element.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.element.setAttribute(\"aria-valuenow\", String(value));\n this.element.style.setProperty(\"--stimeo-meter-ratio\", String(this.#ratio));\n this.element.setAttribute(\"data-state\", this.#state);\n this.#applyValueText(value);\n }\n\n /**\n * Sets `aria-valuetext` from the consumer-provided template, substituting\n * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library;\n * cleared when no template is given.\n */\n #applyValueText(value: number): void {\n if (this.valueTextValue.length === 0) {\n this.element.removeAttribute(\"aria-valuetext\");\n return;\n }\n const percent = Math.round(this.#ratio * 100);\n const text = this.valueTextValue\n .replaceAll(\"{value}\", String(value))\n .replaceAll(\"{percent}\", String(percent))\n .replaceAll(\"{state}\", this.#state);\n this.element.setAttribute(\"aria-valuetext\", text);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/announce.ts","../../src/utils/coerce.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/range.ts","../../src/controllers/meter_controller.ts"],"names":[],"mappings":"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","file":"meter_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","/**\n * Numeric coercion shared by the value-bearing controllers (progress, meter,\n * color-picker).\n *\n * Stimulus already coerces numeric action params to numbers, but a value can also\n * arrive as a string — via a `*:set` CustomEvent `detail`, or an action param\n * whose attribute does not look numeric. Centralizing the parse keeps `setValue`\n * tolerant of either form while rejecting anything that is not a finite number.\n */\n\n/**\n * Coerces `raw` to a finite number, or returns `null` when it is absent, empty,\n * or not parseable. Empty strings are treated as \"no value\" rather than `0`, so a\n * stray blank param cannot silently reset the value.\n */\nexport function toFiniteNumber(raw: number | string | null | undefined): number | null {\n if (raw === null || raw === undefined || raw === \"\") return null;\n const value = typeof raw === \"number\" ? raw : Number(raw);\n return Number.isFinite(value) ? value : null;\n}\n","/**\n * Collapses many target callbacks from one DOM mutation into a single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element, so\n * replacing a list of N options delivers N callbacks — but the useful unit of\n * work is \"reconcile against the DOM that resulted\", once, after the batch has\n * settled. Every controller that owns a reconcilable target set needs the same\n * shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers the *initial* target callbacks\n * ahead of `connect()`. Reconciling there would compute a fallback against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\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 #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","/**\n * Range normalization shared by the value-bearing controllers (progress, meter,\n * slider, range-slider).\n *\n * Each of them publishes \"where the value sits inside `[min, max]`\" as a CSS\n * custom property the consumer multiplies a track by, and each has to answer the\n * same degenerate cases: empty and inverted ranges cannot express progress, and\n * malformed Number Values can produce `NaN`. Keeping the rule in one place is\n * what makes those answers identical across the four.\n */\n\n/**\n * Fraction of `[min, max]` that `value` occupies, always within `[0, 1]`.\n *\n * `value` is clamped into the range first, so a value outside it reports a full\n * or empty track rather than pushing the fraction past the ends.\n *\n * An empty, inverted, or non-numeric range yields `0`. That is a deliberate\n * floor rather than a computed result: `min === max` would produce `NaN`, and\n * `min > max` would report a full track for a value that is really out of range.\n * Non-finite results are floored for the same reason. These results reach\n * assistive tech, because the same fraction drives the percentage substituted\n * into `aria-valuetext`.\n */\nexport function rangeFraction(value: number, min: number, max: number): number {\n const span = max - min;\n if (!(span > 0)) return 0;\n\n const clamped = Math.min(max, Math.max(min, value));\n let fraction: number;\n if (Number.isFinite(span)) {\n fraction = (clamped - min) / span;\n } else {\n // Scaling preserves the ratio when two finite endpoints straddle zero so\n // widely that their subtraction overflows to Infinity.\n const scale = Math.max(Math.abs(min), Math.abs(max));\n fraction = (clamped / scale - min / scale) / (max / scale - min / scale);\n }\n\n if (!Number.isFinite(fraction)) return 0;\n return Math.min(1, Math.max(0, fraction));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\nimport { toFiniteNumber } from \"../utils/coerce\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { rangeFraction } from \"../utils/range\";\n\n/**\n * Event shape `setValue` accepts: an action param `amount` or a `detail.value`.\n * Both are typed `number | string` because, while Stimulus coerces numeric action\n * params to numbers, a `meter:set` CustomEvent (or a non-numeric-looking param)\n * may carry a string; {@link MeterController.setValue} normalizes either form.\n */\ntype SetValueEvent = Event & {\n params?: { amount?: number | string };\n detail?: { value?: number | string };\n};\n\n/** Threshold segment a value falls into, reflected on `data-state`. */\ntype MeterState = \"low\" | \"medium\" | \"high\";\n\n/** One point-in-time reading: the clamped value with the ratio and segment it implies. */\ntype MeterReading = { value: number; ratio: number; state: MeterState };\n\n/**\n * Marks an `aria-valuetext` this controller wrote, so a render takes back only\n * its own text. `aria-valuetext` is shared: a consumer may author it instead of\n * supplying a template, and that text is what carries the threshold segment to\n * readers who cannot see the colour — clearing it would take the segment with it.\n */\nconst OWNED_VALUE_TEXT = \"data-stimeo--meter-owns-valuetext\";\n\n/**\n * Headless meter behavior backed by the WAI-ARIA `meter` role.\n *\n * Markup contract (identifier: `stimeo--meter`):\n * <div data-controller=\"stimeo--meter\" role=\"meter\" aria-label=\"Disk usage\"\n * aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"72\"\n * data-stimeo--meter-value-value=\"72\"\n * data-stimeo--meter-low-value=\"50\" data-stimeo--meter-high-value=\"80\">\n * <div data-stimeo--meter-target=\"bar\"></div>\n * </div>\n *\n * A `meter` is a *point-in-time* scalar within a known range (disk usage,\n * battery, score) — distinct from {@link ProgressController}'s task progress.\n * The controller syncs the ARIA value attributes and, when `low`/`high`\n * thresholds are present, classifies the value into a `low`/`medium`/`high`\n * segment on `data-state` so the consumer can color the bar.\n *\n * @remarks\n * Behavior only. Because state must not be conveyed by color alone (WCAG 1.4.1),\n * a consumer-provided `valueText` template feeds `aria-valuetext` so the segment\n * is also available as text; a consumer that authors `aria-valuetext` itself keeps\n * it instead (see {@link OWNED_VALUE_TEXT}). Threshold presence is read from the\n * *attributes* (an absent attribute means \"no threshold\"), not from a sentinel value.\n */\nexport class MeterController extends Controller<HTMLElement> {\n static override targets = [\"bar\"];\n static override values = {\n announceText: { type: String, default: \"\" },\n value: { type: Number, default: 0 },\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n low: { type: Number, default: 0 },\n high: { type: Number, default: 0 },\n optimum: { type: Number, default: 0 },\n valueText: { type: String, default: \"\" },\n };\n static actions = [\"setValue\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly barTarget: HTMLElement;\n declare readonly hasBarTarget: boolean;\n\n declare valueValue: number;\n declare minValue: number;\n declare maxValue: number;\n declare lowValue: number;\n declare highValue: number;\n declare optimumValue: number;\n declare valueTextValue: string;\n declare announceTextValue: string;\n\n /**\n * Collapses a morph that swaps several render inputs at once into one repaint.\n * A single update usually rewrites the whole set, and each Value would otherwise\n * repaint on its own.\n */\n readonly #repaint = new MicrotaskCoalescer(() => {\n this.#render();\n });\n\n /** The segment last announced, so only a change is read out. */\n #announcedState: MeterState | null = null;\n\n override connect(): void {\n this.#repaint.activate();\n this.#render();\n }\n\n /** Closes the window in which a queued repaint may still run. */\n override disconnect(): void {\n this.#repaint.cancel();\n }\n\n /**\n * Updates the measured value from an action param (`amount`) or a\n * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches\n * `change` with the value, ratio, and computed segment.\n */\n setValue(event: SetValueEvent): void {\n const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);\n if (next === null) return;\n this.valueValue = this.#clamp(next);\n const reading = this.#render();\n this.dispatch(\"change\", { detail: reading });\n // Only the segment is news: reading every value would be unusable, and the\n // number itself is already exposed through `aria-valuenow`.\n if (reading.state !== this.#announcedState) {\n this.#announcedState = reading.state;\n announce(\n fillTemplate(this.announceTextValue, { state: reading.state, value: reading.value }),\n );\n }\n }\n\n /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */\n valueValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */\n minValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */\n maxValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `low` at runtime. */\n lowValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `high` at runtime. */\n highValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */\n valueTextValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Clamps `raw` into the configured `[min, max]` range. */\n #clamp(raw: number): number {\n return Math.min(this.maxValue, Math.max(this.minValue, raw));\n }\n\n /** Whether a threshold attribute is present (absent = no threshold). */\n #hasThreshold(name: \"low\" | \"high\"): boolean {\n return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);\n }\n\n /**\n * Classifies `value` into a `low`/`medium`/`high` segment. Values at or below\n * `low` are `low`; at or above `high` are `high`; otherwise `medium`. With\n * neither threshold present, everything is `medium`.\n */\n #stateOf(value: number): MeterState {\n if (this.#hasThreshold(\"low\") && value <= this.lowValue) return \"low\";\n if (this.#hasThreshold(\"high\") && value >= this.highValue) return \"high\";\n return \"medium\";\n }\n\n /**\n * Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.\n * The reading is derived once and returned, so the `change` detail reports the\n * same numbers the DOM just received.\n */\n #render(): MeterReading {\n const value = this.#clamp(this.valueValue);\n const reading: MeterReading = {\n value,\n ratio: rangeFraction(value, this.minValue, this.maxValue),\n state: this.#stateOf(value),\n };\n this.element.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.element.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.element.setAttribute(\"aria-valuenow\", String(reading.value));\n this.element.style.setProperty(\"--stimeo-meter-ratio\", String(reading.ratio));\n this.element.setAttribute(\"data-state\", reading.state);\n this.#applyValueText(reading);\n return reading;\n }\n\n /**\n * Sets `aria-valuetext` from the consumer-provided template, substituting\n * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library.\n * With no template the attribute belongs to the consumer, so only a text this\n * controller wrote is taken back ({@link OWNED_VALUE_TEXT}).\n */\n #applyValueText({ value, ratio, state }: MeterReading): void {\n if (this.valueTextValue.length === 0) {\n if (this.element.hasAttribute(OWNED_VALUE_TEXT)) {\n this.element.removeAttribute(\"aria-valuetext\");\n this.element.removeAttribute(OWNED_VALUE_TEXT);\n }\n return;\n }\n const percent = Math.round(ratio * 100);\n const text = this.valueTextValue\n .replaceAll(\"{value}\", String(value))\n .replaceAll(\"{percent}\", String(percent))\n .replaceAll(\"{state}\", state);\n this.element.setAttribute(\"aria-valuetext\", text);\n this.element.setAttribute(OWNED_VALUE_TEXT, \"\");\n }\n}\n"]}
|
|
@@ -46,8 +46,8 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
46
46
|
* inputs (default name `options[]`), so the selection submits with a normal
|
|
47
47
|
* form and no consumer JS — parity with {@link TagsInputController}. An optional
|
|
48
48
|
* `form` value sets the hidden inputs' `form` attribute, associating them with a
|
|
49
|
-
* `<form>` by id even when the picker lives outside it.
|
|
50
|
-
*
|
|
49
|
+
* `<form>` by id even when the picker lives outside it. Without a `fields`
|
|
50
|
+
* target the hidden inputs are simply not written.
|
|
51
51
|
*/
|
|
52
52
|
declare class MultiSelectController extends Controller<HTMLElement> {
|
|
53
53
|
#private;
|
|
@@ -90,15 +90,41 @@ declare class MultiSelectController extends Controller<HTMLElement> {
|
|
|
90
90
|
connect(): void;
|
|
91
91
|
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
92
92
|
disconnect(): void;
|
|
93
|
-
/**
|
|
93
|
+
/** Reconciles active state after an option target is added at runtime. */
|
|
94
|
+
optionTargetConnected(): void;
|
|
95
|
+
/** Cleans a removed target and reconciles active state against the surviving DOM. */
|
|
96
|
+
optionTargetDisconnected(option: HTMLElement): void;
|
|
97
|
+
/**
|
|
98
|
+
* Tracks an input added initially or after connect, and makes it describe the
|
|
99
|
+
* widget that is actually on screen: a swapped-in input arrives with the
|
|
100
|
+
* authored ARIA of a fresh node while this controller still holds the popup
|
|
101
|
+
* state, and the open path cannot repair that (it seeds an active option only
|
|
102
|
+
* when there is none), so a live list would go unannounced.
|
|
103
|
+
*/
|
|
94
104
|
inputTargetConnected(input: HTMLInputElement): void;
|
|
95
105
|
/** Removes composition listeners when the active input is replaced or removed. */
|
|
96
106
|
inputTargetDisconnected(input: HTMLInputElement): void;
|
|
97
107
|
/** Filters confirmed input text, opens, and re-seeds the active option. */
|
|
98
108
|
filter(event?: InputEvent): void;
|
|
99
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Opens the list and activates the first visible option when none is active.
|
|
111
|
+
*
|
|
112
|
+
* Needs the input, which owns `aria-expanded` and `aria-activedescendant`: a
|
|
113
|
+
* list shown without one is a popup no assistive technology is told about. So
|
|
114
|
+
* opening is skipped entirely, where {@link close} still closes.
|
|
115
|
+
*/
|
|
100
116
|
open(): void;
|
|
101
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Closes the list and clears the active option.
|
|
119
|
+
*
|
|
120
|
+
* Survives a missing input in both directions. `connect()` calls this second,
|
|
121
|
+
* so dereferencing the input here would throw before the chips, the roving
|
|
122
|
+
* seed, the chip listeners, the hidden fields and the outside-click listener —
|
|
123
|
+
* and Stimulus keeps the controller alive after that throw, so none of them
|
|
124
|
+
* would ever run and the selection would silently stop submitting. An input
|
|
125
|
+
* removed while the list is open must still let it come down *and* forget its
|
|
126
|
+
* active option, so only the `aria-expanded` write is guarded.
|
|
127
|
+
*/
|
|
102
128
|
close(): void;
|
|
103
129
|
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
104
130
|
onKeydown(event: KeyboardEvent): void;
|
|
@@ -19,6 +19,22 @@ function ensureId(element, prefix = "stimeo") {
|
|
|
19
19
|
return id;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// src/utils/logical_scroll.ts
|
|
23
|
+
function isRtl(element) {
|
|
24
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/utils/arrow_step.ts
|
|
28
|
+
function logicalArrowKey(key, element) {
|
|
29
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
30
|
+
if (!isRtl(element)) return key;
|
|
31
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
32
|
+
}
|
|
33
|
+
function isReservedArrowChord(event, allow = []) {
|
|
34
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
35
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
// src/utils/composition_tracker.ts
|
|
23
39
|
var CompositionTracker = class {
|
|
24
40
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -66,6 +82,39 @@ var CompositionTracker = class {
|
|
|
66
82
|
};
|
|
67
83
|
};
|
|
68
84
|
|
|
85
|
+
// src/utils/microtask_coalescer.ts
|
|
86
|
+
var MicrotaskCoalescer = class {
|
|
87
|
+
#run;
|
|
88
|
+
#queued = false;
|
|
89
|
+
#active = false;
|
|
90
|
+
#generation = 0;
|
|
91
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
92
|
+
constructor(run) {
|
|
93
|
+
this.#run = run;
|
|
94
|
+
}
|
|
95
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
96
|
+
activate() {
|
|
97
|
+
this.#active = true;
|
|
98
|
+
}
|
|
99
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
100
|
+
cancel() {
|
|
101
|
+
this.#active = false;
|
|
102
|
+
this.#queued = false;
|
|
103
|
+
this.#generation += 1;
|
|
104
|
+
}
|
|
105
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
106
|
+
schedule() {
|
|
107
|
+
if (!this.#active || this.#queued) return;
|
|
108
|
+
this.#queued = true;
|
|
109
|
+
const generation = this.#generation;
|
|
110
|
+
queueMicrotask(() => {
|
|
111
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
112
|
+
this.#queued = false;
|
|
113
|
+
this.#run();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
69
118
|
// src/utils/option_scroll.ts
|
|
70
119
|
function scrollOptionIntoView(list, option) {
|
|
71
120
|
if (list.scrollHeight <= list.clientHeight) return;
|
|
@@ -111,6 +160,33 @@ var RovingTabindex = class {
|
|
|
111
160
|
}
|
|
112
161
|
};
|
|
113
162
|
|
|
163
|
+
// src/utils/tabindex_loan.ts
|
|
164
|
+
var TabindexLoan = class {
|
|
165
|
+
#value;
|
|
166
|
+
#lent = /* @__PURE__ */ new Set();
|
|
167
|
+
/**
|
|
168
|
+
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
169
|
+
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
170
|
+
* which a scroll region with no focusable content of its own needs.
|
|
171
|
+
*/
|
|
172
|
+
constructor(value = "-1") {
|
|
173
|
+
this.#value = value;
|
|
174
|
+
}
|
|
175
|
+
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
176
|
+
lend(element) {
|
|
177
|
+
if (element.hasAttribute("tabindex")) return;
|
|
178
|
+
element.setAttribute("tabindex", this.#value);
|
|
179
|
+
this.#lent.add(element);
|
|
180
|
+
}
|
|
181
|
+
/** Takes back every loan whose value is still the one that was lent. */
|
|
182
|
+
returnAll() {
|
|
183
|
+
for (const element of this.#lent) {
|
|
184
|
+
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
185
|
+
}
|
|
186
|
+
this.#lent.clear();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
114
190
|
// src/controllers/multi_select_controller.ts
|
|
115
191
|
var MultiSelectController = class extends Controller {
|
|
116
192
|
static targets = [
|
|
@@ -130,8 +206,14 @@ var MultiSelectController = class extends Controller {
|
|
|
130
206
|
};
|
|
131
207
|
static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
|
|
132
208
|
static events = ["change", "filter"];
|
|
133
|
-
/**
|
|
134
|
-
#
|
|
209
|
+
/** Stable id of the active option; the current target is resolved from the DOM. */
|
|
210
|
+
#activeOptionId = null;
|
|
211
|
+
/** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
|
|
212
|
+
#tabindex = new TabindexLoan();
|
|
213
|
+
/** Prevents initial/teardown target callbacks from mutating authored DOM. */
|
|
214
|
+
#connected = false;
|
|
215
|
+
/** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
|
|
216
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
|
|
135
217
|
/** Absorbs the browser's redundant final input after compositionend. */
|
|
136
218
|
#ignorePostCompositionInput = false;
|
|
137
219
|
/** Owns IME lifecycle state; confirmed text emits one filter result. */
|
|
@@ -151,30 +233,109 @@ var MultiSelectController = class extends Controller {
|
|
|
151
233
|
/** Starts closed, syncs chips for any pre-selected options, and listens out. */
|
|
152
234
|
connect() {
|
|
153
235
|
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
236
|
+
this.#normalizeSelection();
|
|
154
237
|
this.close();
|
|
155
238
|
if (this.hasTagsTarget) {
|
|
156
239
|
this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
|
|
157
240
|
this.tagsTarget.addEventListener("click", this.#onTagClick);
|
|
158
|
-
|
|
159
|
-
for (const option of this.#selectedOptions) this.#appendTag(option);
|
|
160
|
-
if (this.#removeButtons.length > 0) this.#roving.setActive(0);
|
|
241
|
+
this.#rebuildTags();
|
|
161
242
|
}
|
|
162
243
|
this.#syncFields();
|
|
163
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
244
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
245
|
+
this.#connected = true;
|
|
246
|
+
this.#reconcile.activate();
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Derives the chips from the selected options, idempotently: a Turbo Drive cache
|
|
250
|
+
* restore or morph can re-connect with chips already in the DOM, so they are
|
|
251
|
+
* cleared before deriving afresh to avoid duplicates.
|
|
252
|
+
*/
|
|
253
|
+
#rebuildTags() {
|
|
254
|
+
if (!this.hasTagsTarget) return;
|
|
255
|
+
for (const tag of this.tagTargets) tag.remove();
|
|
256
|
+
for (const option of this.#selectedOptions) this.#appendTag(option);
|
|
257
|
+
if (this.#removeButtons.length > 0) this.#roving.setActive(0);
|
|
164
258
|
}
|
|
165
259
|
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
166
260
|
disconnect() {
|
|
261
|
+
this.#connected = false;
|
|
262
|
+
this.#reconcile.cancel();
|
|
167
263
|
this.#composition.disconnect();
|
|
168
264
|
this.#ignorePostCompositionInput = false;
|
|
169
265
|
if (this.hasTagsTarget) {
|
|
170
266
|
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
171
267
|
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
172
268
|
}
|
|
173
|
-
document.removeEventListener("click", this.#onOutsideClick);
|
|
269
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
270
|
+
this.#releaseTabindex();
|
|
271
|
+
}
|
|
272
|
+
/** Reconciles active state after an option target is added at runtime. */
|
|
273
|
+
optionTargetConnected() {
|
|
274
|
+
this.#scheduleOptionReconcile();
|
|
174
275
|
}
|
|
175
|
-
/**
|
|
276
|
+
/** Cleans a removed target and reconciles active state against the surviving DOM. */
|
|
277
|
+
optionTargetDisconnected(option) {
|
|
278
|
+
if (!this.#connected) return;
|
|
279
|
+
option.removeAttribute("data-active");
|
|
280
|
+
this.#scheduleOptionReconcile();
|
|
281
|
+
}
|
|
282
|
+
/** Schedules one reconciliation after all callbacks in the mutation batch. */
|
|
283
|
+
#scheduleOptionReconcile() {
|
|
284
|
+
this.#reconcile.schedule();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Keeps a surviving/same-id active target, otherwise falls back to the first
|
|
288
|
+
* visible one — and brings the derived state back in line with the new option set.
|
|
289
|
+
*
|
|
290
|
+
* The baseline pass fills in any missing `aria-selected`, and the chips and
|
|
291
|
+
* hidden fields are re-derived from it, because the options are the truth source
|
|
292
|
+
* for the selection. The chips are rebuilt **only when the selected value set
|
|
293
|
+
* actually moved**: the rebuild removes and recreates every chip, so running it
|
|
294
|
+
* for an unrelated option would drop focus from a chip's remove button to
|
|
295
|
+
* `<body>`, losing the keyboard user's place for something that did not concern
|
|
296
|
+
* them.
|
|
297
|
+
*/
|
|
298
|
+
#reconcileOptions() {
|
|
299
|
+
const visible = this.#visibleOptions;
|
|
300
|
+
const active = this.#activeOption;
|
|
301
|
+
const next = this.#isClosed ? null : active && !active.hidden ? active : visible[0] ?? null;
|
|
302
|
+
this.#setActive(next);
|
|
303
|
+
this.#reflectEmpty();
|
|
304
|
+
this.#normalizeSelection();
|
|
305
|
+
const selected = this.#selectedOptions;
|
|
306
|
+
const nextValues = selected.map((option) => this.#optionValue(option)).sort();
|
|
307
|
+
const tagValues = this.tagTargets.map((tag) => tag.dataset.value ?? "").sort();
|
|
308
|
+
const unchanged = nextValues.length === tagValues.length && nextValues.every((value, index) => value === tagValues[index]);
|
|
309
|
+
if (unchanged) this.#refreshTagLabels(selected);
|
|
310
|
+
else this.#rebuildTags();
|
|
311
|
+
this.#syncFields();
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Gives every option an explicit `aria-selected`, without changing which ones
|
|
315
|
+
* the author chose. An absent value means "not selectable" in ARIA, so a
|
|
316
|
+
* forgotten attribute hides a selectable option. Several `true` is the normal
|
|
317
|
+
* case here — the list is `aria-multiselectable` — so nothing is dropped.
|
|
318
|
+
*/
|
|
319
|
+
#normalizeSelection() {
|
|
320
|
+
for (const option of this.optionTargets) {
|
|
321
|
+
if (option.getAttribute("aria-selected") !== "true") {
|
|
322
|
+
option.setAttribute("aria-selected", "false");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Tracks an input added initially or after connect, and makes it describe the
|
|
328
|
+
* widget that is actually on screen: a swapped-in input arrives with the
|
|
329
|
+
* authored ARIA of a fresh node while this controller still holds the popup
|
|
330
|
+
* state, and the open path cannot repair that (it seeds an active option only
|
|
331
|
+
* when there is none), so a live list would go unannounced.
|
|
332
|
+
*/
|
|
176
333
|
inputTargetConnected(input) {
|
|
177
334
|
this.#composition.observe(input);
|
|
335
|
+
input.setAttribute("aria-expanded", String(!this.#isClosed));
|
|
336
|
+
const active = this.#activeOption;
|
|
337
|
+
if (active) input.setAttribute("aria-activedescendant", ensureId(active, "stimeo-ms-opt"));
|
|
338
|
+
else input.removeAttribute("aria-activedescendant");
|
|
178
339
|
}
|
|
179
340
|
/** Removes composition listeners when the active input is replaced or removed. */
|
|
180
341
|
inputTargetDisconnected(input) {
|
|
@@ -195,28 +356,48 @@ var MultiSelectController = class extends Controller {
|
|
|
195
356
|
}
|
|
196
357
|
this.open();
|
|
197
358
|
const visible = this.#visibleOptions;
|
|
198
|
-
this
|
|
359
|
+
this.#reflectEmpty();
|
|
199
360
|
this.#setActive(visible[0] ?? null);
|
|
200
361
|
this.dispatch("filter", { detail: { query } });
|
|
201
362
|
}
|
|
202
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* Opens the list and activates the first visible option when none is active.
|
|
365
|
+
*
|
|
366
|
+
* Needs the input, which owns `aria-expanded` and `aria-activedescendant`: a
|
|
367
|
+
* list shown without one is a popup no assistive technology is told about. So
|
|
368
|
+
* opening is skipped entirely, where {@link close} still closes.
|
|
369
|
+
*/
|
|
203
370
|
open() {
|
|
204
|
-
if (!this.hasListTarget) return;
|
|
371
|
+
if (!this.hasListTarget || !this.hasInputTarget) return;
|
|
205
372
|
this.listTarget.hidden = false;
|
|
206
373
|
this.inputTarget.setAttribute("aria-expanded", "true");
|
|
207
374
|
if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
|
|
208
375
|
}
|
|
209
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* Closes the list and clears the active option.
|
|
378
|
+
*
|
|
379
|
+
* Survives a missing input in both directions. `connect()` calls this second,
|
|
380
|
+
* so dereferencing the input here would throw before the chips, the roving
|
|
381
|
+
* seed, the chip listeners, the hidden fields and the outside-click listener —
|
|
382
|
+
* and Stimulus keeps the controller alive after that throw, so none of them
|
|
383
|
+
* would ever run and the selection would silently stop submitting. An input
|
|
384
|
+
* removed while the list is open must still let it come down *and* forget its
|
|
385
|
+
* active option, so only the `aria-expanded` write is guarded.
|
|
386
|
+
*/
|
|
210
387
|
close() {
|
|
211
388
|
if (!this.hasListTarget) return;
|
|
212
389
|
this.listTarget.hidden = true;
|
|
213
|
-
this.inputTarget.setAttribute("aria-expanded", "false");
|
|
214
390
|
this.#setActive(null);
|
|
391
|
+
if (!this.hasInputTarget) return;
|
|
392
|
+
this.inputTarget.setAttribute("aria-expanded", "false");
|
|
215
393
|
}
|
|
216
394
|
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
217
395
|
onKeydown(event) {
|
|
396
|
+
if (event.defaultPrevented) return;
|
|
397
|
+
if (isReservedArrowChord(event)) return;
|
|
218
398
|
if (this.#composition.isComposing(event)) return;
|
|
219
|
-
|
|
399
|
+
this.#reconcileActiveForInteraction();
|
|
400
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
220
401
|
case "ArrowDown":
|
|
221
402
|
event.preventDefault();
|
|
222
403
|
if (this.#isClosed) this.open();
|
|
@@ -241,14 +422,16 @@ var MultiSelectController = class extends Controller {
|
|
|
241
422
|
}
|
|
242
423
|
break;
|
|
243
424
|
}
|
|
244
|
-
case "Enter":
|
|
245
|
-
|
|
425
|
+
case "Enter": {
|
|
426
|
+
const active = this.#activeOption;
|
|
427
|
+
if (!this.#isClosed && active) {
|
|
246
428
|
event.preventDefault();
|
|
247
|
-
this.#toggleSelection(
|
|
429
|
+
this.#toggleSelection(active);
|
|
248
430
|
}
|
|
249
431
|
break;
|
|
432
|
+
}
|
|
250
433
|
case "Escape":
|
|
251
|
-
if (
|
|
434
|
+
if (this.#isClosed) break;
|
|
252
435
|
event.preventDefault();
|
|
253
436
|
this.close();
|
|
254
437
|
break;
|
|
@@ -281,9 +464,50 @@ var MultiSelectController = class extends Controller {
|
|
|
281
464
|
*/
|
|
282
465
|
toggleOption(event) {
|
|
283
466
|
const option = event.currentTarget.closest('[role="option"]');
|
|
284
|
-
if (!option) return;
|
|
467
|
+
if (!option || !this.optionTargets.includes(option)) return;
|
|
285
468
|
this.#toggleSelection(option);
|
|
286
|
-
this
|
|
469
|
+
this.#focusInput();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Re-homes focus to the input, or leaves it where it is when there is none.
|
|
473
|
+
*
|
|
474
|
+
* All three callers run *after* an option or a chip already took focus, and all
|
|
475
|
+
* three are reachable without an input — options and chips carry their own
|
|
476
|
+
* `data-action`. Throwing here would leave the chip removed but focus stranded
|
|
477
|
+
* on a detached button.
|
|
478
|
+
*/
|
|
479
|
+
#focusInput() {
|
|
480
|
+
if (this.hasInputTarget) this.inputTarget.focus();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Re-homes focus after the last chip was removed: to the input, else the root.
|
|
484
|
+
*
|
|
485
|
+
* Unlike the other {@link #focusInput} callers, the element that held focus has
|
|
486
|
+
* just left the DOM, so "leave it alone" is not an option — the browser already
|
|
487
|
+
* dropped it to `<body>`. The root borrows a `tabindex="-1"` just-in-time (not a
|
|
488
|
+
* Tab stop, handed back on teardown). Focus that landed on a real element is
|
|
489
|
+
* left alone, so a chip removed out of band never steals it.
|
|
490
|
+
*/
|
|
491
|
+
#focusAfterLastTag() {
|
|
492
|
+
if (this.hasInputTarget) {
|
|
493
|
+
this.inputTarget.focus();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const doc = this.element.ownerDocument;
|
|
497
|
+
const active = doc.activeElement;
|
|
498
|
+
if (active && active !== doc.body && active !== doc.documentElement && active.isConnected) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
this.#tabindex.lend(this.element);
|
|
502
|
+
this.element.focus();
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Returns the borrowed tab stop. Owning the borrow is not enough — the value
|
|
506
|
+
* has to still be the one this instance wrote, since a consumer that changed it
|
|
507
|
+
* afterwards owns it now.
|
|
508
|
+
*/
|
|
509
|
+
#releaseTabindex() {
|
|
510
|
+
this.#tabindex.returnAll();
|
|
287
511
|
}
|
|
288
512
|
/**
|
|
289
513
|
* Removes the chip whose remove button was clicked, deselecting its option.
|
|
@@ -302,7 +526,8 @@ var MultiSelectController = class extends Controller {
|
|
|
302
526
|
const visible = this.#visibleOptions;
|
|
303
527
|
if (visible.length === 0) return;
|
|
304
528
|
const current = this.#activeOption ? visible.indexOf(this.#activeOption) : -1;
|
|
305
|
-
const
|
|
529
|
+
const candidate = current === -1 ? delta > 0 ? 0 : visible.length - 1 : current + delta;
|
|
530
|
+
const next = (candidate + visible.length) % visible.length;
|
|
306
531
|
this.#setActive(visible[next] ?? null);
|
|
307
532
|
}
|
|
308
533
|
/** Selects/deselects `option`, honoring `max`, and syncs chip + live region. */
|
|
@@ -322,6 +547,30 @@ var MultiSelectController = class extends Controller {
|
|
|
322
547
|
this.#syncFields();
|
|
323
548
|
this.dispatch("change", { detail: { values: this.#values } });
|
|
324
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* Re-reads each chip's label from its option, in place.
|
|
552
|
+
*
|
|
553
|
+
* The value order alone does not say the chips are still correct: a server can
|
|
554
|
+
* re-render the same candidate with a new label ("Apple" → "Green Apple"), and
|
|
555
|
+
* the chip text and its `Remove {label}` name are both derived from the option.
|
|
556
|
+
* Updating them here keeps the rebuild — which would drop focus — for the case
|
|
557
|
+
* that actually needs it, a changed selection.
|
|
558
|
+
*/
|
|
559
|
+
#refreshTagLabels(selected) {
|
|
560
|
+
const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
|
|
561
|
+
for (const tag of this.tagTargets) {
|
|
562
|
+
const option = options.get(tag.dataset.value ?? "");
|
|
563
|
+
if (!option) continue;
|
|
564
|
+
const text = this.#optionLabel(option);
|
|
565
|
+
const label = tag.querySelector('[data-multi-select-slot="label"]');
|
|
566
|
+
if (label && label.textContent !== text) label.textContent = text;
|
|
567
|
+
const button = tag.querySelector("button");
|
|
568
|
+
const name = `Remove ${text}`;
|
|
569
|
+
if (button && button.getAttribute("aria-label") !== name) {
|
|
570
|
+
button.setAttribute("aria-label", name);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
325
574
|
/** Builds one chip from the template for `option`. */
|
|
326
575
|
#appendTag(option) {
|
|
327
576
|
if (!this.hasTagTemplateTarget || !this.hasTagsTarget) return;
|
|
@@ -357,19 +606,21 @@ var MultiSelectController = class extends Controller {
|
|
|
357
606
|
this.dispatch("change", { detail: { values: this.#values } });
|
|
358
607
|
const remaining = this.#removeButtons;
|
|
359
608
|
if (remaining.length === 0) {
|
|
360
|
-
this
|
|
609
|
+
this.#focusAfterLastTag();
|
|
361
610
|
} else {
|
|
362
611
|
this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
|
|
363
612
|
}
|
|
364
613
|
}
|
|
365
614
|
/** Arrow navigation and deletion within the chip list (delegated). */
|
|
366
615
|
#onTagKeydown = (event) => {
|
|
616
|
+
if (event.defaultPrevented) return;
|
|
617
|
+
if (isReservedArrowChord(event)) return;
|
|
367
618
|
const button = event.target.closest("button");
|
|
368
619
|
if (!button) return;
|
|
369
620
|
const buttons = this.#removeButtons;
|
|
370
621
|
const index = buttons.indexOf(button);
|
|
371
622
|
if (index === -1) return;
|
|
372
|
-
switch (event.key) {
|
|
623
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
373
624
|
case "ArrowLeft":
|
|
374
625
|
if (index > 0) {
|
|
375
626
|
event.preventDefault();
|
|
@@ -379,7 +630,7 @@ var MultiSelectController = class extends Controller {
|
|
|
379
630
|
case "ArrowRight":
|
|
380
631
|
event.preventDefault();
|
|
381
632
|
if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
|
|
382
|
-
else this
|
|
633
|
+
else this.#focusInput();
|
|
383
634
|
break;
|
|
384
635
|
case "Delete":
|
|
385
636
|
case "Backspace":
|
|
@@ -391,23 +642,49 @@ var MultiSelectController = class extends Controller {
|
|
|
391
642
|
/**
|
|
392
643
|
* Marks `option` active via `data-active` and the input's
|
|
393
644
|
* `aria-activedescendant` (the attribute is removed, not emptied, when null).
|
|
645
|
+
*
|
|
646
|
+
* The state half runs even with no input, so a `close()` that cannot touch ARIA
|
|
647
|
+
* still clears it: {@link open} seeds an active option only when there is none,
|
|
648
|
+
* so a stale one makes the next open skip the seeding and a replacement input
|
|
649
|
+
* gets no `aria-activedescendant` at all.
|
|
394
650
|
*/
|
|
395
651
|
#setActive(option) {
|
|
396
|
-
|
|
652
|
+
const activeId = option ? ensureId(option, "stimeo-ms-opt") : null;
|
|
653
|
+
this.#activeOptionId = activeId;
|
|
397
654
|
for (const candidate of this.optionTargets) {
|
|
398
655
|
candidate.toggleAttribute("data-active", candidate === option);
|
|
399
656
|
}
|
|
400
|
-
if (option)
|
|
401
|
-
|
|
402
|
-
|
|
657
|
+
if (option && this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
|
|
658
|
+
if (!this.hasInputTarget) return;
|
|
659
|
+
if (activeId !== null) {
|
|
660
|
+
this.inputTarget.setAttribute("aria-activedescendant", activeId);
|
|
403
661
|
} else {
|
|
404
662
|
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
405
663
|
}
|
|
406
664
|
}
|
|
665
|
+
/** Repairs only active identity before a key; the fallback waits for the target callback. */
|
|
666
|
+
#reconcileActiveForInteraction() {
|
|
667
|
+
const activeId = this.#activeOptionId;
|
|
668
|
+
if (activeId === null) return;
|
|
669
|
+
const resolved = this.#activeOption;
|
|
670
|
+
const active = resolved && !resolved.hidden ? resolved : null;
|
|
671
|
+
const marked = this.optionTargets.filter((candidate) => candidate.hasAttribute("data-active"));
|
|
672
|
+
const idref = this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null;
|
|
673
|
+
if (!active || marked.length !== 1 || marked[0] !== active || idref !== activeId) {
|
|
674
|
+
this.#setActive(active);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/** Reflects whether the open list currently has no visible option targets. */
|
|
678
|
+
#reflectEmpty() {
|
|
679
|
+
this.element.toggleAttribute(
|
|
680
|
+
"data-stimeo--multi-select-empty",
|
|
681
|
+
!this.#isClosed && this.#visibleOptions.length === 0
|
|
682
|
+
);
|
|
683
|
+
}
|
|
407
684
|
/**
|
|
408
685
|
* Mirrors the selected values into named hidden inputs under the `fields`
|
|
409
686
|
* target so the selection submits with a normal form (parity with tags-input).
|
|
410
|
-
* No-ops without a `fields` target
|
|
687
|
+
* No-ops without a `fields` target. When the
|
|
411
688
|
* `form` value is set, each input gets a matching `form` attribute so the picker
|
|
412
689
|
* can submit with a `<form>` it lives outside of.
|
|
413
690
|
*/
|
|
@@ -449,6 +726,12 @@ var MultiSelectController = class extends Controller {
|
|
|
449
726
|
get #visibleOptions() {
|
|
450
727
|
return this.optionTargets.filter((option) => !option.hidden);
|
|
451
728
|
}
|
|
729
|
+
/** Current active target resolved by stable id, never a detached node reference. */
|
|
730
|
+
get #activeOption() {
|
|
731
|
+
const activeId = this.#activeOptionId;
|
|
732
|
+
if (activeId === null) return null;
|
|
733
|
+
return this.optionTargets.find((option) => option.id === activeId) ?? null;
|
|
734
|
+
}
|
|
452
735
|
/** Options currently selected. */
|
|
453
736
|
get #selectedOptions() {
|
|
454
737
|
return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");
|