phlex_kit 0.9.1 → 0.11.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/_tokens.css +8 -0
  3. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  4. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  5. data/app/components/phlex_kit/alert/alert.rb +1 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +15 -0
  7. data/app/components/phlex_kit/attachment/attachment.css +6 -3
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
  10. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
  11. data/app/components/phlex_kit/avatar/avatar.css +2 -2
  12. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  13. data/app/components/phlex_kit/badge/badge.css +1 -1
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
  15. data/app/components/phlex_kit/bubble/bubble.css +1 -1
  16. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
  18. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  19. data/app/components/phlex_kit/button_group/button_group.css +16 -16
  20. data/app/components/phlex_kit/calendar/calendar.css +10 -2
  21. data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
  22. data/app/components/phlex_kit/carousel/carousel.css +4 -4
  23. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  24. data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
  25. data/app/components/phlex_kit/chart/chart.rb +8 -2
  26. data/app/components/phlex_kit/clipboard/clipboard_controller.js +26 -3
  27. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  28. data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
  29. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  30. data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
  31. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
  32. data/app/components/phlex_kit/combobox/combobox.css +3 -3
  33. data/app/components/phlex_kit/combobox/combobox.rb +3 -3
  34. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  35. data/app/components/phlex_kit/combobox/combobox_controller.js +32 -10
  36. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  37. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  38. data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
  39. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +7 -1
  40. data/app/components/phlex_kit/command/command.css +2 -2
  41. data/app/components/phlex_kit/command/command_controller.js +63 -35
  42. data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
  43. data/app/components/phlex_kit/command/command_group.rb +5 -2
  44. data/app/components/phlex_kit/command/command_item.rb +4 -0
  45. data/app/components/phlex_kit/command/command_list.rb +6 -2
  46. data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
  47. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  49. data/app/components/phlex_kit/data_table/data_table.css +0 -10
  50. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  51. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  52. data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
  53. data/app/components/phlex_kit/dialog/dialog.css +5 -3
  54. data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
  55. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  56. data/app/components/phlex_kit/dialog/dialog_controller.js +22 -2
  57. data/app/components/phlex_kit/drawer/drawer.css +4 -4
  58. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
  59. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
  60. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  62. data/app/components/phlex_kit/empty/empty.css +1 -1
  63. data/app/components/phlex_kit/field/field.css +2 -1
  64. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  65. data/app/components/phlex_kit/form_field/form_field_controller.js +29 -1
  66. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  67. data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
  68. data/app/components/phlex_kit/icon/icon.css +9 -0
  69. data/app/components/phlex_kit/icon/icon.rb +7 -1
  70. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  71. data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
  72. data/app/components/phlex_kit/marker/marker.rb +15 -2
  73. data/app/components/phlex_kit/masked_input/masked_input_controller.js +35 -15
  74. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  75. data/app/components/phlex_kit/menubar/menubar_controller.js +28 -1
  76. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
  77. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
  78. data/app/components/phlex_kit/message/message.rb +5 -0
  79. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +12 -5
  80. data/app/components/phlex_kit/native_select/native_select.css +4 -2
  81. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +7 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +11 -2
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +11 -2
  86. data/app/components/phlex_kit/popover/popover_controller.js +31 -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 +52 -5
  90. data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
  91. data/app/components/phlex_kit/scroll_area/scroll_area.rb +23 -4
  92. data/app/components/phlex_kit/select/select.css +5 -3
  93. data/app/components/phlex_kit/select/select_content.rb +9 -3
  94. data/app/components/phlex_kit/select/select_controller.js +59 -6
  95. data/app/components/phlex_kit/select/select_item.rb +5 -1
  96. data/app/components/phlex_kit/separator/separator.rb +12 -2
  97. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  98. data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
  99. data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
  100. data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
  101. data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
  102. data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
  103. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  104. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  105. data/app/components/phlex_kit/slider/slider.css +15 -0
  106. data/app/components/phlex_kit/switch/switch.css +5 -0
  107. data/app/components/phlex_kit/table/table.css +2 -1
  108. data/app/components/phlex_kit/table/table.rb +5 -2
  109. data/app/components/phlex_kit/tabs/tabs.css +2 -2
  110. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  111. data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
  112. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  113. data/app/components/phlex_kit/tabs/tabs_trigger.rb +15 -3
  114. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  115. data/app/components/phlex_kit/toast/toast.css +3 -2
  116. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  117. data/app/components/phlex_kit/toast/toast_controller.js +13 -5
  118. data/app/components/phlex_kit/toast/toast_region.rb +19 -9
  119. data/app/components/phlex_kit/toast/toaster_controller.js +34 -12
  120. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
  121. data/app/components/phlex_kit/toggle_group/toggle_group.rb +7 -2
  122. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +13 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
  124. data/app/components/phlex_kit/tooltip/tooltip.css +7 -3
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  126. data/app/components/phlex_kit/typography/heading.rb +13 -5
  127. data/app/components/phlex_kit/typography/list.rb +22 -0
  128. data/app/components/phlex_kit/typography/text.rb +7 -1
  129. data/lib/phlex_kit/base_component.rb +13 -0
  130. data/lib/phlex_kit/version.rb +1 -1
  131. metadata +3 -1
