phlex_kit 0.8.1 → 0.9.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +11 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -2
  4. data/app/components/phlex_kit/accordion/accordion_controller.js +8 -2
  5. data/app/components/phlex_kit/alert/alert.css +3 -3
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +12 -8
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
  10. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  11. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  12. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  13. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  14. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  15. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  16. data/app/components/phlex_kit/badge/badge.css +4 -4
  17. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  18. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  19. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  20. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  21. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  22. data/app/components/phlex_kit/button/button.css +6 -6
  23. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  24. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  25. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  26. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  27. data/app/components/phlex_kit/calendar/calendar_body.rb +3 -0
  28. data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
  29. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  30. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  31. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  32. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  34. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  35. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  36. data/app/components/phlex_kit/chart/chart_controller.js +32 -9
  37. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  38. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  39. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  40. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  41. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  42. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  43. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  44. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  45. data/app/components/phlex_kit/combobox/combobox_controller.js +52 -9
  46. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  47. data/app/components/phlex_kit/command/command.css +4 -4
  48. data/app/components/phlex_kit/command/command_controller.js +51 -10
  49. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  50. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  51. data/app/components/phlex_kit/command/command_input.rb +6 -2
  52. data/app/components/phlex_kit/command/command_item.rb +4 -1
  53. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  54. data/app/components/phlex_kit/context_menu/context_menu_controller.js +18 -1
  55. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  56. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  57. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  58. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  59. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  60. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  61. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  62. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  63. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  64. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -3
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  69. data/app/components/phlex_kit/field/field.css +1 -1
  70. data/app/components/phlex_kit/field/field_error.rb +5 -6
  71. data/app/components/phlex_kit/form_field/form_field_controller.js +35 -16
  72. data/app/components/phlex_kit/form_field/form_field_error.rb +5 -1
  73. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  74. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  75. data/app/components/phlex_kit/input/input.css +1 -1
  76. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  77. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  78. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  79. data/app/components/phlex_kit/input_otp/input_otp_controller.js +13 -0
  80. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  81. data/app/components/phlex_kit/item/item.css +1 -1
  82. data/app/components/phlex_kit/item/item_group.rb +4 -2
  83. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  84. data/app/components/phlex_kit/marker/marker.css +1 -1
  85. data/app/components/phlex_kit/masked_input/masked_input_controller.js +10 -4
  86. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  87. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  88. data/app/components/phlex_kit/menubar/menubar_controller.js +70 -7
  89. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  90. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  91. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +4 -1
  92. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  93. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  94. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  95. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +7 -2
  96. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  97. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  98. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  99. data/app/components/phlex_kit/popover/popover.css +1 -1
  100. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  101. data/app/components/phlex_kit/resizable/resizable_controller.js +13 -0
  102. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  103. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  104. data/app/components/phlex_kit/select/select.css +3 -3
  105. data/app/components/phlex_kit/select/select.rb +4 -1
  106. data/app/components/phlex_kit/select/select_controller.js +25 -0
  107. data/app/components/phlex_kit/select/select_item.rb +5 -1
  108. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  109. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  110. data/app/components/phlex_kit/sidebar/sidebar.css +8 -8
  111. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  115. data/app/components/phlex_kit/slider/slider.css +1 -1
  116. data/app/components/phlex_kit/slider/slider.rb +3 -1
  117. data/app/components/phlex_kit/switch/switch.rb +6 -1
  118. data/app/components/phlex_kit/table/table.css +5 -3
  119. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  120. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  121. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  122. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +2 -2
  123. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
  124. data/app/components/phlex_kit/toast/toast.css +32 -14
  125. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  126. data/app/components/phlex_kit/toast/toast_region.rb +13 -3
  127. data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
  128. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  129. data/app/components/phlex_kit/toggle/toggle.rb +6 -2
  130. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  131. data/app/components/phlex_kit/typography/heading.rb +9 -3
  132. data/app/components/phlex_kit/typography/typography.css +4 -3
  133. data/lib/phlex_kit/version.rb +1 -1
  134. metadata +1 -1
