phlex_kit 0.12.0 → 0.14.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
  3. data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +1 -1
  4. data/app/components/phlex_kit/accordion/accordion_trigger.rb +1 -1
  5. data/app/components/phlex_kit/alert/alert.rb +5 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +2 -2
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
  10. data/app/components/phlex_kit/attachment/attachment.css +7 -1
  11. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
  12. data/app/components/phlex_kit/avatar/avatar.css +4 -0
  13. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
  16. data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  18. data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
  19. data/app/components/phlex_kit/calendar/calendar.css +5 -0
  20. data/app/components/phlex_kit/calendar/calendar_controller.js +38 -9
  21. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
  22. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
  23. data/app/components/phlex_kit/carousel/carousel.css +7 -0
  24. data/app/components/phlex_kit/carousel/carousel.rb +8 -4
  25. data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
  26. data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
  27. data/app/components/phlex_kit/chart/chart.rb +1 -1
  28. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  29. data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
  30. data/app/components/phlex_kit/checkbox/checkbox.rb +19 -1
  31. data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
  32. data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
  33. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
  34. data/app/components/phlex_kit/codeblock/codeblock.rb +2 -2
  35. data/app/components/phlex_kit/combobox/combobox.rb +1 -1
  36. data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
  37. data/app/components/phlex_kit/combobox/combobox_controller.js +60 -5
  38. data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
  39. data/app/components/phlex_kit/command/command_controller.js +69 -2
  40. data/app/components/phlex_kit/command/command_dialog_content.rb +15 -6
  41. data/app/components/phlex_kit/command/command_empty.rb +5 -3
  42. data/app/components/phlex_kit/command/command_separator.rb +1 -1
  43. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
  44. data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
  45. data/app/components/phlex_kit/context_menu/context_menu_controller.js +42 -1
  46. data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
  47. data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
  48. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
  49. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
  50. data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
  51. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
  52. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
  53. data/app/components/phlex_kit/data_table/data_table.css +4 -0
  54. data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
  55. data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
  56. data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
  57. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
  58. data/app/components/phlex_kit/date_picker/date_picker_controller.js +3 -5
  59. data/app/components/phlex_kit/drawer/drawer.css +4 -0
  60. data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  62. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
  63. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
  64. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -5
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
  69. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
  72. data/app/components/phlex_kit/empty/empty.rb +1 -0
  73. data/app/components/phlex_kit/field/field.rb +4 -1
  74. data/app/components/phlex_kit/field/field_error.rb +4 -1
  75. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
  76. data/app/components/phlex_kit/icon/icon.rb +6 -2
  77. data/app/components/phlex_kit/input_group/input_group.css +6 -3
  78. data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
  79. data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
  80. data/app/components/phlex_kit/item/item.css +9 -1
  81. data/app/components/phlex_kit/item/item_separator.rb +1 -1
  82. data/app/components/phlex_kit/label/label.css +6 -1
  83. data/app/components/phlex_kit/link/link.rb +10 -1
  84. data/app/components/phlex_kit/menubar/menubar.rb +8 -4
  85. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
  86. data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
  87. data/app/components/phlex_kit/menubar/menubar_controller.js +36 -0
  88. data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
  89. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
  90. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  91. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
  92. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
  93. data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
  94. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
  95. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
  96. data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
  97. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
  98. data/app/components/phlex_kit/pagination/pagination.css +4 -0
  99. data/app/components/phlex_kit/progress/progress.css +19 -0
  100. data/app/components/phlex_kit/progress/progress.rb +29 -13
  101. data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
  102. data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
  103. data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
  104. data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -1
  105. data/app/components/phlex_kit/select/select.rb +1 -1
  106. data/app/components/phlex_kit/select/select_content.rb +7 -5
  107. data/app/components/phlex_kit/select/select_controller.js +10 -0
  108. data/app/components/phlex_kit/select/select_separator.rb +1 -1
  109. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  110. data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
  111. data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
  115. data/app/components/phlex_kit/switch/switch.rb +5 -2
  116. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
  117. data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
  118. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
  119. data/app/components/phlex_kit/toast/toast_controller.js +52 -2
  120. data/app/components/phlex_kit/toast/toast_item.rb +2 -2
  121. data/app/components/phlex_kit/toast/toaster_controller.js +63 -7
  122. data/app/components/phlex_kit/toggle/toggle.rb +1 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
  124. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
  126. data/app/components/phlex_kit/typography/typography.css +5 -2
  127. data/lib/phlex_kit/base_component.rb +9 -0
  128. data/lib/phlex_kit/version.rb +1 -1
  129. metadata +1 -1
