phlex_kit 0.5.1 → 0.6.1

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -8
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -0
  4. data/app/components/phlex_kit/accordion/accordion.rb +1 -1
  5. data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
  6. data/app/components/phlex_kit/accordion/accordion_controller.js +26 -2
  7. data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +20 -4
  8. data/app/components/phlex_kit/accordion/accordion_trigger.rb +18 -2
  9. data/app/components/phlex_kit/alert/alert.rb +1 -1
  10. data/app/components/phlex_kit/alert_dialog/alert_dialog_cancel.rb +1 -1
  11. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +1 -1
  12. data/app/components/phlex_kit/attachment/attachment.rb +2 -2
  13. data/app/components/phlex_kit/attachment/attachment_media.rb +1 -1
  14. data/app/components/phlex_kit/avatar/avatar.rb +1 -1
  15. data/app/components/phlex_kit/badge/badge.rb +1 -1
  16. data/app/components/phlex_kit/button/button.rb +1 -1
  17. data/app/components/phlex_kit/button_group/button_group.rb +1 -1
  18. data/app/components/phlex_kit/calendar/calendar_controller.js +12 -8
  19. data/app/components/phlex_kit/card/card.rb +1 -1
  20. data/app/components/phlex_kit/carousel/carousel.rb +1 -1
  21. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +1 -1
  22. data/app/components/phlex_kit/combobox/combobox.css +21 -12
  23. data/app/components/phlex_kit/combobox/combobox.rb +19 -3
  24. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +7 -2
  25. data/app/components/phlex_kit/combobox/combobox_checkbox.rb +3 -0
  26. data/app/components/phlex_kit/combobox/combobox_controller.js +85 -20
  27. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +11 -2
  28. data/app/components/phlex_kit/combobox/combobox_item.rb +4 -1
  29. data/app/components/phlex_kit/combobox/combobox_list.rb +9 -2
  30. data/app/components/phlex_kit/combobox/combobox_popover.rb +8 -7
  31. data/app/components/phlex_kit/combobox/combobox_radio.rb +3 -0
  32. data/app/components/phlex_kit/combobox/combobox_search_input.rb +8 -3
  33. data/app/components/phlex_kit/command/command.rb +17 -2
  34. data/app/components/phlex_kit/command/command_controller.js +93 -3
  35. data/app/components/phlex_kit/command/command_dialog_content.rb +16 -4
  36. data/app/components/phlex_kit/command/command_input.rb +7 -2
  37. data/app/components/phlex_kit/command/command_item.rb +12 -3
  38. data/app/components/phlex_kit/command/command_list.rb +9 -2
  39. data/app/components/phlex_kit/context_menu/context_menu.css +25 -13
  40. data/app/components/phlex_kit/context_menu/context_menu_content.rb +1 -1
  41. data/app/components/phlex_kit/context_menu/context_menu_controller.js +71 -12
  42. data/app/components/phlex_kit/context_menu/context_menu_item.rb +7 -3
  43. data/app/components/phlex_kit/context_menu/context_menu_label.rb +1 -1
  44. data/app/components/phlex_kit/context_menu/context_menu_trigger.rb +3 -1
  45. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +1 -1
  46. data/app/components/phlex_kit/data_table/data_table_controller.js +12 -1
  47. data/app/components/phlex_kit/data_table/data_table_pagination.rb +12 -10
  48. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +1 -0
  49. data/app/components/phlex_kit/data_table/data_table_sort_head.rb +5 -1
  50. data/app/components/phlex_kit/dialog/dialog_content.rb +12 -4
  51. data/app/components/phlex_kit/dialog/dialog_controller.js +13 -0
  52. data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
  53. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +36 -15
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -3
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +8 -11
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +1 -1
  57. data/app/components/phlex_kit/empty/empty_media.rb +1 -1
  58. data/app/components/phlex_kit/field/field.rb +1 -1
  59. data/app/components/phlex_kit/field/field_legend.rb +1 -1
  60. data/app/components/phlex_kit/hover_card/hover_card.css +27 -8
  61. data/app/components/phlex_kit/hover_card/hover_card.rb +2 -1
  62. data/app/components/phlex_kit/hover_card/hover_card_content.rb +2 -2
  63. data/app/components/phlex_kit/hover_card/hover_card_controller.js +19 -5
  64. data/app/components/phlex_kit/input_group/input_group_addon.rb +1 -1
  65. data/app/components/phlex_kit/input_otp/input_otp.rb +4 -1
  66. data/app/components/phlex_kit/input_otp/input_otp_controller.js +12 -1
  67. data/app/components/phlex_kit/item/item.rb +1 -1
  68. data/app/components/phlex_kit/item/item_media.rb +1 -1
  69. data/app/components/phlex_kit/link/link.rb +1 -1
  70. data/app/components/phlex_kit/marker/marker.rb +1 -1
  71. data/app/components/phlex_kit/menubar/menubar.css +36 -14
  72. data/app/components/phlex_kit/menubar/menubar.rb +1 -1
  73. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +5 -2
  74. data/app/components/phlex_kit/menubar/menubar_content.rb +1 -1
  75. data/app/components/phlex_kit/menubar/menubar_controller.js +100 -12
  76. data/app/components/phlex_kit/menubar/menubar_item.rb +8 -5
  77. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +5 -2
  78. data/app/components/phlex_kit/menubar/menubar_trigger.rb +1 -0
  79. data/app/components/phlex_kit/native_select/native_select.rb +1 -1
  80. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +24 -7
  81. data/app/components/phlex_kit/navigation_menu/navigation_menu_content.rb +3 -1
  82. data/app/components/phlex_kit/popover/popover.css +24 -9
  83. data/app/components/phlex_kit/popover/popover.rb +2 -1
  84. data/app/components/phlex_kit/popover/popover_content.rb +6 -4
  85. data/app/components/phlex_kit/popover/popover_controller.js +12 -8
  86. data/app/components/phlex_kit/popover/popover_trigger.rb +1 -1
  87. data/app/components/phlex_kit/resizable/resizable_panel.rb +4 -2
  88. data/app/components/phlex_kit/select/select.css +24 -12
  89. data/app/components/phlex_kit/select/select_content.rb +6 -4
  90. data/app/components/phlex_kit/select/select_controller.js +21 -17
  91. data/app/components/phlex_kit/select/select_trigger.rb +1 -1
  92. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  93. data/app/components/phlex_kit/sheet/sheet_content_controller.js +68 -2
  94. data/app/components/phlex_kit/sheet/sheet_controller.js +28 -3
  95. data/app/components/phlex_kit/sidebar/sidebar.rb +1 -1
  96. data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +1 -1
  97. data/app/components/phlex_kit/spinner/spinner.rb +1 -1
  98. data/app/components/phlex_kit/switch/switch.rb +7 -4
  99. data/app/components/phlex_kit/tabs/tabs.rb +1 -1
  100. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -1
  101. data/app/components/phlex_kit/tabs/tabs_controller.js +60 -7
  102. data/app/components/phlex_kit/tabs/tabs_list.rb +2 -2
  103. data/app/components/phlex_kit/tabs/tabs_trigger.rb +14 -2
  104. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -3
  105. data/app/components/phlex_kit/toast/toast_controller.js +17 -0
  106. data/app/components/phlex_kit/toast/toast_icon.rb +1 -1
  107. data/app/components/phlex_kit/toast/toast_item.rb +1 -1
  108. data/app/components/phlex_kit/toggle/toggle.rb +2 -2
  109. data/app/components/phlex_kit/toggle_group/toggle_group.rb +2 -2
  110. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +1 -1
  111. data/app/components/phlex_kit/tooltip/tooltip.css +40 -22
  112. data/app/components/phlex_kit/tooltip/tooltip.rb +3 -1
  113. data/app/components/phlex_kit/tooltip/tooltip_content.rb +1 -1
  114. data/app/components/phlex_kit/tooltip/tooltip_controller.js +32 -0
  115. data/app/components/phlex_kit/tooltip/tooltip_trigger.rb +8 -3
  116. data/app/components/phlex_kit/typography/text.rb +1 -1
  117. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  118. data/lib/generators/phlex_kit/component/component_generator.rb +28 -0
  119. data/lib/generators/phlex_kit/install/templates/phlex_kit.rb +3 -0
  120. data/lib/phlex_kit/base_component.rb +12 -0
  121. data/lib/phlex_kit/engine.rb +11 -1
  122. data/lib/phlex_kit/icons/lucide.rb +1 -1
  123. data/lib/phlex_kit/icons/phosphor.rb +1 -1
  124. data/lib/phlex_kit/icons/remix.rb +1 -1
  125. data/lib/phlex_kit/icons/tabler.rb +1 -1
  126. data/lib/phlex_kit/version.rb +1 -1
  127. data/lib/phlex_kit.rb +5 -0
  128. metadata +2 -1
