stimeo-ui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/cable/index.d.ts +16 -16
  3. package/dist/cable/index.js.map +1 -1
  4. package/dist/controllers/accordion_controller.d.ts +3 -2
  5. package/dist/controllers/accordion_controller.js +10 -0
  6. package/dist/controllers/accordion_controller.js.map +1 -1
  7. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  8. package/dist/controllers/alert_dialog_controller.js +318 -0
  9. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  10. package/dist/controllers/breadcrumb_controller.d.ts +97 -10
  11. package/dist/controllers/breadcrumb_controller.js +225 -13
  12. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  13. package/dist/controllers/calendar_controller.d.ts +11 -1
  14. package/dist/controllers/calendar_controller.js +89 -22
  15. package/dist/controllers/calendar_controller.js.map +1 -1
  16. package/dist/controllers/carousel_controller.d.ts +115 -0
  17. package/dist/controllers/carousel_controller.js +313 -0
  18. package/dist/controllers/carousel_controller.js.map +1 -0
  19. package/dist/controllers/clipboard_controller.d.ts +72 -0
  20. package/dist/controllers/clipboard_controller.js +144 -0
  21. package/dist/controllers/clipboard_controller.js.map +1 -0
  22. package/dist/controllers/collapsible_controller.d.ts +70 -0
  23. package/dist/controllers/collapsible_controller.js +327 -0
  24. package/dist/controllers/collapsible_controller.js.map +1 -0
  25. package/dist/controllers/color_picker_controller.d.ts +87 -0
  26. package/dist/controllers/color_picker_controller.js +252 -0
  27. package/dist/controllers/color_picker_controller.js.map +1 -0
  28. package/dist/controllers/combobox_controller.d.ts +14 -1
  29. package/dist/controllers/combobox_controller.js +162 -23
  30. package/dist/controllers/combobox_controller.js.map +1 -1
  31. package/dist/controllers/command_palette_controller.d.ts +2 -0
  32. package/dist/controllers/command_palette_controller.js +194 -17
  33. package/dist/controllers/command_palette_controller.js.map +1 -1
  34. package/dist/controllers/confirm_controller.js.map +1 -1
  35. package/dist/controllers/context_menu_controller.d.ts +7 -6
  36. package/dist/controllers/context_menu_controller.js +32 -10
  37. package/dist/controllers/context_menu_controller.js.map +1 -1
  38. package/dist/controllers/count_up_controller.js +8 -1
  39. package/dist/controllers/count_up_controller.js.map +1 -1
  40. package/dist/controllers/currency_input_controller.d.ts +72 -0
  41. package/dist/controllers/currency_input_controller.js +147 -0
  42. package/dist/controllers/currency_input_controller.js.map +1 -0
  43. package/dist/controllers/data_grid_controller.d.ts +89 -0
  44. package/dist/controllers/data_grid_controller.js +246 -0
  45. package/dist/controllers/data_grid_controller.js.map +1 -0
  46. package/dist/controllers/date_range_picker_controller.d.ts +88 -0
  47. package/dist/controllers/date_range_picker_controller.js +441 -0
  48. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  49. package/dist/controllers/dialog_controller.js.map +1 -1
  50. package/dist/controllers/dismissible_controller.d.ts +57 -0
  51. package/dist/controllers/dismissible_controller.js +117 -0
  52. package/dist/controllers/dismissible_controller.js.map +1 -0
  53. package/dist/controllers/drawer_controller.d.ts +96 -0
  54. package/dist/controllers/drawer_controller.js +630 -0
  55. package/dist/controllers/drawer_controller.js.map +1 -0
  56. package/dist/controllers/dropdown_controller.d.ts +1 -2
  57. package/dist/controllers/dropdown_controller.js.map +1 -1
  58. package/dist/controllers/editable_controller.d.ts +73 -0
  59. package/dist/controllers/editable_controller.js +169 -0
  60. package/dist/controllers/editable_controller.js.map +1 -0
  61. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  62. package/dist/controllers/file_dropzone_controller.js +165 -0
  63. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  64. package/dist/controllers/filter_controller.d.ts +60 -0
  65. package/dist/controllers/filter_controller.js +86 -0
  66. package/dist/controllers/filter_controller.js.map +1 -0
  67. package/dist/controllers/flash_controller.js +36 -5
  68. package/dist/controllers/flash_controller.js.map +1 -1
  69. package/dist/controllers/focus_controller.d.ts +1 -1
  70. package/dist/controllers/focus_controller.js.map +1 -1
  71. package/dist/controllers/form_field_controller.js.map +1 -1
  72. package/dist/controllers/form_validation_controller.js +1 -1
  73. package/dist/controllers/form_validation_controller.js.map +1 -1
  74. package/dist/controllers/highlight_controller.js +6 -4
  75. package/dist/controllers/highlight_controller.js.map +1 -1
  76. package/dist/controllers/hover_card_controller.d.ts +1 -1
  77. package/dist/controllers/hover_card_controller.js.map +1 -1
  78. package/dist/controllers/intersection_controller.js +67 -19
  79. package/dist/controllers/intersection_controller.js.map +1 -1
  80. package/dist/controllers/lazy_frame_controller.js +54 -11
  81. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  82. package/dist/controllers/listbox_controller.d.ts +9 -2
  83. package/dist/controllers/listbox_controller.js +257 -53
  84. package/dist/controllers/listbox_controller.js.map +1 -1
  85. package/dist/controllers/local_time_controller.js +2 -2
  86. package/dist/controllers/local_time_controller.js.map +1 -1
  87. package/dist/controllers/masonry_controller.d.ts +54 -0
  88. package/dist/controllers/masonry_controller.js +142 -0
  89. package/dist/controllers/masonry_controller.js.map +1 -0
  90. package/dist/controllers/menu_controller.d.ts +29 -11
  91. package/dist/controllers/menu_controller.js +104 -17
  92. package/dist/controllers/menu_controller.js.map +1 -1
  93. package/dist/controllers/menubar_controller.d.ts +142 -0
  94. package/dist/controllers/menubar_controller.js +785 -0
  95. package/dist/controllers/menubar_controller.js.map +1 -0
  96. package/dist/controllers/meter_controller.js.map +1 -1
  97. package/dist/controllers/multi_select_controller.d.ts +141 -0
  98. package/dist/controllers/multi_select_controller.js +755 -0
  99. package/dist/controllers/multi_select_controller.js.map +1 -0
  100. package/dist/controllers/navigation_menu_controller.d.ts +146 -0
  101. package/dist/controllers/navigation_menu_controller.js +511 -0
  102. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  103. package/dist/controllers/nested_form_controller.d.ts +4 -5
  104. package/dist/controllers/nested_form_controller.js.map +1 -1
  105. package/dist/controllers/number_input_controller.d.ts +1 -1
  106. package/dist/controllers/number_input_controller.js +7 -0
  107. package/dist/controllers/number_input_controller.js.map +1 -1
  108. package/dist/controllers/otp_controller.js +18 -1
  109. package/dist/controllers/otp_controller.js.map +1 -1
  110. package/dist/controllers/overflow_indicator_controller.d.ts +15 -6
  111. package/dist/controllers/overflow_indicator_controller.js +246 -27
  112. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  113. package/dist/controllers/overflow_menu_controller.d.ts +78 -19
  114. package/dist/controllers/overflow_menu_controller.js +381 -57
  115. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  116. package/dist/controllers/pagination_controller.d.ts +36 -5
  117. package/dist/controllers/pagination_controller.js +163 -32
  118. package/dist/controllers/pagination_controller.js.map +1 -1
  119. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  120. package/dist/controllers/password_reveal_controller.js +117 -0
  121. package/dist/controllers/password_reveal_controller.js.map +1 -0
  122. package/dist/controllers/persist_controller.js +6 -6
  123. package/dist/controllers/persist_controller.js.map +1 -1
  124. package/dist/controllers/pointer_drag_controller.d.ts +2 -3
  125. package/dist/controllers/pointer_drag_controller.js +9 -1
  126. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  127. package/dist/controllers/popover_controller.d.ts +1 -2
  128. package/dist/controllers/popover_controller.js +2 -2
  129. package/dist/controllers/popover_controller.js.map +1 -1
  130. package/dist/controllers/portal_controller.d.ts +4 -4
  131. package/dist/controllers/portal_controller.js.map +1 -1
  132. package/dist/controllers/progress_controller.js.map +1 -1
  133. package/dist/controllers/radio_group_controller.js +22 -3
  134. package/dist/controllers/radio_group_controller.js.map +1 -1
  135. package/dist/controllers/range_slider_controller.d.ts +97 -0
  136. package/dist/controllers/range_slider_controller.js +192 -0
  137. package/dist/controllers/range_slider_controller.js.map +1 -0
  138. package/dist/controllers/rating_controller.js +16 -2
  139. package/dist/controllers/rating_controller.js.map +1 -1
  140. package/dist/controllers/read_more_controller.d.ts +52 -0
  141. package/dist/controllers/read_more_controller.js +238 -0
  142. package/dist/controllers/read_more_controller.js.map +1 -0
  143. package/dist/controllers/reading_progress_controller.js.map +1 -1
  144. package/dist/controllers/resizable_controller.d.ts +3 -1
  145. package/dist/controllers/resizable_controller.js +65 -1
  146. package/dist/controllers/resizable_controller.js.map +1 -1
  147. package/dist/controllers/roving_controller.js +17 -2
  148. package/dist/controllers/roving_controller.js.map +1 -1
  149. package/dist/controllers/scroll_area_controller.js +101 -14
  150. package/dist/controllers/scroll_area_controller.js.map +1 -1
  151. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  152. package/dist/controllers/scroll_restore_controller.js +93 -0
  153. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  154. package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
  155. package/dist/controllers/scroll_visibility_controller.js +40 -6
  156. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  157. package/dist/controllers/scrollspy_controller.d.ts +64 -15
  158. package/dist/controllers/scrollspy_controller.js +369 -74
  159. package/dist/controllers/scrollspy_controller.js.map +1 -1
  160. package/dist/controllers/separator_controller.d.ts +69 -0
  161. package/dist/controllers/separator_controller.js +96 -0
  162. package/dist/controllers/separator_controller.js.map +1 -0
  163. package/dist/controllers/sidebar_controller.d.ts +107 -0
  164. package/dist/controllers/sidebar_controller.js +761 -0
  165. package/dist/controllers/sidebar_controller.js.map +1 -0
  166. package/dist/controllers/skeleton_controller.js +1 -1
  167. package/dist/controllers/skeleton_controller.js.map +1 -1
  168. package/dist/controllers/slider_controller.d.ts +11 -1
  169. package/dist/controllers/slider_controller.js +32 -6
  170. package/dist/controllers/slider_controller.js.map +1 -1
  171. package/dist/controllers/sortable_controller.d.ts +3 -1
  172. package/dist/controllers/sortable_controller.js +34 -3
  173. package/dist/controllers/sortable_controller.js.map +1 -1
  174. package/dist/controllers/spinner_controller.js +1 -1
  175. package/dist/controllers/spinner_controller.js.map +1 -1
  176. package/dist/controllers/stepper_controller.d.ts +4 -0
  177. package/dist/controllers/stepper_controller.js +28 -12
  178. package/dist/controllers/stepper_controller.js.map +1 -1
  179. package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
  180. package/dist/controllers/stick_to_bottom_controller.js +9 -4
  181. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  182. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  183. package/dist/controllers/sticky_observer_controller.js +109 -20
  184. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  185. package/dist/controllers/submit_once_controller.d.ts +2 -2
  186. package/dist/controllers/submit_once_controller.js.map +1 -1
  187. package/dist/controllers/switch_controller.js +1 -0
  188. package/dist/controllers/switch_controller.js.map +1 -1
  189. package/dist/controllers/tabs_controller.d.ts +13 -4
  190. package/dist/controllers/tabs_controller.js +26 -3
  191. package/dist/controllers/tabs_controller.js.map +1 -1
  192. package/dist/controllers/tags_input_controller.d.ts +91 -0
  193. package/dist/controllers/tags_input_controller.js +295 -0
  194. package/dist/controllers/tags_input_controller.js.map +1 -0
  195. package/dist/controllers/theme_controller.d.ts +1 -1
  196. package/dist/controllers/theme_controller.js +42 -13
  197. package/dist/controllers/theme_controller.js.map +1 -1
  198. package/dist/controllers/time_picker_controller.d.ts +67 -0
  199. package/dist/controllers/time_picker_controller.js +231 -0
  200. package/dist/controllers/time_picker_controller.js.map +1 -0
  201. package/dist/controllers/toast_controller.d.ts +5 -14
  202. package/dist/controllers/toast_controller.js +40 -14
  203. package/dist/controllers/toast_controller.js.map +1 -1
  204. package/dist/controllers/toggle_group_controller.js +23 -2
  205. package/dist/controllers/toggle_group_controller.js.map +1 -1
  206. package/dist/controllers/toolbar_controller.d.ts +53 -13
  207. package/dist/controllers/toolbar_controller.js +230 -31
  208. package/dist/controllers/toolbar_controller.js.map +1 -1
  209. package/dist/controllers/tooltip_controller.d.ts +1 -1
  210. package/dist/controllers/tooltip_controller.js.map +1 -1
  211. package/dist/controllers/transition_controller.d.ts +14 -8
  212. package/dist/controllers/transition_controller.js +153 -38
  213. package/dist/controllers/transition_controller.js.map +1 -1
  214. package/dist/controllers/tree_view_controller.d.ts +135 -0
  215. package/dist/controllers/tree_view_controller.js +691 -0
  216. package/dist/controllers/tree_view_controller.js.map +1 -0
  217. package/dist/index.d.ts +27 -1818
  218. package/dist/index.js +4256 -915
  219. package/dist/index.js.map +1 -1
  220. package/dist/inspector/cli.d.ts +375 -39
  221. package/dist/inspector/cli.js +228 -14
  222. package/dist/inspector/cli.js.map +1 -1
  223. package/dist/inspector/cli_bin.js +419 -48
  224. package/dist/inspector/cli_bin.js.map +1 -1
  225. package/dist/inspector/examples.json +26 -26
  226. package/dist/inspector/manifest.json +1054 -127
  227. package/package.json +5 -6
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/roving_tabindex.ts","../../src/controllers/radio_group_controller.ts"],"names":[],"mappings":"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","file":"radio_group_controller.js","sourcesContent":["/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { RovingTabindex, rovingMove } from \"../utils/roving_tabindex\";\n\n/**\n * Headless, accessible radio-group behavior for **custom** (non-native) radios.\n *\n * Markup contract (identifier: `stimeo--radio-group`):\n * <div data-controller=\"stimeo--radio-group\" role=\"radiogroup\" aria-label=\"Plan\">\n * <div role=\"radio\" aria-checked=\"true\" tabindex=\"0\" data-value=\"basic\"\n * data-stimeo--radio-group-target=\"radio\"\n * data-action=\"click->stimeo--radio-group#select\n * keydown->stimeo--radio-group#onKeydown\">Basic</div>\n * <!-- more radios; exactly one tabindex=0 -->\n * <input type=\"hidden\" data-stimeo--radio-group-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Radio Group** pattern. Use this only for custom\n * radios (e.g. button-styled cards); native `<input type=\"radio\">` already does\n * this and should be preferred when its look suffices.\n *\n * @remarks\n * Behavior only — selection is exposed through `aria-checked`, the single Tab\n * stop through roving `tabindex` ({@link RovingTabindex}); the consumer styles\n * off `[role=\"radio\"][aria-checked=\"true\"]`. Per APG, **selection follows\n * focus**: arrow keys move focus and select in one step.\n *\n * Behavior provided:\n * - Click selects a radio.\n * - `ArrowDown`/`ArrowRight` select the next, `ArrowUp`/`ArrowLeft` the previous\n * (wrapping); `Home`/`End` the first/last; `Space` selects the focused radio.\n * - The selected radio's `data-value` is mirrored to the optional hidden `field`,\n * and `stimeo--radio-group:change` is dispatched on every change.\n */\nexport class RadioGroupController extends Controller<HTMLElement> {\n static override targets = [\"radio\", \"field\"];\n static actions = [\"onKeydown\", \"select\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly radioTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasFieldTarget: boolean;\n\n readonly #roving = new RovingTabindex(() => this.radioTargets);\n\n /** Establishes the roving entry point and reflects the initial selection. */\n override connect(): void {\n const selected = this.#selectedIndex();\n // With no selection, the first radio is the (unchecked) Tab entry point.\n this.#roving.setActive(selected === -1 ? 0 : selected);\n // Reflect the server-rendered selection without firing a native `change`:\n // connect is not a user edit, and the field already carries this value.\n if (selected !== -1) this.#reflectField(this.radioTargets[selected], { silent: true });\n }\n\n /** Selects the clicked radio. Bound via `data-action` (click). */\n select(event: Event): void {\n const index = this.radioTargets.indexOf(event.currentTarget as HTMLElement);\n if (index !== -1) this.#selectIndex(index, { focus: false });\n }\n\n /** Arrow/Home/End/Space navigation with selection-follows-focus. */\n onKeydown(event: KeyboardEvent): void {\n const current = this.radioTargets.indexOf(event.currentTarget as HTMLElement);\n if (current === -1) return;\n\n let next: number | null = null;\n switch (event.key) {\n case \"ArrowDown\":\n case \"ArrowRight\":\n next = rovingMove(current, this.radioTargets.length, 1, \"wrap\");\n break;\n case \"ArrowUp\":\n case \"ArrowLeft\":\n next = rovingMove(current, this.radioTargets.length, -1, \"wrap\");\n break;\n case \"Home\":\n next = 0;\n break;\n case \"End\":\n next = this.radioTargets.length - 1;\n break;\n case \" \":\n next = current;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.#selectIndex(next, { focus: true });\n }\n\n /**\n * Checks the radio at `index`, clears the rest, updates the roving Tab stop and\n * the hidden field, and dispatches `change`.\n */\n #selectIndex(index: number, { focus }: { focus: boolean }): void {\n const radio = this.radioTargets[index];\n if (!radio) return;\n this.radioTargets.forEach((item, i) => {\n item.setAttribute(\"aria-checked\", i === index ? \"true\" : \"false\");\n });\n this.#roving.setActive(index, { focus });\n this.#reflectField(radio);\n this.dispatch(\"change\", { detail: { value: this.#radioValue(radio), radio } });\n }\n\n /** Index of the currently checked radio, or `-1` if none. */\n #selectedIndex(): number {\n return this.radioTargets.findIndex((radio) => radio.getAttribute(\"aria-checked\") === \"true\");\n }\n\n /**\n * Mirrors a radio's `data-value` onto the hidden field, when present. Fires a\n * native bubbling `change` on a real value change (matching `listbox`, so\n * `auto-submit` and other native-`change` consumers react) — unless `silent`\n * (the initial connect reflection, which is not a user edit).\n */\n #reflectField(\n radio: HTMLElement | undefined,\n { silent = false }: { silent?: boolean } = {},\n ): void {\n if (!this.hasFieldTarget || !radio) return;\n const value = this.#radioValue(radio);\n if (this.fieldTarget.value === value) return;\n this.fieldTarget.value = value;\n if (!silent) this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n\n /** A radio's submitted value (`data-value`, defaulting to empty). */\n #radioValue(radio: HTMLElement): string {\n return radio.getAttribute(\"data-value\") ?? \"\";\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/utils/arrow_step.ts","../../src/utils/roving_tabindex.ts","../../src/controllers/radio_group_controller.ts"],"names":[],"mappings":"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","file":"radio_group_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","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 * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord, logicalArrowStep } from \"../utils/arrow_step\";\nimport { RovingTabindex, rovingMove } from \"../utils/roving_tabindex\";\n\n/**\n * Headless, accessible radio-group behavior for **custom** (non-native) radios.\n *\n * Markup contract (identifier: `stimeo--radio-group`):\n * <div data-controller=\"stimeo--radio-group\" role=\"radiogroup\" aria-label=\"Plan\">\n * <div role=\"radio\" aria-checked=\"true\" tabindex=\"0\" data-value=\"basic\"\n * data-stimeo--radio-group-target=\"radio\"\n * data-action=\"click->stimeo--radio-group#select\n * keydown->stimeo--radio-group#onKeydown\">Basic</div>\n * <!-- more radios; exactly one tabindex=0 -->\n * <input type=\"hidden\" data-stimeo--radio-group-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Radio Group** pattern. Use this only for custom\n * radios (e.g. button-styled cards); native `<input type=\"radio\">` already does\n * this and should be preferred when its look suffices.\n *\n * @remarks\n * Behavior only — selection is exposed through `aria-checked`, the single Tab\n * stop through roving `tabindex` ({@link RovingTabindex}); the consumer styles\n * off `[role=\"radio\"][aria-checked=\"true\"]`. Per APG, **selection follows\n * focus**: arrow keys move focus and select in one step.\n *\n * Behavior provided:\n * - Click selects a radio.\n * - `ArrowDown`/`ArrowRight` select the next, `ArrowUp`/`ArrowLeft` the previous\n * (wrapping); `Home`/`End` the first/last; `Space` selects the focused radio.\n * - The selected radio's `data-value` is mirrored to the optional hidden `field`,\n * and `stimeo--radio-group:change` is dispatched on every change.\n */\nexport class RadioGroupController extends Controller<HTMLElement> {\n static override targets = [\"radio\", \"field\"];\n static actions = [\"onKeydown\", \"select\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly radioTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasFieldTarget: boolean;\n\n readonly #roving = new RovingTabindex(() => this.radioTargets);\n\n /** Establishes the roving entry point and reflects the initial selection. */\n override connect(): void {\n const selected = this.#selectedIndex();\n // With no selection, the first radio is the (unchecked) Tab entry point.\n this.#roving.setActive(selected === -1 ? 0 : selected);\n // Reflect the server-rendered selection without firing a native `change`:\n // connect is not a user edit, and the field already carries this value.\n if (selected !== -1) this.#reflectField(this.radioTargets[selected], { silent: true });\n }\n\n /** Selects the clicked radio. Bound via `data-action` (click). */\n select(event: Event): void {\n const index = this.radioTargets.indexOf(event.currentTarget as HTMLElement);\n if (index !== -1) this.#selectIndex(index, { focus: false });\n }\n\n /** Arrow/Home/End/Space navigation with selection-follows-focus. */\n onKeydown(event: KeyboardEvent): void {\n // A descendant widget that already claimed the key must not ALSO move the selection —\n // composition depends on this yield.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n const current = this.radioTargets.indexOf(event.currentTarget as HTMLElement);\n if (current === -1) return;\n\n let next: number | null = null;\n // Logical, not physical. The helper reverses only the horizontal\n // pair, so folding Down/Up into the same branch stays correct.\n const step = logicalArrowStep(event.key, this.element);\n switch (event.key) {\n case \"ArrowDown\":\n case \"ArrowRight\":\n case \"ArrowUp\":\n case \"ArrowLeft\":\n next = rovingMove(current, this.radioTargets.length, step, \"wrap\");\n break;\n case \"Home\":\n next = 0;\n break;\n case \"End\":\n next = this.radioTargets.length - 1;\n break;\n case \" \":\n next = current;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.#selectIndex(next, { focus: true });\n }\n\n /**\n * Checks the radio at `index`, clears the rest, updates the roving Tab stop and\n * the hidden field, and dispatches `change`.\n */\n #selectIndex(index: number, { focus }: { focus: boolean }): void {\n const radio = this.radioTargets[index];\n if (!radio) return;\n this.radioTargets.forEach((item, i) => {\n item.setAttribute(\"aria-checked\", i === index ? \"true\" : \"false\");\n });\n this.#roving.setActive(index, { focus });\n this.#reflectField(radio);\n this.dispatch(\"change\", { detail: { value: this.#radioValue(radio), radio } });\n }\n\n /** Index of the currently checked radio, or `-1` if none. */\n #selectedIndex(): number {\n return this.radioTargets.findIndex((radio) => radio.getAttribute(\"aria-checked\") === \"true\");\n }\n\n /**\n * Mirrors a radio's `data-value` onto the hidden field, when present. Fires a\n * native bubbling `change` on a real value change (matching `listbox`, so\n * `auto-submit` and other native-`change` consumers react) — unless `silent`\n * (the initial connect reflection, which is not a user edit).\n */\n #reflectField(\n radio: HTMLElement | undefined,\n { silent = false }: { silent?: boolean } = {},\n ): void {\n if (!this.hasFieldTarget || !radio) return;\n const value = this.#radioValue(radio);\n if (this.fieldTarget.value === value) return;\n this.fieldTarget.value = value;\n if (!silent) this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n\n /** A radio's submitted value (`data-value`, defaulting to empty). */\n #radioValue(radio: HTMLElement): string {\n return radio.getAttribute(\"data-value\") ?? \"\";\n }\n}\n"]}
@@ -0,0 +1,97 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible **two-thumb range slider** behavior (APG Slider —
5
+ * Multi-Thumb). A derivative of {@link SliderController}: it manages two thumbs
6
+ * (`start` ≤ `end`) that constrain each other.
7
+ *
8
+ * Markup contract (identifier: `stimeo--range-slider`):
9
+ * <div data-controller="stimeo--range-slider"
10
+ * data-stimeo--range-slider-min-value="0"
11
+ * data-stimeo--range-slider-max-value="100"
12
+ * data-stimeo--range-slider-step-value="1"
13
+ * data-stimeo--range-slider-start-value="20"
14
+ * data-stimeo--range-slider-end-value="80">
15
+ * <div data-stimeo--range-slider-target="track"
16
+ * data-action="pointerdown->stimeo--range-slider#onPointerDown">
17
+ * <div role="slider" tabindex="0" aria-label="Minimum"
18
+ * data-stimeo--range-slider-target="startThumb"
19
+ * data-action="keydown->stimeo--range-slider#onKeydown"></div>
20
+ * <div role="slider" tabindex="0" aria-label="Maximum"
21
+ * data-stimeo--range-slider-target="endThumb"
22
+ * data-action="keydown->stimeo--range-slider#onKeydown"></div>
23
+ * </div>
24
+ * </div>
25
+ *
26
+ * @remarks
27
+ * Behavior only — the consumer owns all layout (positioning the thumbs and the
28
+ * selected range from the fractions). Only the horizontal orientation is handled.
29
+ * Each thumb's movable range is bounded by the *other* thumb's current value,
30
+ * reflected on its `aria-valuemin`/`aria-valuemax` so assistive tech announces the
31
+ * live constraint.
32
+ *
33
+ * Behavior provided (per focused thumb):
34
+ * - `ArrowRight`/`ArrowUp` increase and `ArrowLeft`/`ArrowDown` decrease by one
35
+ * step; `Home`/`End` jump to that thumb's movable min/max; `PageUp`/`PageDown`
36
+ * move by ten steps. A thumb never crosses the other.
37
+ * - Pointer press/drag on the track moves the nearest thumb.
38
+ *
39
+ * The fractions are value ratios, not positions, so only the consumer knows
40
+ * whether their track mirrors under RTL. Set `logicalTrack` to declare that it
41
+ * does: the pointer mapping and the horizontal arrow pair then follow the
42
+ * writing direction. Left unset, nothing here reads `direction`.
43
+ */
44
+ declare class RangeSliderController extends Controller<HTMLElement> {
45
+ #private;
46
+ static targets: string[];
47
+ static values: {
48
+ min: {
49
+ type: NumberConstructor;
50
+ default: number;
51
+ };
52
+ max: {
53
+ type: NumberConstructor;
54
+ default: number;
55
+ };
56
+ step: {
57
+ type: NumberConstructor;
58
+ default: number;
59
+ };
60
+ start: {
61
+ type: NumberConstructor;
62
+ default: number;
63
+ };
64
+ end: {
65
+ type: NumberConstructor;
66
+ default: number;
67
+ };
68
+ logicalTrack: {
69
+ type: BooleanConstructor;
70
+ default: boolean;
71
+ };
72
+ };
73
+ static actions: readonly ["onKeydown", "onPointerDown"];
74
+ static events: readonly ["change"];
75
+ readonly trackTarget: HTMLElement;
76
+ readonly startThumbTarget: HTMLElement;
77
+ readonly endThumbTarget: HTMLElement;
78
+ readonly hasTrackTarget: boolean;
79
+ readonly hasStartThumbTarget: boolean;
80
+ readonly hasEndThumbTarget: boolean;
81
+ minValue: number;
82
+ maxValue: number;
83
+ stepValue: number;
84
+ startValue: number;
85
+ endValue: number;
86
+ logicalTrackValue: boolean;
87
+ /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
88
+ connect(): void;
89
+ /** Cancels any active pointer drag so document listeners never leak. */
90
+ disconnect(): void;
91
+ /** Keyboard stepping for whichever thumb is focused (the action's element). */
92
+ onKeydown(event: KeyboardEvent): void;
93
+ /** Begins a pointer drag on the track, moving the thumb nearest the press. */
94
+ onPointerDown(event: PointerEvent): void;
95
+ }
96
+
97
+ export { RangeSliderController };
@@ -0,0 +1,192 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/range_slider_controller.ts
4
+
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function logicalArrowKey(key, element) {
12
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
13
+ if (!isRtl(element)) return key;
14
+ return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
15
+ }
16
+ function isReservedArrowChord(event, allow = []) {
17
+ if (!event.key.startsWith("Arrow")) return false;
18
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
+ }
20
+
21
+ // src/controllers/range_slider_controller.ts
22
+ var START_PROPERTY = "--stimeo-range-start";
23
+ var END_PROPERTY = "--stimeo-range-end";
24
+ var RangeSliderController = class extends Controller {
25
+ static targets = ["track", "startThumb", "endThumb"];
26
+ static values = {
27
+ min: { type: Number, default: 0 },
28
+ max: { type: Number, default: 100 },
29
+ step: { type: Number, default: 1 },
30
+ start: { type: Number, default: 0 },
31
+ end: { type: Number, default: 100 },
32
+ logicalTrack: { type: Boolean, default: false }
33
+ };
34
+ static actions = ["onKeydown", "onPointerDown"];
35
+ static events = ["change"];
36
+ /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
37
+ #dragAbort = null;
38
+ /** Whether the consumer declared a mirroring track and the direction mirrors it. */
39
+ get #mirrored() {
40
+ return this.logicalTrackValue && isRtl(this.element);
41
+ }
42
+ /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
43
+ connect() {
44
+ const lo = Math.min(this.startValue, this.endValue);
45
+ const hi = Math.max(this.startValue, this.endValue);
46
+ this.#commit(lo, hi, false);
47
+ }
48
+ /** Cancels any active pointer drag so document listeners never leak. */
49
+ disconnect() {
50
+ this.#dragAbort?.abort();
51
+ this.#dragAbort = null;
52
+ }
53
+ /** Keyboard stepping for whichever thumb is focused (the action's element). */
54
+ onKeydown(event) {
55
+ if (isReservedArrowChord(event)) return;
56
+ const thumb = event.currentTarget;
57
+ const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;
58
+ const current = isStart ? this.startValue : this.endValue;
59
+ const lower = isStart ? this.minValue : this.startValue;
60
+ const upper = isStart ? this.endValue : this.maxValue;
61
+ const big = this.stepValue * 10;
62
+ let next = null;
63
+ switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
64
+ case "ArrowRight":
65
+ case "ArrowUp":
66
+ next = current + this.stepValue;
67
+ break;
68
+ case "ArrowLeft":
69
+ case "ArrowDown":
70
+ next = current - this.stepValue;
71
+ break;
72
+ case "PageUp":
73
+ next = current + big;
74
+ break;
75
+ case "PageDown":
76
+ next = current - big;
77
+ break;
78
+ case "Home":
79
+ next = lower;
80
+ break;
81
+ case "End":
82
+ next = upper;
83
+ break;
84
+ default:
85
+ return;
86
+ }
87
+ event.preventDefault();
88
+ this.#moveThumb(isStart, next);
89
+ }
90
+ /** Begins a pointer drag on the track, moving the thumb nearest the press. */
91
+ onPointerDown(event) {
92
+ if (!this.hasTrackTarget) return;
93
+ const mirrored = this.#mirrored;
94
+ const value = this.#valueFromClientX(event.clientX, mirrored);
95
+ if (value === null) return;
96
+ event.preventDefault();
97
+ const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);
98
+ if (useStart) {
99
+ if (this.hasStartThumbTarget) this.startThumbTarget.focus();
100
+ } else if (this.hasEndThumbTarget) {
101
+ this.endThumbTarget.focus();
102
+ }
103
+ this.#moveThumb(useStart, value);
104
+ this.#dragAbort?.abort();
105
+ const abort = new AbortController();
106
+ this.#dragAbort = abort;
107
+ const onMove = (move) => {
108
+ const moved = this.#valueFromClientX(move.clientX, mirrored);
109
+ if (moved !== null) this.#moveThumb(useStart, moved);
110
+ };
111
+ const onUp = () => {
112
+ abort.abort();
113
+ this.#dragAbort = null;
114
+ };
115
+ document.addEventListener("pointermove", onMove, { signal: abort.signal });
116
+ document.addEventListener("pointerup", onUp, { signal: abort.signal });
117
+ document.addEventListener("pointercancel", onUp, { signal: abort.signal });
118
+ }
119
+ /** Maps a pointer X coordinate to a raw value using the track geometry. */
120
+ #valueFromClientX(clientX, mirrored) {
121
+ const rect = this.trackTarget.getBoundingClientRect();
122
+ if (rect.width === 0) return null;
123
+ const offset = (clientX - rect.left) / rect.width;
124
+ const fraction = mirrored ? 1 - offset : offset;
125
+ return this.minValue + fraction * (this.maxValue - this.minValue);
126
+ }
127
+ /** Moves one thumb to a new raw value, keeping the pair ordered. */
128
+ #moveThumb(isStart, raw) {
129
+ if (isStart) {
130
+ this.#commit(raw, this.endValue, true);
131
+ } else {
132
+ this.#commit(this.startValue, raw, true);
133
+ }
134
+ }
135
+ /**
136
+ * Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and
137
+ * reflects it onto the thumbs' ARIA attributes and the range custom
138
+ * properties. Dispatches `change` only on user-driven updates (`notify`).
139
+ */
140
+ #commit(start, end, notify) {
141
+ const prevStart = this.startValue;
142
+ const prevEnd = this.endValue;
143
+ let nextStart = this.#snap(start);
144
+ let nextEnd = this.#snap(end);
145
+ if (nextStart > nextEnd) {
146
+ if (isUserMovingStart(start, prevStart, end, prevEnd)) nextStart = nextEnd;
147
+ else nextEnd = nextStart;
148
+ }
149
+ this.startValue = nextStart;
150
+ this.endValue = nextEnd;
151
+ this.#render(nextStart, nextEnd);
152
+ if (notify && (nextStart !== prevStart || nextEnd !== prevEnd)) {
153
+ this.dispatch("change", { detail: { start: nextStart, end: nextEnd } });
154
+ }
155
+ }
156
+ /** Reflects the current pair onto thumb ARIA attributes and CSS properties. */
157
+ #render(start, end) {
158
+ if (this.hasStartThumbTarget) {
159
+ this.startThumbTarget.setAttribute("aria-valuemin", String(this.minValue));
160
+ this.startThumbTarget.setAttribute("aria-valuemax", String(end));
161
+ this.startThumbTarget.setAttribute("aria-valuenow", String(start));
162
+ }
163
+ if (this.hasEndThumbTarget) {
164
+ this.endThumbTarget.setAttribute("aria-valuemin", String(start));
165
+ this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
166
+ this.endThumbTarget.setAttribute("aria-valuenow", String(end));
167
+ }
168
+ const span = this.maxValue - this.minValue;
169
+ this.element.style.setProperty(
170
+ START_PROPERTY,
171
+ String(span > 0 ? (start - this.minValue) / span : 0)
172
+ );
173
+ this.element.style.setProperty(
174
+ END_PROPERTY,
175
+ String(span > 0 ? (end - this.minValue) / span : 0)
176
+ );
177
+ }
178
+ /** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
179
+ #snap(raw) {
180
+ const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
181
+ if (this.stepValue <= 0) return clamped;
182
+ const stepped = Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue;
183
+ return Math.min(this.maxValue, Math.max(this.minValue, stepped));
184
+ }
185
+ };
186
+ function isUserMovingStart(start, prevStart, end, prevEnd) {
187
+ return start !== prevStart && end === prevEnd;
188
+ }
189
+
190
+ export { RangeSliderController };
191
+ //# sourceMappingURL=range_slider_controller.js.map
192
+ //# sourceMappingURL=range_slider_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/utils/arrow_step.ts","../../src/controllers/range_slider_controller.ts"],"names":[],"mappings":"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","file":"range_slider_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord, logicalArrowKey } from \"../utils/arrow_step\";\nimport { isRtl } from \"../utils/logical_scroll\";\n\n/** CSS custom properties exposing each thumb's position (0..1) to the consumer. */\nconst START_PROPERTY = \"--stimeo-range-start\";\nconst END_PROPERTY = \"--stimeo-range-end\";\n\n/**\n * Headless, accessible **two-thumb range slider** behavior (APG Slider —\n * Multi-Thumb). A derivative of {@link SliderController}: it manages two thumbs\n * (`start` ≤ `end`) that constrain each other.\n *\n * Markup contract (identifier: `stimeo--range-slider`):\n * <div data-controller=\"stimeo--range-slider\"\n * data-stimeo--range-slider-min-value=\"0\"\n * data-stimeo--range-slider-max-value=\"100\"\n * data-stimeo--range-slider-step-value=\"1\"\n * data-stimeo--range-slider-start-value=\"20\"\n * data-stimeo--range-slider-end-value=\"80\">\n * <div data-stimeo--range-slider-target=\"track\"\n * data-action=\"pointerdown->stimeo--range-slider#onPointerDown\">\n * <div role=\"slider\" tabindex=\"0\" aria-label=\"Minimum\"\n * data-stimeo--range-slider-target=\"startThumb\"\n * data-action=\"keydown->stimeo--range-slider#onKeydown\"></div>\n * <div role=\"slider\" tabindex=\"0\" aria-label=\"Maximum\"\n * data-stimeo--range-slider-target=\"endThumb\"\n * data-action=\"keydown->stimeo--range-slider#onKeydown\"></div>\n * </div>\n * </div>\n *\n * @remarks\n * Behavior only — the consumer owns all layout (positioning the thumbs and the\n * selected range from the fractions). Only the horizontal orientation is handled.\n * Each thumb's movable range is bounded by the *other* thumb's current value,\n * reflected on its `aria-valuemin`/`aria-valuemax` so assistive tech announces the\n * live constraint.\n *\n * Behavior provided (per focused thumb):\n * - `ArrowRight`/`ArrowUp` increase and `ArrowLeft`/`ArrowDown` decrease by one\n * step; `Home`/`End` jump to that thumb's movable min/max; `PageUp`/`PageDown`\n * move by ten steps. A thumb never crosses the other.\n * - Pointer press/drag on the track moves the nearest thumb.\n *\n * The fractions are value ratios, not positions, so only the consumer knows\n * whether their track mirrors under RTL. Set `logicalTrack` to declare that it\n * does: the pointer mapping and the horizontal arrow pair then follow the\n * writing direction. Left unset, nothing here reads `direction`.\n */\nexport class RangeSliderController extends Controller<HTMLElement> {\n static override targets = [\"track\", \"startThumb\", \"endThumb\"];\n static override values = {\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n step: { type: Number, default: 1 },\n start: { type: Number, default: 0 },\n end: { type: Number, default: 100 },\n logicalTrack: { type: Boolean, default: false },\n };\n static actions = [\"onKeydown\", \"onPointerDown\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly trackTarget: HTMLElement;\n declare readonly startThumbTarget: HTMLElement;\n declare readonly endThumbTarget: HTMLElement;\n declare readonly hasTrackTarget: boolean;\n declare readonly hasStartThumbTarget: boolean;\n declare readonly hasEndThumbTarget: boolean;\n declare minValue: number;\n declare maxValue: number;\n declare stepValue: number;\n declare startValue: number;\n declare endValue: number;\n declare logicalTrackValue: boolean;\n\n /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */\n #dragAbort: AbortController | null = null;\n\n /** Whether the consumer declared a mirroring track and the direction mirrors it. */\n get #mirrored(): boolean {\n return this.logicalTrackValue && isRtl(this.element);\n }\n\n /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */\n override connect(): void {\n // Order the initial pair up front so a reversed start/end is corrected by\n // swapping (preserving both values) rather than collapsing one onto the other.\n const lo = Math.min(this.startValue, this.endValue);\n const hi = Math.max(this.startValue, this.endValue);\n this.#commit(lo, hi, false);\n }\n\n /** Cancels any active pointer drag so document listeners never leak. */\n override disconnect(): void {\n this.#dragAbort?.abort();\n this.#dragAbort = null;\n }\n\n /** Keyboard stepping for whichever thumb is focused (the action's element). */\n onKeydown(event: KeyboardEvent): void {\n if (isReservedArrowChord(event)) return;\n const thumb = event.currentTarget as HTMLElement;\n const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;\n const current = isStart ? this.startValue : this.endValue;\n // The opposing thumb caps this one's travel so the two never cross.\n const lower = isStart ? this.minValue : this.startValue;\n const upper = isStart ? this.endValue : this.maxValue;\n const big = this.stepValue * 10;\n\n let next: number | null = null;\n // On a mirrored track the greater value sits at the visual left, so the\n // horizontal pair trades places; `Home`/`End` name bounds, not a direction.\n switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = current + this.stepValue;\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = current - this.stepValue;\n break;\n case \"PageUp\":\n next = current + big;\n break;\n case \"PageDown\":\n next = current - big;\n break;\n case \"Home\":\n next = lower;\n break;\n case \"End\":\n next = upper;\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#moveThumb(isStart, next);\n }\n\n /** Begins a pointer drag on the track, moving the thumb nearest the press. */\n onPointerDown(event: PointerEvent): void {\n if (!this.hasTrackTarget) return;\n // Resolve the direction once for the whole gesture: reading it per move\n // would query computed style on every frame, and a drag that flipped\n // mid-gesture would be incoherent anyway.\n const mirrored = this.#mirrored;\n const value = this.#valueFromClientX(event.clientX, mirrored);\n if (value === null) return;\n event.preventDefault();\n\n // Pick the nearer thumb; ties go to the start thumb so a press exactly\n // between them stays deterministic. Only read the target getter behind its\n // has*Target guard — Stimulus throws when an absent target is accessed.\n const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);\n if (useStart) {\n if (this.hasStartThumbTarget) this.startThumbTarget.focus();\n } else if (this.hasEndThumbTarget) {\n this.endThumbTarget.focus();\n }\n this.#moveThumb(useStart, value);\n\n this.#dragAbort?.abort();\n const abort = new AbortController();\n this.#dragAbort = abort;\n const onMove = (move: PointerEvent): void => {\n const moved = this.#valueFromClientX(move.clientX, mirrored);\n if (moved !== null) this.#moveThumb(useStart, moved);\n };\n const onUp = (): void => {\n abort.abort();\n this.#dragAbort = null;\n };\n document.addEventListener(\"pointermove\", onMove, { signal: abort.signal });\n document.addEventListener(\"pointerup\", onUp, { signal: abort.signal });\n // pointercancel fires when the gesture is interrupted (OS gesture, scroll\n // takeover, device switch); clean up the same way so no listener leaks.\n document.addEventListener(\"pointercancel\", onUp, { signal: abort.signal });\n }\n\n /** Maps a pointer X coordinate to a raw value using the track geometry. */\n #valueFromClientX(clientX: number, mirrored: boolean): number | null {\n const rect = this.trackTarget.getBoundingClientRect();\n if (rect.width === 0) return null;\n const offset = (clientX - rect.left) / rect.width;\n const fraction = mirrored ? 1 - offset : offset;\n return this.minValue + fraction * (this.maxValue - this.minValue);\n }\n\n /** Moves one thumb to a new raw value, keeping the pair ordered. */\n #moveThumb(isStart: boolean, raw: number): void {\n if (isStart) {\n this.#commit(raw, this.endValue, true);\n } else {\n this.#commit(this.startValue, raw, true);\n }\n }\n\n /**\n * Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and\n * reflects it onto the thumbs' ARIA attributes and the range custom\n * properties. Dispatches `change` only on user-driven updates (`notify`).\n */\n #commit(start: number, end: number, notify: boolean): void {\n const prevStart = this.startValue;\n const prevEnd = this.endValue;\n\n let nextStart = this.#snap(start);\n let nextEnd = this.#snap(end);\n // Keep them ordered: a thumb pushed past its partner stops at the partner.\n if (nextStart > nextEnd) {\n if (isUserMovingStart(start, prevStart, end, prevEnd)) nextStart = nextEnd;\n else nextEnd = nextStart;\n }\n\n this.startValue = nextStart;\n this.endValue = nextEnd;\n this.#render(nextStart, nextEnd);\n\n if (notify && (nextStart !== prevStart || nextEnd !== prevEnd)) {\n this.dispatch(\"change\", { detail: { start: nextStart, end: nextEnd } });\n }\n }\n\n /** Reflects the current pair onto thumb ARIA attributes and CSS properties. */\n #render(start: number, end: number): void {\n if (this.hasStartThumbTarget) {\n this.startThumbTarget.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.startThumbTarget.setAttribute(\"aria-valuemax\", String(end));\n this.startThumbTarget.setAttribute(\"aria-valuenow\", String(start));\n }\n if (this.hasEndThumbTarget) {\n this.endThumbTarget.setAttribute(\"aria-valuemin\", String(start));\n this.endThumbTarget.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.endThumbTarget.setAttribute(\"aria-valuenow\", String(end));\n }\n const span = this.maxValue - this.minValue;\n this.element.style.setProperty(\n START_PROPERTY,\n String(span > 0 ? (start - this.minValue) / span : 0),\n );\n this.element.style.setProperty(\n END_PROPERTY,\n String(span > 0 ? (end - this.minValue) / span : 0),\n );\n }\n\n /** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */\n #snap(raw: number): number {\n const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));\n if (this.stepValue <= 0) return clamped;\n const stepped =\n Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue;\n return Math.min(this.maxValue, Math.max(this.minValue, stepped));\n }\n}\n\n/**\n * Decides which thumb yields when a move would cross the pair. The moving thumb\n * is the one whose requested value differs from its previous value; it stops at\n * the partner rather than dragging the partner along.\n */\nfunction isUserMovingStart(\n start: number,\n prevStart: number,\n end: number,\n prevEnd: number,\n): boolean {\n return start !== prevStart && end === prevEnd;\n}\n"]}
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/rating_controller.ts
4
4
 
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function isReservedArrowChord(event, allow = []) {
12
+ if (!event.key.startsWith("Arrow")) return false;
13
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
+ }
15
+
5
16
  // src/utils/roving_tabindex.ts