@@ -31,7 +31,7 @@ module PhlexKit
31
31
  # Canvas content is invisible to AT — announce it as an image with at
32
32
  # least a generic name; pass aria: { label: "…" } (or block fallback
33
33
  # content) to describe the actual data.
34
- base[:role] = "img" unless @attrs.key?(:role) || @attrs.key?("role")
34
+ base[:role] = "img" unless attr_set?(:role)
35
35
  base[:aria] = { label: "Chart" } unless aria_labelled?
36
36
  canvas(**mix(base, @attrs), &)
37
37
  end
@@ -29,7 +29,11 @@ export default class extends Controller {
29
29
  this.disconnectThemeObservers()
30
30
  this.chart?.destroy()
31
31
  if (!this.chart) {
32
- this.dispatch("disconnect", { detail: { canvas: this.element } })
32
+ // Dispatch on document, not this.element: disconnect() runs AFTER the
33
+ // canvas has left the DOM, so an event fired on it neither bubbles nor
34
+ // reaches document/window — delegated host listeners would never see it.
35
+ // The canvas rides the detail so the host can still match its instance.
36
+ this.dispatch("disconnect", { target: document, detail: { canvas: this.element } })
33
37
  }
34
38
  }
35
39
 
@@ -48,6 +48,22 @@
48
48
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
49
49
  }
50
50
  .pk-checkbox[aria-invalid="true"]:checked { border-color: var(--pk-brand); }
51
+ /* Invalid + keyboard focus: the always-on invalid ring above would otherwise
52
+ swallow the equal-specificity :focus-visible rule, leaving no visible focus
53
+ change. Keep the red border (invalid stays legible) but switch the ring to
54
+ the standard focus color — the theme-scoped arms outweigh the theme-scoped
55
+ invalid copies below. */
56
+ .pk-checkbox[aria-invalid="true"]:focus-visible,
57
+ :root[data-theme="light"] .pk-checkbox[aria-invalid="true"]:focus-visible {
58
+ border-color: var(--pk-red);
59
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
60
+ }
61
+ @media (prefers-color-scheme: light) {
62
+ :root[data-theme="system"] .pk-checkbox[aria-invalid="true"]:focus-visible {
63
+ border-color: var(--pk-red);
64
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
65
+ }
66
+ }
51
67
  .pk-checkbox:disabled { cursor: not-allowed; opacity: .5; }
52
68
  :root[data-theme="light"] .pk-checkbox:not(:checked):not(:indeterminate) { background: transparent; }
