phlex_kit 0.13.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 (114) 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_controller.js +16 -6
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
  8. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
  9. data/app/components/phlex_kit/attachment/attachment.css +7 -1
  10. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
  11. data/app/components/phlex_kit/avatar/avatar.css +4 -0
  12. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
  13. data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
  16. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  17. data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
  18. data/app/components/phlex_kit/calendar/calendar.css +5 -0
  19. data/app/components/phlex_kit/calendar/calendar_controller.js +26 -8
  20. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
  21. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
  22. data/app/components/phlex_kit/carousel/carousel.css +7 -0
  23. data/app/components/phlex_kit/carousel/carousel.rb +8 -4
  24. data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
  25. data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
  26. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  27. data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
  28. data/app/components/phlex_kit/checkbox/checkbox.rb +5 -1
  29. data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
  30. data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
  31. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
  32. data/app/components/phlex_kit/combobox/combobox.rb +1 -1
  33. data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
  34. data/app/components/phlex_kit/combobox/combobox_controller.js +56 -5
  35. data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
  36. data/app/components/phlex_kit/command/command_controller.js +9 -1
  37. data/app/components/phlex_kit/command/command_dialog_content.rb +10 -6
  38. data/app/components/phlex_kit/command/command_empty.rb +5 -3
  39. data/app/components/phlex_kit/command/command_separator.rb +1 -1
  40. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
  41. data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
  42. data/app/components/phlex_kit/context_menu/context_menu_controller.js +17 -2
  43. data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
  44. data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
  45. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
  46. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
  47. data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
  49. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
  50. data/app/components/phlex_kit/data_table/data_table.css +4 -0
  51. data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
  52. data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
  53. data/app/components/phlex_kit/drawer/drawer.css +4 -0
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +10 -1
  57. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
  58. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
  59. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
  60. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
  62. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
  63. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
  64. data/app/components/phlex_kit/empty/empty.rb +1 -0
  65. data/app/components/phlex_kit/field/field.rb +4 -1
  66. data/app/components/phlex_kit/field/field_error.rb +4 -1
  67. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
  68. data/app/components/phlex_kit/icon/icon.rb +5 -1
  69. data/app/components/phlex_kit/input_group/input_group.css +6 -3
  70. data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
  71. data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
  72. data/app/components/phlex_kit/item/item.css +9 -1
  73. data/app/components/phlex_kit/item/item_separator.rb +1 -1
  74. data/app/components/phlex_kit/label/label.css +6 -1
  75. data/app/components/phlex_kit/menubar/menubar.rb +5 -3
  76. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
  77. data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
  78. data/app/components/phlex_kit/menubar/menubar_controller.js +11 -1
  79. data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
  80. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
  81. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  82. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
  83. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
  84. data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
  85. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
  86. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
  87. data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
  88. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
  89. data/app/components/phlex_kit/pagination/pagination.css +4 -0
  90. data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
  91. data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
  92. data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
  93. data/app/components/phlex_kit/select/select.rb +1 -1
  94. data/app/components/phlex_kit/select/select_content.rb +7 -5
  95. data/app/components/phlex_kit/select/select_controller.js +10 -0
  96. data/app/components/phlex_kit/select/select_separator.rb +1 -1
  97. data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
  98. data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
  99. data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
  100. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
  101. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
  102. data/app/components/phlex_kit/switch/switch.rb +4 -1
  103. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
  104. data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
  105. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
  106. data/app/components/phlex_kit/toast/toast_controller.js +46 -1
  107. data/app/components/phlex_kit/toast/toaster_controller.js +29 -1
  108. data/app/components/phlex_kit/toggle/toggle.rb +1 -1
  109. data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
  110. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
  111. data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
  112. data/app/components/phlex_kit/typography/typography.css +5 -2
  113. data/lib/phlex_kit/version.rb +1 -1
  114. metadata +1 -1
@@ -24,8 +24,12 @@ module PhlexKit
24
24
  if @include_hidden && @attrs[:name] && !@attrs[:name].to_s.end_with?("[]")
25
25
  # Disabled in lockstep with the checkbox (Rails' check_box idiom) —
26
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.
27
30
  input(type: "hidden", name: @attrs[:name], value: @unchecked_value,
28
- disabled: @attrs[:disabled] ? true : nil)
31
+ disabled: @attrs[:disabled] ? true : nil,
32
+ form: @attrs[:form] || @attrs["form"])
29
33
  end
