stimeo-ui 0.6.0 → 0.8.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 +124 -0
- package/dist/controllers/alert_dialog_controller.d.ts +2 -0
- package/dist/controllers/alert_dialog_controller.js +75 -4
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.d.ts +16 -3
- package/dist/controllers/auto_submit_controller.js +94 -16
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/avatar_controller.js +47 -5
- package/dist/controllers/avatar_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.d.ts +2 -0
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/bulk_select_controller.d.ts +2 -0
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +2 -0
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +81 -47
- package/dist/controllers/carousel_controller.js +451 -100
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.d.ts +52 -20
- package/dist/controllers/character_counter_controller.js +338 -63
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.d.ts +3 -0
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +3 -0
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +2 -0
- 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 +75 -4
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.d.ts +42 -14
- package/dist/controllers/conditional_fields_controller.js +345 -51
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.d.ts +2 -0
- package/dist/controllers/confirm_controller.js +75 -4
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.d.ts +2 -0
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +4 -0
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +40 -4
- package/dist/controllers/currency_input_controller.js +305 -74
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +3 -0
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +75 -4
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.d.ts +59 -25
- package/dist/controllers/direct_upload_controller.js +212 -46
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dirty_form_controller.d.ts +14 -6
- package/dist/controllers/dirty_form_controller.js +192 -29
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +2 -0
- package/dist/controllers/dismissible_controller.js +83 -18
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.js +75 -4
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.d.ts +2 -0
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.d.ts +123 -27
- package/dist/controllers/file_dropzone_controller.js +386 -63
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/filter_controller.d.ts +2 -0
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +7 -1
- package/dist/controllers/flash_controller.js +3 -1
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +2 -0
- package/dist/controllers/focus_controller.js +75 -4
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.d.ts +50 -10
- package/dist/controllers/form_field_controller.js +280 -62
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.d.ts +10 -8
- package/dist/controllers/form_validation_controller.js +208 -83
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +5 -1
- package/dist/controllers/frame_loading_controller.js +2 -1
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.d.ts +2 -0
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +2 -0
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +43 -15
- package/dist/controllers/input_mask_controller.js +251 -76
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.d.ts +2 -0
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +2 -0
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +2 -0
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +2 -0
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +2 -0
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +2 -0
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +52 -13
- package/dist/controllers/nested_form_controller.js +450 -42
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +2 -0
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +2 -0
- package/dist/controllers/number_input_controller.js +47 -5
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +62 -23
- package/dist/controllers/otp_controller.js +472 -114
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +2 -0
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +2 -0
- package/dist/controllers/overflow_menu_controller.js +2 -1
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +2 -0
- package/dist/controllers/pagination_controller.js +2 -1
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +2 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/password_strength_controller.d.ts +2 -0
- package/dist/controllers/password_strength_controller.js.map +1 -1
- package/dist/controllers/persist_controller.d.ts +30 -16
- package/dist/controllers/persist_controller.js +432 -122
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +3 -0
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.js +77 -4
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +2 -0
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.d.ts +2 -0
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/progress_controller.d.ts +3 -0
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +3 -0
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +2 -0
- package/dist/controllers/rating_controller.js +9 -5
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.d.ts +2 -0
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +2 -0
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.d.ts +2 -0
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.d.ts +23 -15
- package/dist/controllers/scroll_area_controller.js +462 -162
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +2 -0
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +2 -0
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +54 -11
- package/dist/controllers/separator_controller.js +354 -38
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.js +83 -10
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +2 -0
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +2 -0
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.d.ts +2 -0
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +4 -1
- package/dist/controllers/spinner_controller.js +7 -5
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +2 -0
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +2 -0
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +2 -0
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +2 -0
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +98 -39
- package/dist/controllers/submit_once_controller.js +402 -122
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +2 -0
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +2 -0
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.d.ts +31 -9
- package/dist/controllers/textarea_autosize_controller.js +131 -3
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +2 -0
- package/dist/controllers/theme_controller.js +8 -6
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +2 -0
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +4 -0
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.d.ts +2 -0
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +2 -0
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +2 -0
- package/dist/controllers/tree_view_controller.js +2 -1
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +4293 -1447
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +115 -4
- package/dist/inspector/cli.js +195 -15
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +257 -47
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +11 -11
- package/dist/inspector/manifest.json +615 -66
- package/dist/positioning/index.d.ts +2 -0
- package/dist/positioning/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/option_scroll.ts","../../src/utils/safe_timeout.ts","../../src/utils/typeahead.ts","../../src/controllers/listbox_controller.ts"],"names":[],"mappings":"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","file":"listbox_controller.js","sourcesContent":["import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same 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 initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output 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 * Scroll-follow for virtually-focused listbox options.\n *\n * Listbox-style widgets that track their active option with\n * `aria-activedescendant` keep DOM focus on the input/trigger, so the\n * browser's native \"scroll the focused element into view\" never runs — in a\n * scrollable list (`max-height` + `overflow`) the active option can walk right\n * out of sight on ArrowDown/ArrowUp. This helper keeps it visible.\n *\n * It adjusts the LIST's own `scrollTop` only — deliberately not\n * `scrollIntoView({ block: \"nearest\" })`, which may also scroll ancestor\n * scrolling boxes (the page) when a floating popup pokes past a viewport\n * edge, desyncing anchored/marker-composed placements. (Command-palette uses\n * `scrollIntoView` because its page is scroll-locked behind the modal;\n * page-floating popups must use this helper instead.)\n */\n\n/**\n * Scrolls `option` into view within `list` by minimally adjusting\n * `list.scrollTop`. No-ops when the list does not actually scroll. Never\n * touches any other scrolling box.\n *\n * @param list - The scrollable listbox container.\n * @param option - The (virtually focused) option to keep visible.\n */\nexport function scrollOptionIntoView(list: HTMLElement, option: HTMLElement): void {\n if (list.scrollHeight <= list.clientHeight) return;\n const listRect = list.getBoundingClientRect();\n const optionRect = option.getBoundingClientRect();\n if (optionRect.top < listRect.top) {\n list.scrollTop -= listRect.top - optionRect.top;\n } else if (optionRect.bottom > listRect.bottom) {\n list.scrollTop += optionRect.bottom - listRect.bottom;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Shared type-ahead (first-letter navigation) primitive for composite widgets.\n *\n * The APG asks list-like widgets to move focus by typed characters: one\n * character jumps to the next item starting with it, and several characters\n * typed in quick succession narrow the match to that prefix. Getting that right\n * needs three pieces of bookkeeping — accumulating the query, dropping it after\n * an idle window, and resolving the label each candidate is matched against —\n * and single-sourcing them here keeps every widget on the same rules instead of\n * letting them drift apart.\n *\n * The helpers here own *only* that mechanical part. They are deliberately\n * **policy-free** in the same sense as `RovingTabindex`: which elements are\n * candidates, where the search starts, and what happens on a match (real DOM\n * focus, a roving tab stop, or a virtual `aria-activedescendant`) differ per APG\n * pattern, so each controller keeps those decisions and calls\n * {@link findTypeaheadMatch} with a candidate list it assembled itself.\n *\n * Three decisions are worth stating up front:\n *\n * - **A repeated character collapses the stored query**, rather than deriving a\n * shorter one at search time. That is what lets a follow-up character resume\n * narrowing: `s`, `s`, `e` searches `\"se\"`, not the dead `\"sse\"` that no label\n * can match. Growing the query instead leaves the widget frozen on exactly\n * that sequence, which is why the choice is made here and not per widget.\n * - **`aria-label` wins over text**, so type-ahead matches what a screen reader\n * announces — but only when it contributes a name. A blank (empty or\n * whitespace-only) one is skipped, because accname skips it too: honoring it\n * would leave an element that AT announces by its text unreachable by that\n * text. This mirrors the accessible-name computation rather than the raw text.\n * - **`Space` and composition input are never type-ahead.** `Space` natively\n * activates a `<button>`-based item, and characters still being composed are\n * not a committed query.\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 contracts belong on the members.\n */\n\nimport { SafeTimeout } from \"./safe_timeout\";\n\n/** Idle window after which an accumulated query is dropped, in milliseconds. */\nexport const TYPEAHEAD_RESET_MS = 500;\n\n/** Construction options for {@link Typeahead}. */\nexport interface TypeaheadOptions {\n /** Idle window before the query resets. Defaults to {@link TYPEAHEAD_RESET_MS}. */\n resetMs?: number;\n}\n\n/**\n * Accumulates typed characters into a search query and drops it when idle.\n *\n * @remarks\n * Owns a {@link SafeTimeout} of its own, so a consumer's `#timers.clearAll()` does\n * **not** reach the pending reset — call {@link Typeahead.reset} from `disconnect()`.\n */\nexport class Typeahead {\n /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */\n readonly #timers = new SafeTimeout();\n /** Idle window before the query resets, in milliseconds. */\n readonly #resetMs: number;\n /** The accumulated lowercase query, empty when idle. */\n #query = \"\";\n /** Id of the pending reset timer, `0` when none is scheduled. */\n #timerId = 0;\n\n /** @param options - Overrides for the idle window. */\n constructor({ resetMs = TYPEAHEAD_RESET_MS }: TypeaheadOptions = {}) {\n this.#resetMs = resetMs;\n }\n\n /** The query a search would currently run with; empty while idle. */\n get query(): string {\n return this.#query;\n }\n\n /**\n * Folds `key` into the query, restarts the idle window, and returns the query to\n * search with. A repeated character collapses the query to that one character.\n */\n push(key: string): string {\n const char = key.toLowerCase();\n const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);\n this.#query = repeated ? char : this.#query + char;\n this.#timers.clear(this.#timerId);\n this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);\n return this.#query;\n }\n\n /** Clears the query and cancels the pending idle reset. */\n reset(): void {\n this.#query = \"\";\n this.#timers.clear(this.#timerId);\n this.#timerId = 0;\n }\n}\n\n/** Whether `event` is a bare printable character usable for type-ahead. */\nexport function isTypeaheadKey(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== \" \" &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey &&\n !event.isComposing\n );\n}\n\n/**\n * The name `element` is matched under, normalized for comparison.\n *\n * @param fallbackText - Produces the name when `aria-label` contributes none.\n * Defaults to the element's `textContent`; pass a narrower source when part of\n * the subtree is not part of the name (a tree item's nested child group, say).\n * Called lazily, so an `aria-label` costs no subtree walk.\n */\nexport function typeaheadLabel(element: HTMLElement, fallbackText?: () => string): string {\n // Blank means absent, matching accname: a whitespace-only `aria-label` is\n // skipped there too, so honoring it here would make an element that a screen\n // reader announces by its text unreachable by that text.\n const label = element.getAttribute(\"aria-label\")?.trim();\n if (label) return label.toLowerCase();\n const text = fallbackText ? fallbackText() : (element.textContent ?? \"\");\n return text.trim().toLowerCase();\n}\n\n/**\n * Index of the first item after `from` whose label starts with `query`, wrapping\n * and evaluating `from` itself last, or `-1` when nothing matches.\n *\n * @param items - Candidates in navigation order, already filtered by the caller.\n * @param from - Index the search moves on from; `-1` starts at the first item. Any\n * out-of-range value is folded back in, so `indexOf` results pass straight through.\n * @param query - Lowercase query, normally the return of {@link Typeahead.push}.\n * @param label - Resolves a candidate's name. Defaults to {@link typeaheadLabel}.\n */\nexport function findTypeaheadMatch(\n items: readonly HTMLElement[],\n from: number,\n query: string,\n label: (item: HTMLElement) => string = (item) => typeaheadLabel(item),\n): number {\n // An empty query would prefix-match everything; an empty list never enters the\n // loop, so it needs no guard of its own.\n if (query === \"\") return -1;\n const count = items.length;\n for (let step = 1; step <= count; step += 1) {\n // Folded twice so a negative `from` still lands on a real slot: `%` keeps the\n // sign of its left operand, and a negative index would read `undefined`.\n const index = (((from + step) % count) + count) % count;\n const candidate = items[index];\n if (candidate && label(candidate).startsWith(query)) return index;\n }\n return -1;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { scrollOptionIntoView } from \"../utils/option_scroll\";\nimport { findTypeaheadMatch, isTypeaheadKey, Typeahead } from \"../utils/typeahead\";\n\n/**\n * Headless, accessible select-only listbox behavior.\n *\n * Markup contract (identifier: `stimeo--listbox`):\n * <div data-controller=\"stimeo--listbox\">\n * <span id=\"lb-label\">Favorite fruit</span>\n * <button type=\"button\" role=\"combobox\" aria-haspopup=\"listbox\"\n * aria-expanded=\"false\" aria-controls=\"lb-list\"\n * aria-labelledby=\"lb-label lb-value\"\n * data-stimeo--listbox-target=\"trigger\"\n * data-action=\"click->stimeo--listbox#toggle\n * keydown->stimeo--listbox#onTriggerKeydown\">\n * <span id=\"lb-value\" data-stimeo--listbox-target=\"value\">Choose…</span>\n * </button>\n * <ul id=\"lb-list\" role=\"listbox\" aria-label=\"Options\" hidden\n * data-stimeo--listbox-target=\"list\">\n * <li id=\"opt-1\" role=\"option\" aria-selected=\"false\" data-value=\"1\"\n * data-stimeo--listbox-target=\"option\"\n * data-action=\"click->stimeo--listbox#select\">Option 1</li>\n * <!-- more options -->\n * </ul>\n * <input type=\"hidden\" data-stimeo--listbox-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Listbox** pattern in its collapsed\n * (Select-Only Combobox) form. Focus stays on the trigger; the active option is\n * tracked with `aria-activedescendant` rather than by moving DOM focus. For a\n * text-filtered popup use {@link ComboboxController | Combobox}; for multiple\n * selection use Multi-Select.\n *\n * @remarks\n * Behavior only. Static placement is the consumer's CSS; dynamic placement is\n * delegated to the opt-in `stimeo-ui/positioning` module. The look is keyed off\n * `aria-selected` / `data-active`. Because `role=\"combobox\"` is not named by its\n * contents, give the trigger an accessible name via `aria-labelledby` (a visible\n * label plus the value span) or `aria-label`.\n *\n * Behavior provided:\n * - Open/close the list, syncing `aria-expanded` and the list's `hidden`.\n * - `ArrowDown`/`ArrowUp` (wrapping), `Home`/`End`, and printable-character\n * typeahead move the active option; opening picks the selected option (else\n * the first).\n * - Single selection syncs `aria-selected`, reflects the label into the trigger\n * value and the field's value, and dispatches `stimeo--listbox:change` plus a\n * native bubbling `change` on the field when its value actually changed.\n * Making the field a validatable mirror (`<input type=\"text\" hidden required>`\n * instead of `type=\"hidden\"`) lets `stimeo--form-validation` enforce native\n * constraints on the committed value with no extra JavaScript.\n * - `Enter`/`Space` select and close; `Escape` and outside click / `Tab` close;\n * closing via select/Escape returns focus to the trigger.\n */\nexport class ListboxController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"value\", \"list\", \"option\", \"field\"];\n static actions = [\"close\", \"onTriggerKeydown\", \"open\", \"select\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly valueTarget: HTMLElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasValueTarget: boolean;\n declare readonly hasListTarget: boolean;\n declare readonly hasFieldTarget: boolean;\n\n /** Stable ID of the active option; DOM targets are resolved afresh before use. */\n #activeId: string | null = null;\n /** Target ID order captured while an option is active, used only for removal fallback. */\n #activeOrder: string[] = [];\n #connected = false;\n /** Collapses one mutation batch of target callbacks into a single pass. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());\n /** Accumulated typeahead query and its idle-reset timer. */\n readonly #typeahead = new Typeahead();\n\n /** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */\n override connect(): void {\n this.#normalizeSelection();\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n this.#connected = true;\n this.#reconcile.activate();\n }\n\n /**\n * Establishes an inactive baseline for a late option, re-resolves active\n * identity, and re-applies the selection baseline.\n */\n optionTargetConnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (!this.#connected) return;\n this.#normalizeSelection();\n this.#queueOptionReconciliation();\n }\n\n /** Removes controller-owned active state and reconciles the surviving targets. */\n optionTargetDisconnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (this.#connected) this.#queueOptionReconciliation();\n }\n\n /**\n * Brings the authored DOM to the shape the APG requires, and derives the state\n * that follows from the initial selection.\n *\n * Three things happen, and only these three — which option is chosen is the\n * author's, and is never changed:\n *\n * 1. Every option gets an explicit value. An absent `aria-selected` means \"not\n * selectable\" in ARIA, so a forgotten attribute hides a selectable option\n * from assistive technology.\n * 2. At most one stays `true`. The first in DOM order wins, since that is the\n * only deterministic reading of \"which one did the author mean\".\n * 3. The trigger label and the hidden field are derived from that selection.\n * Without this the widget announces a choice it does not submit: the popup\n * says \"Banana\", the trigger still says \"Choose…\", and the form posts \"\".\n *\n * No `change` fires — nothing changed, this is the initial state being told\n * properly. The scan is the `option` target set: a `role=\"option\"` without the\n * target is outside the contract and is neither counted nor written.\n */\n #normalizeSelection(): void {\n const options = this.optionTargets;\n const selected = options.find((option) => option.getAttribute(\"aria-selected\") === \"true\");\n for (const option of options) {\n option.setAttribute(\"aria-selected\", option === selected ? \"true\" : \"false\");\n }\n if (selected) this.#applySelection(selected);\n }\n\n /** Removes the document listener and clears the typeahead timer. */\n override disconnect(): void {\n this.#connected = false;\n this.#reconcile.cancel();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n // The typeahead keeps its idle reset in a `SafeTimeout` of its own, so this is\n // the only teardown that reaches it — there is no controller-level registry to\n // fall back on. Every timer this controller can schedule lives in there.\n this.#typeahead.reset();\n }\n\n /**\n * Toggles the list on a real mouse click. Keyboard activation of the\n * `<button>` also fires a click (`detail === 0`); the keydown handler already\n * drives that, so the synthetic click is ignored to avoid double-toggling.\n */\n toggle(event: MouseEvent): void {\n if (event.detail === 0) return;\n if (this.#isClosed) {\n this.open();\n } else {\n this.close();\n }\n }\n\n /** Yields claimed keys; otherwise routes the APG select-only keyboard model. */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (!this.#isClosed) this.#reconcileActive();\n const options = this.optionTargets;\n const length = options.length;\n const activeIndex = this.#findActiveIndex(options);\n if (this.#isClosed) {\n switch (event.key) {\n case \"Enter\":\n case \" \":\n case \"ArrowDown\":\n case \"ArrowUp\":\n event.preventDefault();\n this.open();\n break;\n default:\n break;\n }\n return;\n }\n\n // With no options, only Escape/Tab are meaningful; ignore navigation and\n // typeahead so the active index can never become NaN (`% 0`).\n if (length === 0 && event.key !== \"Escape\" && event.key !== \"Tab\") {\n return;\n }\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? length - 1 : (activeIndex - 1 + length) % length);\n break;\n case \"Home\":\n event.preventDefault();\n this.#setActive(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#setActive(length - 1);\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n this.#commitActive();\n break;\n case \"Escape\":\n // The entry guard already yields a press another handler owned. A press\n // during IME composition never dismisses, keeping one rule across the\n // widgets (the trigger is a button, so composition does not start here).\n if (event.isComposing) break;\n event.preventDefault();\n this.close();\n this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Let focus leave naturally; just don't keep a stale popup open.\n this.close();\n break;\n default:\n if (isTypeaheadKey(event)) {\n event.preventDefault();\n this.#typeaheadTo(options, activeIndex, event.key);\n }\n break;\n }\n }\n\n /** Selects the clicked option and closes, returning focus to the trigger. */\n select(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (!option || !this.optionTargets.includes(option)) return;\n this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Opens the list and activates the selected option (else the first). */\n open(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = false;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (this.optionTargets.length === 0) {\n // An empty listbox has nothing to activate; leave activedescendant cleared.\n this.#setActive(-1);\n return;\n }\n const selected = this.optionTargets.findIndex(\n (option) => option.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#setActive(selected === -1 ? 0 : selected);\n }\n\n /** Closes the list, clears the active option, and resets the typeahead buffer. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.#typeahead.reset();\n }\n\n /** Commits the active option (keyboard) and closes, returning focus. */\n #commitActive(): void {\n this.#reconcileActive();\n const options = this.optionTargets;\n const activeIndex = this.#findActiveIndex(options);\n const option = activeIndex < 0 ? undefined : options[activeIndex];\n if (option) this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */\n #selectOption(option: HTMLElement): void {\n const { value, fieldChanged } = this.#applySelection(option);\n if (fieldChanged) {\n // A native bubbling change (matching <select> semantics: only on an actual\n // value change) so form-level behaviors — validation re-checks, auto-submit\n // — hear the commit without knowing this widget.\n this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"change\", { detail: { value, option } });\n }\n\n /**\n * Writes `option` into `aria-selected`, the trigger label and the hidden field.\n * Emits nothing — {@link #normalizeSelection} reuses this at connect, where the\n * state is being described rather than changed.\n */\n #applySelection(option: HTMLElement): { value: string; fieldChanged: boolean } {\n for (const candidate of this.optionTargets) {\n candidate.setAttribute(\"aria-selected\", candidate === option ? \"true\" : \"false\");\n }\n const label = (option.textContent ?? \"\").trim();\n const value = option.dataset.value ?? label;\n if (this.hasValueTarget) this.valueTarget.textContent = label;\n const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;\n if (fieldChanged) this.fieldTarget.value = value;\n return { value, fieldChanged };\n }\n\n /**\n * Marks the option at `index` active via `data-active` and the trigger's\n * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not\n * set to empty, per the APG).\n */\n #setActive(index: number): void {\n const options = this.optionTargets;\n const active = index < 0 ? null : (options[index] ?? null);\n // Only the options whose marker actually changes are written, so a held arrow\n // key costs two attribute writes rather than one per option. The whole set is\n // still read: that is what makes a stray marker — one a morph left behind on\n // an element that never re-connected as a target — heal on the next move.\n for (const option of options) {\n const marked = option.hasAttribute(\"data-active\");\n if (option === active) {\n if (!marked) option.setAttribute(\"data-active\", \"\");\n } else if (marked) {\n option.removeAttribute(\"data-active\");\n }\n }\n if (active?.id) {\n this.#activeId = active.id;\n this.triggerTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.#activeId = null;\n this.triggerTarget.removeAttribute(\"aria-activedescendant\");\n }\n this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];\n // Virtual focus never triggers the browser's native focus-scrolling, so a\n // scrollable list must follow the active option itself (list-only scroll).\n if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);\n }\n\n /** Resolves active state against the current target collection. */\n #reconcileActive(): void {\n if (!this.hasTriggerTarget || this.#isClosed) {\n if (this.hasTriggerTarget) this.#setActive(-1);\n return;\n }\n\n const options = this.optionTargets;\n const currentIndex = this.#findActiveIndex(options);\n if (currentIndex >= 0) {\n const active = options[currentIndex] ?? null;\n const marked = options.filter((option) => option.hasAttribute(\"data-active\"));\n const stateMatches =\n this.#activeId === (active?.id || null) &&\n marked.length === 1 &&\n marked[0] === active &&\n this.triggerTarget.getAttribute(\"aria-activedescendant\") === (active?.id || null);\n\n if (stateMatches) {\n // Keep the deletion fallback snapshot current without re-scrolling the\n // already-active option before every keyboard command.\n this.#activeOrder = options.map((option) => option.id).filter(Boolean);\n } else {\n this.#setActive(currentIndex);\n }\n return;\n }\n\n const activeId = this.triggerTarget.getAttribute(\"aria-activedescendant\") ?? this.#activeId;\n this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);\n }\n\n /** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */\n #findActiveIndex(options: readonly HTMLElement[]): number {\n const activeId =\n (this.hasTriggerTarget ? this.triggerTarget.getAttribute(\"aria-activedescendant\") : null) ??\n this.#activeId;\n if (activeId) return options.findIndex((option) => option.id === activeId);\n return options.findIndex((option) => option.hasAttribute(\"data-active\"));\n }\n\n /** Chooses a surviving former successor, then a former predecessor. */\n #findFallbackIndex(options: readonly HTMLElement[], activeId: string): number {\n const oldIndex = this.#activeOrder.indexOf(activeId);\n if (oldIndex < 0) return -1;\n const indexesById = new Map(options.map((option, index) => [option.id, index]));\n for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n for (let index = oldIndex - 1; index >= 0; index -= 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n return -1;\n }\n\n /** Coalesces all target callbacks from one MutationObserver batch. */\n #queueOptionReconciliation(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Advances the typeahead query and activates the next matching option.\n *\n * The search resumes just after the active option so repeating a character\n * cycles through the options starting with it, rather than re-activating the\n * same first match on every press.\n */\n #typeaheadTo(options: HTMLElement[], activeIndex: number, char: string): void {\n const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));\n if (index !== -1) this.#setActive(index);\n }\n\n /** Closes on an outside click before an inside handler can detach its target. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Whether the list is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/option_scroll.ts","../../src/utils/safe_timeout.ts","../../src/utils/typeahead.ts","../../src/controllers/listbox_controller.ts"],"names":[],"mappings":"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","file":"listbox_controller.js","sourcesContent":["import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same 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 initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output 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 * Scroll-follow for virtually-focused listbox options.\n *\n * Listbox-style widgets that track their active option with\n * `aria-activedescendant` keep DOM focus on the input/trigger, so the\n * browser's native \"scroll the focused element into view\" never runs — in a\n * scrollable list (`max-height` + `overflow`) the active option can walk right\n * out of sight on ArrowDown/ArrowUp. This helper keeps it visible.\n *\n * It adjusts the LIST's own `scrollTop` only — deliberately not\n * `scrollIntoView({ block: \"nearest\" })`, which may also scroll ancestor\n * scrolling boxes (the page) when a floating popup pokes past a viewport\n * edge, desyncing anchored/marker-composed placements. (Command-palette uses\n * `scrollIntoView` because its page is scroll-locked behind the modal;\n * page-floating popups must use this helper instead.)\n */\n\n/**\n * Scrolls `option` into view within `list` by minimally adjusting\n * `list.scrollTop`. No-ops when the list does not actually scroll. Never\n * touches any other scrolling box.\n *\n * @param list - The scrollable listbox container.\n * @param option - The (virtually focused) option to keep visible.\n */\nexport function scrollOptionIntoView(list: HTMLElement, option: HTMLElement): void {\n if (list.scrollHeight <= list.clientHeight) return;\n const listRect = list.getBoundingClientRect();\n const optionRect = option.getBoundingClientRect();\n if (optionRect.top < listRect.top) {\n list.scrollTop -= listRect.top - optionRect.top;\n } else if (optionRect.bottom > listRect.bottom) {\n list.scrollTop += optionRect.bottom - listRect.bottom;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Shared type-ahead (first-letter navigation) primitive for composite widgets.\n *\n * The APG asks list-like widgets to move focus by typed characters: one\n * character jumps to the next item starting with it, and several characters\n * typed in quick succession narrow the match to that prefix. Getting that right\n * needs three pieces of bookkeeping — accumulating the query, dropping it after\n * an idle window, and resolving the label each candidate is matched against —\n * and single-sourcing them here keeps every widget on the same rules instead of\n * letting them drift apart.\n *\n * The helpers here own *only* that mechanical part. They are deliberately\n * **policy-free** in the same sense as `RovingTabindex`: which elements are\n * candidates, where the search starts, and what happens on a match (real DOM\n * focus, a roving tab stop, or a virtual `aria-activedescendant`) differ per APG\n * pattern, so each controller keeps those decisions and calls\n * {@link findTypeaheadMatch} with a candidate list it assembled itself.\n *\n * Three decisions are worth stating up front:\n *\n * - **A repeated character collapses the stored query**, rather than deriving a\n * shorter one at search time. That is what lets a follow-up character resume\n * narrowing: `s`, `s`, `e` searches `\"se\"`, not the dead `\"sse\"` that no label\n * can match. Growing the query instead leaves the widget frozen on exactly\n * that sequence, which is why the choice is made here and not per widget.\n * - **`aria-label` wins over text**, so type-ahead matches what a screen reader\n * announces — but only when it contributes a name. A blank (empty or\n * whitespace-only) one is skipped, because accname skips it too: honoring it\n * would leave an element that AT announces by its text unreachable by that\n * text. This mirrors the accessible-name computation rather than the raw text.\n * - **`Space` and composition input are never type-ahead.** `Space` natively\n * activates a `<button>`-based item, and characters still being composed are\n * not a committed query.\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 contracts belong on the members.\n */\n\nimport { SafeTimeout } from \"./safe_timeout\";\n\n/** Idle window after which an accumulated query is dropped, in milliseconds. */\nexport const TYPEAHEAD_RESET_MS = 500;\n\n/** Construction options for {@link Typeahead}. */\nexport interface TypeaheadOptions {\n /** Idle window before the query resets. Defaults to {@link TYPEAHEAD_RESET_MS}. */\n resetMs?: number;\n}\n\n/**\n * Accumulates typed characters into a search query and drops it when idle.\n *\n * @remarks\n * Owns a {@link SafeTimeout} of its own, so a consumer's `#timers.clearAll()` does\n * **not** reach the pending reset — call {@link Typeahead.reset} from `disconnect()`.\n */\nexport class Typeahead {\n /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */\n readonly #timers = new SafeTimeout();\n /** Idle window before the query resets, in milliseconds. */\n readonly #resetMs: number;\n /** The accumulated lowercase query, empty when idle. */\n #query = \"\";\n /** Id of the pending reset timer, `0` when none is scheduled. */\n #timerId = 0;\n\n /** @param options - Overrides for the idle window. */\n constructor({ resetMs = TYPEAHEAD_RESET_MS }: TypeaheadOptions = {}) {\n this.#resetMs = resetMs;\n }\n\n /** The query a search would currently run with; empty while idle. */\n get query(): string {\n return this.#query;\n }\n\n /**\n * Folds `key` into the query, restarts the idle window, and returns the query to\n * search with. A repeated character collapses the query to that one character.\n */\n push(key: string): string {\n const char = key.toLowerCase();\n const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);\n this.#query = repeated ? char : this.#query + char;\n this.#timers.clear(this.#timerId);\n this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);\n return this.#query;\n }\n\n /** Clears the query and cancels the pending idle reset. */\n reset(): void {\n this.#query = \"\";\n this.#timers.clear(this.#timerId);\n this.#timerId = 0;\n }\n}\n\n/** Whether `event` is a bare printable character usable for type-ahead. */\nexport function isTypeaheadKey(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== \" \" &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey &&\n !event.isComposing\n );\n}\n\n/**\n * The name `element` is matched under, normalized for comparison.\n *\n * @param fallbackText - Produces the name when `aria-label` contributes none.\n * Defaults to the element's `textContent`; pass a narrower source when part of\n * the subtree is not part of the name (a tree item's nested child group, say).\n * Called lazily, so an `aria-label` costs no subtree walk.\n */\nexport function typeaheadLabel(element: HTMLElement, fallbackText?: () => string): string {\n // Blank means absent, matching accname: a whitespace-only `aria-label` is\n // skipped there too, so honoring it here would make an element that a screen\n // reader announces by its text unreachable by that text.\n const label = element.getAttribute(\"aria-label\")?.trim();\n if (label) return label.toLowerCase();\n const text = fallbackText ? fallbackText() : (element.textContent ?? \"\");\n return text.trim().toLowerCase();\n}\n\n/**\n * Index of the first item after `from` whose label starts with `query`, wrapping\n * and evaluating `from` itself last, or `-1` when nothing matches.\n *\n * @param items - Candidates in navigation order, already filtered by the caller.\n * @param from - Index the search moves on from; `-1` starts at the first item. Any\n * out-of-range value is folded back in, so `indexOf` results pass straight through.\n * @param query - Lowercase query, normally the return of {@link Typeahead.push}.\n * @param label - Resolves a candidate's name. Defaults to {@link typeaheadLabel}.\n */\nexport function findTypeaheadMatch(\n items: readonly HTMLElement[],\n from: number,\n query: string,\n label: (item: HTMLElement) => string = (item) => typeaheadLabel(item),\n): number {\n // An empty query would prefix-match everything; an empty list never enters the\n // loop, so it needs no guard of its own.\n if (query === \"\") return -1;\n const count = items.length;\n for (let step = 1; step <= count; step += 1) {\n // Folded twice so a negative `from` still lands on a real slot: `%` keeps the\n // sign of its left operand, and a negative index would read `undefined`.\n const index = (((from + step) % count) + count) % count;\n const candidate = items[index];\n if (candidate && label(candidate).startsWith(query)) return index;\n }\n return -1;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { scrollOptionIntoView } from \"../utils/option_scroll\";\nimport { findTypeaheadMatch, isTypeaheadKey, Typeahead } from \"../utils/typeahead\";\n\n/**\n * Headless, accessible select-only listbox behavior.\n *\n * Markup contract (identifier: `stimeo--listbox`):\n * <div data-controller=\"stimeo--listbox\">\n * <span id=\"lb-label\">Favorite fruit</span>\n * <button type=\"button\" role=\"combobox\" aria-haspopup=\"listbox\"\n * aria-expanded=\"false\" aria-controls=\"lb-list\"\n * aria-labelledby=\"lb-label lb-value\"\n * data-stimeo--listbox-target=\"trigger\"\n * data-action=\"click->stimeo--listbox#toggle\n * keydown->stimeo--listbox#onTriggerKeydown\">\n * <span id=\"lb-value\" data-stimeo--listbox-target=\"value\">Choose…</span>\n * </button>\n * <ul id=\"lb-list\" role=\"listbox\" aria-label=\"Options\" hidden\n * data-stimeo--listbox-target=\"list\">\n * <li id=\"opt-1\" role=\"option\" aria-selected=\"false\" data-value=\"1\"\n * data-stimeo--listbox-target=\"option\"\n * data-action=\"click->stimeo--listbox#select\">Option 1</li>\n * <!-- more options -->\n * </ul>\n * <input type=\"hidden\" data-stimeo--listbox-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Listbox** pattern in its collapsed\n * (Select-Only Combobox) form. Focus stays on the trigger; the active option is\n * tracked with `aria-activedescendant` rather than by moving DOM focus. For a\n * text-filtered popup use {@link ComboboxController | Combobox}; for multiple\n * selection use Multi-Select.\n *\n * `change` dispatches `{ value: string, option: HTMLElement }`.\n *\n * @remarks\n * Behavior only. Static placement is the consumer's CSS; dynamic placement is\n * delegated to the opt-in `stimeo-ui/positioning` module. The look is keyed off\n * `aria-selected` / `data-active`. Because `role=\"combobox\"` is not named by its\n * contents, give the trigger an accessible name via `aria-labelledby` (a visible\n * label plus the value span) or `aria-label`.\n *\n * Behavior provided:\n * - Open/close the list, syncing `aria-expanded` and the list's `hidden`.\n * - `ArrowDown`/`ArrowUp` (wrapping), `Home`/`End`, and printable-character\n * typeahead move the active option; opening picks the selected option (else\n * the first).\n * - Single selection syncs `aria-selected`, reflects the label into the trigger\n * value and the field's value, and dispatches `stimeo--listbox:change` plus a\n * native bubbling `change` on the field when its value actually changed.\n * Making the field a validatable mirror (`<input type=\"text\" hidden required>`\n * instead of `type=\"hidden\"`) lets `stimeo--form-validation` enforce native\n * constraints on the committed value with no extra JavaScript.\n * - `Enter`/`Space` select and close; `Escape` and outside click / `Tab` close;\n * closing via select/Escape returns focus to the trigger.\n */\nexport class ListboxController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"value\", \"list\", \"option\", \"field\"];\n static actions = [\"close\", \"onTriggerKeydown\", \"open\", \"select\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly valueTarget: HTMLElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasValueTarget: boolean;\n declare readonly hasListTarget: boolean;\n declare readonly hasFieldTarget: boolean;\n\n /** Stable ID of the active option; DOM targets are resolved afresh before use. */\n #activeId: string | null = null;\n /** Target ID order captured while an option is active, used only for removal fallback. */\n #activeOrder: string[] = [];\n #connected = false;\n /** Collapses one mutation batch of target callbacks into a single pass. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());\n /** Accumulated typeahead query and its idle-reset timer. */\n readonly #typeahead = new Typeahead();\n\n /** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */\n override connect(): void {\n this.#normalizeSelection();\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n this.#connected = true;\n this.#reconcile.activate();\n }\n\n /**\n * Establishes an inactive baseline for a late option, re-resolves active\n * identity, and re-applies the selection baseline.\n */\n optionTargetConnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (!this.#connected) return;\n this.#normalizeSelection();\n this.#queueOptionReconciliation();\n }\n\n /** Removes controller-owned active state and reconciles the surviving targets. */\n optionTargetDisconnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (this.#connected) this.#queueOptionReconciliation();\n }\n\n /**\n * Brings the authored DOM to the shape the APG requires, and derives the state\n * that follows from the initial selection.\n *\n * Three things happen, and only these three — which option is chosen is the\n * author's, and is never changed:\n *\n * 1. Every option gets an explicit value. An absent `aria-selected` means \"not\n * selectable\" in ARIA, so a forgotten attribute hides a selectable option\n * from assistive technology.\n * 2. At most one stays `true`. The first in DOM order wins, since that is the\n * only deterministic reading of \"which one did the author mean\".\n * 3. The trigger label and the hidden field are derived from that selection.\n * Without this the widget announces a choice it does not submit: the popup\n * says \"Banana\", the trigger still says \"Choose…\", and the form posts \"\".\n *\n * No `change` fires — nothing changed, this is the initial state being told\n * properly. The scan is the `option` target set: a `role=\"option\"` without the\n * target is outside the contract and is neither counted nor written.\n */\n #normalizeSelection(): void {\n const options = this.optionTargets;\n const selected = options.find((option) => option.getAttribute(\"aria-selected\") === \"true\");\n for (const option of options) {\n option.setAttribute(\"aria-selected\", option === selected ? \"true\" : \"false\");\n }\n if (selected) this.#applySelection(selected);\n }\n\n /** Removes the document listener and clears the typeahead timer. */\n override disconnect(): void {\n this.#connected = false;\n this.#reconcile.cancel();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n // The typeahead keeps its idle reset in a `SafeTimeout` of its own, so this is\n // the only teardown that reaches it — there is no controller-level registry to\n // fall back on. Every timer this controller can schedule lives in there.\n this.#typeahead.reset();\n }\n\n /**\n * Toggles the list on a real mouse click. Keyboard activation of the\n * `<button>` also fires a click (`detail === 0`); the keydown handler already\n * drives that, so the synthetic click is ignored to avoid double-toggling.\n */\n toggle(event: MouseEvent): void {\n if (event.detail === 0) return;\n if (this.#isClosed) {\n this.open();\n } else {\n this.close();\n }\n }\n\n /** Yields claimed keys; otherwise routes the APG select-only keyboard model. */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (!this.#isClosed) this.#reconcileActive();\n const options = this.optionTargets;\n const length = options.length;\n const activeIndex = this.#findActiveIndex(options);\n if (this.#isClosed) {\n switch (event.key) {\n case \"Enter\":\n case \" \":\n case \"ArrowDown\":\n case \"ArrowUp\":\n event.preventDefault();\n this.open();\n break;\n default:\n break;\n }\n return;\n }\n\n // With no options, only Escape/Tab are meaningful; ignore navigation and\n // typeahead so the active index can never become NaN (`% 0`).\n if (length === 0 && event.key !== \"Escape\" && event.key !== \"Tab\") {\n return;\n }\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? length - 1 : (activeIndex - 1 + length) % length);\n break;\n case \"Home\":\n event.preventDefault();\n this.#setActive(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#setActive(length - 1);\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n this.#commitActive();\n break;\n case \"Escape\":\n // The entry guard already yields a press another handler owned. A press\n // during IME composition never dismisses, keeping one rule across the\n // widgets (the trigger is a button, so composition does not start here).\n if (event.isComposing) break;\n event.preventDefault();\n this.close();\n this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Let focus leave naturally; just don't keep a stale popup open.\n this.close();\n break;\n default:\n if (isTypeaheadKey(event)) {\n event.preventDefault();\n this.#typeaheadTo(options, activeIndex, event.key);\n }\n break;\n }\n }\n\n /** Selects the clicked option and closes, returning focus to the trigger. */\n select(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (!option || !this.optionTargets.includes(option)) return;\n this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Opens the list and activates the selected option (else the first). */\n open(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = false;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (this.optionTargets.length === 0) {\n // An empty listbox has nothing to activate; leave activedescendant cleared.\n this.#setActive(-1);\n return;\n }\n const selected = this.optionTargets.findIndex(\n (option) => option.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#setActive(selected === -1 ? 0 : selected);\n }\n\n /** Closes the list, clears the active option, and resets the typeahead buffer. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.#typeahead.reset();\n }\n\n /** Commits the active option (keyboard) and closes, returning focus. */\n #commitActive(): void {\n this.#reconcileActive();\n const options = this.optionTargets;\n const activeIndex = this.#findActiveIndex(options);\n const option = activeIndex < 0 ? undefined : options[activeIndex];\n if (option) this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */\n #selectOption(option: HTMLElement): void {\n const { value, fieldChanged } = this.#applySelection(option);\n if (fieldChanged) {\n // A native bubbling change (matching <select> semantics: only on an actual\n // value change) so form-level behaviors — validation re-checks, auto-submit\n // — hear the commit without knowing this widget.\n this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"change\", { detail: { value, option } });\n }\n\n /**\n * Writes `option` into `aria-selected`, the trigger label and the hidden field.\n * Emits nothing — {@link #normalizeSelection} reuses this at connect, where the\n * state is being described rather than changed.\n */\n #applySelection(option: HTMLElement): { value: string; fieldChanged: boolean } {\n for (const candidate of this.optionTargets) {\n candidate.setAttribute(\"aria-selected\", candidate === option ? \"true\" : \"false\");\n }\n const label = (option.textContent ?? \"\").trim();\n const value = option.dataset.value ?? label;\n if (this.hasValueTarget) this.valueTarget.textContent = label;\n const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;\n if (fieldChanged) this.fieldTarget.value = value;\n return { value, fieldChanged };\n }\n\n /**\n * Marks the option at `index` active via `data-active` and the trigger's\n * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not\n * set to empty, per the APG).\n */\n #setActive(index: number): void {\n const options = this.optionTargets;\n const active = index < 0 ? null : (options[index] ?? null);\n // Only the options whose marker actually changes are written, so a held arrow\n // key costs two attribute writes rather than one per option. The whole set is\n // still read: that is what makes a stray marker — one a morph left behind on\n // an element that never re-connected as a target — heal on the next move.\n for (const option of options) {\n const marked = option.hasAttribute(\"data-active\");\n if (option === active) {\n if (!marked) option.setAttribute(\"data-active\", \"\");\n } else if (marked) {\n option.removeAttribute(\"data-active\");\n }\n }\n if (active?.id) {\n this.#activeId = active.id;\n this.triggerTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.#activeId = null;\n this.triggerTarget.removeAttribute(\"aria-activedescendant\");\n }\n this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];\n // Virtual focus never triggers the browser's native focus-scrolling, so a\n // scrollable list must follow the active option itself (list-only scroll).\n if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);\n }\n\n /** Resolves active state against the current target collection. */\n #reconcileActive(): void {\n if (!this.hasTriggerTarget || this.#isClosed) {\n if (this.hasTriggerTarget) this.#setActive(-1);\n return;\n }\n\n const options = this.optionTargets;\n const currentIndex = this.#findActiveIndex(options);\n if (currentIndex >= 0) {\n const active = options[currentIndex] ?? null;\n const marked = options.filter((option) => option.hasAttribute(\"data-active\"));\n const stateMatches =\n this.#activeId === (active?.id || null) &&\n marked.length === 1 &&\n marked[0] === active &&\n this.triggerTarget.getAttribute(\"aria-activedescendant\") === (active?.id || null);\n\n if (stateMatches) {\n // Keep the deletion fallback snapshot current without re-scrolling the\n // already-active option before every keyboard command.\n this.#activeOrder = options.map((option) => option.id).filter(Boolean);\n } else {\n this.#setActive(currentIndex);\n }\n return;\n }\n\n const activeId = this.triggerTarget.getAttribute(\"aria-activedescendant\") ?? this.#activeId;\n this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);\n }\n\n /** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */\n #findActiveIndex(options: readonly HTMLElement[]): number {\n const activeId =\n (this.hasTriggerTarget ? this.triggerTarget.getAttribute(\"aria-activedescendant\") : null) ??\n this.#activeId;\n if (activeId) return options.findIndex((option) => option.id === activeId);\n return options.findIndex((option) => option.hasAttribute(\"data-active\"));\n }\n\n /** Chooses a surviving former successor, then a former predecessor. */\n #findFallbackIndex(options: readonly HTMLElement[], activeId: string): number {\n const oldIndex = this.#activeOrder.indexOf(activeId);\n if (oldIndex < 0) return -1;\n const indexesById = new Map(options.map((option, index) => [option.id, index]));\n for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n for (let index = oldIndex - 1; index >= 0; index -= 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n return -1;\n }\n\n /** Coalesces all target callbacks from one MutationObserver batch. */\n #queueOptionReconciliation(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Advances the typeahead query and activates the next matching option.\n *\n * The search resumes just after the active option so repeating a character\n * cycles through the options starting with it, rather than re-activating the\n * same first match on every press.\n */\n #typeaheadTo(options: HTMLElement[], activeIndex: number, char: string): void {\n const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));\n if (index !== -1) this.#setActive(index);\n }\n\n /** Closes on an outside click before an inside handler can detach its target. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Whether the list is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
|
|
@@ -16,6 +16,8 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
16
16
|
* locale/zone on connect. This is the *absolute* localization axis, distinct from
|
|
17
17
|
* {@link RelativeTimeController}'s "3 minutes ago".
|
|
18
18
|
*
|
|
19
|
+
* `format` dispatches `{ formatted }`.
|
|
20
|
+
*
|
|
19
21
|
* @remarks
|
|
20
22
|
* Behavior only. The machine-readable `datetime` (UTC) is left untouched so
|
|
21
23
|
* assistive tech and crawlers keep the canonical value while only the display
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/microtask_coalescer.ts","../../src/controllers/local_time_controller.ts"],"names":[],"mappings":"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","file":"local_time_controller.js","sourcesContent":["/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same 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 initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output 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","import { Controller } from \"@hotwired/stimulus\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** A date/time style keyword accepted by `Intl` `dateStyle` / `timeStyle`. */\ntype DateTimeStyle = \"full\" | \"long\" | \"medium\" | \"short\";\n\n/** The valid `Intl` style keywords, used to validate the string-typed values. */\nconst STYLES = new Set<DateTimeStyle>([\"full\", \"long\", \"medium\", \"short\"]);\n\n/** Narrows an arbitrary string to a valid {@link DateTimeStyle}, or `undefined`. */\nfunction toStyle(value: string): DateTimeStyle | undefined {\n return (STYLES as Set<string>).has(value) ? (value as DateTimeStyle) : undefined;\n}\n\n/**\n * Headless local-time behavior: renders the UTC instant in a `<time datetime>`\n * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No\n * dedicated APG pattern; it follows the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--local-time`):\n * <time datetime=\"2026-06-08T12:30:00Z\"\n * data-controller=\"stimeo--local-time\"\n * data-stimeo--local-time-date-style-value=\"medium\"\n * data-stimeo--local-time-time-style-value=\"short\">2026-06-08 12:30 UTC</time>\n *\n * The server emits UTC (cache-friendly — it never needs to know the viewer's\n * timezone), and the controller reformats the visible text into the viewer's\n * locale/zone on connect. This is the *absolute* localization axis, distinct from\n * {@link RelativeTimeController}'s \"3 minutes ago\".\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` (UTC) is left untouched so\n * assistive tech and crawlers keep the canonical value while only the display\n * text — and an optional `title` — change. Formatting is a pure function of\n * `datetime` with no module-scope state or timers, so a Turbo Drive cache restore\n * re-runs `connect()` and stays consistent. A parse or `Intl` error leaves the\n * authored absolute text in place rather than throwing.\n *\n * Render inputs are followed at runtime: a morph that swaps a Value or the\n * `datetime` attribute on the live element — which keeps the element, so\n * `connect()` never runs again — repaints through one coalesced pass\n * ({@link MicrotaskCoalescer}) rather than leaving a stale reading on screen.\n */\nexport class LocalTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n timeZone: { type: String, default: \"\" },\n dateStyle: { type: String, default: \"medium\" },\n timeStyle: { type: String, default: \"short\" },\n titleFormat: { type: String, default: \"\" },\n };\n static events = [\"format\"] as const;\n\n declare localeValue: string;\n declare timeZoneValue: string;\n declare dateStyleValue: string;\n declare timeStyleValue: string;\n declare titleFormatValue: string;\n\n /** Collapses a morph that swaps several render inputs at once into one repaint. */\n readonly #resync = new MicrotaskCoalescer(() => this.#render());\n /**\n * Watches the one render input that is not a Value. Only `datetime` is filtered\n * in, so the text and `title` this controller writes cannot re-enter the pass.\n */\n readonly #datetimeWatch = new MutationObserver(() => {\n this.#resync.schedule();\n });\n\n override connect(): void {\n this.#resync.activate();\n this.#datetimeWatch.observe(this.element, { attributeFilter: [\"datetime\"] });\n this.#render();\n }\n\n override disconnect(): void {\n this.#resync.cancel();\n this.#datetimeWatch.disconnect();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */\n localeValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */\n timeZoneValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */\n dateStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */\n timeStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */\n titleFormatValueChanged(): void {\n this.#resync.schedule();\n }\n\n /**\n * Formats the instant in `datetime` against the current Values and writes it out.\n *\n * The `format` event rides with every pass, including a repaint a morph triggers:\n * its condition is that formatting was applied, and a repaint applies it with a\n * new result. A pass that cannot format writes nothing and emits nothing, so the\n * authored absolute text stays as the fallback.\n *\n * @stimeoRenderRoot\n */\n #render(): void {\n const date = this.#parse();\n if (date === null) return;\n\n const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);\n if (formatted === null) return;\n\n // Only the visible text (and optional title) change; `datetime` is the\n // immutable machine-readable source and is never rewritten.\n this.element.textContent = formatted;\n\n const title = this.#title(date);\n if (title !== null) this.element.setAttribute(\"title\", title);\n\n this.dispatch(\"format\", { detail: { formatted } });\n }\n\n /**\n * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace\n * around the attribute value is tolerated.\n */\n #parse(): Date | null {\n const raw = this.element.getAttribute(\"datetime\");\n if (!raw) return null;\n const ms = Date.parse(this.#asUtc(raw.trim()));\n return Number.isNaN(ms) ? null : new Date(ms);\n }\n\n /**\n * Reads a timezone-less date-time as UTC — the input contract of this\n * controller — since `Date.parse` would otherwise read `\"2026-06-08T12:30:00\"` in\n * the *runtime's* local zone, contradicting \"the server emits UTC\". Values that\n * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already\n * parsed as UTC) are returned unchanged.\n *\n * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is\n * left to each engine, so a whole value shaped that way is normalized to the `T`\n * separator first. The pattern is anchored: a value trailing anything else — a\n * zone word such as `\"2026-06-08 12:30:00 UTC\"` — is handed to `Date.parse` as\n * authored instead of being turned into a string nothing can parse.\n */\n #asUtc(value: string): string {\n const isoLike = value.replace(\n /^(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})?)$/,\n \"$1T$2\",\n );\n const hasTime = /T\\d{2}:\\d{2}/.test(isoLike);\n const hasZone = /(Z|[+-]\\d{2}:?\\d{2})$/.test(isoLike);\n return hasTime && !hasZone ? `${isoLike}Z` : isoLike;\n }\n\n /**\n * Builds the optional detailed `title`. `titleFormat` is an `Intl` style\n * keyword applied to *both* date and time; empty (the default) adds no title.\n */\n #title(date: Date): string | null {\n if (this.titleFormatValue.length === 0) return null;\n return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);\n }\n\n /**\n * Formats `date` with `Intl.DateTimeFormat`, including each style only when it\n * is a valid keyword (so a consumer can show date-only or time-only by clearing\n * the other). Returns `null` when neither style is usable or `Intl` throws, so\n * the caller can leave the authored text untouched.\n */\n #applyFormat(date: Date, dateStyle: string, timeStyle: string): string | null {\n // `toStyle` yields `undefined` for an empty/invalid keyword; assigning it is\n // equivalent to omitting the option, so a consumer can show date- or time-only.\n const options: Intl.DateTimeFormatOptions = {\n dateStyle: toStyle(dateStyle),\n timeStyle: toStyle(timeStyle),\n };\n if (options.dateStyle === undefined && options.timeStyle === undefined) return null;\n if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;\n\n try {\n return new Intl.DateTimeFormat(this.#locale, options).format(date);\n } catch {\n // An invalid locale / timeZone (or unsupported style) must not break the\n // page; the authored absolute text remains as the graceful fallback.\n return null;\n }\n }\n\n /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.closest(\"[lang]\")?.getAttribute(\"lang\") || undefined;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/microtask_coalescer.ts","../../src/controllers/local_time_controller.ts"],"names":[],"mappings":"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","file":"local_time_controller.js","sourcesContent":["/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same 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 initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output 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","import { Controller } from \"@hotwired/stimulus\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** A date/time style keyword accepted by `Intl` `dateStyle` / `timeStyle`. */\ntype DateTimeStyle = \"full\" | \"long\" | \"medium\" | \"short\";\n\n/** The valid `Intl` style keywords, used to validate the string-typed values. */\nconst STYLES = new Set<DateTimeStyle>([\"full\", \"long\", \"medium\", \"short\"]);\n\n/** Narrows an arbitrary string to a valid {@link DateTimeStyle}, or `undefined`. */\nfunction toStyle(value: string): DateTimeStyle | undefined {\n return (STYLES as Set<string>).has(value) ? (value as DateTimeStyle) : undefined;\n}\n\n/**\n * Headless local-time behavior: renders the UTC instant in a `<time datetime>`\n * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No\n * dedicated APG pattern; it follows the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--local-time`):\n * <time datetime=\"2026-06-08T12:30:00Z\"\n * data-controller=\"stimeo--local-time\"\n * data-stimeo--local-time-date-style-value=\"medium\"\n * data-stimeo--local-time-time-style-value=\"short\">2026-06-08 12:30 UTC</time>\n *\n * The server emits UTC (cache-friendly — it never needs to know the viewer's\n * timezone), and the controller reformats the visible text into the viewer's\n * locale/zone on connect. This is the *absolute* localization axis, distinct from\n * {@link RelativeTimeController}'s \"3 minutes ago\".\n *\n * `format` dispatches `{ formatted }`.\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` (UTC) is left untouched so\n * assistive tech and crawlers keep the canonical value while only the display\n * text — and an optional `title` — change. Formatting is a pure function of\n * `datetime` with no module-scope state or timers, so a Turbo Drive cache restore\n * re-runs `connect()` and stays consistent. A parse or `Intl` error leaves the\n * authored absolute text in place rather than throwing.\n *\n * Render inputs are followed at runtime: a morph that swaps a Value or the\n * `datetime` attribute on the live element — which keeps the element, so\n * `connect()` never runs again — repaints through one coalesced pass\n * ({@link MicrotaskCoalescer}) rather than leaving a stale reading on screen.\n */\nexport class LocalTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n timeZone: { type: String, default: \"\" },\n dateStyle: { type: String, default: \"medium\" },\n timeStyle: { type: String, default: \"short\" },\n titleFormat: { type: String, default: \"\" },\n };\n static events = [\"format\"] as const;\n\n declare localeValue: string;\n declare timeZoneValue: string;\n declare dateStyleValue: string;\n declare timeStyleValue: string;\n declare titleFormatValue: string;\n\n /** Collapses a morph that swaps several render inputs at once into one repaint. */\n readonly #resync = new MicrotaskCoalescer(() => this.#render());\n /**\n * Watches the one render input that is not a Value. Only `datetime` is filtered\n * in, so the text and `title` this controller writes cannot re-enter the pass.\n */\n readonly #datetimeWatch = new MutationObserver(() => {\n this.#resync.schedule();\n });\n\n override connect(): void {\n this.#resync.activate();\n this.#datetimeWatch.observe(this.element, { attributeFilter: [\"datetime\"] });\n this.#render();\n }\n\n override disconnect(): void {\n this.#resync.cancel();\n this.#datetimeWatch.disconnect();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */\n localeValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */\n timeZoneValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */\n dateStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */\n timeStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */\n titleFormatValueChanged(): void {\n this.#resync.schedule();\n }\n\n /**\n * Formats the instant in `datetime` against the current Values and writes it out.\n *\n * The `format` event rides with every pass, including a repaint a morph triggers:\n * its condition is that formatting was applied, and a repaint applies it with a\n * new result. A pass that cannot format writes nothing and emits nothing, so the\n * authored absolute text stays as the fallback.\n *\n * @stimeoRenderRoot\n */\n #render(): void {\n const date = this.#parse();\n if (date === null) return;\n\n const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);\n if (formatted === null) return;\n\n // Only the visible text (and optional title) change; `datetime` is the\n // immutable machine-readable source and is never rewritten.\n this.element.textContent = formatted;\n\n const title = this.#title(date);\n if (title !== null) this.element.setAttribute(\"title\", title);\n\n this.dispatch(\"format\", { detail: { formatted } });\n }\n\n /**\n * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace\n * around the attribute value is tolerated.\n */\n #parse(): Date | null {\n const raw = this.element.getAttribute(\"datetime\");\n if (!raw) return null;\n const ms = Date.parse(this.#asUtc(raw.trim()));\n return Number.isNaN(ms) ? null : new Date(ms);\n }\n\n /**\n * Reads a timezone-less date-time as UTC — the input contract of this\n * controller — since `Date.parse` would otherwise read `\"2026-06-08T12:30:00\"` in\n * the *runtime's* local zone, contradicting \"the server emits UTC\". Values that\n * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already\n * parsed as UTC) are returned unchanged.\n *\n * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is\n * left to each engine, so a whole value shaped that way is normalized to the `T`\n * separator first. The pattern is anchored: a value trailing anything else — a\n * zone word such as `\"2026-06-08 12:30:00 UTC\"` — is handed to `Date.parse` as\n * authored instead of being turned into a string nothing can parse.\n */\n #asUtc(value: string): string {\n const isoLike = value.replace(\n /^(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})?)$/,\n \"$1T$2\",\n );\n const hasTime = /T\\d{2}:\\d{2}/.test(isoLike);\n const hasZone = /(Z|[+-]\\d{2}:?\\d{2})$/.test(isoLike);\n return hasTime && !hasZone ? `${isoLike}Z` : isoLike;\n }\n\n /**\n * Builds the optional detailed `title`. `titleFormat` is an `Intl` style\n * keyword applied to *both* date and time; empty (the default) adds no title.\n */\n #title(date: Date): string | null {\n if (this.titleFormatValue.length === 0) return null;\n return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);\n }\n\n /**\n * Formats `date` with `Intl.DateTimeFormat`, including each style only when it\n * is a valid keyword (so a consumer can show date-only or time-only by clearing\n * the other). Returns `null` when neither style is usable or `Intl` throws, so\n * the caller can leave the authored text untouched.\n */\n #applyFormat(date: Date, dateStyle: string, timeStyle: string): string | null {\n // `toStyle` yields `undefined` for an empty/invalid keyword; assigning it is\n // equivalent to omitting the option, so a consumer can show date- or time-only.\n const options: Intl.DateTimeFormatOptions = {\n dateStyle: toStyle(dateStyle),\n timeStyle: toStyle(timeStyle),\n };\n if (options.dateStyle === undefined && options.timeStyle === undefined) return null;\n if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;\n\n try {\n return new Intl.DateTimeFormat(this.#locale, options).format(date);\n } catch {\n // An invalid locale / timeZone (or unsupported style) must not break the\n // page; the authored absolute text remains as the graceful fallback.\n return null;\n }\n }\n\n /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.closest(\"[lang]\")?.getAttribute(\"lang\") || undefined;\n }\n}\n"]}
|
|
@@ -19,6 +19,8 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
19
19
|
* element as the `--stimeo--masonry-columns` custom property and each item gets a
|
|
20
20
|
* `data-column` index, so the consumer's CSS owns the actual placement.
|
|
21
21
|
*
|
|
22
|
+
* `layout` dispatches `{ columns: number }`.
|
|
23
|
+
*
|
|
22
24
|
* @remarks
|
|
23
25
|
* Behavior only. **DOM order is never changed** — reading order and focus order
|
|
24
26
|
* stay the source markup order (WCAG 1.3.2). The visual packing is purely the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/masonry_controller.ts"],"names":[],"mappings":"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","file":"masonry_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Layout-sensitive widgets (sliders, resizable panes, scroll spies, popovers)\n * need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\n\n/** CSS custom property exposing the current column count to consumer CSS. */\nconst COLUMNS_PROPERTY = \"--stimeo--masonry-columns\";\n\n/**\n * Headless **Masonry** layout helper: assigns each item to the shortest column so\n * variable-height cards pack without vertical gaps. There is no APG widget — this\n * is a layout-only utility that emits state hooks, never visual structure.\n *\n * Markup contract (identifier: `stimeo--masonry`):\n * <div data-controller=\"stimeo--masonry\"\n * data-stimeo--masonry-min-column-width-value=\"240\"\n * data-stimeo--masonry-gap-value=\"16\">\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * </div>\n *\n * The column count is derived responsively from the container width and\n * `minColumnWidth`; each item is then placed into whichever column is currently\n * shortest (measured from item heights). The count is published on the controller\n * element as the `--stimeo--masonry-columns` custom property and each item gets a\n * `data-column` index, so the consumer's CSS owns the actual placement.\n *\n * @remarks\n * Behavior only. **DOM order is never changed** — reading order and focus order\n * stay the source markup order (WCAG 1.3.2). The visual packing is purely the\n * column assignment a consumer reads from `data-column`; this controller writes no\n * positioning styles. Re-layout runs on connect, on resize ({@link LayoutObserver}),\n * and on item add/remove ({@link MutationObserver}); both observers are released on\n * `disconnect()` (Turbo navigation included). Use only for independent cards whose\n * visual order carries no meaning.\n */\nexport class MasonryController extends Controller<HTMLElement> {\n static override targets = [\"item\"];\n static override values = {\n minColumnWidth: { type: Number, default: 240 },\n gap: { type: Number, default: 16 },\n };\n static events = [\"layout\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare minColumnWidthValue: number;\n declare gapValue: number;\n\n readonly #layout = new LayoutObserver(() => this.#relayout());\n #mutationObserver: MutationObserver | null = null;\n /** Last published column count, so `layout` fires only on real changes. */\n #lastColumns = 0;\n\n /**\n * Re-pack when a descendant resource finishes loading. Images/iframes report a\n * height of 0 until loaded, which would skew the shortest-column packing if the\n * first pass ran before they settled; `load` does not bubble, so this is bound in\n * the capture phase to catch every descendant.\n */\n readonly #onLoad = (): void => this.#relayout();\n\n /** Observes size/content changes and performs the first layout pass. */\n override connect(): void {\n this.#layout.observe(this.element);\n this.#layout.observeViewport();\n\n if (typeof MutationObserver !== \"undefined\") {\n this.#mutationObserver = new MutationObserver(() => this.#relayout());\n this.#mutationObserver.observe(this.element, { childList: true, subtree: true });\n }\n this.element.addEventListener(\"load\", this.#onLoad, true);\n this.#relayout();\n }\n\n /** Releases both observers and the load listener so nothing fires after detach. */\n override disconnect(): void {\n this.#layout.disconnect();\n this.#mutationObserver?.disconnect();\n this.#mutationObserver = null;\n this.element.removeEventListener(\"load\", this.#onLoad, true);\n this.#lastColumns = 0;\n }\n\n /**\n * Recomputes the column count and assigns every item to the shortest column.\n * Runs automatically on connect, on resize, on item add/remove, and when a\n * descendant resource loads (private — there is no public action; the observers\n * and the capture-phase `load` listener drive it). Items are walked in DOM\n * order; each lands in the column with the least accumulated height, which\n * keeps the packing balanced without reordering the DOM.\n */\n #relayout(): void {\n const items = this.itemTargets;\n const columns = this.#columnCount();\n\n const heights = new Array<number>(columns).fill(0);\n for (const item of items) {\n let shortest = 0;\n for (let col = 1; col < columns; col++) {\n if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;\n }\n item.setAttribute(\"data-column\", String(shortest));\n heights[shortest] =\n (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;\n }\n\n this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));\n\n if (columns !== this.#lastColumns) {\n this.#lastColumns = columns;\n this.dispatch(\"layout\", { detail: { columns } });\n }\n }\n\n /**\n * Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,\n * never fewer than one. When the width is unmeasurable (detached, or a layout\n * engine that reports `0`), it falls back to a single column so every item still\n * gets a valid `data-column`.\n */\n #columnCount(): number {\n const width = this.element.getBoundingClientRect().width;\n const denominator = this.minColumnWidthValue + this.gapValue;\n if (width <= 0 || denominator <= 0) return 1;\n return Math.max(1, Math.floor((width + this.gapValue) / denominator));\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/masonry_controller.ts"],"names":[],"mappings":"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","file":"masonry_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Layout-sensitive widgets (sliders, resizable panes, scroll spies, popovers)\n * need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\n\n/** CSS custom property exposing the current column count to consumer CSS. */\nconst COLUMNS_PROPERTY = \"--stimeo--masonry-columns\";\n\n/**\n * Headless **Masonry** layout helper: assigns each item to the shortest column so\n * variable-height cards pack without vertical gaps. There is no APG widget — this\n * is a layout-only utility that emits state hooks, never visual structure.\n *\n * Markup contract (identifier: `stimeo--masonry`):\n * <div data-controller=\"stimeo--masonry\"\n * data-stimeo--masonry-min-column-width-value=\"240\"\n * data-stimeo--masonry-gap-value=\"16\">\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * </div>\n *\n * The column count is derived responsively from the container width and\n * `minColumnWidth`; each item is then placed into whichever column is currently\n * shortest (measured from item heights). The count is published on the controller\n * element as the `--stimeo--masonry-columns` custom property and each item gets a\n * `data-column` index, so the consumer's CSS owns the actual placement.\n *\n * `layout` dispatches `{ columns: number }`.\n *\n * @remarks\n * Behavior only. **DOM order is never changed** — reading order and focus order\n * stay the source markup order (WCAG 1.3.2). The visual packing is purely the\n * column assignment a consumer reads from `data-column`; this controller writes no\n * positioning styles. Re-layout runs on connect, on resize ({@link LayoutObserver}),\n * and on item add/remove ({@link MutationObserver}); both observers are released on\n * `disconnect()` (Turbo navigation included). Use only for independent cards whose\n * visual order carries no meaning.\n */\nexport class MasonryController extends Controller<HTMLElement> {\n static override targets = [\"item\"];\n static override values = {\n minColumnWidth: { type: Number, default: 240 },\n gap: { type: Number, default: 16 },\n };\n static events = [\"layout\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare minColumnWidthValue: number;\n declare gapValue: number;\n\n readonly #layout = new LayoutObserver(() => this.#relayout());\n #mutationObserver: MutationObserver | null = null;\n /** Last published column count, so `layout` fires only on real changes. */\n #lastColumns = 0;\n\n /**\n * Re-pack when a descendant resource finishes loading. Images/iframes report a\n * height of 0 until loaded, which would skew the shortest-column packing if the\n * first pass ran before they settled; `load` does not bubble, so this is bound in\n * the capture phase to catch every descendant.\n */\n readonly #onLoad = (): void => this.#relayout();\n\n /** Observes size/content changes and performs the first layout pass. */\n override connect(): void {\n this.#layout.observe(this.element);\n this.#layout.observeViewport();\n\n if (typeof MutationObserver !== \"undefined\") {\n this.#mutationObserver = new MutationObserver(() => this.#relayout());\n this.#mutationObserver.observe(this.element, { childList: true, subtree: true });\n }\n this.element.addEventListener(\"load\", this.#onLoad, true);\n this.#relayout();\n }\n\n /** Releases both observers and the load listener so nothing fires after detach. */\n override disconnect(): void {\n this.#layout.disconnect();\n this.#mutationObserver?.disconnect();\n this.#mutationObserver = null;\n this.element.removeEventListener(\"load\", this.#onLoad, true);\n this.#lastColumns = 0;\n }\n\n /**\n * Recomputes the column count and assigns every item to the shortest column.\n * Runs automatically on connect, on resize, on item add/remove, and when a\n * descendant resource loads (private — there is no public action; the observers\n * and the capture-phase `load` listener drive it). Items are walked in DOM\n * order; each lands in the column with the least accumulated height, which\n * keeps the packing balanced without reordering the DOM.\n */\n #relayout(): void {\n const items = this.itemTargets;\n const columns = this.#columnCount();\n\n const heights = new Array<number>(columns).fill(0);\n for (const item of items) {\n let shortest = 0;\n for (let col = 1; col < columns; col++) {\n if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;\n }\n item.setAttribute(\"data-column\", String(shortest));\n heights[shortest] =\n (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;\n }\n\n this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));\n\n if (columns !== this.#lastColumns) {\n this.#lastColumns = columns;\n this.dispatch(\"layout\", { detail: { columns } });\n }\n }\n\n /**\n * Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,\n * never fewer than one. When the width is unmeasurable (detached, or a layout\n * engine that reports `0`), it falls back to a single column so every item still\n * gets a valid `data-column`.\n */\n #columnCount(): number {\n const width = this.element.getBoundingClientRect().width;\n const denominator = this.minColumnWidthValue + this.gapValue;\n if (width <= 0 || denominator <= 0) return 1;\n return Math.max(1, Math.floor((width + this.gapValue) / denominator));\n }\n}\n"]}
|
|
@@ -31,6 +31,8 @@ type SetValueEvent = Event & {
|
|
|
31
31
|
* thresholds are present, classifies the value into a `low`/`medium`/`high`
|
|
32
32
|
* segment on `data-state` so the consumer can color the bar.
|
|
33
33
|
*
|
|
34
|
+
* `change` dispatches `{ value: number, ratio: number, state: string }`.
|
|
35
|
+
*
|
|
34
36
|
* @remarks
|
|
35
37
|
* Behavior only. Because state must not be conveyed by color alone (WCAG 1.4.1),
|
|
36
38
|
* a consumer-provided `valueText` template feeds `aria-valuetext` so the segment
|
|
@@ -1 +1 @@
|
|
|
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 Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same 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 initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output 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 * @stimeoRenderRoot\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"]}
|
|
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":";;;;;AAoBO,SAAS,QAAA,CAAS,OAAA,EAAiB,OAAA,GAAmC,EAAC,EAAS;AACrF,EAAA,MAAM,IAAA,GAAO,QAAQ,IAAA,EAAK;AAC1B,EAAA,IAAI,IAAA,CAAK,WAAW,CAAA,EAAG;AACvB,EAAA,MAAA,CAAO,aAAA;AAAA,IACL,IAAI,YAAY,4BAAA,EAA8B;AAAA,MAC5C,QAAQ,EAAE,OAAA,EAAS,MAAM,SAAA,EAAW,OAAA,CAAQ,cAAc,IAAA;AAAK,KAChE;AAAA,GACH;AACF;AAUO,SAAS,YAAA,CAAa,UAAkB,MAAA,EAAiD;AAC9F,EAAA,OAAO,QAAA,CAAS,OAAA,CAAQ,6BAAA,EAA+B,CAAC,OAAO,IAAA,KAAiB;AAC9E,IAAA,MAAM,WAAA,GAAc,OAAO,IAAI,CAAA;AAC/B,IAAA,OAAO,WAAA,KAAgB,MAAA,GAAY,KAAA,GAAQ,MAAA,CAAO,WAAW,CAAA;AAAA,EAC/D,CAAC,CAAA;AACH;;;AC5BO,SAAS,eAAe,GAAA,EAAwD;AACrF,EAAA,IAAI,QAAQ,IAAA,IAAQ,GAAA,KAAQ,MAAA,IAAa,GAAA,KAAQ,IAAI,OAAO,IAAA;AAC5D,EAAA,MAAM,QAAQ,OAAO,GAAA,KAAQ,QAAA,GAAW,GAAA,GAAM,OAAO,GAAG,CAAA;AACxD,EAAA,OAAO,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA,GAAI,KAAA,GAAQ,IAAA;AAC1C;;;ACkCO,IAAM,qBAAN,MAAyB;AAAA,EACrB,IAAA;AAAA,EACT,OAAA,GAAU,KAAA;AAAA,EACV,OAAA,GAAU,KAAA;AAAA,EACV,WAAA,GAAc,CAAA;AAAA;AAAA,EAGd,YAAY,GAAA,EAAiB;AAC3B,IAAA,IAAA,CAAK,IAAA,GAAO,GAAA;AAAA,EACd;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AAAA,EACjB;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,IAAA,IAAA,CAAK,WAAA,IAAe,CAAA;AAAA,EACtB;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,IAAW,IAAA,CAAK,OAAA,EAAS;AACnC,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,IAAA,MAAM,aAAa,IAAA,CAAK,WAAA;AACxB,IAAA,cAAA,CAAe,MAAM;AAEnB,MAAA,IAAI,UAAA,KAAe,KAAK,WAAA,IAAe,CAAC,KAAK,OAAA,IAAW,CAAC,KAAK,OAAA,EAAS;AACvE,MAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,MAAA,IAAA,CAAK,IAAA,EAAK;AAAA,IACZ,CAAC,CAAA;AAAA,EACH;AACF,CAAA;;;AChEO,SAAS,aAAA,CAAc,KAAA,EAAe,GAAA,EAAa,GAAA,EAAqB;AAC7E,EAAA,MAAM,OAAO,GAAA,GAAM,GAAA;AACnB,EAAA,IAAI,EAAE,IAAA,GAAO,CAAA,CAAA,EAAI,OAAO,CAAA;AAExB,EAAA,MAAM,OAAA,GAAU,KAAK,GAAA,CAAI,GAAA,EAAK,KAAK,GAAA,CAAI,GAAA,EAAK,KAAK,CAAC,CAAA;AAClD,EAAA,IAAI,QAAA;AACJ,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,IAAI,CAAA,EAAG;AACzB,IAAA,QAAA,GAAA,CAAY,UAAU,GAAA,IAAO,IAAA;AAAA,EAC/B,CAAA,MAAO;AAGL,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,GAAG,CAAA,EAAG,IAAA,CAAK,GAAA,CAAI,GAAG,CAAC,CAAA;AACnD,IAAA,QAAA,GAAA,CAAY,UAAU,KAAA,GAAQ,GAAA,GAAM,KAAA,KAAU,GAAA,GAAM,QAAQ,GAAA,GAAM,KAAA,CAAA;AAAA,EACpE;AAEA,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,QAAQ,GAAG,OAAO,CAAA;AACvC,EAAA,OAAO,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,GAAA,CAAI,CAAA,EAAG,QAAQ,CAAC,CAAA;AAC1C;;;ACZA,IAAM,gBAAA,GAAmB,mCAAA;AA4BlB,IAAM,eAAA,GAAN,cAA8B,UAAA,CAAwB;AAAA,EAC3D,OAAgB,OAAA,GAAU,CAAC,KAAK,CAAA;AAAA,EAChC,OAAgB,MAAA,GAAS;AAAA,IACvB,YAAA,EAAc,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IAC1C,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAClC,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAChC,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,GAAA,EAAI;AAAA,IAClC,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAChC,IAAA,EAAM,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACjC,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACpC,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GACzC;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,UAAU,CAAA;AAAA,EAC5B,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmBhB,QAAA,GAAW,IAAI,kBAAA,CAAmB,MAAM;AAC/C,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf,CAAC,CAAA;AAAA;AAAA,EAGD,eAAA,GAAqC,IAAA;AAAA,EAE5B,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,SAAS,QAAA,EAAS;AACvB,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA;AAAA,EAGS,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,SAAS,MAAA,EAAO;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,SAAS,KAAA,EAA4B;AACnC,IAAA,MAAM,OAAO,cAAA,CAAe,KAAA,CAAM,QAAQ,MAAA,IAAU,KAAA,CAAM,QAAQ,KAAK,CAAA;AACvE,IAAA,IAAI,SAAS,IAAA,EAAM;AACnB,IAAA,IAAA,CAAK,UAAA,GAAa,IAAA,CAAK,MAAA,CAAO,IAAI,CAAA;AAClC,IAAA,MAAM,OAAA,GAAU,KAAK,OAAA,EAAQ;AAC7B,IAAA,IAAA,CAAK,QAAA,CAAS,QAAA,EAAU,EAAE,MAAA,EAAQ,SAAS,CAAA;AAG3C,IAAA,IAAI,OAAA,CAAQ,KAAA,KAAU,IAAA,CAAK,eAAA,EAAiB;AAC1C,MAAA,IAAA,CAAK,kBAAkB,OAAA,CAAQ,KAAA;AAC/B,MAAA,QAAA;AAAA,QACE,YAAA,CAAa,IAAA,CAAK,iBAAA,EAAmB,EAAE,KAAA,EAAO,QAAQ,KAAA,EAAO,KAAA,EAAO,OAAA,CAAQ,KAAA,EAAO;AAAA,OACrF;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,iBAAA,GAA0B;AACxB,IAAA,IAAA,CAAK,SAAS,QAAA,EAAS;AAAA,EACzB;AAAA;AAAA,EAGA,eAAA,GAAwB;AACtB,IAAA,IAAA,CAAK,SAAS,QAAA,EAAS;AAAA,EACzB;AAAA;AAAA,EAGA,eAAA,GAAwB;AACtB,IAAA,IAAA,CAAK,SAAS,QAAA,EAAS;AAAA,EACzB;AAAA;AAAA,EAGA,eAAA,GAAwB;AACtB,IAAA,IAAA,CAAK,SAAS,QAAA,EAAS;AAAA,EACzB;AAAA;AAAA,EAGA,gBAAA,GAAyB;AACvB,IAAA,IAAA,CAAK,SAAS,QAAA,EAAS;AAAA,EACzB;AAAA;AAAA,EAGA,qBAAA,GAA8B;AAC5B,IAAA,IAAA,CAAK,SAAS,QAAA,EAAS;AAAA,EACzB;AAAA;AAAA,EAGA,OAAO,GAAA,EAAqB;AAC1B,IAAA,OAAO,IAAA,CAAK,IAAI,IAAA,CAAK,QAAA,EAAU,KAAK,GAAA,CAAI,IAAA,CAAK,QAAA,EAAU,GAAG,CAAC,CAAA;AAAA,EAC7D;AAAA;AAAA,EAGA,cAAc,IAAA,EAA+B;AAC3C,IAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,CAAA,mBAAA,EAAsB,IAAI,CAAA,MAAA,CAAQ,CAAA;AAAA,EACrE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,SAAS,KAAA,EAA2B;AAClC,IAAA,IAAI,KAAK,aAAA,CAAc,KAAK,KAAK,KAAA,IAAS,IAAA,CAAK,UAAU,OAAO,KAAA;AAChE,IAAA,IAAI,KAAK,aAAA,CAAc,MAAM,KAAK,KAAA,IAAS,IAAA,CAAK,WAAW,OAAO,MAAA;AAClE,IAAA,OAAO,QAAA;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,OAAA,GAAwB;AACtB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,IAAA,CAAK,UAAU,CAAA;AACzC,IAAA,MAAM,OAAA,GAAwB;AAAA,MAC5B,KAAA;AAAA,MACA,OAAO,aAAA,CAAc,KAAA,EAAO,IAAA,CAAK,QAAA,EAAU,KAAK,QAAQ,CAAA;AAAA,MACxD,KAAA,EAAO,IAAA,CAAK,QAAA,CAAS,KAAK;AAAA,KAC5B;AACA,IAAA,IAAA,CAAK,QAAQ,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,IAAA,CAAK,QAAQ,CAAC,CAAA;AAChE,IAAA,IAAA,CAAK,QAAQ,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,IAAA,CAAK,QAAQ,CAAC,CAAA;AAChE,IAAA,IAAA,CAAK,QAAQ,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,OAAA,CAAQ,KAAK,CAAC,CAAA;AAChE,IAAA,IAAA,CAAK,QAAQ,KAAA,CAAM,WAAA,CAAY,yBAAyB,MAAA,CAAO,OAAA,CAAQ,KAAK,CAAC,CAAA;AAC7E,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,OAAA,CAAQ,KAAK,CAAA;AACrD,IAAA,IAAA,CAAK,gBAAgB,OAAO,CAAA;AAC5B,IAAA,OAAO,OAAA;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,eAAA,CAAgB,EAAE,KAAA,EAAO,KAAA,EAAO,OAAM,EAAuB;AAC3D,IAAA,IAAI,IAAA,CAAK,cAAA,CAAe,MAAA,KAAW,CAAA,EAAG;AACpC,MAAA,IAAI,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,gBAAgB,CAAA,EAAG;AAC/C,QAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,gBAAgB,CAAA;AAC7C,QAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,gBAAgB,CAAA;AAAA,MAC/C;AACA,MAAA;AAAA,IACF;AACA,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,KAAA,CAAM,KAAA,GAAQ,GAAG,CAAA;AACtC,IAAA,MAAM,OAAO,IAAA,CAAK,cAAA,CACf,UAAA,CAAW,SAAA,EAAW,OAAO,KAAK,CAAC,CAAA,CACnC,UAAA,CAAW,aAAa,MAAA,CAAO,OAAO,CAAC,CAAA,CACvC,UAAA,CAAW,WAAW,KAAK,CAAA;AAC9B,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,gBAAA,EAAkB,IAAI,CAAA;AAChD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,gBAAA,EAAkB,EAAE,CAAA;AAAA,EAChD;AACF","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 Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same 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 initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output 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 * `change` dispatches `{ value: number, ratio: number, state: string }`.\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 * @stimeoRenderRoot\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"]}
|
|
@@ -60,6 +60,8 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
60
60
|
* `form` value sets the hidden inputs' `form` attribute, associating them with a
|
|
61
61
|
* `<form>` by id even when the picker lives outside it. Without a `fields`
|
|
62
62
|
* target the hidden inputs are simply not written.
|
|
63
|
+
* `change` and `reconcile` dispatch `{ values: string[] }`.
|
|
64
|
+
* `filter` dispatches `{ query: string }`.
|
|
63
65
|
*/
|
|
64
66
|
declare class MultiSelectController extends Controller<HTMLElement> {
|
|
65
67
|
#private;
|