@@ -12,7 +12,13 @@ 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
+ base = { class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }
16
+ # A region landmark without a name is an axe violation — default one
17
+ # from the syntax; a caller aria: { label: } replaces it (never fused).
18
+ unless aria_labelled?
19
+ base[:aria] = { label: @syntax ? "#{@syntax} code" : "Code" }
20
+ end
21
+ div(**mix(base, @attrs)) do
16
22
  pre do
17
23
  if block
18
24
  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
@@ -6,10 +6,19 @@ export default class extends Controller {
6
6
  static values = { open: { type: Boolean, default: false } }
7
7
 
8
8
  connect() {
9
- // The trigger renders aria-expanded from its own `open:` kwarg but can't
10
- // know the content's id at render time wire aria-controls here.
11
- if (this.hasTriggerTarget && this.hasContentTarget && this.contentTarget.id) {
12
- this.triggerTarget.setAttribute("aria-controls", this.contentTarget.id)
9
+ // aria-expanded must live on the element AT reads state from — the
10
+ // focused control, not the plain wrapper div. The trigger wrapper renders
11
+ // aria-expanded server-side (it can't reach into its block), so resolve
12
+ // the nested focusable control here, relocate the state onto it, and keep
13
+ // toggling there. Wrapper-only triggers keep the wrapper as carrier.
14
+ if (this.hasTriggerTarget) {
15
+ this.control = this.triggerTarget.querySelector("button, [role='button'], a[href], input") || this.triggerTarget
16
+ if (this.control !== this.triggerTarget) this.triggerTarget.removeAttribute("aria-expanded")
17
+ // The trigger can't know the content's id at render time — wire
18
+ // aria-controls here.
19
+ if (this.hasContentTarget && this.contentTarget.id) {
20
+ this.control.setAttribute("aria-controls", this.contentTarget.id)
21
+ }
13
22
  }
14
23
  this.openValue ? this.open() : this.close()
15
24
  }
@@ -18,13 +27,13 @@ export default class extends Controller {
18
27
 
19
28
  open() {
20
29
  if (this.hasContentTarget) { this.contentTarget.classList.remove("pk-hidden"); this.openValue = true }
21
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true")
30
+ if (this.control) this.control.setAttribute("aria-expanded", "true")
22
31
  this.element.dataset.state = "open";
23
32
  }
24
33
 
25
34
  close() {
26
35
  if (this.hasContentTarget) { this.contentTarget.classList.add("pk-hidden"); this.openValue = false }
27
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false")
36
+ if (this.control) this.control.setAttribute("aria-expanded", "false")
28
37
  this.element.dataset.state = "closed";
29
38
  }
30
39
  }
@@ -4,8 +4,11 @@ module PhlexKit
4
4
  # control (a <button>, e.g. PhlexKit::Button) inside it for keyboard
5
5
  # operability and semantics, the same composition pattern as
6
6
  # AttachmentTrigger. Pass `open:` matching the parent Collapsible's so
7
- # aria-expanded is correct before JS; the controller keeps it in sync and
8
- # wires aria-controls to the content's id in connect().
7
+ # aria-expanded is correct before JS. The wrapper carries the server-rendered
8
+ # aria-expanded only because the component can't reach into its block; on
9
+ # connect the controller relocates it (plus aria-controls) onto the nested
10
+ # focusable control — AT reads state from the focused element, not the
11
+ # wrapper — falling back to the wrapper when no control is nested.
9
12
  class CollapsibleTrigger < BaseComponent
10
13
  def initialize(open: false, **attrs)
11
14
  @open = open
@@ -152,7 +152,7 @@
152
152
  margin: 0;
153
153
  accent-color: var(--pk-brand);
154
154
  }
155
- .pk-combobox-checkbox { border-radius: .125rem; }
155
+ .pk-combobox-checkbox { border-radius: calc(var(--pk-radius) - 8px); }
156
156
  .pk-combobox-checkbox:disabled, .pk-combobox-radio:disabled { cursor: not-allowed; opacity: .5; }
157
157
  .pk-combobox-checkbox:focus-visible, .pk-combobox-radio:focus-visible {
158
158
  outline: none;
@@ -296,7 +296,7 @@
296
296
  width: .875rem;
297
297
  height: .875rem;
298
298
  border: 0;
299
- border-radius: .125rem;
299
+ border-radius: calc(var(--pk-radius) - 8px);
300
300
  padding: 0;
301
301
  background: transparent;
302
302
  color: var(--pk-muted);
@@ -318,7 +318,7 @@
318
318
  flex: none;
319
319
  margin-inline-start: auto;
320
320
  border: 0;
321
- border-radius: .125rem;
321
+ border-radius: calc(var(--pk-radius) - 8px);
322
322
  padding: 0;
323
323
  background: transparent;
324
324
  color: var(--pk-muted);
@@ -5,9 +5,9 @@ module PhlexKit
5
5
  # toggle-all live in the phlex-kit--combobox controller. Like PhlexKit::Select, the
6
6
  # @floating-ui/dom + native-popover positioning is replaced with CSS (panel
7
7
  # anchored below the trigger, click-outside via window action). Compose
8
- # Combobox(term:) > trigger + Popover(SearchInput + List(ListGroup >
9
- # Item(Checkbox|Radio + text + ItemIndicator)) + EmptyState [+
10
- # ToggleAllCheckbox]). Three trigger flavours: ComboboxTrigger (button +
8
+ # Combobox(term:) > trigger + Popover(SearchInput + List([Item(
9
+ # ToggleAllCheckbox) first +] ListGroup > Item(Checkbox|Radio + text +
10
+ # ItemIndicator) + EmptyState)). Three trigger flavours: ComboboxTrigger (button +
11
11
  # popover search), ComboboxInputTrigger (the field is the filter), and
12
12
  # ComboboxBadgeTrigger (multi-select chips + ComboboxClearButton) — the
13
13
  # latter two shipped unfinished in ruby_ui (no controller support) and are
@@ -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,11 @@ 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()
48
53
  this.generateItemIds()
49
54
  this.updateTriggerContent()
50
55
  }
@@ -174,6 +179,11 @@ export default class extends Controller {
174
179
  this.inputTargets.forEach((input) => {
175
180
  input.parentElement.setAttribute("aria-selected", input.checked ? "true" : "false")
176
181
  })
182
+ // The toggle-all row is an option too (first row inside the listbox) —
183
+ // keep its aria-selected in step with its checkbox.
184
+ if (this.hasToggleAllTarget && this.toggleAllTarget.parentElement.getAttribute("role") === "option") {
185
+ this.toggleAllTarget.parentElement.setAttribute("aria-selected", this.toggleAllTarget.checked ? "true" : "false")
186
+ }
177
187
  }
178
188
 
179
189
  renderBadges(checked) {
@@ -212,6 +222,8 @@ export default class extends Controller {
212
222
  }
213
223
 
214
224
  handleBadgeInputBackspace(e) {
225
+ // Bound as a bare keydown: "backspace" is not a Stimulus key filter.
226
+ if (e.key !== "Backspace") return
215
227
  if (this.badgeInputTarget.value !== "") return
216
228
 
217
229
  const checked = this.checkedInputs()
@@ -273,7 +285,14 @@ export default class extends Controller {
273
285
  }
274
286
  }
275
287
 
276
- closePopover() {
288
+ closePopover(e) {
289
+ // Escape must keep its browser default (e.g. cancelling an enclosing
290
+ // <dialog>) when the popover is already closed — only swallow the key
291
+ // when this close actually consumes it.
292
+ if (e?.type === "keydown") {
293
+ if (!this.isOpen()) return
294
+ e.preventDefault()
295
+ }
277
296
  // Hiding the popover while focus is inside it (search input, option) drops
278
297
  // focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
279
298
  // only when focus would actually be orphaned — a click elsewhere on the
@@ -284,7 +303,7 @@ export default class extends Controller {
284
303
  // aria-activedescendant holds an element id; on close it must be removed,
285
304
  // not left pointing at a hidden option.
286
305
  this.clearActiveDescendant()
287
- if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
306
+ if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
288
307
  this.updateTriggerContent() // reflect the selection into an input trigger
289
308
 
290
309
  if (focusWasInside) {
@@ -398,20 +417,23 @@ export default class extends Controller {
398
417
  }
399
418
 
400
419
  focusSelectedInput() {
401
- const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("pk-hidden"))
402
- if (visibleInputs.length === 0) return
420
+ // Walk the option rows themselves, not the inner selection inputs: the
421
+ // toggle-all row is an option without an "input" target and must be
422
+ // arrow-reachable like any other option.
423
+ const visibleItems = this.itemTargets.filter(item => !item.classList.contains("pk-hidden"))
424
+ if (visibleItems.length === 0) return
403
425
 
404
- this.wrapSelectedInputIndex(visibleInputs.length)
426
+ this.wrapSelectedInputIndex(visibleItems.length)
405
427
 
406
- visibleInputs.forEach((input, index) => {
428
+ visibleItems.forEach((item, index) => {
407
429
  if (index == this.selectedItemIndex) {
408
- input.parentElement.ariaCurrent = "true"
430
+ item.ariaCurrent = "true"
409
431
  // Focus stays in the filter field; the highlighted option is exposed
410
432
  // via aria-activedescendant (option ids come from generateItemIds).
411
- this.setActiveDescendant(input.parentElement.id)
412
- input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
433
+ this.setActiveDescendant(item.id)
434
+ item.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
413
435
  } else {
414
- input.parentElement.ariaCurrent = "false"
436
+ item.ariaCurrent = "false"
415
437
  }
416
438
  })
417
439
  }
@@ -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" }
@@ -26,7 +26,9 @@ module PhlexKit
26
26
  class: "pk-combobox-search-input",
27
27
  data: {
28
28
  phlex_kit__combobox_target: "searchInput",
29
- action: "keyup->phlex-kit--combobox#filterItems search->phlex-kit--combobox#filterItems"
29
+ # `input` covers context-menu paste and IME composition, which
30
+ # fire no keyup/search (mirrors ComboboxInputTrigger).
31
+ action: "keyup->phlex-kit--combobox#filterItems input->phlex-kit--combobox#filterItems search->phlex-kit--combobox#filterItems"
30
32
  }
31
33
  }, @attrs))
32
34
  end
@@ -1,6 +1,8 @@
1
1
  module PhlexKit
2
2
  # "Select all" checkbox for a multi-select combobox — wrap it in a
3
- # PhlexKit::ComboboxItem above the list. Hidden while a filter term is active.
3
+ # PhlexKit::ComboboxItem as the FIRST row inside the ComboboxList (the item
4
+ # carries role="option", which is only valid inside the listbox, and arrow-key
5
+ # nav walks the list's rows). Hidden while a filter term is active.
4
6
  # See combobox.rb.
5
7
  class ComboboxToggleAllCheckbox < BaseComponent
6
8
  def initialize(**attrs)
@@ -11,6 +13,10 @@ module PhlexKit
11
13
  input(**mix({
12
14
  type: :checkbox,
13
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",
14
20
  data: {
15
21
  phlex_kit__combobox_target: "toggleAll",
16
22
  action: "change->phlex-kit--combobox#toggleAllItems"
@@ -8,7 +8,7 @@
8
8
  .pk-command {
9
9
  background: var(--pk-surface);
10
10
  color: var(--pk-text);
11
- border-radius: .75rem;
11
+ border-radius: calc(var(--pk-radius) + 2px);
12
12
  padding: .25rem;
13
13
  }
14
14
  .pk-command-dialog-trigger { display: inline-block; }
@@ -34,7 +34,7 @@
34
34
  width: 100%;
35
35
  max-width: 32rem;
36
36
  border: 1px solid var(--pk-border);
37
- border-radius: .75rem;
37
+ border-radius: calc(var(--pk-radius) + 2px);
38
38
  background: var(--pk-surface);
39
39
  color: var(--pk-text);
40
40
  box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
@@ -35,8 +35,16 @@ function fuzzyScore(query, value) {
35
35
  export default class extends Controller {
36
36
  static targets = ["input", "group", "item", "empty", "separator", "list", "liveRegion"];
37
37
 
38
- connect() {
38
+ initialize() {
39
+ // Stimulus fires [target]Connected before connect(): anything
40
+ // itemTargetConnected uses must be initialized here (mirrors
41
+ // combobox_controller.js).
42
+ this.itemIdCounter = 0;
43
+ this.searchEntries = [];
39
44
  this.selectedIndex = -1;
45
+ }
46
+
47
+ connect() {
40
48
  // Remember what had focus before the palette grabbed it (for a cloned
41
49
  // dialog this is the element focused when the overlay was inserted), so
42
50
  // dismiss() can hand focus back instead of dropping it on <body>.
@@ -50,7 +58,6 @@ export default class extends Controller {
50
58
  // Only the cloned dialog overlay grabs focus on connect — an inline
51
59
  // palette connecting at page load must not steal it.
52
60
  if (this.isDialogClone()) this.inputTarget.focus();
53
- this.searchIndex = this.buildSearchIndex();
54
61
  this.toggleVisibility(this.emptyTargets, false);
55
62
  }
56
63
 
@@ -60,23 +67,49 @@ export default class extends Controller {
60
67
  return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
61
68
  }
62
69
 
63
- // ARIA plumbing: every result gets an id derived from the listbox id so the
64
- // input can point aria-activedescendant at the keyboard highlight, and
65
- // aria-controls is wired to the listbox (mirrors select_controller.js).
70
+ // ARIA plumbing: aria-controls is wired to the listbox (mirrors
71
+ // select_controller.js); the per-result ids come from itemTargetConnected.
66
72
  generateItemIds() {
73
+ this.inputTarget.setAttribute("aria-controls", this.listId());
74
+ }
75
+
76
+ listId() {
67
77
  const list = this.hasListTarget ? this.listTarget : this.element;
68
78
  if (!list.id) list.id = `pk-command-list-${Math.random().toString(36).slice(2, 10)}`;
79
+ return list.id;
80
+ }
69
81
 
70
- this.itemTargets.forEach((item, index) => {
71
- if (!item.id) item.id = `${list.id}-${index}`;
82
+ // Every result gets an id derived from the listbox id so the input can
83
+ // point aria-activedescendant at the keyboard highlight, and a search-index
84
+ // entry so filtering can find it. A target callback (not a connect() loop)
85
+ // so items rendered in by the host after load are id'd and findable too.
86
+ // Counter-based, so a removed item's id is never reissued to a later
87
+ // arrival (mirrors combobox_controller.js).
88
+ itemTargetConnected(item) {
89
+ if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
90
+ this.searchEntries.push({
91
+ value: (item.dataset.value || "").toLowerCase(),
92
+ text: (item.dataset.text || "").toLowerCase(),
93
+ element: item,
72
94
  });
95
+ }
73
96
 
74
- this.inputTarget.setAttribute("aria-controls", list.id);
97
+ itemTargetDisconnected(item) {
98
+ this.searchEntries = this.searchEntries.filter(
99
+ (entry) => entry.element !== item,
100
+ );
101
+ // The highlight index is positional — after a removal it points at the
102
+ // wrong (or a missing) row, so Enter would activate the wrong item.
103
+ this.selectedIndex = -1;
75
104
  }
76
105
 
77
106
  dismiss() {
78
107
  // Cloned dialog overlay: tear the clone down and hand focus back.
79
108
  if (this.isDialogClone()) {
109
+ // Dispatched BEFORE teardown: the command-dialog controller listens on
110
+ // the overlay and un-inerts the page here — focus() below would be a
111
+ // silent no-op while previouslyFocused sits under an inert ancestor.
112
+ this.dispatch("dismiss");
80
113
  // allow scroll on body
81
114
  document.body.style.removeProperty("overflow");
82
115
  const previous = this.previouslyFocused;
@@ -157,7 +190,7 @@ export default class extends Controller {
157
190
 
158
191
  this.toggleVisibility(this.itemTargets, false);
159
192
 
160
- const results = this.searchIndex.search(query);
193
+ const results = this.searchItems(query);
161
194
  results.forEach((result) =>
162
195
  this.toggleVisibility([result.item.element], true),
163
196
  );
@@ -201,31 +234,24 @@ export default class extends Controller {
201
234
  this.toggleVisibility(this.emptyTargets, false);
202
235
  }
203
236
 
204
- // Upstream builds a Fuse index here; this keeps the same search() shape —
205
- // [{ item }] sorted best-match-first — using the fuzzy scorer above.
206
- // Both data-value and data-text are searchable (an item's `text:` was
207
- // rendered but never indexed); the better of the two scores wins.
208
- buildSearchIndex() {
209
- const items = this.itemTargets.map((el) => ({
210
- value: (el.dataset.value || "").toLowerCase(),
211
- text: (el.dataset.text || "").toLowerCase(),
212
- element: el,
213
- }));
214
- return {
215
- search(query) {
216
- const q = query.toLowerCase();
217
- return items
218
- .map((item) => {
219
- const scores = [item.value, item.text]
220
- .filter(Boolean)
221
- .map((field) => fuzzyScore(q, field))
222
- .filter((score) => score !== null);
223
- return { item, score: scores.length ? Math.max(...scores) : null };
224
- })
225
- .filter((result) => result.score !== null)
226
- .sort((a, b) => b.score - a.score);
227
- },
228
- };
237
+ // Upstream builds a Fuse index here; this keeps the same result shape —
238
+ // [{ item }] sorted best-match-first — using the fuzzy scorer above over
239
+ // the live searchEntries (maintained by itemTargetConnected/Disconnected,
240
+ // so host-rendered items are searchable). Both data-value and data-text are
241
+ // searchable (an item's `text:` was rendered but never indexed); the better
242
+ // of the two scores wins.
243
+ searchItems(query) {
244
+ const q = query.toLowerCase();
245
+ return this.searchEntries
246
+ .map((item) => {
247
+ const scores = [item.value, item.text]
248
+ .filter(Boolean)
249
+ .map((field) => fuzzyScore(q, field))
250
+ .filter((score) => score !== null);
251
+ return { item, score: scores.length ? Math.max(...scores) : null };
252
+ })
253
+ .filter((result) => result.score !== null)
254
+ .sort((a, b) => b.score - a.score);
229
255
  }
230
256
 
231
257
  handleKeydown(e) {
@@ -243,7 +269,9 @@ export default class extends Controller {
243
269
  this.updateSelectedItem(visibleItems, -1);
244
270
  } else if (e.key === "Enter" && this.selectedIndex !== -1) {
245
271
  e.preventDefault();
246
- visibleItems[this.selectedIndex].click();
272
+ // Optional-chained: a stale selectedIndex after dynamic item removal
273
+ // (turbo stream re-render) must not throw.
274
+ visibleItems[this.selectedIndex]?.click();
247
275
  }
248
276
  }
249
277
 
@@ -6,14 +6,27 @@ import { Controller } from "@hotwired/stimulus";
6
6
  // upstream's document-scoped outlet (which let one dialog adopt another's
7
7
  // open palette), each instance tracks its own clone — sheet_controller's
8
8
  // pattern — and clears it on turbo:before-cache so a cached page never
9
- // restores a stale overlay + scroll lock.
9
+ // restores a stale overlay + scroll lock. While the overlay is up, the page
10
+ // behind it is inert-ed per element with prior state saved (the same modal
11
+ // contract as alert_dialog/sheet/sidebar — the helper is duplicated per
12
+ // controller by design); restore runs on dismiss (via the overlay's
13
+ // phlex-kit--command:dismiss event), disconnect, AND turbo:before-cache.
10
14
  // Connects to data-controller="phlex-kit--command-dialog"
11
15
  export default class extends Controller {
12
16
  static targets = ["content"];
13
17
 
14
18
  initialize() {
15
19
  this.overlay = null;
20
+ this.inerted = null;
21
+ // The command controller's dismiss() dispatches this on the overlay
22
+ // BEFORE it removes the clone / restores focus — un-inert first so the
23
+ // opener is focusable again.
24
+ this.restoreInert = () => {
25
+ for (const [el, wasInert] of this.inerted || []) el.inert = wasInert;
26
+ this.inerted = null;
27
+ };
16
28
  this.clearOverlay = () => {
29
+ this.restoreInert();
17
30
  if (this.overlay?.isConnected) {
18
31
  this.overlay.remove();
19
32
  document.body.style.removeProperty("overflow");
@@ -49,7 +62,23 @@ export default class extends Controller {
49
62
 
50
63
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
51
64
  this.overlay = document.body.lastElementChild;
65
+ this.overlay.addEventListener("phlex-kit--command:dismiss", this.restoreInert);
52
66
  // prevent scroll on body
53
67
  document.body.style.overflow = "hidden";
68
+ this.#inertOthers();
69
+ }
70
+
71
+ // Make everything behind the overlay inert (the clone is <body>'s last
72
+ // child, so its siblings are the whole page). Prior inert state is saved
73
+ // per element and restored by restoreInert. The command controller's Tab
74
+ // trap on the clone stays as belt and braces.
75
+ #inertOthers() {
76
+ this.inerted = [];
77
+ for (const el of document.body.children) {
78
+ if (el === this.overlay) continue;
79
+ if (["SCRIPT", "STYLE", "LINK", "TEMPLATE"].includes(el.tagName)) continue;
80
+ this.inerted.push([el, el.inert]);
81
+ el.inert = true;
82
+ }
54
83
  }
55
84
  }
@@ -5,6 +5,9 @@ module PhlexKit
5
5
  class CommandGroup < BaseComponent
6
6
  def initialize(title: nil, **attrs)
7
7
  @title = title
8
+ # Names the role="group" items wrapper via aria-labelledby → heading id
9
+ # (matches cmdk/shadcn; id pattern mirrors CommandList).
10
+ @heading_id = "pk-command-group-heading-#{SecureRandom.hex(4)}" if title
8
11
  @attrs = attrs
9
12
  end
10
13
 
@@ -22,11 +25,11 @@ module PhlexKit
22
25
  private
23
26
 
24
27
  def render_header
25
- div(group_heading: @title) { @title }
28
+ div(id: @heading_id, group_heading: @title) { @title }
26
29
  end
27
30
 
28
31
  def render_items(&)
29
- div(group_items: "", role: "group", &)
32
+ div(group_items: "", role: "group", aria_labelledby: @heading_id, &)
30
33
  end
31
34
  end
32
35
  end
@@ -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" }
@@ -23,12 +23,37 @@ 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()
26
36
  }
27
37
 
28
38
  disconnect() {
29
39
  document.removeEventListener("click", this.onDoc)
30
40
  document.removeEventListener("contextmenu", this.onDoc)
31
41
  document.removeEventListener("keydown", this.onKey)
42
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
43
+ this.subFrames.forEach((id) => cancelAnimationFrame(id))
44
+ this.subFrames.clear()
45
+ }
46
+
47
+ // Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
48
+ // sub trigger's aria-expanded — same wiring as dropdown_menu/menubar.
49
+ syncSub(e) {
50
+ const sub = e.currentTarget
51
+ const id = requestAnimationFrame(() => {
52
+ this.subFrames.delete(id)
53
+ sub.querySelector(":scope > [aria-haspopup]")
54
+ ?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
55
+ })
56
+ this.subFrames.add(id)
32
57
  }
33
58
 
34
59
  open(e) {
@@ -5,7 +5,9 @@ module PhlexKit
5
5
  class ContextMenuSub < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-context-menu-sub" }, @attrs), &)
8
+ # syncSub mirrors the CSS-driven reveal state onto the sub trigger's
9
+ # aria-expanded (same wiring as DropdownMenuSub / MenubarSub).
10
+ div(**mix({ class: "pk-context-menu-sub", data: { action: "mouseenter->phlex-kit--context-menu#syncSub mouseleave->phlex-kit--context-menu#syncSub focusin->phlex-kit--context-menu#syncSub focusout->phlex-kit--context-menu#syncSub" } }, @attrs), &)
9
11
  end
10
12
  end
11
13
  end