phlex_kit 0.10.0 → 0.12.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -1
  5. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +38 -7
  7. data/app/components/phlex_kit/attachment/attachment.css +15 -0
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +8 -0
  10. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  11. data/app/components/phlex_kit/badge/badge.css +1 -1
  12. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
  13. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  14. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  15. data/app/components/phlex_kit/calendar/calendar_controller.js +56 -15
  16. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  17. data/app/components/phlex_kit/carousel/carousel_controller.js +59 -4
  18. data/app/components/phlex_kit/chart/chart.rb +10 -2
  19. data/app/components/phlex_kit/chart/chart_controller.js +37 -8
  20. data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
  21. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  22. data/app/components/phlex_kit/codeblock/codeblock.rb +11 -1
  23. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  24. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  25. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  26. data/app/components/phlex_kit/combobox/combobox_controller.js +72 -3
  27. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  28. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  29. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
  30. data/app/components/phlex_kit/command/command_controller.js +23 -2
  31. data/app/components/phlex_kit/command/command_input.rb +3 -2
  32. data/app/components/phlex_kit/command/command_item.rb +4 -0
  33. data/app/components/phlex_kit/command/command_list.rb +6 -2
  34. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  35. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  36. data/app/components/phlex_kit/context_menu/context_menu_controller.js +74 -12
  37. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  38. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  39. data/app/components/phlex_kit/data_table/data_table.css +1 -11
  40. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  41. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  42. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  43. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  44. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  45. data/app/components/phlex_kit/date_picker/date_picker.rb +19 -2
  46. data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -0
  47. data/app/components/phlex_kit/dialog/dialog.css +3 -1
  48. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  49. data/app/components/phlex_kit/dialog/dialog_controller.js +16 -2
  50. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  51. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  52. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +9 -2
  53. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +81 -13
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  57. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  58. data/app/components/phlex_kit/form_field/form_field_controller.js +54 -25
  59. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  60. data/app/components/phlex_kit/hover_card/hover_card.css +10 -2
  61. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  62. data/app/components/phlex_kit/icon/icon.css +9 -0
  63. data/app/components/phlex_kit/icon/icon.rb +7 -1
  64. data/app/components/phlex_kit/input/input.css +0 -1
  65. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  66. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  67. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  68. data/app/components/phlex_kit/item/item.css +2 -1
  69. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  70. data/app/components/phlex_kit/label/label.css +1 -1
  71. data/app/components/phlex_kit/link/link.rb +1 -0
  72. data/app/components/phlex_kit/marker/marker.css +2 -1
  73. data/app/components/phlex_kit/marker/marker.rb +9 -2
  74. data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
  75. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  76. data/app/components/phlex_kit/menubar/menubar_controller.js +46 -16
  77. data/app/components/phlex_kit/message/message.rb +5 -0
  78. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
  79. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  80. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  81. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
  86. data/app/components/phlex_kit/popover/popover_controller.js +42 -1
  87. data/app/components/phlex_kit/progress/progress.css +8 -0
  88. data/app/components/phlex_kit/progress/progress.rb +14 -5
  89. data/app/components/phlex_kit/resizable/resizable_controller.js +3 -0
  90. data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -15
  91. data/app/components/phlex_kit/select/select.css +5 -2
  92. data/app/components/phlex_kit/select/select_controller.js +49 -4
  93. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  94. data/app/components/phlex_kit/separator/separator.rb +5 -1
  95. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  96. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  97. data/app/components/phlex_kit/sheet/sheet_content_controller.js +40 -0
  98. data/app/components/phlex_kit/sheet/sheet_controller.js +31 -1
  99. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  100. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  101. data/app/components/phlex_kit/slider/slider.css +15 -0
  102. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  103. data/app/components/phlex_kit/switch/switch.css +5 -0
  104. data/app/components/phlex_kit/switch/switch.rb +6 -0
  105. data/app/components/phlex_kit/table/table.css +2 -1
  106. data/app/components/phlex_kit/table/table.rb +5 -2
  107. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  108. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  109. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  110. data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
  111. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  112. data/app/components/phlex_kit/toast/toast.css +2 -1
  113. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  114. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  115. data/app/components/phlex_kit/toast/toast_controller.js +40 -21
  116. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  117. data/app/components/phlex_kit/toast/toast_region.rb +29 -10
  118. data/app/components/phlex_kit/toast/toaster_controller.js +17 -7
  119. data/app/components/phlex_kit/toggle_group/toggle_group.rb +17 -2
  120. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +16 -9
  121. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +20 -2
  122. data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
  123. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  124. data/app/components/phlex_kit/typography/heading.rb +7 -1
  125. data/app/components/phlex_kit/typography/list.rb +22 -0
  126. data/app/components/phlex_kit/typography/text.rb +7 -1
  127. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  128. data/lib/phlex_kit/base_component.rb +23 -0
  129. data/lib/phlex_kit/version.rb +1 -1
  130. data/lib/phlex_kit.rb +1 -0
  131. metadata +4 -1