53
69
  :root[data-theme="light"] .pk-checkbox[aria-invalid="true"] {
@@ -8,11 +8,29 @@ module PhlexKit
8
8
  # `name:`/`value:`/`checked:`/`**on(...)` pass through via `mix`. Styled by
9
9
  # `.pk-checkbox` (checkbox.css).
10
10
  class Checkbox < BaseComponent
11
- def initialize(**attrs)
11
+ # include_hidden mirrors Rails' check_box (and PhlexKit::Switch): an
12
+ # unchecked box posts nothing, so a paired hidden field carries the
13
+ # unchecked value. Emitted only when a `name:` is present — and never for
14
+ # array-style names ("ids[]"), where an unchecked "0" would inject a
15
+ # bogus element into the collection param (use a single blank hidden for
16
+ # the whole collection instead, as Rails' collection helpers do).
17
+ def initialize(include_hidden: true, unchecked_value: "0", **attrs)
18
+ @include_hidden = include_hidden
19
+ @unchecked_value = unchecked_value
12
20
  @attrs = attrs
13
21
  end
14
22
 
15
23
  def view_template
24
+ if @include_hidden && @attrs[:name] && !@attrs[:name].to_s.end_with?("[]")
25
+ # Disabled in lockstep with the checkbox (Rails' check_box idiom) —
26
+ # a disabled checkbox must not still post its unchecked value.
27
+ # form: rides along too (Rails' check_box slices name/disabled/form
28
+ # onto the hidden field) — a form-attributed checkbox outside its
29
+ # <form> must post the unchecked value to the same form.
30
+ input(type: "hidden", name: @attrs[:name], value: @unchecked_value,
31
+ disabled: @attrs[:disabled] ? true : nil,
32
+ form: @attrs[:form] || @attrs["form"])
33
+ end
16
34
  input(**mix({
17
35
  type: :checkbox,
18
36
  class: "pk-checkbox",
@@ -13,6 +13,13 @@ module PhlexKit
13
13
  div(&block)
14
14
  render PhlexKit::ClipboardPopover.new(type: :success) { @success }
15
15
  render PhlexKit::ClipboardPopover.new(type: :error) { @error }
16
+ # A persistent (never display:none) sr-only live region: the visual
17
+ # popovers are shown by removing display:none, which re-inserts them
18
+ # into the a11y tree with pre-existing content — a change AT does not
19
+ # announce. This region stays in the tree; the controller writes the
20
+ # confirmation text into it on copy, and THAT mutation announces.
21
+ div(class: "pk-sr-only", role: "status", aria: { live: "polite" },
22
+ data: { phlex_kit__clipboard_target: "liveRegion" })
16
23
  end
17
24
  end
18
25
  end
@@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus"
4
4
  // flashes a success/error popover (CSS-positioned, no @floating-ui). Ported from ruby_ui.
5
5
  export default class extends Controller {
6
6
  // No "trigger" target: the click->#copy action alone wires the trigger.
7
- static targets = ["source", "successPopover", "errorPopover"]
7
+ static targets = ["source", "successPopover", "errorPopover", "liveRegion"]
8
8
 
9
9
  connect() {
10
10
  // Turbo snapshots at turbo:before-cache, BEFORE disconnect — hide the
@@ -49,6 +49,17 @@ export default class extends Controller {
49
49
  show(target) {
50
50
  this.hideAll()
51
51
  target.classList.remove("pk-hidden")
52
+ // Announce via the persistent live region (not the visual popover, which
53
+ // display:none keeps out of the a11y tree). Clear then re-assert on the
54
+ // next frame so an identical consecutive message still reads as a content
55
+ // change — aria-live announces changes, not the current state.
56
+ if (this.hasLiveRegionTarget) {
57
+ const message = target.textContent.trim()
58
+ this.liveRegionTarget.textContent = ""
59
+ requestAnimationFrame(() => {
60
+ if (this.hasLiveRegionTarget) this.liveRegionTarget.textContent = message
61
+ })
62
+ }
52
63
  clearTimeout(this.timer)
53
64
  this.timer = setTimeout(() => target.classList.add("pk-hidden"), 1500)
54
65
  }
@@ -6,9 +6,11 @@ module PhlexKit
6
6
  @attrs = attrs
7
7
  end
8
8
  def view_template(&block)
9
- # role="status": the shown/hidden confirmation is a live region, so
10
- # screen readers announce "Copied!" instead of a silent visual flash.
11
- div(class: "pk-clipboard-popover pk-hidden", role: "status", data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
9
+ # Visual-only now (aria-hidden): the announcement rides Clipboard's
10
+ # persistent sr-only role="status" live region toggling display:none
11
+ # on this element never announced, since AT ignores content that was
12
+ # already present when a region re-enters the tree.
13
+ div(class: "pk-clipboard-popover pk-hidden", aria: { hidden: "true" }, data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
12
14
  div(**mix({ class: "pk-clipboard-popover-inner" }, @attrs), &block)
13
15
  end
14
16
  end
@@ -15,8 +15,8 @@ module PhlexKit
15
15
  # Defaults only when the caller didn't supply their own — `mix` would
16
16
  # fuse tabindex="0 -1" / role="region article" instead of overriding.
17
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")
18
+ base[:tabindex] = "0" unless attr_set?(:tabindex)
19
+ base[:role] = "region" unless attr_set?(:role)
20
20
  # A region landmark without a name is an axe violation — default one
21
21
  # from the syntax; a caller aria: { label: } replaces it (never fused).
22
22
  unless aria_labelled?
@@ -28,7 +28,7 @@ module PhlexKit
28
28
  controller: "phlex-kit--combobox",
29
29
  phlex_kit__combobox_term_value: @term,
30
30
  phlex_kit__combobox_auto_highlight_value: (@auto_highlight ? "true" : nil),
31
- action: "turbo:morph@window->phlex-kit--combobox#updateTriggerContent click@window->phlex-kit--combobox#onClickOutside"
31
+ action: "turbo:morph@window->phlex-kit--combobox#updateTriggerContent click@window->phlex-kit--combobox#onClickOutside focusout->phlex-kit--combobox#onFocusout"
32
32
  }
33
33
  }, @attrs)) do
34
34
  live_region
@@ -9,15 +9,17 @@ module PhlexKit
9
9
  end
10
10
 
11
11
  def view_template
12
- button(**mix({
12
+ base = {
13
13
  type: :button,
14
14
  class: "pk-combobox-clear-button pk-hidden",
15
- aria: { label: "Clear selection" },
16
15
  data: {
17
16
  phlex_kit__combobox_target: "clearButton",
18
17
  action: "phlex-kit--combobox#clearAll"
19
18
  }
20
- }, @attrs)) do
19
+ }
20
+ # Default only when the caller didn't supply their own — `mix` fuses.
21
+ base[:aria] = { label: "Clear selection" } unless aria_labelled?
22
+ button(**mix(base, @attrs)) do
21
23
  render Icon.new(:x, size: 24)
22
24
  end
23
25
  end
@@ -42,6 +42,10 @@ export default class extends Controller {
42
42
  // itemTargetConnected uses must be initialized here.
43
43
  this.itemIdCounter = 0
44
44
  this.refocusing = false
45
+ // The query the list is currently filtered by. Initialized to "" so a
46
+ // caret/modifier keyup on an empty, unfiltered field reads as unchanged
47
+ // and filterItems skips re-running (which would wipe the highlight).
48
+ this._lastFilter = ""
45
49
  }
46
50
 
47
51
  connect() {
@@ -109,16 +113,20 @@ export default class extends Controller {
109
113
  }
110
114
 
111
115
  inputChanged(e) {
116
+ // Uncheck "select all" BEFORE updateTriggerContent: syncAriaSelected reads
117
+ // the toggle-all checkbox to set its row's aria-selected, so syncing first
118
+ // left the row marked aria-selected="true" while its checkbox showed
119
+ // unchecked.
120
+ if (this.hasToggleAllTarget && !e.target.checked) {
121
+ this.toggleAllTarget.checked = false
122
+ }
123
+
112
124
  this.updateTriggerContent()
113
125
 
114
126
  if (e.target.type == "radio") {
115
127
  this.closePopover()
116
128
  }
117
129
 
118
- if (this.hasToggleAllTarget && !e.target.checked) {
119
- this.toggleAllTarget.checked = false
120
- }
121
-
122
130
  // Selecting from a chip trigger consumes the query: clear it and reshow
123
131
  // the full list for the next pick.
124
132
  if (this.hasBadgeInputTarget && this.badgeInputTarget.value !== "") {
@@ -336,6 +344,15 @@ export default class extends Controller {
336
344
  if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
337
345
  this.updateTriggerContent() // reflect the selection into an input trigger
338
346
 
347
+ // Reset the filter so a REOPEN shows the full list, not the last query's
348
+ // leftover pk-hidden classes. The input trigger keeps showing the
349
+ // selection label (updateTriggerContent, above) but that's display-only —
350
+ // the active query is empty until the user types again; an in-popover or
351
+ // badge search field is cleared to match.
352
+ if (this.hasSearchInputTarget) this.searchInputTarget.value = ""
353
+ if (this.hasBadgeInputTarget) this.badgeInputTarget.value = ""
354
+ this.applyFilter("")
355
+
339
356
  if (focusWasInside) {
340
357
  const field = this.filterField()
341
358
  // Input/badge layouts: back to the filter field (it lives outside the
@@ -350,10 +367,26 @@ export default class extends Controller {
350
367
  }
351
368
  }
352
369
 
370
+ // APG (combobox): Tab out of an open combobox closes the popup —
371
+ // dropdown_menu's onFocusout pattern. relatedTarget is null when the
372
+ // window itself blurs; leave the popup alone then. closePopover's
373
+ // focus-restore is a no-op here (focus has already left the popover, so
374
+ // focusWasInside is false — the user's Tab target keeps focus).
375
+ onFocusout(event) {
376
+ if (!this.isOpen()) return
377
+ const to = event.relatedTarget
378
+ if (!to || this.element.contains(to)) return
379
+ this.closePopover()
380
+ }
381
+
353
382
  onClickOutside(event) {
354
383
  if (!this.isOpen()) return
355
384
  if (this.element.contains(event.target)) return
356
385
 
386
+ // Kit-wide dismiss contract: the combobox is POPOVER-family (shadcn
387
+ // builds it on Popover light-dismiss), so the dismissing outside click
388
+ // deliberately clicks through — no preventDefault. Only the MENU
389
+ // overlays (dropdown/context/menubar/select) swallow theirs.
357
390
  this.closePopover()
358
391
  }
359
392
 
@@ -376,7 +409,25 @@ export default class extends Controller {
376
409
  const field = (e.target instanceof HTMLInputElement) ? e.target : this.filterField()
377
410
  if (!field) return
378
411
 
379
- this.applyFilter(field.value.toLowerCase())
412
+ const term = field.value.toLowerCase()
413
+ // Only re-filter when the query text actually changed. filterItems runs on
414
+ // every keyup, so caret/modifier keys the exclusion list above misses
415
+ // (ArrowLeft/Right/Home/End, releasing Shift) would otherwise re-run
416
+ // applyFilter on the unchanged query and wipe the keyboard highlight
417
+ // (selectedItemIndex, aria-current, aria-activedescendant).
418
+ if (term === this._lastFilter) return
419
+
420
+ // Typing into a CLOSED input/badge trigger must reveal the results — the
421
+ // filtering is otherwise invisible (only click/focusin/Arrow keys opened
422
+ // it). Open without openPopover()'s field.select(), which would select the
423
+ // just-typed text so the next keystroke replaced it. The button layout's
424
+ // search field lives inside the popover, so it can't be typed while closed.
425
+ if (!this.isOpen() && term && (this.hasInputTriggerTarget || this.hasBadgeInputTarget)) {
426
+ this.setExpanded(true)
427
+ this.popoverTarget.showPopover()
428
+ }
429
+
430
+ this.applyFilter(term)
380
431
  }
381
432
 
382
433
  // Strips combining diacritical marks after an NFD decomposition, so "é"
@@ -386,6 +437,10 @@ export default class extends Controller {
386
437
  }
387
438
 
388
439
  applyFilter(filterTerm) {
440
+ // The query the list is currently filtered by — filterItems reads this to
441
+ // skip re-filtering (and re-wiping the highlight) on caret/modifier keys.
442
+ this._lastFilter = filterTerm
443
+
389
444
  if (this.hasToggleAllTarget) {
390
445
  if (filterTerm) this.toggleAllTarget.parentElement.classList.add("pk-hidden")
391
446
  else this.toggleAllTarget.parentElement.classList.remove("pk-hidden")
@@ -11,12 +11,15 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&)
14
- label(**mix({
14
+ base = {
15
15
  class: "pk-combobox-item",
16
- role: "option",
17
- aria: { selected: "false" },
18
16
  data: { phlex_kit__combobox_target: "item" }
19
- }, @attrs), &)
17
+ }
18
+ # Defaults only when the caller didn't supply their own — `mix` fuses
19
+ # (a server-rendered initially-selected option sets its own selected).
20
+ base[:role] = "option" unless attr_set?(:role)
21
+ base[:aria] = { selected: "false" } unless aria_key_set?(:selected)
22
+ label(**mix(base, @attrs), &)
20
23
  end
21
24
  end
22
25
  end
@@ -49,6 +49,7 @@ export default class extends Controller {
49
49
  // itemTargetConnected uses must be initialized here (mirrors
50
50
  // combobox_controller.js).
51
51
  this.itemIdCounter = 0;
52
+ this.orderCounter = 0;
52
53
  this.searchEntries = [];
53
54
  this.selectedIndex = -1;
54
55
  }
@@ -74,7 +75,15 @@ export default class extends Controller {
74
75
  // Only the cloned dialog overlay grabs focus on connect — an inline
75
76
  // palette connecting at page load must not steal it.
76
77
  if (this.isDialogClone()) this.inputTarget.focus();
77
- this.toggleVisibility(this.emptyTargets, false);
78
+ // Re-derive item + empty-state visibility from the LIVE input value, not an
79
+ // unconditional hide: a Turbo cache restore of an inline palette can
80
+ // reconnect with an active no-match query still in the input — hiding the
81
+ // empty row then showed the query with neither items NOR a "No results".
82
+ if (this.inputTarget.value) {
83
+ this.filter({ target: this.inputTarget });
84
+ } else {
85
+ this.toggleVisibility(this.emptyTargets, false);
86
+ }
78
87
  }
79
88
 
80
89
  // The command-dialog controller clones its <template> content into <body>;
@@ -83,6 +92,13 @@ export default class extends Controller {
83
92
  return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
84
93
  }
85
94
 
95
+ // Clones are appended as direct <body> children; the last stamped clone is
96
+ // the visually topmost overlay across all clone-based overlay families.
97
+ isTopmostClone() {
98
+ const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
99
+ return clones.length > 0 && clones[clones.length - 1] === this.element;
100
+ }
101
+
86
102
  // ARIA plumbing: aria-controls is wired to the listbox (mirrors
87
103
  // select_controller.js); the per-result ids come from itemTargetConnected.
88
104
  generateItemIds() {
@@ -103,6 +119,10 @@ export default class extends Controller {
103
119
  // arrival (mirrors combobox_controller.js).
104
120
  itemTargetConnected(item) {
105
121
  if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
122
+ // Original position, stamped once — filter() reorders rows by fuzzy
123
+ // score and resetVisibility() restores this order. Survives the
124
+ // disconnect/reconnect a DOM move itself triggers.
125
+ if (!item.dataset.pkOrder) item.dataset.pkOrder = String(this.orderCounter++);
106
126
  this.searchEntries.push({
107
127
  value: (item.dataset.value || "").toLowerCase(),
108
128
  text: (item.dataset.text || "").toLowerCase(),
@@ -119,7 +139,22 @@ export default class extends Controller {
119
139
  this.selectedIndex = -1;
120
140
  }
121
141
 
122
- dismiss() {
142
+ dismiss(e) {
143
+ if (e?.type === "keydown" && this.isDialogClone()) {
144
+ // The backdrop's Escape binding is window-scoped, so it fires even
145
+ // with another overlay (alert dialog, sheet) stacked ON TOP of the
146
+ // palette — those clones are later <body> siblings carrying the same
147
+ // [data-pk-overlay-clone] marker. Only the topmost clone may act on
148
+ // Escape (mirrors alert_dialog_controller.js#topmost).
149
+ if (!this.isTopmostClone()) return;
150
+ // The input's Escape binding is element-scoped and fires BEFORE lower
151
+ // overlays' document/window listeners — stop the keydown here, or by
152
+ // the time they run this clone (and its marker) is already removed and
153
+ // their own topmost check would wrongly elect them (mirrors
154
+ // sheet_content_controller.js's Escape guard).
155
+ e.stopPropagation();
156
+ }
157
+
123
158
  // Cloned dialog overlay: tear the clone down and hand focus back.
124
159
  if (this.isDialogClone()) {
125
160
  // Dispatched BEFORE teardown: the command-dialog controller listens on
@@ -210,6 +245,9 @@ export default class extends Controller {
210
245
  results.forEach((result) =>
211
246
  this.toggleVisibility([result.item.element], true),
212
247
  );
248
+ // cmdk parity: the best match lists first. Within each parent only —
249
+ // groups keep their identity, matches never jump between them.
250
+ this.reorderWithinParents(results.map((r) => r.item.element));
213
251
 
214
252
  this.announceResultCount(results.length);
215
253
  this.toggleVisibility(this.emptyTargets, results.length === 0);
@@ -248,6 +286,35 @@ export default class extends Controller {
248
286
  this.toggleVisibility(this.groupTargets, true);
249
287
  this.toggleVisibility(this.separatorTargets, true);
250
288
  this.toggleVisibility(this.emptyTargets, false);
289
+ // Undo any fuzzy-score reordering: back to server-rendered order.
290
+ this.reorderWithinParents(
291
+ [...this.itemTargets].sort((a, b) => Number(a.dataset.pkOrder) - Number(b.dataset.pkOrder)),
292
+ );
293
+ }
294
+
295
+ // Re-inserts `orderedItems` in the given sequence, each within its own
296
+ // parent, at the position of that parent's first item row — headings,
297
+ // separators and empty-state siblings keep their places. (Items are
298
+ // assumed contiguous within a parent; a separator BETWEEN items of one
299
+ // parent would drift below them.) The moves retrigger item target
300
+ // disconnect/connect, which is safe: ids and pkOrder are stamped once,
301
+ // and filter()/dismiss() have already reset the highlight.
302
+ reorderWithinParents(orderedItems) {
303
+ const itemSet = new Set(this.itemTargets);
304
+ const byParent = new Map();
305
+ orderedItems.forEach((el) => {
306
+ const parent = el.parentElement;
307
+ if (!parent) return;
308
+ if (!byParent.has(parent)) byParent.set(parent, []);
309
+ byParent.get(parent).push(el);
310
+ });
311
+ byParent.forEach((els, parent) => {
312
+ const firstItem = [...parent.children].find((c) => itemSet.has(c));
313
+ const placeholder = document.createComment("");
314
+ parent.insertBefore(placeholder, firstItem || null);
315
+ els.forEach((el) => parent.insertBefore(el, placeholder));
316
+ placeholder.remove();
317
+ });
251
318
  }
252
319
 
253
320
  // Upstream builds a Fuse index here; this keeps the same result shape —
@@ -17,18 +17,27 @@ module PhlexKit
17
17
  template(data: { phlex_kit__command_dialog_target: "content" }) do
18
18
  # The keydown action is the focus trap: Tab cycles within the cloned
19
19
  # overlay instead of escaping to the page underneath.
20
+ # data-pk-overlay-clone is the common marker stamped by every
21
+ # clone-based overlay family (see alert_dialog_controller.js#topmost):
22
+ # it lets stacked overlays of DIFFERENT types resolve which one is
23
+ # topmost, so one Escape never closes both layers at once.
20
24
  div(data: {
21
25
  controller: "phlex-kit--command",
22
26
  phlex_kit__command_dialog_instance: true,
27
+ pk_overlay_clone: "",
23
28
  action: "keydown->phlex-kit--command#trapFocus"
24
29
  }) do
25
30
  backdrop
26
- div(**mix({
27
- class: panel_classes,
28
- role: "dialog",
29
- aria: { modal: "true", label: @aria_label },
30
- data: { state: "open" }
31
- }, @attrs), &block)
31
+ panel_attrs = { class: panel_classes, data: { state: "open" } }
32
+ # Defaults only when the caller didn't supply their own — `mix`
33
+ # would fuse role="dialog x" / aria-modal="true false" (same guard
34
+ # as AlertDialogContent/SheetContent/DrawerContent, round 7).
35
+ panel_attrs[:role] = "dialog" unless attr_set?(:role)
36
+ aria = {}
37
+ aria[:modal] = "true" unless aria_key_set?(:modal)
38
+ aria[:label] = @aria_label unless aria_labelled?
39
+ panel_attrs[:aria] = aria unless aria.empty?
40
+ div(**mix(panel_attrs, @attrs), &block)
32
41
  end
33
42
  end
34
43
  end
@@ -1,6 +1,8 @@
1
1
  module PhlexKit
2
- # "No results" row — hidden by the controller until a filter matches nothing.
3
- # See command.rb.
2
+ # "No results" row — revealed by the controller only when a filter matches
3
+ # nothing. Server-rendered with pk-hidden so an inline palette doesn't flash
4
+ # "No results" under its items before Stimulus connects, and doesn't show it
5
+ # permanently with JS disabled. See command.rb.
4
6
  class CommandEmpty < BaseComponent
5
7
  def initialize(**attrs)
6
8
  @attrs = attrs
@@ -8,7 +10,7 @@ module PhlexKit
8
10
 
9
11
  def view_template(&)
10
12
  div(**mix({
11
- class: "pk-command-empty",
13
+ class: "pk-command-empty pk-hidden",
12
14
  role: "presentation",
13
15
  data: { phlex_kit__command_target: "empty" }
14
16
  }, @attrs), &)
@@ -10,7 +10,7 @@ module PhlexKit
10
10
  div(**mix({
11
11
  class: "pk-command-separator",
12
12
  role: "separator",
13
- aria: { hidden: true },
13
+ aria: { hidden: "true" },
14
14
  data: { phlex_kit__command_target: "separator" }
15
15
  }, @attrs))
16
16
  end
@@ -12,13 +12,15 @@ module PhlexKit
12
12
  end
13
13
 
14
14
  def view_template(&block)
15
- label(**mix({
15
+ base = {
16
16
  class: "pk-context-menu-item pk-context-menu-checkbox-item",
17
- role: "menuitemcheckbox",
18
- tabindex: "-1",
19
- aria: { checked: @checked ? "true" : "false" },
20
17
  data: { phlex_kit__context_menu_target: "menuItem" }
21
- }, @attrs)) do
18
+ }
19
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
20
+ base[:role] = "menuitemcheckbox" unless attr_set?(:role)
21
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
22
+ base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
23
+ label(**mix(base, @attrs)) do
22
24
  input(type: :checkbox, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
23
25
  tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
24
26
  span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
@@ -2,7 +2,13 @@ module PhlexKit
2
2
  class ContextMenuContent < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template(&)
5
- div(**mix({ class: "pk-context-menu-content", popover: "manual", role: "menu", tabindex: "-1", aria: { orientation: "vertical" }, data: { phlex_kit__context_menu_target: "content", state: "closed" } }, @attrs), &)
5
+ base = { class: "pk-context-menu-content", data: { phlex_kit__context_menu_target: "content", state: "closed" } }
6
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
7
+ base[:popover] = "manual" unless attr_set?(:popover)
8
+ base[:role] = "menu" unless attr_set?(:role)
9
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
10
+ base[:aria] = { orientation: "vertical" } unless aria_key_set?(:orientation)
11
+ div(**mix(base, @attrs), &)
6
12
  end
7
13
  end
8
14
  end
@@ -8,12 +8,39 @@ import { Controller } from "@hotwired/stimulus"
8
8
  // native [popover=manual] in the top layer (context_menu.css); it is placed
9
9
  // in viewport coordinates after showPopover() — a hidden popover has no
10
10
  // size — and clamped so it never overflows the viewport (no floating-ui).
11
+
12
+ // One-shot capture listener swallowing the click an outside mousedown is
13
+ // about to produce (see onDocMousedown; same helper in dropdown/menubar —
14
+ // duplicated per controller by design). {once} self-removes; the DOM
15
+ // dedupes re-arming with the same fn. If the gesture's click never fires
16
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
17
+ // one — rare enough to accept.
18
+ const swallowClick = (ev) => {
19
+ // preventDefault alone suppresses only native default actions; the
20
+ // dismissing outside click would still fire addEventListener handlers on
21
+ // whatever sits under the pointer. stopPropagation (this listener is
22
+ // capture-phase on window, the outermost node, so it runs before any deeper
23
+ // handler) starves those too — the modal-menu contract is that an outside
24
+ // click ONLY dismisses.
25
+ ev.preventDefault()
26
+ ev.stopPropagation()
27
+ }
28
+ function armSwallowClick() {
29
+ window.addEventListener("click", swallowClick, { once: true, capture: true })
30
+ }
31
+
11
32
  export default class extends Controller {
12
33
  static targets = ["content", "trigger", "menuItem"]
13
34
 
14
35
  connect() {
15
36
  this.onDoc = (e) => {
16
- if (this.contentTarget.contains(e.target)) return
37
+ if (this.contentTarget.contains(e.target)) {
38
+ // A right-click ON the open menu must not pop the NATIVE context menu
39
+ // on top of it — native menus swallow their own contextmenu. Suppress
40
+ // it and leave the kit menu open (no reposition for an inside click).
41
+ if (e.type === "contextmenu") e.preventDefault()
42
+ return
43
+ }
17
44
  // The opening right-click bubbles to document AFTER open() adds this
18
45
  // listener — a contextmenu inside our own element must not close what
19
46
  // it just opened (it repositions instead). A contextmenu anywhere
@@ -22,6 +49,17 @@ export default class extends Controller {
22
49
  if (e.type === "contextmenu" && this.element.contains(e.target)) return
23
50
  this.close()
24
51
  }
52
+ // Kit-wide dismiss contract (Radix modal-menu parity): a plain click
53
+ // outside only dismisses — swallowed, like dropdown/menubar/select.
54
+ // Armed at MOUSEDOWN: the gesture's focusout close() removes onDoc
55
+ // before the click even fires. Right-button mousedowns are ignored so a
56
+ // contextmenu elsewhere stays click-through (that's how a second kit —
57
+ // or the native — context menu opens in the same gesture).
58
+ this.onDocMousedown = (e) => {
59
+ if (this.contentTarget.contains(e.target)) return
60
+ if (e.button !== 0) return
61
+ armSwallowClick()
62
+ }
25
63
  this.onKey = (e) => this.keydown(e)
26
64
  // Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
27
65
  // stale data-state="open" (the panel itself can't resurrect, but host
@@ -47,6 +85,7 @@ export default class extends Controller {
47
85
  }
48
86
 
49
87
  disconnect() {
88
+ document.removeEventListener("mousedown", this.onDocMousedown)
50
89
  document.removeEventListener("click", this.onDoc)
51
90
  document.removeEventListener("contextmenu", this.onDoc)
52
91
  document.removeEventListener("keydown", this.onKey)
@@ -95,6 +134,7 @@ export default class extends Controller {
95
134
  c.style.left = `${x}px`
96
135
  c.style.top = `${y}px`
97
136
  c.dataset.state = "open"
137
+ document.addEventListener("mousedown", this.onDocMousedown)
98
138
  document.addEventListener("click", this.onDoc)
99
139
  document.addEventListener("contextmenu", this.onDoc)
100
140
  document.addEventListener("keydown", this.onKey)
@@ -109,6 +149,7 @@ export default class extends Controller {
109
149
  if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault()
110
150
  if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
111
151
  this.contentTarget.dataset.state = "closed"
152
+ document.removeEventListener("mousedown", this.onDocMousedown)
112
153
  document.removeEventListener("click", this.onDoc)
113
154
  document.removeEventListener("contextmenu", this.onDoc)
114
155
  document.removeEventListener("keydown", this.onKey)
@@ -4,7 +4,10 @@ module PhlexKit
4
4
  class ContextMenuGroup < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-context-menu-group", role: "group" }, @attrs), &)
7
+ base = { class: "pk-context-menu-group" }
8
+ # Default only when the caller didn't supply their own — `mix` fuses.
9
+ base[:role] = "group" unless attr_set?(:role)
10
+ div(**mix(base, @attrs), &)
8
11
  end
9
12
  end
10
13
  end