6
17
  var RovingTabindex = class {
7
18
  /** Returns the current ordered item elements; called on every operation. */
@@ -77,16 +88,19 @@ var RatingController = class extends Controller {
77
88
  }
78
89
  /** Arrow/Home/End/Space keyboard control, clamped (no wrap). */
79
90
  onKeydown(event) {
91
+ if (event.defaultPrevented) return;
92
+ if (isReservedArrowChord(event)) return;
80
93
  if (this.readonlyValue) return;
81
94
  let next = null;
95
+ const rtl = isRtl(this.element);
82
96
  switch (event.key) {
83
97
  case "ArrowRight":
84
98
  case "ArrowUp":
85
- next = this.valueValue + 1;
99
+ next = this.valueValue + (event.key === "ArrowRight" && rtl ? -1 : 1);
86
100
  break;
87
101
  case "ArrowLeft":
88
102
  case "ArrowDown":
89
- next = this.valueValue - 1;
103
+ next = this.valueValue - (event.key === "ArrowLeft" && rtl ? -1 : 1);
90
104
  break;
91
105
  case "Home":
92
106
  next = this.#minValue;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/roving_tabindex.ts","../../src/controllers/rating_controller.ts"],"names":[],"mappings":"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","file":"rating_controller.js","sourcesContent":["/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { RovingTabindex } from \"../utils/roving_tabindex\";\n\n/**\n * Headless, accessible rating (star) behavior.\n *\n * Markup contract (identifier: `stimeo--rating`):\n * <div data-controller=\"stimeo--rating\" role=\"radiogroup\" aria-label=\"Rating\"\n * data-stimeo--rating-value-value=\"3\" data-stimeo--rating-max-value=\"5\">\n * <span role=\"radio\" aria-checked=\"false\" aria-label=\"1 star\" tabindex=\"-1\"\n * data-rating-value=\"1\" data-stimeo--rating-target=\"symbol\"\n * data-action=\"click->stimeo--rating#select\n * mouseenter->stimeo--rating#preview\n * mouseleave->stimeo--rating#endPreview\n * focus->stimeo--rating#preview\n * blur->stimeo--rating#endPreview\n * keydown->stimeo--rating#onKeydown\">★</span>\n * <!-- symbols 2..max; the selected one (or the first when unrated) tabindex=0 -->\n * <input type=\"hidden\" data-stimeo--rating-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Radio Group** pattern (an ordinal scale): exactly\n * one symbol is `aria-checked`. Unlike a generic radio group it deliberately does\n * **not** wrap — being ordinal, arrows clamp at the bounds.\n *\n * @remarks\n * Behavior only — symbols are styled by the consumer off `[aria-checked]` and the\n * `data-rating-hover` fill hook. In `readonly` mode the group becomes\n * `role=\"img\"`: because Stimeo never emits human-readable prose, the accessible\n * name (e.g. \"Rated 3 of 5\") is the consumer's `aria-label`/`aria-labelledby`.\n *\n * Behavior provided:\n * - Click selects a symbol; clicking the selected symbol clears to 0 when\n * `clearable`.\n * - `ArrowRight`/`ArrowUp` raise and `ArrowLeft`/`ArrowDown` lower the value\n * (clamped, down to 0 when `clearable`); `Home`/`End` jump to min/max;\n * `Space`/`Enter` select the focused symbol.\n * - Hover/focus previews a fill range via `data-rating-hover`; leaving restores\n * the selected range. `stimeo--rating:change` is dispatched on every change.\n */\nexport class RatingController extends Controller<HTMLElement> {\n static override targets = [\"symbol\", \"field\"];\n static override values = {\n value: { type: Number, default: 0 },\n max: { type: Number, default: 5 },\n clearable: { type: Boolean, default: true },\n readonly: { type: Boolean, default: false },\n };\n static actions = [\"endPreview\", \"onKeydown\", \"preview\", \"select\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly symbolTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasFieldTarget: boolean;\n declare valueValue: number;\n declare maxValue: number;\n declare clearableValue: boolean;\n declare readonlyValue: boolean;\n\n readonly #roving = new RovingTabindex(() => this.symbolTargets);\n\n /** Reflects the initial value, or switches to the non-interactive readonly view. */\n override connect(): void {\n if (this.readonlyValue) {\n this.#applyReadonly();\n return;\n }\n this.#apply(this.#clamp(this.valueValue), { focus: false });\n }\n\n /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */\n select(event: Event): void {\n if (this.readonlyValue) return;\n // Clamp the clicked value so a malformed data-rating-value can never push the\n // roving Tab stop out of range.\n const value = this.#clamp(this.#symbolValue(event.currentTarget as HTMLElement));\n if (this.clearableValue && value === this.valueValue) {\n // Clicking the selected symbol clears the rating; focus returns to the first.\n this.#render(0, { focus: true });\n } else {\n this.#render(value, { focus: false });\n }\n }\n\n /** Previews a fill range on hover/focus. Bound via `data-action` (mouseenter/focus). */\n preview(event: Event): void {\n if (this.readonlyValue) return;\n this.#setFillRange(this.#clamp(this.#symbolValue(event.currentTarget as HTMLElement)));\n }\n\n /** Restores the fill range to the selected value. Bound via `data-action` (mouseleave/blur). */\n endPreview(): void {\n if (this.readonlyValue) return;\n this.#setFillRange(this.valueValue);\n }\n\n /** Arrow/Home/End/Space keyboard control, clamped (no wrap). */\n onKeydown(event: KeyboardEvent): void {\n if (this.readonlyValue) return;\n let next: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = this.valueValue + 1;\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = this.valueValue - 1;\n break;\n case \"Home\":\n next = this.#minValue;\n break;\n case \"End\":\n next = this.maxValue;\n break;\n case \" \":\n case \"Enter\":\n next = this.#symbolValue(event.currentTarget as HTMLElement);\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#render(this.#clamp(next), { focus: true });\n }\n\n /**\n * Applies `value` (already clamped) everywhere, then dispatches `change`.\n * Use for user-driven changes; on connect call `#apply` directly so\n * initialization mirrors state without emitting an event.\n */\n #render(value: number, { focus }: { focus: boolean }): void {\n this.#apply(value, { focus });\n this.dispatch(\"change\", { detail: { value } });\n }\n\n /**\n * Stores `value`, syncs `aria-checked` and the roving Tab stop, the hidden\n * field, and the fill range — without dispatching `change`. Idempotent and\n * safe on connect (and across Turbo morphing).\n */\n #apply(value: number, { focus }: { focus: boolean }): void {\n this.valueValue = value;\n this.symbolTargets.forEach((symbol) => {\n symbol.setAttribute(\n \"aria-checked\",\n value > 0 && this.#symbolValue(symbol) === value ? \"true\" : \"false\",\n );\n });\n // The selected symbol is the Tab stop; when unrated, the first symbol is.\n this.#roving.setActive(value > 0 ? value - 1 : 0, { focus });\n if (this.hasFieldTarget) this.fieldTarget.value = String(value);\n this.#setFillRange(value);\n }\n\n /** Marks symbols up to `range` with `data-rating-hover` (the consumer's fill hook). */\n #setFillRange(range: number): void {\n for (const symbol of this.symbolTargets) {\n if (this.#symbolValue(symbol) <= range && range > 0) {\n symbol.setAttribute(\"data-rating-hover\", \"\");\n } else {\n symbol.removeAttribute(\"data-rating-hover\");\n }\n }\n }\n\n /** Turns the group into a non-interactive `role=\"img\"` snapshot of the value. */\n #applyReadonly(): void {\n const value = this.#clamp(this.valueValue);\n this.valueValue = value;\n this.element.setAttribute(\"role\", \"img\");\n for (const symbol of this.symbolTargets) {\n // Drop the interactive radio role so the img has no nested interactive\n // descendants; aria-hidden removes them from the tree entirely.\n symbol.removeAttribute(\"role\");\n symbol.setAttribute(\"aria-hidden\", \"true\");\n symbol.tabIndex = -1;\n }\n if (this.hasFieldTarget) this.fieldTarget.value = String(value);\n this.#setFillRange(value);\n }\n\n /**\n * Clamps `value` to `[min, max]` (min is 0 when clearable, else 1). The upper\n * bound is also capped at the number of symbols so the roving Tab stop\n * (`value - 1`) always maps to a real symbol — even if the consumer's `max`\n * value and rendered symbol count disagree, the tabbable item is never lost.\n */\n #clamp(value: number): number {\n const max = Math.min(this.maxValue, this.symbolTargets.length);\n return Math.min(max, Math.max(this.#minValue, value));\n }\n\n /** Lowest selectable value: 0 when clearable, otherwise 1. */\n get #minValue(): number {\n return this.clearableValue ? 0 : 1;\n }\n\n /** A symbol's ordinal value (`data-rating-value`, defaulting to its position). */\n #symbolValue(symbol: HTMLElement): number {\n const raw = Number(symbol.getAttribute(\"data-rating-value\"));\n return Number.isFinite(raw) && raw > 0 ? raw : this.symbolTargets.indexOf(symbol) + 1;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/utils/arrow_step.ts","../../src/utils/roving_tabindex.ts","../../src/controllers/rating_controller.ts"],"names":[],"mappings":"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","file":"rating_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","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 * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { isRtl } from \"../utils/logical_scroll\";\nimport { RovingTabindex } from \"../utils/roving_tabindex\";\n\n/**\n * Headless, accessible rating (star) behavior.\n *\n * Markup contract (identifier: `stimeo--rating`):\n * <div data-controller=\"stimeo--rating\" role=\"radiogroup\" aria-label=\"Rating\"\n * data-stimeo--rating-value-value=\"3\" data-stimeo--rating-max-value=\"5\">\n * <span role=\"radio\" aria-checked=\"false\" aria-label=\"1 star\" tabindex=\"-1\"\n * data-rating-value=\"1\" data-stimeo--rating-target=\"symbol\"\n * data-action=\"click->stimeo--rating#select\n * mouseenter->stimeo--rating#preview\n * mouseleave->stimeo--rating#endPreview\n * focus->stimeo--rating#preview\n * blur->stimeo--rating#endPreview\n * keydown->stimeo--rating#onKeydown\">★</span>\n * <!-- symbols 2..max; the selected one (or the first when unrated) tabindex=0 -->\n * <input type=\"hidden\" data-stimeo--rating-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Radio Group** pattern (an ordinal scale): exactly\n * one symbol is `aria-checked`. Unlike a generic radio group it deliberately does\n * **not** wrap — being ordinal, arrows clamp at the bounds.\n *\n * @remarks\n * Behavior only — symbols are styled by the consumer off `[aria-checked]` and the\n * `data-rating-hover` fill hook. In `readonly` mode the group becomes\n * `role=\"img\"`: because Stimeo never emits human-readable prose, the accessible\n * name (e.g. \"Rated 3 of 5\") is the consumer's `aria-label`/`aria-labelledby`.\n *\n * Behavior provided:\n * - Click selects a symbol; clicking the selected symbol clears to 0 when\n * `clearable`.\n * - `ArrowRight`/`ArrowUp` raise and `ArrowLeft`/`ArrowDown` lower the value\n * (clamped, down to 0 when `clearable`); `Home`/`End` jump to min/max;\n * `Space`/`Enter` select the focused symbol.\n * - Hover/focus previews a fill range via `data-rating-hover`; leaving restores\n * the selected range. `stimeo--rating:change` is dispatched on every change.\n */\nexport class RatingController extends Controller<HTMLElement> {\n static override targets = [\"symbol\", \"field\"];\n static override values = {\n value: { type: Number, default: 0 },\n max: { type: Number, default: 5 },\n clearable: { type: Boolean, default: true },\n readonly: { type: Boolean, default: false },\n };\n static actions = [\"endPreview\", \"onKeydown\", \"preview\", \"select\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly symbolTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasFieldTarget: boolean;\n declare valueValue: number;\n declare maxValue: number;\n declare clearableValue: boolean;\n declare readonlyValue: boolean;\n\n readonly #roving = new RovingTabindex(() => this.symbolTargets);\n\n /** Reflects the initial value, or switches to the non-interactive readonly view. */\n override connect(): void {\n if (this.readonlyValue) {\n this.#applyReadonly();\n return;\n }\n this.#apply(this.#clamp(this.valueValue), { focus: false });\n }\n\n /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */\n select(event: Event): void {\n if (this.readonlyValue) return;\n // Clamp the clicked value so a malformed data-rating-value can never push the\n // roving Tab stop out of range.\n const value = this.#clamp(this.#symbolValue(event.currentTarget as HTMLElement));\n if (this.clearableValue && value === this.valueValue) {\n // Clicking the selected symbol clears the rating; focus returns to the first.\n this.#render(0, { focus: true });\n } else {\n this.#render(value, { focus: false });\n }\n }\n\n /** Previews a fill range on hover/focus. Bound via `data-action` (mouseenter/focus). */\n preview(event: Event): void {\n if (this.readonlyValue) return;\n this.#setFillRange(this.#clamp(this.#symbolValue(event.currentTarget as HTMLElement)));\n }\n\n /** Restores the fill range to the selected value. Bound via `data-action` (mouseleave/blur). */\n endPreview(): void {\n if (this.readonlyValue) return;\n this.#setFillRange(this.valueValue);\n }\n\n /** Arrow/Home/End/Space keyboard control, clamped (no wrap). */\n onKeydown(event: KeyboardEvent): void {\n // A descendant widget that already claimed the key must not ALSO change the rating —\n // composition depends on this yield.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (this.readonlyValue) return;\n let next: number | null = null;\n // Logical, not physical, for the horizontal pair only: the stars are an\n // ordered row, so which one is \"next\" follows the writing direction.\n //\n // The shared `logicalArrowStep` is deliberately not used here. It encodes the\n // list-order convention (`ArrowDown` = next), and this widget pairs the arrows\n // by *value* instead — `ArrowUp` means \"more\" — so borrowing it would silently\n // invert the vertical axis.\n const rtl = isRtl(this.element);\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = this.valueValue + (event.key === \"ArrowRight\" && rtl ? -1 : 1);\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = this.valueValue - (event.key === \"ArrowLeft\" && rtl ? -1 : 1);\n break;\n case \"Home\":\n next = this.#minValue;\n break;\n case \"End\":\n next = this.maxValue;\n break;\n case \" \":\n case \"Enter\":\n next = this.#symbolValue(event.currentTarget as HTMLElement);\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#render(this.#clamp(next), { focus: true });\n }\n\n /**\n * Applies `value` (already clamped) everywhere, then dispatches `change`.\n * Use for user-driven changes; on connect call `#apply` directly so\n * initialization mirrors state without emitting an event.\n */\n #render(value: number, { focus }: { focus: boolean }): void {\n this.#apply(value, { focus });\n this.dispatch(\"change\", { detail: { value } });\n }\n\n /**\n * Stores `value`, syncs `aria-checked` and the roving Tab stop, the hidden\n * field, and the fill range — without dispatching `change`. Idempotent and\n * safe on connect (and across Turbo morphing).\n */\n #apply(value: number, { focus }: { focus: boolean }): void {\n this.valueValue = value;\n this.symbolTargets.forEach((symbol) => {\n symbol.setAttribute(\n \"aria-checked\",\n value > 0 && this.#symbolValue(symbol) === value ? \"true\" : \"false\",\n );\n });\n // The selected symbol is the Tab stop; when unrated, the first symbol is.\n this.#roving.setActive(value > 0 ? value - 1 : 0, { focus });\n if (this.hasFieldTarget) this.fieldTarget.value = String(value);\n this.#setFillRange(value);\n }\n\n /** Marks symbols up to `range` with `data-rating-hover` (the consumer's fill hook). */\n #setFillRange(range: number): void {\n for (const symbol of this.symbolTargets) {\n if (this.#symbolValue(symbol) <= range && range > 0) {\n symbol.setAttribute(\"data-rating-hover\", \"\");\n } else {\n symbol.removeAttribute(\"data-rating-hover\");\n }\n }\n }\n\n /** Turns the group into a non-interactive `role=\"img\"` snapshot of the value. */\n #applyReadonly(): void {\n const value = this.#clamp(this.valueValue);\n this.valueValue = value;\n this.element.setAttribute(\"role\", \"img\");\n for (const symbol of this.symbolTargets) {\n // Drop the interactive radio role so the img has no nested interactive\n // descendants; aria-hidden removes them from the tree entirely.\n symbol.removeAttribute(\"role\");\n symbol.setAttribute(\"aria-hidden\", \"true\");\n symbol.tabIndex = -1;\n }\n if (this.hasFieldTarget) this.fieldTarget.value = String(value);\n this.#setFillRange(value);\n }\n\n /**\n * Clamps `value` to `[min, max]` (min is 0 when clearable, else 1). The upper\n * bound is also capped at the number of symbols so the roving Tab stop\n * (`value - 1`) always maps to a real symbol — even if the consumer's `max`\n * value and rendered symbol count disagree, the tabbable item is never lost.\n */\n #clamp(value: number): number {\n const max = Math.min(this.maxValue, this.symbolTargets.length);\n return Math.min(max, Math.max(this.#minValue, value));\n }\n\n /** Lowest selectable value: 0 when clearable, otherwise 1. */\n get #minValue(): number {\n return this.clearableValue ? 0 : 1;\n }\n\n /** A symbol's ordinal value (`data-rating-value`, defaulting to its position). */\n #symbolValue(symbol: HTMLElement): number {\n const raw = Number(symbol.getAttribute(\"data-rating-value\"));\n return Number.isFinite(raw) && raw > 0 ? raw : this.symbolTargets.indexOf(symbol) + 1;\n }\n}\n"]}
@@ -0,0 +1,52 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless "read more / truncate" behavior for visually clamped text.
5
+ *
6
+ * Markup contract (identifier: `stimeo--read-more`):
7
+ * <div data-controller="stimeo--read-more">
8
+ * <p id="bio" data-stimeo--read-more-target="content" data-state="collapsed">…</p>
9
+ * <button data-stimeo--read-more-target="trigger"
10
+ * data-action="stimeo--read-more#toggle"
11
+ * aria-expanded="false" aria-controls="bio" hidden>Read more</button>
12
+ * </div>
13
+ *
14
+ * There is no dedicated APG widget; the toggle borrows the **Disclosure**
15
+ * convention (`aria-expanded`). The visual clamp itself (`-webkit-line-clamp`
16
+ * etc.) is the consumer's CSS, keyed off `data-state`.
17
+ *
18
+ * @remarks
19
+ * Behavior only. The full text always stays in the DOM — the clamp is purely
20
+ * visual, so assistive technology reads everything regardless of state; here
21
+ * `aria-expanded` therefore signals the *visual* expansion, not content hidden
22
+ * from AT. The controller's extra job is **overflow detection**: when the
23
+ * content is not actually clamped (it fits), the toggle is `hidden` so no
24
+ * pointless "read more" is offered. Resizes, content changes, and target
25
+ * replacement re-evaluate it; hiding a focused toggle waits until blur.
26
+ */
27
+ declare class ReadMoreController extends Controller<HTMLElement> {
28
+ #private;
29
+ static targets: string[];
30
+ static values: {
31
+ collapsed: {
32
+ type: BooleanConstructor;
33
+ default: boolean;
34
+ };
35
+ };
36
+ static actions: readonly ["toggle"];
37
+ readonly contentTarget: HTMLElement;
38
+ readonly triggerTarget: HTMLElement;
39
+ readonly hasContentTarget: boolean;
40
+ readonly hasTriggerTarget: boolean;
41
+ collapsedValue: boolean;
42
+ connect(): void;
43
+ disconnect(): void;
44
+ contentTargetConnected(): void;
45
+ contentTargetDisconnected(): void;
46
+ triggerTargetConnected(): void;
47
+ triggerTargetDisconnected(trigger: HTMLElement): void;
48
+ /** Toggles between the collapsed (clamped) and expanded states. */
49
+ toggle(): void;
50
+ }
51
+
52
+ export { ReadMoreController };