@@ -26,7 +26,7 @@ export default class extends Controller {
26
26
  }
27
27
 
28
28
  disconnect() {
29
- this.themeObserver?.disconnect()
29
+ this.disconnectThemeObservers()
30
30
  this.chart?.destroy()
31
31
  if (!this.chart) {
32
32
  this.dispatch("disconnect", { detail: { canvas: this.element } })
@@ -44,9 +44,14 @@ export default class extends Controller {
44
44
  }
45
45
 
46
46
  // Kit tokens are plain color values (hex / color-mix results), not the hsl
47
- // triplets ruby_ui reads — return them as-is.
47
+ // triplets ruby_ui reads — return them as-is. Resolved from this.element,
48
+ // not document.documentElement: a .pk-dark island (_tokens.css:123-129)
49
+ // sets the --pk-* custom properties at the SUBTREE root, and per-element
50
+ // getComputedStyle correctly picks up the nearest ancestor's values —
51
+ // reading from the document root would ignore the island and always
52
+ // return the page-level theme.
48
53
  getThemeColor(name) {
49
- return getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim()
54
+ return getComputedStyle(this.element).getPropertyValue(`--${name}`).trim()
50
55
  }
51
56
 
52
57
  // shadcn-style series colors: dataset N takes --pk-chart-(N%5 + 1). Line/area
@@ -80,6 +85,13 @@ export default class extends Controller {
80
85
 
81
86
  setDefaultColorsForChart() {
82
87
  const Chart = this.chartLibrary()
88
+ // Chart.defaults is a library-wide singleton (chart.js has no per-instance
89
+ // defaults object), so it can only ever reflect ONE theme — the
90
+ // documented caveat. It's resolved from the document root, not
91
+ // this.element: a chart inside a .pk-dark island still gets per-dataset
92
+ // colors from the island (getThemeColor/seriesColor, above), just not
93
+ // these library-global defaults (grid/tooltip/legend chrome).
94
+ //
83
95
  // One computed-style resolve for all nine tokens (this runs on every
84
96
  // connect; assigning identical values to Chart.defaults is free — charts
85
97
  // read defaults at construction — so the style resolves are the only cost
@@ -122,20 +134,37 @@ export default class extends Controller {
122
134
  // unrelated class flip is the expensive path.
123
135
  initThemeObserver() {
124
136
  this._themeKey = this.currentThemeKey()
125
- this.themeObserver = new MutationObserver(() => {
137
+ const onMutate = () => {
126
138
  const key = this.currentThemeKey()
127
139
  if (key === this._themeKey) return
128
140
  this._themeKey = key
129
141
  this.refreshChart()
130
- })
142
+ }
143
+ this.themeObserver = new MutationObserver(onMutate)
131
144
  this.themeObserver.observe(document.documentElement, { attributeFilter: ["data-theme", "class"] })
145
+
146
+ // A .pk-dark island (_tokens.css:123-129) re-themes a subtree without
147
+ // touching the document root, so the root-only observer above misses it.
148
+ // Watch the nearest island ancestor too, if this chart lives inside one.
149
+ const island = this.element.closest(".pk-dark")
150
+ if (island && island !== document.documentElement) {
151
+ this.islandObserver = new MutationObserver(onMutate)
152
+ this.islandObserver.observe(island, { attributeFilter: ["data-theme", "class"] })
153
+ }
154
+ }
155
+
156
+ disconnectThemeObservers() {
157
+ this.themeObserver?.disconnect()
158
+ this.islandObserver?.disconnect()
132
159
  }
133
160
 
134
161
  // Cheap fingerprint of the resolved theme: two tokens that differ between
135
- // every light/dark pair. One style resolve per mutation vs a full chart
136
- // rebuild.
162
+ // every light/dark pair. Resolved from this.element (island-aware, see
163
+ // getThemeColor) so a mutation on an ancestor .pk-dark island is detected
164
+ // even though the document root never changes. One style resolve per
165
+ // mutation vs a full chart rebuild.
137
166
  currentThemeKey() {
138
- const styles = getComputedStyle(document.documentElement)
167
+ const styles = getComputedStyle(this.element)
139
168
  return `${styles.getPropertyValue("--pk-bg")}|${styles.getPropertyValue("--pk-text")}`
140
169
  }
141
170
 
@@ -3,7 +3,8 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="phlex-kit--clipboard". Copies the source text and
4
4
  // flashes a success/error popover (CSS-positioned, no @floating-ui). Ported from ruby_ui.
5
5
  export default class extends Controller {
6
- static targets = ["trigger", "source", "successPopover", "errorPopover"]
6
+ // No "trigger" target: the click->#copy action alone wires the trigger.
7
+ static targets = ["source", "successPopover", "errorPopover"]
7
8
 
8
9
  connect() {
9
10
  // Turbo snapshots at turbo:before-cache, BEFORE disconnect — hide the
@@ -28,10 +29,16 @@ export default class extends Controller {
28
29
  // A source usually wraps one element (input/code/…), but bare text is
29
30
  // legal too — fall back to the source's own text instead of erroring.
30
31
  const el = this.sourceTarget.children[0]
32
+ // TEXTAREA reads .value like INPUT — innerText would return the initial
33
+ // content, not the user-edited value.
31
34
  const text = el
32
- ? (el.tagName === "INPUT" ? el.value : el.innerText)
35
+ ? (["INPUT", "TEXTAREA"].includes(el.tagName) ? el.value : el.innerText)
33
36
  : this.sourceTarget.textContent.trim()
34
37
  if (!text) { this.show(this.errorPopoverTarget); return }
38
+ // navigator.clipboard is undefined in insecure contexts (plain-HTTP
39
+ // hosts, some webviews) — that's the error popover's job, not an
40
+ // unhandled TypeError's.
41
+ if (!navigator.clipboard) { this.show(this.errorPopoverTarget); return }
35
42
  navigator.clipboard.writeText(text)
36
43
  .then(() => this.show(this.successPopoverTarget))
37
44
  .catch(() => this.show(this.errorPopoverTarget))
@@ -1,6 +1,6 @@
1
1
  module PhlexKit
2
2
  class ClipboardTrigger < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
- def view_template(&) = div(**mix({ class: "pk-clipboard-trigger", data: { phlex_kit__clipboard_target: "trigger", action: "click->phlex-kit--clipboard#copy" } }, @attrs), &)
4
+ def view_template(&) = div(**mix({ class: "pk-clipboard-trigger", data: { action: "click->phlex-kit--clipboard#copy" } }, @attrs), &)
5
5
  end
6
6
  end
@@ -12,7 +12,17 @@ module PhlexKit
12
12
  # tabindex=0 + role=region: the block scrolls (overflow:auto), so it must
13
13
  # be keyboard-focusable (WCAG 2.1.1 scrollable-region-focusable). Pass
14
14
  # `aria: { label: ... }` to name the region for AT.
15
- div(**mix({ class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }, @attrs)) do
15
+ # Defaults only when the caller didn't supply their own `mix` would
16
+ # fuse tabindex="0 -1" / role="region article" instead of overriding.
17
+ base = { class: "pk-codeblock", data: { syntax: @syntax } }
18
+ base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
19
+ base[:role] = "region" unless @attrs.key?(:role) || @attrs.key?("role")
20
+ # A region landmark without a name is an axe violation — default one
21
+ # from the syntax; a caller aria: { label: } replaces it (never fused).
22
+ unless aria_labelled?
23
+ base[:aria] = { label: @syntax ? "#{@syntax} code" : "Code" }
24
+ end
25
+ div(**mix(base, @attrs)) do
16
26
  pre do
17
27
  if block
18
28
  code(&block)
@@ -7,7 +7,10 @@ module PhlexKit
7
7
  @attrs = attrs
8
8
  end
9
9
  def view_template(&)
10
- div(**mix({ class: "pk-collapsible", data: { controller: "phlex-kit--collapsible", phlex_kit__collapsible_open_value: @open } }, @attrs), &)
10
+ # data-state is the CSS styling hook (chevron rotation) it must be
11
+ # server-rendered or an open: true collapsible renders a closed-pointing
12
+ # chevron until Stimulus connects (and always, with JS off).
13
+ div(**mix({ class: "pk-collapsible", data: { controller: "phlex-kit--collapsible", phlex_kit__collapsible_open_value: @open, state: @open ? "open" : "closed" } }, @attrs), &)
11
14
  end
12
15
  end
13
16
  end
@@ -107,7 +107,7 @@
107
107
  padding: .25rem 0;
108
108
  border-bottom: 1px solid var(--pk-border);
109
109
  }
110
- .pk-combobox-group::before {
110
+ .pk-combobox-group[data-label]::before {
111
111
  content: attr(data-label);
112
112
  padding: .375rem .5rem;
113
113
  font-size: .75rem;
@@ -30,7 +30,8 @@ module PhlexKit
30
30
  "keydown.down->phlex-kit--combobox#keyDownPressed",
31
31
  "keydown.up->phlex-kit--combobox#keyUpPressed",
32
32
  "keydown.enter->phlex-kit--combobox#keyEnterPressed",
33
- "keydown.esc->phlex-kit--combobox#closePopover:prevent"
33
+ # no :prevent — closePopover only swallows Escape while open.
34
+ "keydown.esc->phlex-kit--combobox#closePopover"
34
35
  ].join(" ")
35
36
  }
36
37
  }, @attrs)) do
@@ -49,7 +50,10 @@ module PhlexKit
49
50
  action: [
50
51
  "keyup->phlex-kit--combobox#filterItems",
51
52
  "input->phlex-kit--combobox#filterItems",
52
- "keydown.backspace->phlex-kit--combobox#handleBadgeInputBackspace"
53
+ # plain keydown — "backspace" is not a Stimulus key filter
54
+ # (using it throws "unknown key filter" on every keystroke and
55
+ # the handler never ran); the method guards on e.key itself.
56
+ "keydown->phlex-kit--combobox#handleBadgeInputBackspace"
53
57
  ].join(" ")
54
58
  }
55
59
  )
@@ -45,6 +45,15 @@ export default class extends Controller {
45
45
  }
46
46
 
47
47
  connect() {
48
+ // A Turbo snapshot serializes aria-expanded / aria-activedescendant even
49
+ // though :popover-open does not survive the restore — normalize here so
50
+ // a restored page doesn't announce an open listbox over a closed combobox.
51
+ this.setExpanded(false)
52
+ this.clearActiveDescendant()
53
+ // Mirrors select_controller.js's connect(): a Turbo snapshot also
54
+ // serializes the keyboard-highlighted item's aria-current even though
55
+ // the popover itself does not survive the restore.
56
+ this.itemTargets.forEach((item) => item.removeAttribute("aria-current"))
48
57
  this.generateItemIds()
49
58
  this.updateTriggerContent()
50
59
  }
@@ -203,7 +212,19 @@ export default class extends Controller {
203
212
  remove.textContent = "×"
204
213
  remove.addEventListener("click", (e) => {
205
214
  e.stopPropagation() // don't reopen the popover via the trigger's click action
215
+ // Removing a focused chip drops focus with it (the remove button is
216
+ // torn out of the DOM by the renderBadges() rebuild below) — hand
217
+ // focus to the badge input instead of stranding it on <body>.
218
+ const wasFocused = document.activeElement === remove
206
219
  this.uncheck(input)
220
+ // badgeInput sits inside the trigger wrapper, so focusing it bubbles a
221
+ // focusin the trigger's own focusin->openPopover action would otherwise
222
+ // reopen — same guard closePopover() uses for its own focus restore.
223
+ if (wasFocused && this.hasBadgeInputTarget) {
224
+ this.refocusing = true
225
+ this.badgeInputTarget.focus()
226
+ this.refocusing = false
227
+ }
207
228
  })
208
229
 
209
230
  badge.append(remove)
@@ -217,6 +238,8 @@ export default class extends Controller {
217
238
  }
218
239
 
219
240
  handleBadgeInputBackspace(e) {
241
+ // Bound as a bare keydown: "backspace" is not a Stimulus key filter.
242
+ if (e.key !== "Backspace") return
220
243
  if (this.badgeInputTarget.value !== "") return
221
244
 
222
245
  const checked = this.checkedInputs()
@@ -228,6 +251,10 @@ export default class extends Controller {
228
251
  }
229
252
 
230
253
  clearAll(e) {
254
+ // The clear button hides itself once nothing is checked (updateTriggerContent
255
+ // below) — if it held focus, hand focus to the badge input rather than
256
+ // stranding it on a now-hidden element.
257
+ const wasFocused = this.hasClearButtonTarget && document.activeElement === this.clearButtonTarget
231
258
  if (e) {
232
259
  e.preventDefault()
233
260
  e.stopPropagation() // the button sits inside the trigger's click-to-open area
@@ -239,6 +266,11 @@ export default class extends Controller {
239
266
  })
240
267
  if (this.hasToggleAllTarget) this.toggleAllTarget.checked = false
241
268
  this.updateTriggerContent()
269
+ if (wasFocused && this.hasBadgeInputTarget) {
270
+ this.refocusing = true
271
+ this.badgeInputTarget.focus()
272
+ this.refocusing = false
273
+ }
242
274
  }
243
275
 
244
276
  togglePopover(event) {
@@ -278,7 +310,14 @@ export default class extends Controller {
278
310
  }
279
311
  }
280
312
 
281
- closePopover() {
313
+ closePopover(e) {
314
+ // Escape must keep its browser default (e.g. cancelling an enclosing
315
+ // <dialog>) when the popover is already closed — only swallow the key
316
+ // when this close actually consumes it.
317
+ if (e?.type === "keydown") {
318
+ if (!this.isOpen()) return
319
+ e.preventDefault()
320
+ }
282
321
  // Hiding the popover while focus is inside it (search input, option) drops
283
322
  // focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
284
323
  // only when focus would actually be orphaned — a click elsewhere on the
@@ -289,6 +328,11 @@ export default class extends Controller {
289
328
  // aria-activedescendant holds an element id; on close it must be removed,
290
329
  // not left pointing at a hidden option.
291
330
  this.clearActiveDescendant()
331
+ // Also drop the highlight itself — otherwise a closed combobox still
332
+ // exposes an aria-current="true" option that Enter would go on to
333
+ // activate (APG: Enter on a closed combobox must be a no-op).
334
+ this.selectedItemIndex = null
335
+ this.itemTargets.forEach(item => item.ariaCurrent = "false")
292
336
  if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
293
337
  this.updateTriggerContent() // reflect the selection into an input trigger
294
338
 
@@ -335,6 +379,12 @@ export default class extends Controller {
335
379
  this.applyFilter(field.value.toLowerCase())
336
380
  }
337
381
 
382
+ // Strips combining diacritical marks after an NFD decomposition, so "é"
383
+ // (query or candidate) matches "e" instead of never comparing equal.
384
+ normalizeDiacritics(s) {
385
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "")
386
+ }
387
+
338
388
  applyFilter(filterTerm) {
339
389
  if (this.hasToggleAllTarget) {
340
390
  if (filterTerm) this.toggleAllTarget.parentElement.classList.add("pk-hidden")
@@ -349,10 +399,12 @@ export default class extends Controller {
349
399
  // aria-current="true" stayed the Enter target while invisible.
350
400
  this.itemTargets.forEach(item => item.ariaCurrent = "false")
351
401
 
402
+ const normalizedFilterTerm = this.normalizeDiacritics(filterTerm)
403
+
352
404
  this.inputTargets.forEach((input) => {
353
- const text = this.inputContent(input).toLowerCase()
405
+ const text = this.normalizeDiacritics(this.inputContent(input).toLowerCase())
354
406
 
355
- if (text.indexOf(filterTerm) > -1) {
407
+ if (text.indexOf(normalizedFilterTerm) > -1) {
356
408
  input.parentElement.classList.remove("pk-hidden")
357
409
  resultCount++
358
410
  } else {
@@ -381,6 +433,12 @@ export default class extends Controller {
381
433
  keyDownPressed(e) {
382
434
  if (e) e.preventDefault()
383
435
 
436
+ // APG: ArrowDown on a closed combobox opens it and highlights the first
437
+ // option, rather than walking aria-current through invisible options.
438
+ // openPopover() resets selectedItemIndex to null, so the branch below
439
+ // still lands on 0.
440
+ if (!this.isOpen()) this.openPopover()
441
+
384
442
  if (this.selectedItemIndex !== null) {
385
443
  this.selectedItemIndex++
386
444
  } else {
@@ -393,6 +451,11 @@ export default class extends Controller {
393
451
  keyUpPressed(e) {
394
452
  if (e) e.preventDefault()
395
453
 
454
+ // Same as keyDownPressed: ArrowUp on a closed combobox opens it and
455
+ // highlights the last option (wrapSelectedInputIndex turns -1 into the
456
+ // final index).
457
+ if (!this.isOpen()) this.openPopover()
458
+
396
459
  if (this.selectedItemIndex !== null) {
397
460
  this.selectedItemIndex--
398
461
  } else {
@@ -425,6 +488,12 @@ export default class extends Controller {
425
488
  }
426
489
 
427
490
  keyEnterPressed(event) {
491
+ // APG: Enter on a CLOSED combobox is a no-op — it must not activate
492
+ // whatever option happened to keep aria-current="true" from before the
493
+ // popover closed. Bail before preventDefault so a closed field keeps
494
+ // Enter's ordinary default behaviour.
495
+ if (!this.isOpen()) return
496
+
428
497
  event.preventDefault()
429
498
  const option = this.itemTargets.find(item => item.ariaCurrent === "true")
430
499
 
@@ -36,7 +36,10 @@ module PhlexKit
36
36
  "keydown.down->phlex-kit--combobox#keyDownPressed",
37
37
  "keydown.up->phlex-kit--combobox#keyUpPressed",
38
38
  "keydown.enter->phlex-kit--combobox#keyEnterPressed",
39
- "keydown.esc->phlex-kit--combobox#closePopover:prevent"
39
+ # no :prevent — closePopover only swallows Escape while the
40
+ # popover is open, so a closed field keeps the key's default
41
+ # (e.g. cancelling an enclosing <dialog>).
42
+ "keydown.esc->phlex-kit--combobox#closePopover"
40
43
  ].join(" ")
41
44
  }
42
45
  }, @attrs)) do
@@ -3,13 +3,17 @@ module PhlexKit
3
3
  # role and an id the controller points the combobox element's aria-controls
4
4
  # at (and derives option ids from). See combobox.rb.
5
5
  class ComboboxList < BaseComponent
6
- def initialize(**attrs)
6
+ # id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
7
+ # caller id with the generated one into an invalid two-token id, breaking
8
+ # aria-controls (the documented list_id: wiring) and derived option ids.
9
+ def initialize(id: nil, **attrs)
10
+ @id = id || "pk-combobox-list-#{SecureRandom.hex(4)}"
7
11
  @attrs = attrs
8
12
  end
9
13
 
10
14
  def view_template(&)
11
15
  div(**mix({
12
- id: "pk-combobox-list-#{SecureRandom.hex(4)}",
16
+ id: @id,
13
17
  class: "pk-combobox-list",
14
18
  role: "listbox",
15
19
  data: { phlex_kit__combobox_target: "list" }
@@ -13,6 +13,10 @@ module PhlexKit
13
13
  input(**mix({
14
14
  type: :checkbox,
15
15
  class: "pk-combobox-checkbox",
16
+ # The option label is the interactive surface (ARIA option); the real
17
+ # input must not be a second tab stop inside it — same as
18
+ # ComboboxCheckbox/ComboboxRadio.
19
+ tabindex: "-1",
16
20
  data: {
17
21
  phlex_kit__combobox_target: "toggleAll",
18
22
  action: "change->phlex-kit--combobox#toggleAllItems"
@@ -1,5 +1,12 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
+ // Strips combining diacritical marks after an NFD decomposition, so "é"
4
+ // (query or candidate) matches "e" — the scorer otherwise treats accented
5
+ // characters as never equal to their bare ASCII counterparts.
6
+ function normalizeDiacritics(s) {
7
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
8
+ }
9
+
3
10
  // Fuzzy subsequence score: every query character must appear in order in the
4
11
  // value (else null = no match). Consecutive runs compound, word-starts score
5
12
  // extra, and a verbatim substring outranks scattered hits — so "set" beats
@@ -7,6 +14,8 @@ import { Controller } from "@hotwired/stimulus";
7
14
  // what fuse.js gave ruby_ui.
8
15
  function fuzzyScore(query, value) {
9
16
  if (!query) return 0;
17
+ query = normalizeDiacritics(query);
18
+ value = normalizeDiacritics(value);
10
19
  let qi = 0;
11
20
  let score = 0;
12
21
  let streak = 0;
@@ -50,10 +59,17 @@ export default class extends Controller {
50
59
  // dismiss() can hand focus back instead of dropping it on <body>.
51
60
  this.previouslyFocused = document.activeElement;
52
61
 
62
+ // A Turbo snapshot serializes stale aria-selected on rows / aria-
63
+ // activedescendant on the input even though the live JS highlight state
64
+ // doesn't survive restore — normalize here so a restored page doesn't
65
+ // announce a stale highlight (mirrors select_controller.js's connect()).
66
+ this.itemTargets.forEach((item) => item.removeAttribute("aria-selected"));
67
+
53
68
  if (!this.hasInputTarget) {
54
69
  return;
55
70
  }
56
71
 
72
+ this.inputTarget.removeAttribute("aria-activedescendant");
57
73
  this.generateItemIds();
58
74
  // Only the cloned dialog overlay grabs focus on connect — an inline
59
75
  // palette connecting at page load must not steal it.
@@ -98,6 +114,9 @@ export default class extends Controller {
98
114
  this.searchEntries = this.searchEntries.filter(
99
115
  (entry) => entry.element !== item,
100
116
  );
117
+ // The highlight index is positional — after a removal it points at the
118
+ // wrong (or a missing) row, so Enter would activate the wrong item.
119
+ this.selectedIndex = -1;
101
120
  }
102
121
 
103
122
  dismiss() {
@@ -171,7 +190,7 @@ export default class extends Controller {
171
190
  }
172
191
 
173
192
  focusInput() {
174
- this.inputTarget?.focus();
193
+ if (this.hasInputTarget) this.inputTarget.focus();
175
194
  }
176
195
 
177
196
  filter(e) {
@@ -266,7 +285,9 @@ export default class extends Controller {
266
285
  this.updateSelectedItem(visibleItems, -1);
267
286
  } else if (e.key === "Enter" && this.selectedIndex !== -1) {
268
287
  e.preventDefault();
269
- visibleItems[this.selectedIndex].click();
288
+ // Optional-chained: a stale selectedIndex after dynamic item removal
289
+ // (turbo stream re-render) must not throw.
290
+ visibleItems[this.selectedIndex]?.click();
270
291
  }
271
292
  }
272
293
 
@@ -11,10 +11,11 @@ module PhlexKit
11
11
  # `autofocus:` defaults off so an inline palette doesn't steal page focus
12
12
  # on load; the dialog clone needs no autofocus either — the command
13
13
  # controller's connect() focuses the input when the clone is inserted.
14
- def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, **attrs)
14
+ def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, value: "", **attrs)
15
15
  @placeholder = placeholder
16
16
  @list_id = list_id
17
17
  @autofocus = autofocus
18
+ @value = value
18
19
  @attrs = attrs
19
20
  end
20
21
 
@@ -31,7 +32,7 @@ module PhlexKit
31
32
  spellcheck: "false",
32
33
  autofocus: @autofocus,
33
34
  role: "combobox",
34
- value: "",
35
+ value: @value,
35
36
  aria: { autocomplete: "list", expanded: "true", controls: @list_id },
36
37
  data: {
37
38
  phlex_kit__command_target: "input",
@@ -18,6 +18,10 @@ module PhlexKit
18
18
  class: "pk-command-item",
19
19
  href: @href,
20
20
  role: "option",
21
+ # Options in an aria-activedescendant listbox are not tab stops —
22
+ # keyboard access is arrows/Enter from the input (cmdk parity); a
23
+ # tabbable anchor per row also made disabled items focusable.
24
+ tabindex: "-1",
21
25
  aria: { disabled: (@disabled ? "true" : nil) },
22
26
  data: {
23
27
  phlex_kit__command_target: "item",
@@ -3,13 +3,17 @@ module PhlexKit
3
3
  # listbox role and an id the controller points the input's aria-controls at
4
4
  # (and derives result ids from, for aria-activedescendant). See command.rb.
5
5
  class CommandList < BaseComponent
6
- def initialize(**attrs)
6
+ # id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
7
+ # caller id with the generated one into an invalid two-token id, breaking
8
+ # aria-controls (the documented list_id: wiring) and derived result ids.
9
+ def initialize(id: nil, **attrs)
10
+ @id = id || "pk-command-list-#{SecureRandom.hex(4)}"
7
11
  @attrs = attrs
8
12
  end
9
13
 
10
14
  def view_template(&)
11
15
  div(**mix({
12
- id: "pk-command-list-#{SecureRandom.hex(4)}",
16
+ id: @id,
13
17
  class: "pk-command-list",
14
18
  role: "listbox",
15
19
  data: { phlex_kit__command_target: "list" }
@@ -72,6 +72,9 @@
72
72
  the viewport right edge. */
73
73
  .pk-context-menu-sub { anchor-name: --pk-context-menu-sub; anchor-scope: --pk-context-menu-sub; }
74
74
  .pk-context-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
75
+ /* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
76
+ [dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
77
+ [dir="rtl"] .pk-context-menu-sub-chevron { transform: scaleX(-1); }
75
78
  .pk-context-menu-sub-content {
76
79
  position: fixed;
77
80
  position-anchor: --pk-context-menu-sub;
@@ -5,7 +5,15 @@ module PhlexKit
5
5
  class ContextMenu < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-context-menu", data: { controller: "phlex-kit--context-menu" } }, @attrs), &)
8
+ div(**mix({
9
+ class: "pk-context-menu",
10
+ data: {
11
+ controller: "phlex-kit--context-menu",
12
+ # focusout: tabbing (or otherwise moving real focus) out of the
13
+ # menu closes the open [popover=manual] panel (menubar's pattern).
14
+ action: "focusout->phlex-kit--context-menu#onFocusout"
15
+ }
16
+ }, @attrs), &)
9
17
  end
10
18
  end
11
19
  end
@@ -23,12 +23,60 @@ export default class extends Controller {
23
23
  this.close()
24
24
  }
25
25
  this.onKey = (e) => this.keydown(e)
26
+ // Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
27
+ // stale data-state="open" (the panel itself can't resurrect, but host
28
+ // CSS keyed on the state hook would misfire after a restore).
29
+ this.onBeforeCache = () => {
30
+ if (this.contentTarget.matches(":popover-open")) this.close()
31
+ }
32
+ document.addEventListener("turbo:before-cache", this.onBeforeCache)
33
+ // Outstanding syncSub rAF handles — cancelled on disconnect (menubar's
34
+ // pattern).
35
+ this.subFrames = new Set()
36
+ // A Turbo snapshot (or a detach/reattach that mimics one) serializes
37
+ // aria-expanded="true" on sub triggers even though no submenu can be
38
+ // open at connect time — sweep every stale marker back to false
39
+ // (menubar's pattern).
40
+ this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
41
+ // Same shape for checkbox/radio rows: their `checked` DOM property
42
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
43
+ // from it — resync every row from its live input.
44
+ this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
45
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
46
+ })
26
47
  }
27
48
 
28
49
  disconnect() {
29
50
  document.removeEventListener("click", this.onDoc)
30
51
  document.removeEventListener("contextmenu", this.onDoc)
31
52
  document.removeEventListener("keydown", this.onKey)
53
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
54
+ this.subFrames.forEach((id) => cancelAnimationFrame(id))
55
+ this.subFrames.clear()
56
+ }
57
+
58
+ // Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
59
+ // sub trigger's aria-expanded — same wiring as dropdown_menu/menubar.
60
+ syncSub(e) {
61
+ const sub = e.currentTarget
62
+ const id = requestAnimationFrame(() => {
63
+ this.subFrames.delete(id)
64
+ sub.querySelector(":scope > [aria-haspopup]")
65
+ ?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
66
+ })
67
+ this.subFrames.add(id)
68
+ }
69
+
70
+ // Tabbing (or otherwise moving real focus) out of the menu closes it —
71
+ // menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
72
+ // null when the window itself blurs — leave the menu alone then. The
73
+ // trigger + content are both DOM children of this.element even though the
74
+ // content is a top-layer popover, so one containment check covers both.
75
+ onFocusout(e) {
76
+ if (!this.contentTarget.matches(":popover-open")) return
77
+ const to = e.relatedTarget
78
+ if (!to || this.element.contains(to)) return
79
+ this.close()
32
80
  }
33
81
 
34
82
  open(e) {
@@ -68,6 +116,13 @@ export default class extends Controller {
68
116
  }
69
117
 
70
118
  keydown(e) {
119
+ // This listener stays attached document-wide while the menu is open (see
120
+ // open()/close()) so it keeps receiving events even once real focus has
121
+ // left the menu — scope the roving-nav keys to when focus is actually
122
+ // within the widget (this.element, not just contentTarget, so a focused
123
+ // trigger still counts). Escape stays global while the menu is open (APG).
124
+ if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return
125
+
71
126
  const items = this.items()
72
127
  const index = items.indexOf(document.activeElement)
73
128
  switch (e.key) {
@@ -101,19 +156,26 @@ export default class extends Controller {
101
156
  }
102
157
  break
103
158
  case "ArrowRight":
104
- // On a sub trigger, enter the submenu (focus reveals it via
105
- // :focus-within, making its rows visible to the roving nav).
106
- if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
107
- e.preventDefault()
108
- this.enterSub(document.activeElement)
109
- }
110
- break
111
159
  case "ArrowLeft": {
112
- // Inside a submenu, step back to its trigger (closes it on focus-out).
113
- const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
114
- if (sub) {
115
- e.preventDefault()
116
- sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
160
+ // Submenus open inline-end visually LEFT in RTL so the enter key
161
+ // follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
162
+ // (and the reverse in LTR). Runtime dir check is reliable after a flip.
163
+ const rtl = getComputedStyle(this.element).direction === "rtl"
164
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
165
+ if (e.key === enterKey) {
166
+ // On a sub trigger, enter the submenu (focus reveals it via
167
+ // :focus-within, making its rows visible to the roving nav).
168
+ if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
169
+ e.preventDefault()
170
+ this.enterSub(document.activeElement)
171
+ }
172
+ } else {
173
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
174
+ const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
175
+ if (sub) {
176
+ e.preventDefault()
177
+ sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
178
+ }
117
179
  }
118
180
  break
119
181
  }