@@ -41,7 +41,7 @@
41
41
  .pk-combobox-trigger:disabled,
42
42
  .pk-combobox-trigger[aria-disabled="true"] { pointer-events: none; opacity: .5; }
43
43
  .pk-combobox-trigger-content { overflow: hidden; text-overflow: ellipsis; }
44
- .pk-combobox-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-left: .5rem; opacity: .5; }
44
+ .pk-combobox-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-inline-start: .5rem; opacity: .5; }
45
45
 
46
46
  /* Dropdown panel — anchored below the combobox, flips above at the viewport
47
47
  bottom. Width: trigger width with a 15rem floor (was JS
@@ -58,7 +58,7 @@
58
58
  border-radius: var(--pk-radius);
59
59
  background: var(--pk-surface);
60
60
  color: var(--pk-text);
61
- box-shadow: 0 4px 16px rgba(0, 0, 0, .4);
61
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
62
62
  }
63
63
 
64
64
  /* Search row. */
@@ -69,7 +69,7 @@
69
69
  border-bottom: 1px solid var(--pk-border);
70
70
  color: var(--pk-muted);
71
71
  }
72
- .pk-combobox-search-icon { flex: none; width: 1rem; height: 1rem; margin-right: .5rem; opacity: .5; }
72
+ .pk-combobox-search-icon { flex: none; width: 1rem; height: 1rem; margin-inline-end: .5rem; opacity: .5; }
73
73
  .pk-combobox-search-input {
74
74
  width: 100%;
75
75
  height: 2rem;
@@ -95,8 +95,10 @@
95
95
  color: var(--pk-text);
96
96
  }
97
97
 
98
- /* Labelled group — the label renders from the `label` attribute, and the group
99
- hides itself (pure CSS) when filtering leaves no visible item inside. */
98
+ /* Labelled group — the label renders from the `data-label` attribute (the
99
+ invalid bare `label` attribute was dropped; aria-label carries it for AT),
100
+ and the group hides itself (pure CSS) when filtering leaves no visible item
101
+ inside. */
100
102
  .pk-combobox-group { display: none; }
101
103
  .pk-combobox-group:has(label:not(.pk-hidden)) {
102
104
  display: flex;
@@ -106,7 +108,7 @@
106
108
  border-bottom: 1px solid var(--pk-border);
107
109
  }
