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
@@ -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); }
@@ -66,7 +66,13 @@ export default class extends Controller {
66
66
  }
67
67
 
68
68
  if (this.hasSelectionSummaryTarget) {
69
- this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`;
69
+ // The component stamps its (possibly localized) %{selected}/%{total}
70
+ // template as data-format; live updates interpolate the same string
71
+ // the server rendered.
72
+ const format = this.selectionSummaryTarget.dataset.format || "%{selected} of %{total} row(s) selected.";
73
+ this.selectionSummaryTarget.textContent = format
74
+ .replace("%{selected}", String(selected))
75
+ .replace("%{total}", String(total));
70
76
  }
71
77
 
72
78
  if (this.hasBulkActionsTarget) {
@@ -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
@@ -1,9 +1,15 @@
1
1
  module PhlexKit
2
2
  # "N of M row(s) selected." line, kept current by the controller.
3
+ # `format:` localizes it — a template with %{selected} and %{total}
4
+ # placeholders, used for the server render AND stamped on the element so
5
+ # the controller's live updates interpolate the same string.
3
6
  # See data_table.rb.
4
7
  class DataTableSelectionSummary < BaseComponent
5
- def initialize(total_on_page: 0, **attrs)
8
+ DEFAULT_FORMAT = "%{selected} of %{total} row(s) selected."
9
+
10
+ def initialize(total_on_page: 0, format: DEFAULT_FORMAT, **attrs)
6
11
  @total_on_page = total_on_page
12
+ @format = format
7
13
  @attrs = attrs
8
14
  end
9
15
 
@@ -11,9 +17,12 @@ module PhlexKit
11
17
  div(**mix({
12
18
  class: "pk-data-table-selection-summary",
13
19
  role: "status", # live region — selection changes are announced to AT
14
- data: { phlex_kit__data_table_target: "selectionSummary" }
20
+ data: {
21
+ phlex_kit__data_table_target: "selectionSummary",
22
+ format: @format
23
+ }
15
24
  }, @attrs)) do
16
- plain "0 of #{@total_on_page} row(s) selected."
25
+ plain Kernel.format(@format, selected: 0, total: @total_on_page)
17
26
  end
18
27
  end
19
28
  end
@@ -13,11 +13,9 @@ export default class extends Controller {
13
13
  onCalendarChange(e) {
14
14
  const { mode, rangeEnd } = e.detail
15
15
  if (mode === "multiple") return
16
- // calendar_controller.js clears rangeEnd via `this.rangeEndValue = null`,
17
- // which goes through Element#setAttribute the DOM stringifies a null
18
- // argument to the literal "null", so a genuinely-unset rangeEnd reads
19
- // back as that string, not JS null/undefined/"".
20
- if (mode === "range" && (!rangeEnd || rangeEnd === "null")) return
16
+ // calendar_controller.js clears rangeEnd by assigning undefined (Stimulus
17
+ // removes the attribute), so an unset rangeEnd reads back as null here.
18
+ if (mode === "range" && !rangeEnd) return
21
19
  this.popoverController()?.close()
22
20
  }
23
21
 
@@ -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 {
@@ -18,9 +18,9 @@ module PhlexKit
18
18
  # Defaults only when the caller didn't supply their own — `mix`
19
19
  # would fuse role="dialog region" / aria-modal="true false" /
20
20
  # tabindex="-1 0" instead of overriding.
21
- panel_attrs[:role] = "dialog" unless @attrs.key?(:role) || @attrs.key?("role")
21
+ panel_attrs[:role] = "dialog" unless attr_set?(:role)
22
22
  panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
- panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
23
+ panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
24
24
  div(**mix(panel_attrs, @attrs)) do
25
25
  div(class: "pk-drawer-handle", aria: { hidden: "true" })
26
26
  yield if block
@@ -20,7 +20,9 @@ module PhlexKit
20
20
  phlex_kit__dropdown_menu_open_value: @open,
21
21
  # focusout: tabbing (or otherwise moving real focus) out of the
22
22
  # menu closes the open [popover=manual] panel (menubar's pattern).
23
- action: "click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
23
+ # mousedown@window arms the modal-menu click swallow before the
24
+ # gesture's focusout closes the panel (see the controller).
25
+ action: "mousedown@window->phlex-kit--dropdown-menu#onMousedownOutside click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
24
26
  }
25
27
  }, @attrs), &block)
26
28
  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-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
 
@@ -10,6 +10,27 @@ import { Controller } from "@hotwired/stimulus";
10
10
  // sub-rows are skipped via a visibility filter. Escape returns focus to the
11
11
  // trigger; Enter/Space activate the focused row (links, checkbox/radio
12
12
  // labels, and sub triggers alike).
13
+
14
+ // One-shot capture listener swallowing the click an outside mousedown is
15
+ // about to produce (see onMousedownOutside; same helper in menubar/context
16
+ // menu — duplicated per controller by design). {once} self-removes; the DOM
17
+ // dedupes re-arming with the same fn. If the gesture's click never fires
18
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
19
+ // one — rare enough to accept.
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
+ };
30
+ function armSwallowClick() {
31
+ window.addEventListener("click", swallowClick, { once: true, capture: true });
32
+ }
33
+
13
34
  export default class extends Controller {
14
35
  static targets = ["trigger", "content", "menuItem"];
15
36
  static values = { open: { type: Boolean, default: false } };
@@ -69,16 +90,23 @@ export default class extends Controller {
69
90
  this.subFrames.add(id);
70
91
  }
71
92
 
93
+ // Deliberate (matches Radix's modal dismiss): the dismissing outside
94
+ // click ONLY dismisses the menu — swallowed rather than also acting on
95
+ // whatever was under the pointer (e.g. navigating a link). Armed at
96
+ // MOUSEDOWN: for a focusable click target the same gesture's focusout
97
+ // closes the panel before the click fires, so a click-time swallow
98
+ // silently skipped those targets (audit round 8).
99
+ onMousedownOutside(event) {
100
+ if (!this.contentTarget.matches(":popover-open")) return;
101
+ if (this.element.contains(event.target)) return;
102
+ armSwallowClick();
103
+ }
104
+
72
105
  onClickOutside(event) {
73
106
  // Gate on the live :popover-open state, not the stored flag — a stale
74
107
  // flag is how a close on an already-closed panel becomes an open.
75
108
  if (!this.contentTarget.matches(":popover-open")) return;
76
109
  if (this.element.contains(event.target)) return;
77
-
78
- // Deliberate (matches Radix's modal dismiss): the outside click ONLY
79
- // dismisses the menu — it is swallowed rather than also acting on
80
- // whatever was under the pointer (e.g. navigating a link).
81
- event.preventDefault();
82
110
  this.close();
83
111
  }
84
112
 
@@ -5,7 +5,10 @@ module PhlexKit
5
5
  class DropdownMenuGroup < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-dropdown-menu-group", role: "group" }, @attrs), &)
8
+ base = { class: "pk-dropdown-menu-group" }
9
+ # Default only when the caller didn't supply their own — `mix` fuses.
10
+ base[:role] = "group" unless attr_set?(:role)
11
+ div(**mix(base, @attrs), &)
9
12
  end
10
13
  end
11
14
  end
@@ -13,11 +13,12 @@ module PhlexKit
13
13
 
14
14
  def view_template(&block)
15
15
  base = {
16
- role: "menuitem",
17
- tabindex: "-1",
18
16
  class: [ "pk-dropdown-menu-item", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" "),
19
17
  data: { phlex_kit__dropdown_menu_target: "menuItem", action: "click->phlex-kit--dropdown-menu#close" }
20
18
  }
19
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
20
+ base[:role] = "menuitem" unless attr_set?(:role)
21
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
21
22
  base[:href] = @href unless @as == :div
22
23
  send(@as, **mix(base, @attrs), &block)
23
24
  end
@@ -4,7 +4,10 @@ module PhlexKit
4
4
  class DropdownMenuRadioGroup < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-dropdown-menu-group", role: "radiogroup" }, @attrs), &)
7
+ base = { class: "pk-dropdown-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-dropdown-menu-item pk-dropdown-menu-radio-item",
16
- role: "menuitemradio",
17
- tabindex: "-1",
18
- aria: { checked: @checked ? "true" : "false" },
19
16
  data: { phlex_kit__dropdown_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-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
22
24
  tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
23
25
  span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) do
@@ -6,7 +6,12 @@ module PhlexKit
6
6
  end
7
7
 
8
8
  def view_template
9
- div(**mix({ role: "separator", "aria-orientation": "horizontal", class: "pk-dropdown-menu-separator" }, @attrs))
9
+ base = { class: "pk-dropdown-menu-separator" }
10
+ # Defaults only when the caller didn't supply their own — `mix` fuses,
11
+ # and aria_key_set? covers the aria: hash spelling the flat key missed.
12
+ base[:role] = "separator" unless attr_set?(:role)
13
+ base[:"aria-orientation"] = "horizontal" unless aria_key_set?(:orientation)
14
+ div(**mix(base, @attrs))
10
15
  end
11
16
  end
12
17
  end
@@ -4,7 +4,10 @@ module PhlexKit
4
4
  class DropdownMenuSubContent < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-dropdown-menu-sub-content", role: "menu" }, @attrs)) do
7
+ base = { class: "pk-dropdown-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-dropdown-menu-viewport", &)
9
12
  end
10
13
  end
@@ -4,17 +4,20 @@ module PhlexKit
4
4
  class DropdownMenuSubTrigger < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&block)
7
- div(**mix({
7
+ base = {
8
8
  class: "pk-dropdown-menu-item pk-dropdown-menu-sub-trigger",
9
- role: "menuitem",
10
- # -1: roving focus reaches it via arrows (focus-within opens the sub);
11
- # tabindex 0 made it a stray tab stop inside the menu.
12
- tabindex: "-1",
13
- # expanded starts false and is mirrored live by the controller's
14
- # syncSub (the reveal itself is pure CSS :hover/:focus-within).
15
- aria: { haspopup: "menu", expanded: "false" },
16
9
  data: { phlex_kit__dropdown_menu_target: "menuItem" }
17
- }, @attrs)) do
10
+ }
11
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
12
+ base[:role] = "menuitem" unless attr_set?(:role)
13
+ # -1: roving focus reaches it via arrows (focus-within opens the sub);
14
+ # tabindex 0 made it a stray tab stop inside the menu.
15
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
16
+ base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
17
+ # expanded starts false and is mirrored live by the controller's
18
+ # syncSub (the reveal itself is pure CSS :hover/:focus-within).
19
+ base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
20
+ div(**mix(base, @attrs)) do
18
21
  block&.call
19
22
  render Icon.new(:chevron_right, size: nil, class: "pk-dropdown-menu-sub-chevron")
20
23
  end
@@ -1,4 +1,5 @@
1
1
  module PhlexKit
2
+ # Empty-state container, ported from shadcn/ui's Empty.
2
3
  class Empty < BaseComponent
3
4
  def initialize(**attrs) = (@attrs = attrs)
4
5
  def view_template(&) = div(**mix({ class: "pk-empty" }, @attrs), &)
@@ -29,7 +29,10 @@ module PhlexKit
29
29
  data = { slot: "field", orientation: fetch_option(ORIENTATIONS, @orientation, :orientation) }
30
30
  data[:invalid] = "true" if @invalid
31
31
  data[:disabled] = "true" if @disabled
32
- div(**mix({ class: "pk-field", role: "group", data: data }, @attrs), &)
32
+ base = { class: "pk-field", data: data }
33
+ # Default only when the caller didn't supply their own — `mix` fuses.
34
+ base[:role] = "group" unless attr_set?(:role)
35
+ div(**mix(base, @attrs), &)
33
36
  end
34
37
  end
35
38
  end
@@ -13,7 +13,10 @@ module PhlexKit
13
13
  def view_template(&block)
14
14
  return if @errors.empty? && !block
15
15
 
16
- div(**mix({ class: "pk-field-error", role: "alert", data: { slot: "field-error" } }, @attrs)) do
16
+ base = { class: "pk-field-error", data: { slot: "field-error" } }
17
+ # Default only when the caller didn't supply their own — `mix` fuses.
18
+ base[:role] = "alert" unless attr_set?(:role)
19
+ div(**mix(base, @attrs)) do
17
20
  if @errors.length == 1
18
21
  plain @errors.first
19
22
  elsif @errors.length > 1
@@ -49,11 +49,28 @@ export default class extends Controller {
49
49
  error.preventDefault();
50
50
 
51
51
  // preventDefault also cancels the browser's focus-first-invalid — restore
52
- // it for the first invalid control of the submit pass (all invalid events
53
- // fire in the same task, so the microtask reset scopes "first" correctly).
54
- if (!this._focusedInvalid) {
55
- this._focusedInvalid = true;
56
- queueMicrotask(() => { this._focusedInvalid = false; });
52
+ // it for the FIRST invalid control of the submit pass. Coordinate across
53
+ // sibling FormField controllers via a flag on the shared form: invalid
54
+ // events fire in document order, so the first control to claim wins. A
55
+ // per-controller flag alone let each FormField focus its own control, so
56
+ // the LAST invalid field ended up focused, inverting native behavior. No
57
+ // shared JS util (kit rule) — the form element itself is the coordination
58
+ // point. The reset runs on a macrotask, NOT queueMicrotask: a microtask
59
+ // checkpoint can drain between two invalid events (the JS stack empties
60
+ // after each listener), clearing the flag mid-pass so the next field
61
+ // re-claims. setTimeout survives the whole synchronous validation pass and
62
+ // resets before the next submit. A control with no form (rare) falls back
63
+ // to the per-controller flag.
64
+ const form = error.target.form;
65
+ const alreadyClaimed = form ? form.dataset.pkInvalidFocused : this._focusedInvalid;
66
+ if (!alreadyClaimed) {
67
+ if (form) {
68
+ form.dataset.pkInvalidFocused = "1";
69
+ setTimeout(() => { delete form.dataset.pkInvalidFocused; }, 0);
70
+ } else {
71
+ this._focusedInvalid = true;
72
+ setTimeout(() => { this._focusedInvalid = false; }, 0);
73
+ }
57
74
  error.target.focus({ preventScroll: true });
58
75
  error.target.scrollIntoView({ block: "nearest" });
59
76
  }
@@ -23,9 +23,13 @@ module PhlexKit
23
23
  base = base_attrs(icon)
24
24
  # `mix` merges duplicate string attrs ("16 24") — drop a generated attr
25
25
  # whenever the caller supplies their own copy, so theirs wins.
26
- %i[width height viewbox aria-hidden].each do |key|
27
- base.delete(key) if @attrs.key?(key) || @attrs.key?(key.to_s)
26
+ %i[width height viewbox].each do |key|
27
+ base.delete(key) if attr_set?(key)
28
28
  end
29
+ # aria-hidden needs the aria-aware guard: the aria: hash and underscore
30
+ # spellings must also suppress the default (a caller-supplied
31
+ # accessible name must not be hidden from AT by our default).
32
+ base.delete(:"aria-hidden") if aria_key_set?(:hidden) || aria_labelled?
29
33
  svg(**mix(base, @attrs)) do |s|
30
34
  icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
31
35
  end
@@ -34,7 +34,10 @@
34
34
  border-color: color-mix(in oklab, var(--pk-red) 50%, transparent);
35
35
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
36
36
  }
37
- .pk-input-group:has(:disabled) {
37
+ /* Group-disabled presentation follows the CONTROL only (the direct-child
38
+ input/textarea/select) — a disabled addon button must not dim a group whose
39
+ input is still editable. */
40
+ .pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) {
38
41
  opacity: .5;
39
42
  background: color-mix(in oklab, var(--pk-input) 80%, transparent);
40
43
  }
@@ -97,14 +100,14 @@
97
100
  .pk-input-group-text svg { pointer-events: none; }
98
101
 
99
102
  :root[data-theme="light"] .pk-input-group { background: transparent; }
100
- :root[data-theme="light"] .pk-input-group:has(:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
103
+ :root[data-theme="light"] .pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
101
104
  :root[data-theme="light"] .pk-input-group:has([aria-invalid="true"]) {
102
105
  border-color: var(--pk-red);
103
106
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
104
107
  }
105
108
  @media (prefers-color-scheme: light) {
106
109
  :root[data-theme="system"] .pk-input-group { background: transparent; }
107
- :root[data-theme="system"] .pk-input-group:has(:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
110
+ :root[data-theme="system"] .pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
108
111
  :root[data-theme="system"] .pk-input-group:has([aria-invalid="true"]) {
109
112
  border-color: var(--pk-red);
110
113
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);