30
34
  input(**mix({
31
35
  type: :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
@@ -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,6 +367,18 @@ 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
@@ -380,7 +409,25 @@ export default class extends Controller {
380
409
  const field = (e.target instanceof HTMLInputElement) ? e.target : this.filterField()
381
410
  if (!field) return
382
411
 
383
- 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)
384
431
  }
385
432
 
386
433
  // Strips combining diacritical marks after an NFD decomposition, so "é"
@@ -390,6 +437,10 @@ export default class extends Controller {
390
437
  }
391
438
 
392
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
+
393
444
  if (this.hasToggleAllTarget) {
394
445
  if (filterTerm) this.toggleAllTarget.parentElement.classList.add("pk-hidden")
395
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
@@ -75,7 +75,15 @@ export default class extends Controller {
75
75
  // Only the cloned dialog overlay grabs focus on connect — an inline
76
76
  // palette connecting at page load must not steal it.
77
77
  if (this.isDialogClone()) this.inputTarget.focus();
78
- 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
+ }
79
87
  }
80
88
 
81
89
  // The command-dialog controller clones its <template> content into <body>;
@@ -28,12 +28,16 @@ module PhlexKit
28
28
  action: "keydown->phlex-kit--command#trapFocus"
29
29
  }) do
30
30
  backdrop
31
- div(**mix({
32
- class: panel_classes,
33
- role: "dialog",
34
- aria: { modal: "true", label: @aria_label },
35
- data: { state: "open" }
36
- }, @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)
37
41
  end
38
42
  end
39
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
@@ -15,7 +15,16 @@ import { Controller } from "@hotwired/stimulus"
15
15
  // dedupes re-arming with the same fn. If the gesture's click never fires
16
16
  // (e.g. suppressed after a selection drag) the armed swallow eats the next
17
17
  // one — rare enough to accept.
18
- const swallowClick = (ev) => ev.preventDefault()
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
+ }
19
28
  function armSwallowClick() {
20
29
  window.addEventListener("click", swallowClick, { once: true, capture: true })
21
30
  }