108
110
  .pk-combobox-group::before {
109
- content: attr(label);
111
+ content: attr(data-label);
110
112
  padding: .375rem .5rem;
111
113
  font-size: .75rem;
112
114
  font-weight: 500;
@@ -159,7 +161,7 @@
159
161
 
160
162
  /* Trailing ✓, visible while the row's input is checked. */
161
163
  .pk-combobox-item-indicator {
162
- margin-left: auto;
164
+ margin-inline-start: auto;
163
165
  width: 1rem;
164
166
  height: 1rem;
165
167
  flex: none;
@@ -314,7 +316,7 @@
314
316
  align-items: center;
315
317
  justify-content: center;
316
318
  flex: none;
317
- margin-left: auto;
319
+ margin-inline-start: auto;
318
320
  border: 0;
319
321
  border-radius: .125rem;
320
322
  padding: 0;
@@ -37,23 +37,37 @@ export default class extends Controller {
37
37
 
38
38
  selectedItemIndex = null
39
39
 
40
+ initialize() {
41
+ // Stimulus fires [target]Connected before connect(): anything
42
+ // itemTargetConnected uses must be initialized here.
43
+ this.itemIdCounter = 0
44
+ this.refocusing = false
45
+ }
46
+
40
47
  connect() {
41
48
  this.generateItemIds()
42
49
  this.updateTriggerContent()
43
50
  }
44
51
 
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).
52
+ // ARIA plumbing: aria-controls is wired to the listbox (mirrors
53
+ // select_controller.js); the per-option ids come from itemTargetConnected.
48
54
  generateItemIds() {
55
+ this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-controls", this.listId()))
56
+ }
57
+
58
+ listId() {
49
59
  const list = this.hasListTarget ? this.listTarget : (this.hasPopoverTarget ? this.popoverTarget : this.element)
50
60
  if (!list.id) list.id = `pk-combobox-list-${Math.random().toString(36).slice(2, 10)}`
61
+ return list.id
62
+ }
51
63
 
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))
64
+ // Every option gets an id derived from the listbox id so the combobox
65
+ // element can point aria-activedescendant at the keyboard highlight. A
66
+ // target callback (not a connect() loop) so options added after connect —
67
+ // e.g. rendered in by the host — get ids too. Counter-based, so a removed
68
+ // option's id is never reissued to a later arrival.
69
+ itemTargetConnected(item) {
70
+ if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`
57
71
  }
58
72
 
59
73
  // The elements carrying the open/closed combobox state: the trigger button
@@ -233,6 +247,14 @@ export default class extends Controller {
233
247
  }
234
248
 
235
249
  openPopover(event) {
250
+ // The remove-× on a chip and the clear-all button live inside the badge
251
+ // trigger, whose focusin (and click) actions open the popover — clicking
252
+ // them must not reopen it (stopPropagation covers click but focusin still
253
+ // fires when the button takes focus).
254
+ if (event?.target?.closest?.(".pk-combobox-badge-remove, .pk-combobox-clear-button")) return
255
+ // closePopover's focus restore fires the input/badge wrapper's
256
+ // focusin->openPopover action synchronously — don't bounce straight back open.
257
+ if (this.refocusing) return
236
258
  if (event) event.preventDefault()
237
259
  if (this.isOpen()) return
238
260
 
@@ -252,12 +274,31 @@ export default class extends Controller {
252
274
  }
253
275
 
254
276
  closePopover() {
277
+ // Hiding the popover while focus is inside it (search input, option) drops
278
+ // focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
279
+ // only when focus would actually be orphaned — a click elsewhere on the
280
+ // page (onClickOutside) must not have its focus stolen.
281
+ const focusWasInside = this.hasPopoverTarget && this.popoverTarget.contains(document.activeElement)
282
+
255
283
  this.setExpanded(false)
256
284
  // aria-activedescendant holds an element id; on close it must be removed,
257
285
  // not left pointing at a hidden option.
258
286
  this.clearActiveDescendant()
259
287
  if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
260
288
  this.updateTriggerContent() // reflect the selection into an input trigger
289
+
290
+ if (focusWasInside) {
291
+ const field = this.filterField()
292
+ // Input/badge layouts: back to the filter field (it lives outside the
293
+ // popover). Button layout: back to the trigger button (its search input
294
+ // is inside the popover, now hidden).
295
+ const anchor = (field && !this.popoverTarget.contains(field)) ? field : (this.hasTriggerTarget ? this.triggerTarget : null)
296
+ // focus() dispatches focusin synchronously; the flag stops the
297
+ // wrapper's focusin->openPopover action from reopening the panel.
298
+ this.refocusing = true
299
+ anchor?.focus()
300
+ this.refocusing = false
301
+ }
261
302
  }
262
303
 
263
304
  onClickOutside(event) {
@@ -277,7 +318,9 @@ export default class extends Controller {
277
318
  }
278
319
 
279
320
  filterItems(e) {
280
- if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) {
321
+ // Escape's keyup arrives after keydown.esc already closed the popover —
322
+ // re-running the filter then would wipe the highlight state for nothing.
323
+ if (["ArrowDown", "ArrowUp", "Tab", "Enter", "Escape"].includes(e.key)) {
281
324
  return
282
325
  }
283
326
 
@@ -1,14 +1,22 @@
1
1
  module PhlexKit
2
- # Labelled option group. Pass `label:` as a plain attribute CSS renders it via
3
- # ::before content(attr(label)), and the group auto-hides (pure CSS :has) when
4
- # filtering leaves it with no visible items. See combobox.rb.
2
+ # Labelled option group. Pass `label:` it renders as aria-label (a bare
3
+ # `label` attribute is invalid on <div> and silent to AT) plus data-label,
4
+ # which CSS renders via ::before content(attr(data-label)). The group
5
+ # auto-hides (pure CSS :has) when filtering leaves it with no visible items.
6
+ # See combobox.rb.
5
7
  class ComboboxListGroup < BaseComponent
6
- def initialize(**attrs)
8
+ def initialize(label: nil, **attrs)
9
+ @label = label
7
10
  @attrs = attrs
8
11
  end
9
12
 
10
13
  def view_template(&)
11
- div(**mix({ class: "pk-combobox-group", role: "group" }, @attrs), &)
14
+ div(**mix({
15
+ class: "pk-combobox-group",
16
+ role: "group",
17
+ aria: { label: @label },
18
+ data: { label: @label }
19
+ }, @attrs), &)
12
20
  end
13
21
  end
14
22
  end
@@ -19,7 +19,7 @@
19
19
  inset: 0;
20
20
  z-index: 50;
21
21
  pointer-events: auto;
22
- background: rgb(0 0 0 / .1);
22
+ background: color-mix(in srgb, var(--pk-overlay) 10%, transparent);
23
23
  backdrop-filter: blur(4px);
24
24
  }
25
25
 
@@ -37,7 +37,7 @@
37
37
  border-radius: .75rem;
38
38
  background: var(--pk-surface);
39
39
  color: var(--pk-text);
40
- box-shadow: 0 10px 30px rgb(0 0 0 / .35);
40
+ box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
41
41
  }
42
42
  .pk-command-dialog.xs { max-width: 24rem; }
43
43
  .pk-command-dialog.sm { max-width: 28rem; }
@@ -121,7 +121,7 @@
121
121
  .pk-command-item[data-disabled] { pointer-events: none; opacity: .5; }
122
122
 
123
123
  .pk-command-shortcut {
124
- margin-left: auto;
124
+ margin-inline-start: auto;
125
125
  font-size: .75rem;
126
126
  letter-spacing: .1em;
127
127
  color: var(--pk-muted);
@@ -136,5 +136,5 @@
136
136
  font-size: .875rem;
137
137
  }
138
138
  @supports not (backdrop-filter: blur(1px)) {
139
- .pk-command-overlay { background: rgb(0 0 0 / .5); }
139
+ .pk-command-overlay { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
140
140
  }
@@ -47,11 +47,19 @@ export default class extends Controller {
47
47
  }
48
48
 
49
49
  this.generateItemIds();
50
- this.inputTarget.focus();
50
+ // Only the cloned dialog overlay grabs focus on connect — an inline
51
+ // palette connecting at page load must not steal it.
52
+ if (this.isDialogClone()) this.inputTarget.focus();
51
53
  this.searchIndex = this.buildSearchIndex();
52
54
  this.toggleVisibility(this.emptyTargets, false);
53
55
  }
54
56
 
57
+ // The command-dialog controller clones its <template> content into <body>;
58
+ // the clone's wrapper carries this marker (CommandDialogContent renders it).
59
+ isDialogClone() {
60
+ return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
61
+ }
62
+
55
63
  // ARIA plumbing: every result gets an id derived from the listbox id so the
56
64
  // input can point aria-activedescendant at the keyboard highlight, and
57
65
  // aria-controls is wired to the listbox (mirrors select_controller.js).
@@ -67,15 +75,37 @@ export default class extends Controller {
67
75
  }
68
76
 
69
77
  dismiss() {
70
- // allow scroll on body
71
- document.body.style.removeProperty("overflow");
72
- const previous = this.previouslyFocused;
73
- // remove the element
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
+ // Cloned dialog overlay: tear the clone down and hand focus back.
79
+ if (this.isDialogClone()) {
80
+ // allow scroll on body
81
+ document.body.style.removeProperty("overflow");
82
+ const previous = this.previouslyFocused;
83
+ // remove the element
84
+ this.element.remove();
85
+ // restore focus to wherever it was before the palette opened
86
+ if (previous instanceof HTMLElement && document.contains(previous)) {
87
+ previous.focus();
88
+ }
89
+ return;
78
90
  }
91
+
92
+ // Inline palette: removing the element would delete it permanently.
93
+ // Reset the query instead and let the host react via the dispatched
94
+ // phlex-kit--command:dismiss event.
95
+ if (this.hasInputTarget && this.inputTarget.value !== "") {
96
+ this.inputTarget.value = "";
97
+ this.deselectAll();
98
+ this.resetVisibility();
99
+ this.announceResultCount(null);
100
+ }
101
+ this.dispatch("dismiss");
102
+ }
103
+
104
+ // Guards the items' default href="#": without it an Enter-synthesized
105
+ // click() or a mouse click scrolls to top and appends # to the URL
106
+ // (mirrors dropdown_menu_controller's close() guard).
107
+ onItemClick(event) {
108
+ if (event?.target?.closest?.('a[href="#"]')) event.preventDefault();
79
109
  }
80
110
 
81
111
  // Focus trap for the cloned dialog overlay: Tab / Shift+Tab cycle among the
@@ -173,16 +203,25 @@ export default class extends Controller {
173
203
 
174
204
  // Upstream builds a Fuse index here; this keeps the same search() shape —
175
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.
176
208
  buildSearchIndex() {
177
209
  const items = this.itemTargets.map((el) => ({
178
210
  value: (el.dataset.value || "").toLowerCase(),
211
+ text: (el.dataset.text || "").toLowerCase(),
179
212
  element: el,
180
213
  }));
181
214
  return {
182
215
  search(query) {
183
216
  const q = query.toLowerCase();
184
217
  return items
185
- .map((item) => ({ item, score: fuzzyScore(q, item.value) }))
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
+ })
186
225
  .filter((result) => result.score !== null)
187
226
  .sort((a, b) => b.score - a.score);
188
227
  },
@@ -225,6 +264,8 @@ export default class extends Controller {
225
264
  }
226
265
 
227
266
  this.toggleAriaSelected(visibleItems[this.selectedIndex], true);
267
+ // Keep the highlight visible inside the max-height scrollable list.
268
+ visibleItems[this.selectedIndex].scrollIntoView({ block: "nearest" });
228
269
 
229
270
  // Focus stays in the input; the highlighted option is exposed via
230
271
  // aria-activedescendant (result ids come from generateItemIds).
@@ -1,7 +1,9 @@
1
1
  module PhlexKit
2
2
  # Wrapper wiring a CommandDialogTrigger to its CommandDialogContent. The
3
- # outlet selector finds the live palette instance (if already open) so a
4
- # second trigger press just refocuses the input. See command.rb.
3
+ # controller tracks its own live clone (sheet pattern) so a second trigger
4
+ # press just refocuses the input per instance, unlike the old
5
+ # document-scoped outlet selector, which let one dialog adopt another's
6
+ # open palette. See command.rb.
5
7
  class CommandDialog < BaseComponent
6
8
  def initialize(**attrs)
7
9
  @attrs = attrs
@@ -9,10 +11,7 @@ module PhlexKit
9
11
 
10
12
  def view_template(&)
11
13
  div(**mix({
12
- data: {
13
- controller: "phlex-kit--command-dialog",
14
- phlex_kit__command_dialog_phlex_kit__command_outlet: "[data-phlex-kit--command-dialog-instance]"
15
- }
14
+ data: { controller: "phlex-kit--command-dialog" }
16
15
  }, @attrs), &)
17
16
  end
18
17
  end
@@ -2,18 +2,33 @@ import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Ported from ruby_ui's command-dialog controller (Stimulus-only upstream),
4
4
  // identifiers renamed ruby-ui → phlex-kit and the Tailwind body scroll-lock
5
- // class swapped for inline overflow (matching the shipped dialog).
5
+ // class swapped for inline overflow (matching the shipped dialog). Unlike
6
+ // upstream's document-scoped outlet (which let one dialog adopt another's
7
+ // open palette), each instance tracks its own clone — sheet_controller's
8
+ // pattern — and clears it on turbo:before-cache so a cached page never
9
+ // restores a stale overlay + scroll lock.
6
10
  // Connects to data-controller="phlex-kit--command-dialog"
7
11
  export default class extends Controller {
8
12
  static targets = ["content"];
9
- static outlets = ["phlex-kit--command"];
10
13
 
11
- phlexKitCommandOutletConnected(controller) {
12
- this.openOutlet = controller;
14
+ initialize() {
15
+ this.overlay = null;
16
+ this.clearOverlay = () => {
17
+ if (this.overlay?.isConnected) {
18
+ this.overlay.remove();
19
+ document.body.style.removeProperty("overflow");
20
+ }
21
+ this.overlay = null;
22
+ };
13
23
  }
14
24
 
15
- phlexKitCommandOutletDisconnected() {
16
- this.openOutlet = null;
25
+ connect() {
26
+ document.addEventListener("turbo:before-cache", this.clearOverlay);
27
+ }
28
+
29
+ disconnect() {
30
+ document.removeEventListener("turbo:before-cache", this.clearOverlay);
31
+ this.clearOverlay();
17
32
  }
18
33
 
19
34
  open(e) {
@@ -25,12 +40,15 @@ export default class extends Controller {
25
40
  return;
26
41
  }
27
42
 
28
- if (this.openOutlet) {
29
- this.openOutlet.focusInput();
43
+ // Already open (the command controller's dismiss may have removed the
44
+ // clone behind our back — isConnected catches that): just refocus.
45
+ if (this.overlay?.isConnected) {
46
+ this.overlay.querySelector("[data-phlex-kit--command-target='input']")?.focus();
30
47
  return;
31
48
  }
32
49
 
33
50
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
51
+ this.overlay = document.body.lastElementChild;
34
52
  // prevent scroll on body
35
53
  document.body.style.overflow = "hidden";
36
54
  }
@@ -8,9 +8,13 @@ module PhlexKit
8
8
  # stays "true" because the list is always visible (filtering hides items,
9
9
  # never the listbox itself).
10
10
  class CommandInput < BaseComponent
11
- def initialize(placeholder: "Type a command or search...", list_id: nil, **attrs)
11
+ # `autofocus:` defaults off so an inline palette doesn't steal page focus
12
+ # on load; the dialog clone needs no autofocus either — the command
13
+ # controller's connect() focuses the input when the clone is inserted.
14
+ def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, **attrs)
12
15
  @placeholder = placeholder
13
16
  @list_id = list_id
17
+ @autofocus = autofocus
14
18
  @attrs = attrs
15
19
  end
16
20
 
@@ -25,7 +29,7 @@ module PhlexKit
25
29
  autocomplete: "off",
26
30
  autocorrect: "off",
27
31
  spellcheck: "false",
28
- autofocus: true,
32
+ autofocus: @autofocus,
29
33
  role: "combobox",
30
34
  value: "",
31
35
  aria: { autocomplete: "list", expanded: "true", controls: @list_id },
@@ -23,7 +23,10 @@ module PhlexKit
23
23
  phlex_kit__command_target: "item",
24
24
  value: @value,
25
25
  text: @text,
26
- disabled: (@disabled ? "true" : nil)
26
+ disabled: (@disabled ? "true" : nil),
27
+ # Guards the default href="#": without it a mouse click (or the
28
+ # Enter-synthesized click()) scrolls to top and appends # to the URL.
29
+ action: "click->phlex-kit--command#onItemClick"
27
30
  }
28
31
  }, @attrs), &)
29
32
  end
@@ -9,7 +9,7 @@
9
9
  .pk-context-menu-content {
10
10
  position: fixed; inset: auto; margin: 0; min-width: 8rem; overflow: visible;
11
11
  border: 1px solid var(--pk-border); border-radius: calc(var(--pk-radius) - 2px); background: var(--pk-surface);
12
- color: var(--pk-text); padding: .25rem; box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
12
+ color: var(--pk-text); padding: .25rem; box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
13
13
  }
14
14
  .pk-context-menu-item {
15
15
  position: relative; display: flex; align-items: center; gap: .5rem;
@@ -20,15 +20,15 @@
20
20
  .pk-context-menu-item > svg { flex: none; width: 1rem; height: 1rem; color: var(--pk-muted); }
21
21
  .pk-context-menu-item:hover, .pk-context-menu-item:focus { background: var(--pk-accent); color: var(--pk-text); }
22
22
  .pk-context-menu-item[data-disabled="true"] { pointer-events: none; opacity: .5; }
23
- .pk-context-menu-item.inset { padding-left: 2rem; }
23
+ .pk-context-menu-item.inset { padding-inline-start: 2rem; }
24
24
  .pk-context-menu-check {
25
25
  display: inline-flex; align-items: center; justify-content: center;
26
26
  flex: none; width: 1rem; height: 1rem;
27
27
  }
28
28
  .pk-context-menu-check svg { width: .875rem; height: .875rem; }
29
- .pk-context-menu-shortcut { margin-left: auto; font-size: .75rem; letter-spacing: .1em; color: var(--pk-muted); }
29
+ .pk-context-menu-shortcut { margin-inline-start: auto; font-size: .75rem; letter-spacing: .1em; color: var(--pk-muted); }
30
30
  .pk-context-menu-label { padding: .375rem .5rem; font-size: .875rem; font-weight: 600; color: var(--pk-text); }
31
- .pk-context-menu-label.inset { padding-left: 2rem; }
31
+ .pk-context-menu-label.inset { padding-inline-start: 2rem; }
32
32
  .pk-context-menu-separator { height: 1px; margin: .25rem -.25rem; background: var(--pk-border); }
33
33
  .pk-context-menu-group { display: contents; }
34
34
 
@@ -71,7 +71,7 @@
71
71
  The row is its own scoped anchor; the panel flips to the other side at
72
72
  the viewport right edge. */
73
73
  .pk-context-menu-sub { anchor-name: --pk-context-menu-sub; anchor-scope: --pk-context-menu-sub; }
74
- .pk-context-menu-sub-chevron { margin-left: auto; width: 1rem; height: 1rem; }
74
+ .pk-context-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
75
75
  .pk-context-menu-sub-content {
76
76
  position: fixed;
77
77
  position-anchor: --pk-context-menu-sub;
@@ -92,7 +92,7 @@
92
92
  background: var(--pk-surface);
93
93
  color: var(--pk-text);
94
94
  padding: .25rem;
95
- box-shadow: 0 8px 24px rgb(0 0 0 / .3);
95
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
96
96
  }
97
97
  .pk-context-menu-sub:hover > .pk-context-menu-sub-content,
98
98
  .pk-context-menu-sub:focus-within > .pk-context-menu-sub-content { display: block; }
@@ -12,12 +12,22 @@ export default class extends Controller {
12
12
  static targets = ["content", "trigger", "menuItem"]
13
13
 
14
14
  connect() {
15
- this.onDoc = (e) => { if (!this.contentTarget.contains(e.target)) this.close() }
15
+ this.onDoc = (e) => {
16
+ if (this.contentTarget.contains(e.target)) return
17
+ // The opening right-click bubbles to document AFTER open() adds this
18
+ // listener — a contextmenu inside our own element must not close what
19
+ // it just opened (it repositions instead). A contextmenu anywhere
20
+ // else closes us, which also covers opening a second kit context
21
+ // menu: its trigger's contextmenu is "outside" for this instance.
22
+ if (e.type === "contextmenu" && this.element.contains(e.target)) return
23
+ this.close()
24
+ }
16
25
  this.onKey = (e) => this.keydown(e)
17
26
  }
18
27
 
19
28
  disconnect() {
20
29
  document.removeEventListener("click", this.onDoc)
30
+ document.removeEventListener("contextmenu", this.onDoc)
21
31
  document.removeEventListener("keydown", this.onKey)
22
32
  }
23
33
 
@@ -38,14 +48,21 @@ export default class extends Controller {
38
48
  c.style.top = `${y}px`
39
49
  c.dataset.state = "open"
40
50
  document.addEventListener("click", this.onDoc)
51
+ document.addEventListener("contextmenu", this.onDoc)
41
52
  document.addEventListener("keydown", this.onKey)
42
53
  this.items()[0]?.focus()
43
54
  }
44
55
 
45
56
  close(opts = {}) {
57
+ // Menu rows default to href="#"; without this an Enter/click on a
58
+ // non-link row scrolls to top and appends # to the URL. (`opts` is the
59
+ // click event when close runs as the item's data-action; Enter/Space
60
+ // arrive here too, via keydown()'s synthesized click.)
61
+ if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault()
46
62
  if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
47
63
  this.contentTarget.dataset.state = "closed"
48
64
  document.removeEventListener("click", this.onDoc)
65
+ document.removeEventListener("contextmenu", this.onDoc)
49
66
  document.removeEventListener("keydown", this.onKey)
50
67
  if (opts.refocus === true) this.anchor().focus()
51
68
  }
@@ -22,7 +22,7 @@
22
22
 
23
23
  /* "Columns" dropdown rows. */
24
24
  .pk-data-table-column-toggle { position: relative; }
25
- .pk-data-table-column-toggle-icon { width: 1rem; height: 1rem; margin-left: .25rem; }
25
+ .pk-data-table-column-toggle-icon { width: 1rem; height: 1rem; margin-inline-start: .25rem; }
26
26
  .pk-data-table-column-option {
27
27
  display: flex;
28
28
  align-items: center;
@@ -22,18 +22,17 @@ module PhlexKit
22
22
  end
23
23
  render DropdownMenuContent.new do
24
24
  @columns.each do |col|
25
- label(class: "pk-data-table-column-option") do
26
- input(
27
- type: :checkbox,
28
- checked: true,
29
- class: "pk-checkbox",
30
- data: {
31
- column_key: col[:key].to_s,
32
- action: "change->phlex-kit--data-table-column-visibility#toggle"
33
- }
34
- )
35
- span { plain col[:label] }
36
- end
25
+ # A real menu row (role="menuitemcheckbox" + aria-checked, arrow-
26
+ # navigable) — plain <label>s directly inside role="menu" are
27
+ # invalid ARIA. The change event bubbles from the hidden checkbox
28
+ # to this row, where our toggle action picks it up.
29
+ render DropdownMenuCheckboxItem.new(
30
+ checked: true,
31
+ data: {
32
+ column_key: col[:key].to_s,
33
+ action: "change->phlex-kit--data-table-column-visibility#toggle"
34
+ }
35
+ ) { plain col[:label] }
37
36
  end
38
37
  end
39
38
  end
@@ -7,8 +7,11 @@ import { Controller } from "@hotwired/stimulus";
7
7
  // Connects to data-controller="phlex-kit--data-table-column-visibility"
8
8
  export default class extends Controller {
9
9
  toggle(event) {
10
- const key = event.target.dataset.columnKey;
10
+ // The change event originates on the checkbox, but data-column-key lives
11
+ // on the menu row (DropdownMenuCheckboxItem) — look up through ancestors.
12
+ const key = event.target.closest("[data-column-key]")?.dataset.columnKey;
11
13
  const visible = event.target.checked;
14
+ if (!key) return;
12
15
  const root = this.element.closest('[data-controller~="phlex-kit--data-table"]');
13
16
  if (!root) return;
14
17
  root
@@ -12,6 +12,7 @@ module PhlexKit
12
12
 
13
13
  def total_count = @pagy.count
14
14
 
15
- def per_page = @pagy.items
15
+ # Pagy 9 renamed #items to #limit (and removed #items) — support both.
16
+ def per_page = @pagy.respond_to?(:limit) ? @pagy.limit : @pagy.items
16
17
  end
17
18
  end