@@ -1,10 +1,11 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- // Ported from ruby_ui's combobox controller. TWO changes from upstream: the
4
- // @floating-ui/dom dependency and the native Popover API are removedthe panel
5
- // is a CSS-positioned child of the combobox (.pk-combobox-popover, toggled with
6
- // .pk-hidden) with click-outside handled by a window action, so only
7
- // @hotwired/stimulus is needed. PhlexKit also COMPLETES upstream's unfinished
3
+ // Ported from ruby_ui's combobox controller, minus the @floating-ui/dom
4
+ // dependency: like upstream, the panel is a native popover (manual this
5
+ // controller owns open/close and click-outside via a window action), and CSS
6
+ // anchor positioning replaces computePosition (combobox.css: trigger-width
7
+ // floor via anchor-size(), viewport-edge flip via position-try-fallbacks),
8
+ // so only @hotwired/stimulus is needed. PhlexKit also COMPLETES upstream's unfinished
8
9
  // input/badge trigger variants: the inputTrigger / badgeContainer / badgeInput /
9
10
  // clearButton targets, filtering from whichever field fired, chip rendering
10
11
  // with per-chip remove, backspace removal, and clearAll all had no upstream
@@ -14,7 +15,6 @@ import { Controller } from "@hotwired/stimulus";
14
15
  export default class extends Controller {
15
16
  static values = {
16
17
  term: String,
17
- minPopoverWidth: { type: Number, default: 240 },
18
18
  autoHighlight: { type: Boolean, default: false }
19
19
  }
20
20
 
@@ -30,15 +30,61 @@ export default class extends Controller {
30
30
  "inputTrigger",
31
31
  "badgeContainer",
32
32
  "badgeInput",
33
- "clearButton"
33
+ "clearButton",
34
+ "list",
35
+ "liveRegion"
34
36
  ]
35
37
 
36
38
  selectedItemIndex = null
37
39
 
38
40
  connect() {
41
+ this.generateItemIds()
39
42
  this.updateTriggerContent()
40
43
  }
41
44
 
45
+ // ARIA plumbing: every option gets an id derived from the listbox id so the
46
+ // combobox element can point aria-activedescendant at the keyboard highlight,
47
+ // and aria-controls is wired to the listbox (mirrors select_controller.js).
48
+ generateItemIds() {
49
+ const list = this.hasListTarget ? this.listTarget : (this.hasPopoverTarget ? this.popoverTarget : this.element)
50
+ if (!list.id) list.id = `pk-combobox-list-${Math.random().toString(36).slice(2, 10)}`
51
+
52
+ this.itemTargets.forEach((item, index) => {
53
+ if (!item.id) item.id = `${list.id}-${index}`
54
+ })
55
+
56
+ this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-controls", list.id))
57
+ }
58
+
59
+ // The elements carrying the open/closed combobox state: the trigger button
60
+ // (button layout) and/or whichever filter field the layout has.
61
+ ariaExpandedElements() {
62
+ const els = []
63
+ if (this.hasInputTriggerTarget) els.push(this.inputTriggerTarget)
64
+ if (this.hasBadgeInputTarget) els.push(this.badgeInputTarget)
65
+ if (els.length === 0 && this.hasTriggerTarget) els.push(this.triggerTarget)
66
+ if (this.hasSearchInputTarget) els.push(this.searchInputTarget)
67
+ return els
68
+ }
69
+
70
+ isOpen() {
71
+ return this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")
72
+ }
73
+
74
+ setExpanded(expanded) {
75
+ this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-expanded", expanded ? "true" : "false"))
76
+ }
77
+
78
+ setActiveDescendant(id) {
79
+ const field = this.filterField()
80
+ if (field) field.setAttribute("aria-activedescendant", id)
81
+ }
82
+
83
+ clearActiveDescendant() {
84
+ const field = this.filterField()
85
+ if (field) field.removeAttribute("aria-activedescendant")
86
+ }
87
+
42
88
  inputChanged(e) {
43
89
  this.updateTriggerContent()
44
90
 
@@ -81,13 +127,15 @@ export default class extends Controller {
81
127
  updateTriggerContent() {
82
128
  const checked = this.checkedInputs()
83
129
 
130
+ this.syncAriaSelected()
131
+
84
132
  if (this.hasTriggerContentTarget) {
85
133
  this.triggerContentTarget.innerText = this.selectionLabel(checked) || this.triggerTarget.dataset.placeholder
86
134
  }
87
135
 
88
136
  // Input trigger: the field is also the filter, so only reflect the
89
137
  // selection while the popover is closed — never stomp a query mid-typing.
90
- if (this.hasInputTriggerTarget && this.triggerTarget.ariaExpanded !== "true") {
138
+ if (this.hasInputTriggerTarget && !this.isOpen()) {
91
139
  this.inputTriggerTarget.value = this.selectionLabel(checked)
92
140
  }
93
141
 
@@ -100,6 +148,14 @@ export default class extends Controller {
100
148
  }
101
149
  }
102
150
 
151
+ // The chosen option(s) carry aria-selected; the option label is the ARIA
152
+ // option, its inner input holds the real checked state.
153
+ syncAriaSelected() {
154
+ this.inputTargets.forEach((input) => {
155
+ input.parentElement.setAttribute("aria-selected", input.checked ? "true" : "false")
156
+ })
157
+ }
158
+
103
159
  renderBadges(checked) {
104
160
  this.badgeContainerTarget.replaceChildren(...checked.map((input) => this.buildBadge(input)))
105
161
  this.badgeContainerTarget.classList.toggle("pk-hidden", checked.length === 0)
@@ -159,7 +215,7 @@ export default class extends Controller {
159
215
  togglePopover(event) {
160
216
  event.preventDefault()
161
217
 
162
- if (this.triggerTarget.ariaExpanded === "true") {
218
+ if (this.isOpen()) {
163
219
  this.closePopover()
164
220
  } else {
165
221
  this.openPopover(event)
@@ -168,13 +224,13 @@ export default class extends Controller {
168
224
 
169
225
  openPopover(event) {
170
226
  if (event) event.preventDefault()
171
- if (this.triggerTarget.ariaExpanded === "true") return
227
+ if (this.isOpen()) return
172
228
 
173
- this.updatePopoverWidth()
174
- this.triggerTarget.ariaExpanded = "true"
229
+ this.setExpanded(true)
175
230
  this.selectedItemIndex = null
176
231
  this.itemTargets.forEach(item => item.ariaCurrent = "false")
177
- this.popoverTarget.classList.remove("pk-hidden")
232
+ this.clearActiveDescendant()
233
+ this.popoverTarget.showPopover()
178
234
 
179
235
  const field = this.filterField()
180
236
  if (field) {
@@ -186,13 +242,16 @@ export default class extends Controller {
186
242
  }
187
243
 
188
244
  closePopover() {
189
- this.triggerTarget.ariaExpanded = "false"
190
- this.popoverTarget.classList.add("pk-hidden")
245
+ this.setExpanded(false)
246
+ // aria-activedescendant holds an element id; on close it must be removed,
247
+ // not left pointing at a hidden option.
248
+ this.clearActiveDescendant()
249
+ if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
191
250
  this.updateTriggerContent() // reflect the selection into an input trigger
192
251
  }
193
252
 
194
253
  onClickOutside(event) {
195
- if (this.triggerTarget.ariaExpanded !== "true") return
254
+ if (!this.isOpen()) return
196
255
  if (this.element.contains(event.target)) return
197
256
 
198
257
  this.closePopover()
@@ -227,6 +286,7 @@ export default class extends Controller {
227
286
  let resultCount = 0
228
287
 
229
288
  this.selectedItemIndex = null
289
+ this.clearActiveDescendant()
230
290
 
231
291
  this.inputTargets.forEach((input) => {
232
292
  const text = this.inputContent(input).toLowerCase()
@@ -243,6 +303,12 @@ export default class extends Controller {
243
303
  this.emptyStateTarget.classList.toggle("pk-hidden", resultCount !== 0)
244
304
  }
245
305
 
306
+ // Announce the filtered result count to screen readers.
307
+ if (this.hasLiveRegionTarget) {
308
+ this.liveRegionTarget.textContent =
309
+ resultCount === 0 ? "No results" : `${resultCount} result${resultCount === 1 ? "" : "s"}`
310
+ }
311
+
246
312
  // autoHighlight: keep the first visible option marked while typing so
247
313
  // Enter picks it immediately (their autoHighlight prop).
248
314
  if (this.autoHighlightValue && filterTerm && resultCount > 0) {
@@ -284,6 +350,9 @@ export default class extends Controller {
284
350
  visibleInputs.forEach((input, index) => {
285
351
  if (index == this.selectedItemIndex) {
286
352
  input.parentElement.ariaCurrent = "true"
353
+ // Focus stays in the filter field; the highlighted option is exposed
354
+ // via aria-activedescendant (option ids come from generateItemIds).
355
+ this.setActiveDescendant(input.parentElement.id)
287
356
  input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
288
357
  } else {
289
358
  input.parentElement.ariaCurrent = "false"
@@ -304,8 +373,4 @@ export default class extends Controller {
304
373
  this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length
305
374
  }
306
375
 
307
- updatePopoverWidth() {
308
- const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)
309
- this.popoverTarget.style.width = `${width}px`
310
- }
311
376
  }
@@ -9,15 +9,20 @@ module PhlexKit
9
9
  # popover's own bindings only fire for the button+search layout.
10
10
  # See combobox.rb.
11
11
  class ComboboxInputTrigger < BaseComponent
12
- def initialize(placeholder: "", disabled: false, invalid: false, **attrs)
12
+ def initialize(placeholder: "", disabled: false, invalid: false, list_id: nil, **attrs)
13
13
  @placeholder = placeholder
14
14
  @disabled = disabled
15
15
  @invalid = invalid
16
+ @list_id = list_id
16
17
  @attrs = attrs
17
18
  end
18
19
 
19
20
  def view_template
20
- wrapper_aria = { haspopup: "listbox", expanded: "false" }
21
+ # The ARIA combobox role sits on the field itself (the interactive
22
+ # element); the wrapper keeps aria-invalid only because the invalid
23
+ # styling targets it. Pass `list_id:` matching the ComboboxList id to
24
+ # wire aria-controls statically — otherwise the controller wires it.
25
+ wrapper_aria = {}
21
26
  wrapper_aria[:invalid] = "true" if @invalid
22
27
  div(**mix({
23
28
  class: "pk-combobox-input-trigger",
@@ -35,8 +40,12 @@ module PhlexKit
35
40
  ].join(" ")
36
41
  }
37
42
  }, @attrs)) do
43
+ field_aria = { haspopup: "listbox", expanded: "false", autocomplete: "list", controls: @list_id }
44
+ field_aria[:invalid] = "true" if @invalid
38
45
  input(
39
46
  type: :text,
47
+ role: "combobox",
48
+ aria: field_aria,
40
49
  placeholder: @placeholder,
41
50
  disabled: @disabled,
42
51
  autocomplete: "off",
@@ -2,7 +2,9 @@ module PhlexKit
2
2
  # One option row — a <label> wrapping a ComboboxCheckbox/ComboboxRadio, the
3
3
  # option text, and optionally a ComboboxItemIndicator. Being a label, clicking
4
4
  # anywhere toggles the input; the controller hides it (.pk-hidden) when
5
- # filtered out. See combobox.rb.
5
+ # filtered out, assigns each option an id (for the combobox element's
6
+ # aria-activedescendant), and keeps aria-selected in sync with the inner
7
+ # input's checked state. See combobox.rb.
6
8
  class ComboboxItem < BaseComponent
7
9
  def initialize(**attrs)
8
10
  @attrs = attrs
@@ -12,6 +14,7 @@ module PhlexKit
12
14
  label(**mix({
13
15
  class: "pk-combobox-item",
14
16
  role: "option",
17
+ aria: { selected: "false" },
15
18
  data: { phlex_kit__combobox_target: "item" }
16
19
  }, @attrs), &)
17
20
  end
@@ -1,12 +1,19 @@
1
1
  module PhlexKit
2
- # Scrollable option list in a PhlexKit::ComboboxPopover. See combobox.rb.
2
+ # Scrollable option list in a PhlexKit::ComboboxPopover. Carries the listbox
3
+ # role and an id the controller points the combobox element's aria-controls
4
+ # at (and derives option ids from). See combobox.rb.
3
5
  class ComboboxList < BaseComponent
4
6
  def initialize(**attrs)
5
7
  @attrs = attrs
6
8
  end
7
9
 
8
10
  def view_template(&)
9
- div(**mix({ class: "pk-combobox-list", role: "listbox" }, @attrs), &)
11
+ div(**mix({
12
+ id: "pk-combobox-list-#{SecureRandom.hex(4)}",
13
+ class: "pk-combobox-list",
14
+ role: "listbox",
15
+ data: { phlex_kit__combobox_target: "list" }
16
+ }, @attrs), &)
10
17
  end
11
18
  end
12
19
  end
@@ -1,8 +1,9 @@
1
1
  module PhlexKit
2
- # The combobox dropdown panel. Upstream uses the native Popover API +
3
- # @floating-ui; here it's a CSS-positioned panel toggled with .pk-hidden (the
4
- # keyboard nav actions ride on it, since focus sits in the search input
5
- # inside). See combobox.rb.
2
+ # The combobox dropdown panel like upstream, a native [popover] (manual:
3
+ # the controller owns open/close and click-outside), anchor-positioned to
4
+ # the combobox with viewport-edge flipping (combobox.css). The keyboard nav
5
+ # actions ride on it, since focus sits in the search input inside. See
6
+ # combobox.rb.
6
7
  class ComboboxPopover < BaseComponent
7
8
  def initialize(**attrs)
8
9
  @attrs = attrs
@@ -10,15 +11,15 @@ module PhlexKit
10
11
 
11
12
  def view_template(&)
12
13
  div(**mix({
13
- class: "pk-combobox-popover pk-hidden",
14
+ class: "pk-combobox-popover",
15
+ popover: "manual",
14
16
  data: {
15
17
  phlex_kit__combobox_target: "popover",
16
18
  action: [
17
19
  "keydown.down->phlex-kit--combobox#keyDownPressed",
18
20
  "keydown.up->phlex-kit--combobox#keyUpPressed",
19
21
  "keydown.enter->phlex-kit--combobox#keyEnterPressed",
20
- "keydown.esc->phlex-kit--combobox#closePopover:prevent",
21
- "resize@window->phlex-kit--combobox#updatePopoverWidth"
22
+ "keydown.esc->phlex-kit--combobox#closePopover:prevent"
22
23
  ].join(" ")
23
24
  }
24
25
  }, @attrs), &)
@@ -10,6 +10,9 @@ module PhlexKit
10
10
  def view_template
11
11
  input(**mix({
12
12
  type: :radio,
13
+ # The option label is the interactive surface (ARIA option); the real
14
+ # input must not be a second tab stop inside it.
15
+ tabindex: "-1",
13
16
  class: "pk-combobox-radio",
14
17
  data: {
15
18
  phlex_kit__combobox_target: "input",
@@ -1,10 +1,14 @@
1
1
  module PhlexKit
2
2
  # Filter input at the top of a PhlexKit::ComboboxPopover (magnifier icon +
3
3
  # borderless search field). Filtering happens client-side against each item's
4
- # text (or data-text). See combobox.rb.
4
+ # text (or data-text). The field carries the ARIA combobox role (keyboard nav
5
+ # highlights options via aria-activedescendant while focus stays here); pass
6
+ # `list_id:` matching the ComboboxList id to wire aria-controls statically —
7
+ # otherwise the controller wires it on connect. See combobox.rb.
5
8
  class ComboboxSearchInput < BaseComponent
6
- def initialize(placeholder:, **attrs)
9
+ def initialize(placeholder:, list_id: nil, **attrs)
7
10
  @placeholder = placeholder
11
+ @list_id = list_id
8
12
  @attrs = attrs
9
13
  end
10
14
 
@@ -13,7 +17,8 @@ module PhlexKit
13
17
  icon
14
18
  input(**mix({
15
19
  type: :search,
16
- role: "searchbox",
20
+ role: "combobox",
21
+ aria: { autocomplete: "list", expanded: "false", controls: @list_id },
17
22
  autocorrect: "off",
18
23
  autocomplete: "off",
19
24
  spellcheck: "false",
@@ -11,8 +11,23 @@ module PhlexKit
11
11
  @attrs = attrs
12
12
  end
13
13
 
14
- def view_template(&)
15
- div(**mix({ class: "pk-command" }, @attrs), &)
14
+ def view_template
15
+ div(**mix({ class: "pk-command" }, @attrs)) do
16
+ live_region
17
+ yield
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # Screen-reader announcement of the filtered result count — the controller
24
+ # writes "N results" / "No results" into it from filter().
25
+ def live_region
26
+ div(
27
+ class: "pk-sr-only",
28
+ aria: { live: "polite" },
29
+ data: { phlex_kit__command_target: "liveRegion" }
30
+ )
16
31
  end
17
32
  end
18
33
  end
@@ -33,25 +33,81 @@ function fuzzyScore(query, value) {
33
33
  // nav, dismiss) is unchanged.
34
34
  // Connects to data-controller="phlex-kit--command"
35
35
  export default class extends Controller {
36
- static targets = ["input", "group", "item", "empty", "separator"];
36
+ static targets = ["input", "group", "item", "empty", "separator", "list", "liveRegion"];
37
37
 
38
38
  connect() {
39
39
  this.selectedIndex = -1;
40
+ // Remember what had focus before the palette grabbed it (for a cloned
41
+ // dialog this is the element focused when the overlay was inserted), so
42
+ // dismiss() can hand focus back instead of dropping it on <body>.
43
+ this.previouslyFocused = document.activeElement;
40
44
 
41
45
  if (!this.hasInputTarget) {
42
46
  return;
43
47
  }
44
48
 
49
+ this.generateItemIds();
45
50
  this.inputTarget.focus();
46
51
  this.searchIndex = this.buildSearchIndex();
47
52
  this.toggleVisibility(this.emptyTargets, false);
48
53
  }
49
54
 
55
+ // ARIA plumbing: every result gets an id derived from the listbox id so the
56
+ // input can point aria-activedescendant at the keyboard highlight, and
57
+ // aria-controls is wired to the listbox (mirrors select_controller.js).
58
+ generateItemIds() {
59
+ const list = this.hasListTarget ? this.listTarget : this.element;
60
+ if (!list.id) list.id = `pk-command-list-${Math.random().toString(36).slice(2, 10)}`;
61
+
62
+ this.itemTargets.forEach((item, index) => {
63
+ if (!item.id) item.id = `${list.id}-${index}`;
64
+ });
65
+
66
+ this.inputTarget.setAttribute("aria-controls", list.id);
67
+ }
68
+
50
69
  dismiss() {
51
70
  // allow scroll on body
52
71
  document.body.style.removeProperty("overflow");
72
+ const previous = this.previouslyFocused;
53
73
  // remove the element
54
74
  this.element.remove();
75
+ // restore focus to wherever it was before the palette opened
76
+ if (previous instanceof HTMLElement && document.contains(previous)) {
77
+ previous.focus();
78
+ }
79
+ }
80
+
81
+ // Focus trap for the cloned dialog overlay: Tab / Shift+Tab cycle among the
82
+ // overlay's own focusable elements instead of escaping to the page beneath.
83
+ trapFocus(e) {
84
+ if (e.key !== "Tab") return;
85
+
86
+ const focusables = Array.from(
87
+ this.element.querySelectorAll(
88
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
89
+ ),
90
+ ).filter((el) => !el.closest(".pk-hidden"));
91
+
92
+ if (focusables.length === 0) {
93
+ e.preventDefault();
94
+ return;
95
+ }
96
+
97
+ const first = focusables[0];
98
+ const last = focusables[focusables.length - 1];
99
+ const active = document.activeElement;
100
+
101
+ if (e.shiftKey && active === first) {
102
+ e.preventDefault();
103
+ last.focus();
104
+ } else if (!e.shiftKey && active === last) {
105
+ e.preventDefault();
106
+ first.focus();
107
+ } else if (!this.element.contains(active)) {
108
+ e.preventDefault();
109
+ first.focus();
110
+ }
55
111
  }
56
112
 
57
113
  focusInput() {
@@ -65,6 +121,7 @@ export default class extends Controller {
65
121
  const query = e.target.value.toLowerCase();
66
122
  if (query.length === 0) {
67
123
  this.resetVisibility();
124
+ this.announceResultCount(null);
68
125
  return;
69
126
  }
70
127
 
@@ -75,11 +132,24 @@ export default class extends Controller {
75
132
  this.toggleVisibility([result.item.element], true),
76
133
  );
77
134
 
135
+ this.announceResultCount(results.length);
78
136
  this.toggleVisibility(this.emptyTargets, results.length === 0);
79
137
  this.toggleVisibility(this.separatorTargets, false);
80
138
  this.updateGroupVisibility();
81
139
  }
82
140
 
141
+ // Announce the filtered result count to screen readers (null = no query).
142
+ announceResultCount(count) {
143
+ if (!this.hasLiveRegionTarget) return;
144
+
145
+ if (count === null) {
146
+ this.liveRegionTarget.textContent = "";
147
+ } else {
148
+ this.liveRegionTarget.textContent =
149
+ count === 0 ? "No results" : `${count} result${count === 1 ? "" : "s"}`;
150
+ }
151
+ }
152
+
83
153
  toggleVisibility(elements, isVisible) {
84
154
  elements.forEach((el) => el.classList.toggle("pk-hidden", !isVisible));
85
155
  }
@@ -120,8 +190,11 @@ export default class extends Controller {
120
190
  }
121
191
 
122
192
  handleKeydown(e) {
193
+ // Disabled items stay visible (dimmed via [data-disabled]) but are
194
+ // skipped by keyboard selection.
123
195
  const visibleItems = this.itemTargets.filter(
124
- (item) => !item.classList.contains("pk-hidden"),
196
+ (item) =>
197
+ !item.classList.contains("pk-hidden") && !item.dataset.disabled,
125
198
  );
126
199
  if (e.key === "ArrowDown") {
127
200
  e.preventDefault();
@@ -136,7 +209,9 @@ export default class extends Controller {
136
209
  }
137
210
 
138
211
  updateSelectedItem(visibleItems, direction) {
139
- if (this.selectedIndex >= 0) {
212
+ if (visibleItems.length === 0) return;
213
+
214
+ if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) {
140
215
  this.toggleAriaSelected(visibleItems[this.selectedIndex], false);
141
216
  }
142
217
 
@@ -150,6 +225,15 @@ export default class extends Controller {
150
225
  }
151
226
 
152
227
  this.toggleAriaSelected(visibleItems[this.selectedIndex], true);
228
+
229
+ // Focus stays in the input; the highlighted option is exposed via
230
+ // aria-activedescendant (result ids come from generateItemIds).
231
+ if (this.hasInputTarget) {
232
+ this.inputTarget.setAttribute(
233
+ "aria-activedescendant",
234
+ visibleItems[this.selectedIndex].id,
235
+ );
236
+ }
153
237
  }
154
238
 
155
239
  toggleAriaSelected(element, isSelected) {
@@ -159,5 +243,11 @@ export default class extends Controller {
159
243
  deselectAll() {
160
244
  this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false));
161
245
  this.selectedIndex = -1;
246
+
247
+ // aria-activedescendant holds an element id; with nothing highlighted it
248
+ // must be removed, not left pointing at a stale option.
249
+ if (this.hasInputTarget) {
250
+ this.inputTarget.removeAttribute("aria-activedescendant");
251
+ }
162
252
  }
163
253
  }
@@ -7,16 +7,28 @@ module PhlexKit
7
7
  class CommandDialogContent < BaseComponent
8
8
  SIZES = { xs: "xs", sm: "sm", md: nil, lg: "lg", xl: "xl", full: "full" }.freeze
9
9
 
10
- def initialize(size: :md, **attrs)
10
+ def initialize(size: :md, aria_label: "Command palette", **attrs)
11
11
  @size = size.to_sym
12
+ @aria_label = aria_label
12
13
  @attrs = attrs
13
14
  end
14
15
 
15
16
  def view_template(&block)
16
17
  template(data: { phlex_kit__command_dialog_target: "content" }) do
17
- div(data: { controller: "phlex-kit--command", phlex_kit__command_dialog_instance: true }) do
18
+ # The keydown action is the focus trap: Tab cycles within the cloned
19
+ # overlay instead of escaping to the page underneath.
20
+ div(data: {
21
+ controller: "phlex-kit--command",
22
+ phlex_kit__command_dialog_instance: true,
23
+ action: "keydown->phlex-kit--command#trapFocus"
24
+ }) do
18
25
  backdrop
19
- div(**mix({ class: panel_classes, data: { state: "open" } }, @attrs), &block)
26
+ div(**mix({
27
+ class: panel_classes,
28
+ role: "dialog",
29
+ aria: { modal: "true", label: @aria_label },
30
+ data: { state: "open" }
31
+ }, @attrs), &block)
20
32
  end
21
33
  end
22
34
  end
@@ -24,7 +36,7 @@ module PhlexKit
24
36
  private
25
37
 
26
38
  def panel_classes
27
- [ "pk-command-dialog", SIZES.fetch(@size) ].compact.join(" ")
39
+ [ "pk-command-dialog", fetch_option(SIZES, @size, :size) ].compact.join(" ")
28
40
  end
29
41
 
30
42
  def backdrop
@@ -3,9 +3,14 @@ module PhlexKit
3
3
  # type, arrows/enter drive selection, esc dismisses. Renders as shadcn's
4
4
  # current bordered pill inside the padded palette (input-wrapper >
5
5
  # input-group). See command.rb.
6
+ # Pass `list_id:` matching the CommandList id to wire aria-controls
7
+ # statically — otherwise the controller wires it on connect. aria-expanded
8
+ # stays "true" because the list is always visible (filtering hides items,
9
+ # never the listbox itself).
6
10
  class CommandInput < BaseComponent
7
- def initialize(placeholder: "Type a command or search...", **attrs)
11
+ def initialize(placeholder: "Type a command or search...", list_id: nil, **attrs)
8
12
  @placeholder = placeholder
13
+ @list_id = list_id
9
14
  @attrs = attrs
10
15
  end
11
16
 
@@ -23,7 +28,7 @@ module PhlexKit
23
28
  autofocus: true,
24
29
  role: "combobox",
25
30
  value: "",
26
- aria: { autocomplete: "list", expanded: "true" },
31
+ aria: { autocomplete: "list", expanded: "true", controls: @list_id },
27
32
  data: {
28
33
  phlex_kit__command_target: "input",
29
34
  action: [
@@ -1,12 +1,15 @@
1
1
  module PhlexKit
2
2
  # One palette result — an <a> (pass `href:`) filtered by `value:`, activated
3
3
  # by click or enter. aria-selected is toggled by keyboard navigation.
4
- # See command.rb.
4
+ # `disabled: true` renders the [data-disabled] styling + aria-disabled and
5
+ # the controller skips it during keyboard navigation (matches
6
+ # ContextMenuItem's pattern). See command.rb.
5
7
  class CommandItem < BaseComponent
6
- def initialize(value:, text: "", href: "#", **attrs)
8
+ def initialize(value:, text: "", href: "#", disabled: false, **attrs)
7
9
  @value = value
8
10
  @text = text
9
11
  @href = href
12
+ @disabled = disabled
10
13
  @attrs = attrs
11
14
  end
12
15
 
@@ -15,7 +18,13 @@ module PhlexKit
15
18
  class: "pk-command-item",
16
19
  href: @href,
17
20
  role: "option",
18
- data: { phlex_kit__command_target: "item", value: @value, text: @text }
21
+ aria: { disabled: (@disabled ? "true" : nil) },
22
+ data: {
23
+ phlex_kit__command_target: "item",
24
+ value: @value,
25
+ text: @text,
26
+ disabled: (@disabled ? "true" : nil)
27
+ }
19
28
  }, @attrs), &)
20
29
  end
21
30
  end
@@ -1,12 +1,19 @@
1
1
  module PhlexKit
2
- # Container for CommandGroups, with dividers between them. See command.rb.
2
+ # Container for CommandGroups, with dividers between them. Carries the
3
+ # listbox role and an id the controller points the input's aria-controls at
4
+ # (and derives result ids from, for aria-activedescendant). See command.rb.
3
5
  class CommandList < BaseComponent
4
6
  def initialize(**attrs)
5
7
  @attrs = attrs
6
8
  end
7
9
 
8
10
  def view_template(&)
9
- div(**mix({ class: "pk-command-list" }, @attrs), &)
11
+ div(**mix({
12
+ id: "pk-command-list-#{SecureRandom.hex(4)}",
13
+ class: "pk-command-list",
14
+ role: "listbox",
15
+ data: { phlex_kit__command_target: "list" }
16
+ }, @attrs), &)
10
17
  end
11
18
  end
12
19
  end