@@ -25,7 +34,13 @@ export default class extends Controller {
25
34
 
26
35
  connect() {
27
36
  this.onDoc = (e) => {
28
- 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
+ }
29
44
  // The opening right-click bubbles to document AFTER open() adds this
30
45
  // listener — a contextmenu inside our own element must not close what
31
46
  // it just opened (it repositions instead). A contextmenu anywhere
@@ -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
@@ -18,11 +18,14 @@ module PhlexKit
18
18
 
19
19
  def view_template(&block)
20
20
  classes = [ "pk-context-menu-item", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
21
- base = { href: @href, role: "menuitem", tabindex: "-1", class: classes,
21
+ base = { href: @href, class: classes,
22
22
  data: { action: ("click->phlex-kit--context-menu#close" unless @disabled),
23
23
  phlex_kit__context_menu_target: "menuItem",
24
24
  disabled: (@disabled ? "true" : nil) }.compact }
25
- base[:aria] = { disabled: "true" } if @disabled
25
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
26
+ base[:role] = "menuitem" unless attr_set?(:role)
27
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
28
+ base[:"aria-disabled"] = "true" if @disabled && !aria_key_set?(:disabled)
26
29
  a(**mix(base, @attrs)) do
27
30
  if @checked
28
31
  span(class: "pk-context-menu-check") do
@@ -4,7 +4,10 @@ module PhlexKit
4
4
  class ContextMenuRadioGroup < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-context-menu-group", role: "radiogroup" }, @attrs), &)
7
+ base = { class: "pk-context-menu-group" }
8
+ # Default only when the caller didn't supply their own — `mix` fuses.
9
+ base[:role] = "radiogroup" unless attr_set?(:role)
10
+ div(**mix(base, @attrs), &)
8
11
  end
9
12
  end
10
13
  end
@@ -11,13 +11,15 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&block)
14
- label(**mix({
14
+ base = {
15
15
  class: "pk-context-menu-item pk-context-menu-radio-item",
16
- role: "menuitemradio",
17
- tabindex: "-1",
18
- aria: { checked: @checked ? "true" : "false" },
19
16
  data: { phlex_kit__context_menu_target: "menuItem" }
20
- }, @attrs)) do
17
+ }
18
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
19
+ base[:role] = "menuitemradio" unless attr_set?(:role)
20
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
21
+ base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
22
+ label(**mix(base, @attrs)) do
21
23
  input(type: :radio, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
22
24
  tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
23
25
  span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
@@ -2,7 +2,12 @@ module PhlexKit
2
2
  class ContextMenuSeparator < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template
5
- div(**mix({ role: "separator", class: "pk-context-menu-separator", aria: { orientation: "horizontal" } }, @attrs))
5
+ base = { class: "pk-context-menu-separator" }
6
+ # Defaults only when the caller didn't supply their own — `mix` fuses,
7
+ # and aria_key_set? covers the aria: hash spelling the flat key missed.
8
+ base[:role] = "separator" unless attr_set?(:role)
9
+ base[:"aria-orientation"] = "horizontal" unless aria_key_set?(:orientation)
10
+ div(**mix(base, @attrs))
6
11
  end
7
12
  end
8
13
  end
@@ -4,7 +4,10 @@ module PhlexKit
4
4
  class ContextMenuSubContent < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-context-menu-sub-content", role: "menu" }, @attrs)) do
7
+ base = { class: "pk-context-menu-sub-content" }
8
+ # Default only when the caller didn't supply their own — `mix` fuses.
9
+ base[:role] = "menu" unless attr_set?(:role)
10
+ div(**mix(base, @attrs)) do
8
11
  div(class: "pk-context-menu-sub-viewport", &)
9
12
  end
10
13
  end
@@ -3,17 +3,20 @@ module PhlexKit
3
3
  class ContextMenuSubTrigger < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
5
  def view_template(&block)
6
- div(**mix({
6
+ base = {
7
7
  class: "pk-context-menu-item pk-context-menu-sub-trigger",
8
- role: "menuitem",
9
- # -1: roving focus reaches it via arrows (focus-within opens the sub);
10
- # tabindex 0 made it a stray tab stop inside the menu.
11
- tabindex: "-1",
12
- # expanded starts false and is mirrored live by the controller's
13
- # syncSub (the reveal itself is pure CSS :hover/:focus-within).
14
- aria: { haspopup: "menu", expanded: "false" },
15
8
  data: { phlex_kit__context_menu_target: "menuItem" }
16
- }, @attrs)) do
9
+ }
10
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
11
+ base[:role] = "menuitem" unless attr_set?(:role)
12
+ # -1: roving focus reaches it via arrows (focus-within opens the sub);
13
+ # tabindex 0 made it a stray tab stop inside the menu.
14
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
15
+ base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
16
+ # expanded starts false and is mirrored live by the controller's
17
+ # syncSub (the reveal itself is pure CSS :hover/:focus-within).
18
+ base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
19
+ div(**mix(base, @attrs)) do
17
20
  block&.call
18
21
  render Icon.new(:chevron_right, size: nil, class: "pk-context-menu-sub-chevron")
19
22
  end
@@ -98,3 +98,7 @@
98
98
  opacity: .5;
99
99
  pointer-events: none;
100
100
  }
101
+
102
+ /* RTL: the collapsed expand-toggle chevron points along the reading
103
+ direction; the expanded state's rotate(90deg) points down either way. */
104
+ [dir="rtl"] .pk-data-table-expand-toggle:not([aria-expanded="true"]) svg { transform: scaleX(-1); }
@@ -39,7 +39,13 @@ export default class extends Controller {
39
39
  this.element.requestSubmit();
40
40
  }
41
41
 
42
- captureBeforeRender() {
42
+ captureBeforeRender(event) {
43
+ // Only capture for a render of the frame THIS form drives — the listener
44
+ // is document-wide, so an unrelated frame rendering elsewhere while the
45
+ // search input is focused would otherwise write a PENDING_FOCUS entry no
46
+ // matching swap ever consumes, stealing focus on a later restore.
47
+ const frame = event?.target;
48
+ if (!frame || !this.rendersInto(frame)) return;
43
49
  const input = this.input();
44
50
  if (!input || document.activeElement !== input) return;
45
51
  PENDING_FOCUS.set(this.key(), {
@@ -48,6 +54,14 @@ export default class extends Controller {
48
54
  });
49
55
  }
50
56
 
57
+ // True when `frame` is the <turbo-frame> this form's submission renders: the
58
+ // frame named by the form's data-turbo-frame, else the frame the form lives
59
+ // inside (Turbo's default target).
60
+ rendersInto(frame) {
61
+ const targeted = this.element.dataset.turboFrame;
62
+ return targeted ? frame.id === targeted : frame.contains(this.element);
63
+ }
64
+
51
65
  restoreIfPending() {
52
66
  const state = PENDING_FOCUS.get(this.key());
53
67
  if (!state) return;
@@ -2,18 +2,22 @@ module PhlexKit
2
2
  # Header checkbox selecting every DataTableRowCheckbox on the page (the
3
3
  # controller sets indeterminate for partial selections). See data_table.rb.
4
4
  class DataTableSelectAllCheckbox < BaseComponent
5
- def initialize(**attrs)
5
+ def initialize(label: "Select all", **attrs)
6
+ @label = label
6
7
  @attrs = attrs
7
8
  end
8
9
 
9
10
  def view_template
10
- render Checkbox.new(**mix({
11
- aria: { label: "Select all" },
11
+ base = {
12
12
  data: {
13
13
  phlex_kit__data_table_target: "selectAll",
14
14
  action: "change->phlex-kit--data-table#toggleAll"
15
15
  }
16
- }, @attrs))
16
+ }
17
+ # label: kwarg (mirrors DataTableRowCheckbox); default only when the
18
+ # caller didn't supply their own accessible name — `mix` fuses.
19
+ base[:aria] = { label: @label } unless aria_labelled?
20
+ render Checkbox.new(**mix(base, @attrs))
17
21
  end
18
22
  end
19
23
  end
@@ -20,6 +20,10 @@
20
20
  background: var(--pk-surface);
21
21
  color: var(--pk-text);
22
22
  font-size: .875rem;
23
+ /* Content taller than the panel's max-height must scroll, not squash/spill
24
+ (sheet.css does the same on .pk-sheet-content); overflow also keeps the
25
+ rounded corners clipping. */
26
+ overflow: auto;
23
27
  }
24
28
  /* bottom (default) */
25
29
  .pk-drawer.bottom {
@@ -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-dropdown-menu-item pk-dropdown-menu-checkbox-item",
17
- role: "menuitemcheckbox",
18
- tabindex: "-1",
19
- aria: { checked: @checked ? "true" : "false" },
20
17
  data: { phlex_kit__dropdown_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-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
23
25
  tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
24
26
  span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) { check_icon }
@@ -14,9 +14,12 @@ module PhlexKit
14
14
 
15
15
  def view_template(&block)
16
16
  div(class: wrapper_classes, popover: "manual", data: { phlex_kit__dropdown_menu_target: "content" }) do
17
+ base = { class: "pk-dropdown-menu-viewport" }
17
18
  # role="menu" on the items' direct parent — the rows render
18
19
  # role="menuitem", which is invalid ARIA without a menu ancestor.
19
- div(**mix({ class: "pk-dropdown-menu-viewport", role: "menu" }, @attrs), &block)
20
+ # Default only when the caller didn't supply their own — `mix` fuses.
21
+ base[:role] = "menu" unless attr_set?(:role)
22
+ div(**mix(base, @attrs), &block)
20
23
  end
21
24
  end
22
25
 
@@ -17,7 +17,16 @@ import { Controller } from "@hotwired/stimulus";
17
17
  // dedupes re-arming with the same fn. If the gesture's click never fires
18
18
  // (e.g. suppressed after a selection drag) the armed swallow eats the next
19
19
  // one — rare enough to accept.
20
- const swallowClick = (ev) => ev.preventDefault();
20
+ const swallowClick = (ev) => {
21
+ // preventDefault alone suppresses only native default actions; the
22
+ // dismissing outside click would still fire addEventListener handlers on
23
+ // whatever sits under the pointer. stopPropagation (this listener is
24
+ // capture-phase on window, the outermost node, so it runs before any deeper
25
+ // handler) starves those too — the modal-menu contract is that an outside
26
+ // click ONLY dismisses.
27
+ ev.preventDefault();
28
+ ev.stopPropagation();
29
+ };
21
30
  function armSwallowClick() {
22
31
  window.addEventListener("click", swallowClick, { once: true, capture: true });
23
32
  }