phlex_kit 0.8.0 → 0.9.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 (148) 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_content.rb +11 -2
  5. data/app/components/phlex_kit/accordion/accordion_controller.js +23 -4
  6. data/app/components/phlex_kit/alert/alert.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +11 -4
  9. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +123 -6
  10. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +10 -4
  11. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  12. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  13. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  14. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  15. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  16. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  17. data/app/components/phlex_kit/badge/badge.css +4 -4
  18. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  19. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  20. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  21. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  22. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  23. data/app/components/phlex_kit/button/button.css +6 -6
  24. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  25. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  26. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  27. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  28. data/app/components/phlex_kit/calendar/calendar_body.rb +12 -1
  29. data/app/components/phlex_kit/calendar/calendar_controller.js +151 -12
  30. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  31. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  32. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  34. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  35. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  36. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  37. data/app/components/phlex_kit/chart/chart_controller.js +37 -10
  38. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  39. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  40. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  41. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
  42. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  43. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  44. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  45. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  46. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  47. data/app/components/phlex_kit/combobox/combobox_controller.js +67 -11
  48. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  49. data/app/components/phlex_kit/command/command.css +4 -4
  50. data/app/components/phlex_kit/command/command_controller.js +51 -10
  51. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  52. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  53. data/app/components/phlex_kit/command/command_input.rb +6 -2
  54. data/app/components/phlex_kit/command/command_item.rb +4 -1
  55. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  56. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
  57. data/app/components/phlex_kit/context_menu/context_menu_controller.js +69 -2
  58. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
  59. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
  60. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  61. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  62. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  63. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  64. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  65. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  66. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  67. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  68. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  69. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +6 -5
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  72. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
  73. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
  74. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +99 -45
  75. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  76. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
  77. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
  78. data/app/components/phlex_kit/field/field.css +1 -1
  79. data/app/components/phlex_kit/field/field_error.rb +5 -6
  80. data/app/components/phlex_kit/form_field/form_field_controller.js +44 -12
  81. data/app/components/phlex_kit/form_field/form_field_error.rb +8 -1
  82. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  83. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  84. data/app/components/phlex_kit/input/input.css +1 -1
  85. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  86. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  87. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  88. data/app/components/phlex_kit/input_otp/input_otp_controller.js +43 -4
  89. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  90. data/app/components/phlex_kit/item/item.css +1 -1
  91. data/app/components/phlex_kit/item/item_group.rb +4 -2
  92. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  93. data/app/components/phlex_kit/marker/marker.css +1 -1
  94. data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
  95. data/app/components/phlex_kit/masked_input/masked_input_controller.js +34 -9
  96. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  97. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  98. data/app/components/phlex_kit/menubar/menubar_controller.js +119 -12
  99. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  100. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  101. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +7 -2
  102. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  103. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  104. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  105. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +11 -3
  106. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  107. data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
  108. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  109. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  110. data/app/components/phlex_kit/popover/popover.css +1 -1
  111. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  112. data/app/components/phlex_kit/resizable/resizable_controller.js +78 -3
  113. data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
  114. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  115. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  116. data/app/components/phlex_kit/select/select.css +3 -3
  117. data/app/components/phlex_kit/select/select.rb +11 -7
  118. data/app/components/phlex_kit/select/select_controller.js +35 -4
  119. data/app/components/phlex_kit/select/select_item.rb +5 -2
  120. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  121. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  122. data/app/components/phlex_kit/sidebar/sidebar.css +27 -15
  123. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  124. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  125. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  126. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  127. data/app/components/phlex_kit/slider/slider.css +3 -3
  128. data/app/components/phlex_kit/slider/slider.rb +3 -1
  129. data/app/components/phlex_kit/switch/switch.rb +6 -1
  130. data/app/components/phlex_kit/table/table.css +5 -3
  131. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  132. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  133. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  134. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
  135. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +49 -7
  136. data/app/components/phlex_kit/toast/toast.css +32 -14
  137. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  138. data/app/components/phlex_kit/toast/toast_region.rb +19 -5
  139. data/app/components/phlex_kit/toast/toaster_controller.js +20 -6
  140. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  141. data/app/components/phlex_kit/toggle/toggle.rb +9 -3
  142. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  143. data/app/components/phlex_kit/typography/heading.rb +9 -3
  144. data/app/components/phlex_kit/typography/typography.css +4 -3
  145. data/app/javascript/phlex_kit/controllers/index.js +0 -2
  146. data/lib/phlex_kit/version.rb +1 -1
  147. metadata +1 -2
  148. data/app/components/phlex_kit/select/select_item_controller.js +0 -14
@@ -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; }
@@ -14,9 +14,13 @@ module PhlexKit
14
14
  def view_template(&block)
15
15
  label(**mix({
16
16
  class: "pk-context-menu-item pk-context-menu-checkbox-item",
17
- role: "menuitemcheckbox"
17
+ role: "menuitemcheckbox",
18
+ tabindex: "-1",
19
+ aria: { checked: @checked ? "true" : "false" },
20
+ data: { phlex_kit__context_menu_target: "menuItem" }
18
21
  }, @attrs)) do
19
- input(type: :checkbox, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked)
22
+ input(type: :checkbox, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
23
+ tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
20
24
  span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
21
25
  render Icon.new(:check, size: nil)
22
26
  end
@@ -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
  }
@@ -74,14 +91,64 @@ export default class extends Controller {
74
91
  e.preventDefault()
75
92
  items[items.length - 1]?.focus()
76
93
  break
94
+ case "Enter":
95
+ case " ":
96
+ // Explicit click so label rows (checkbox/radio) activate — labels
97
+ // have no native keyboard activation (and this gives links Space).
98
+ if (index >= 0) {
99
+ e.preventDefault()
100
+ items[index].click()
101
+ }
102
+ break
103
+ case "ArrowRight":
104
+ // On a sub trigger, enter the submenu (focus reveals it via
105
+ // :focus-within, making its rows visible to the roving nav).
106
+ if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
107
+ e.preventDefault()
108
+ this.enterSub(document.activeElement)
109
+ }
110
+ break
111
+ case "ArrowLeft": {
112
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
113
+ const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
114
+ if (sub) {
115
+ e.preventDefault()
116
+ sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
117
+ }
118
+ break
119
+ }
77
120
  }
78
121
  }
79
122
 
123
+ enterSub(trigger) {
124
+ trigger.focus()
125
+ const panel = trigger.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-content")
126
+ if (!panel) return
127
+ const first = [...panel.querySelectorAll("[role^=\"menuitem\"]")]
128
+ .find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
129
+ first?.focus()
130
+ }
131
+
132
+ // Mirrors a checkbox/radio row's native input state onto the row's
133
+ // aria-checked (radios also reset their group's siblings).
134
+ syncChecked(e) {
135
+ const input = e.target
136
+ const group = input.name
137
+ ? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
138
+ : [input]
139
+ group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
140
+ }
141
+
80
142
  anchor() {
81
143
  return this.hasTriggerTarget ? this.triggerTarget : this.element
82
144
  }
83
145
 
84
146
  items() {
85
- return this.menuItemTargets.filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
147
+ // getClientRects also skips rows inside a CLOSED sub panel (its
148
+ // display:none comes from the hover/focus-within CSS, not .pk-hidden) —
149
+ // focus() on those silently fails and the roving nav jams there.
150
+ return this.menuItemTargets.filter(
151
+ (el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0
152
+ )
86
153
  }
87
154
  }
@@ -13,9 +13,13 @@ module PhlexKit
13
13
  def view_template(&block)
14
14
  label(**mix({
15
15
  class: "pk-context-menu-item pk-context-menu-radio-item",
16
- role: "menuitemradio"
16
+ role: "menuitemradio",
17
+ tabindex: "-1",
18
+ aria: { checked: @checked ? "true" : "false" },
19
+ data: { phlex_kit__context_menu_target: "menuItem" }
17
20
  }, @attrs)) do
18
- input(type: :radio, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked)
21
+ input(type: :radio, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
22
+ tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
19
23
  span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
20
24
  # A filled selection dot is geometry, not icon-library vocabulary —
21
25
  # identical across icon_library settings (matches the dropdown).
@@ -6,8 +6,11 @@ module PhlexKit
6
6
  div(**mix({
7
7
  class: "pk-context-menu-item pk-context-menu-sub-trigger",
8
8
  role: "menuitem",
9
- tabindex: "0",
10
- aria: { haspopup: "menu" }
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
+ aria: { haspopup: "menu" },
13
+ data: { phlex_kit__context_menu_target: "menuItem" }
11
14
  }, @attrs)) do
12
15
  block&.call
13
16
  render Icon.new(:chevron_right, size: nil, class: "pk-context-menu-sub-chevron")
@@ -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
@@ -1,28 +1,42 @@
1
1
  module PhlexKit
2
2
  # Per-page NativeSelect in a GET form that self-submits on change (targeting
3
- # the table's turbo-frame when `frame_id:` is given). See data_table.rb.
3
+ # the table's turbo-frame when `frame_id:` is given). Extra filter params
4
+ # (search/sort/direction/...) survive via `preserved_params:` hidden inputs,
5
+ # mirroring DataTableSearch. See data_table.rb.
4
6
  class DataTablePerPageSelect < BaseComponent
5
- def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], **attrs)
7
+ def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, **attrs)
6
8
  @path = path
7
9
  @name = name
8
10
  @value = value
9
11
  @frame_id = frame_id
10
12
  @options = options
13
+ @preserved_params = preserved_params
11
14
  @attrs = attrs
12
15
  end
13
16
 
14
17
  def view_template
15
- form_attrs = { class: "pk-data-table-per-page", action: @path, method: "get" }
16
- form_attrs[:data] = { turbo_frame: @frame_id } if @frame_id
18
+ form_attrs = {
19
+ class: "pk-data-table-per-page",
20
+ action: @path,
21
+ method: "get",
22
+ # Reuse the search controller for its CSP-safe #submitNow action.
23
+ data: { controller: "phlex-kit--data-table-search" }
24
+ }
25
+ form_attrs[:data][:turbo_frame] = @frame_id if @frame_id
17
26
 
18
27
  form(**mix(form_attrs, @attrs)) do
19
- render NativeSelect.new(name: @name, onchange: safe("this.form.requestSubmit()")) do
28
+ render NativeSelect.new(name: @name, data: { action: "change->phlex-kit--data-table-search#submitNow" }) do
20
29
  @options.each do |opt|
21
30
  option_attrs = { value: opt.to_s }
22
31
  option_attrs[:selected] = true if opt.to_s == @value.to_s
23
32
  option(**option_attrs) { plain opt.to_s }
24
33
  end
25
34
  end
35
+ @preserved_params.each do |k, v|
36
+ next if v.nil? || (v.respond_to?(:empty?) && v.empty?)
37
+ next if k.to_s == @name
38
+ input(type: :hidden, name: k.to_s, value: v.to_s)
39
+ end
26
40
  end
27
41
  end
28
42
  end
@@ -32,6 +32,13 @@ export default class extends Controller {
32
32
  this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue);
33
33
  }
34
34
 
35
+ // Immediate, undebounced submit — used by change-driven controls like the
36
+ // per-page select (CSP-safe replacement for an inline onchange handler).
37
+ submitNow() {
38
+ clearTimeout(this.timer);
39
+ this.element.requestSubmit();
40
+ }
41
+
35
42
  captureBeforeRender() {
36
43
  const input = this.input();
37
44
  if (!input || document.activeElement !== input) return;
@@ -62,6 +69,11 @@ export default class extends Controller {
62
69
  }
63
70
 
64
71
  key() {
65
- return this.element.action || "_";
72
+ // The key must survive the Turbo Frame swap (the form element is replaced),
73
+ // so it's derived from stable markup: the form id when the caller set one,
74
+ // else action URL + search-input name — two same-path forms (e.g. search
75
+ // alongside the per-page select) must not share a focus-restore slot.
76
+ if (this.element.id) return `#${this.element.id}`;
77
+ return `${this.element.action || "_"}::${this.input()?.name ?? ""}`;
66
78
  }
67
79
  }
@@ -24,10 +24,13 @@ module PhlexKit
24
24
  @id = id || "date-picker-#{SecureRandom.hex(4)}"
25
25
  @name = name
26
26
  @label = label
27
- @value = value || selected_date&.to_s
28
- @placeholder = placeholder
29
27
  @selected_date = selected_date
30
28
  @date_format = date_format
29
+ # Seed the input with the same date_format the calendar controller
30
+ # writes — a bare `selected_date.to_s` (ISO) would mismatch the format
31
+ # until the first interaction.
32
+ @value = value || format_selected_date
33
+ @placeholder = placeholder
31
34
  @input_attrs = input_attrs
32
35
  @calendar_attrs = calendar_attrs
33
36
  @trigger_attrs = trigger_attrs
@@ -45,7 +48,7 @@ module PhlexKit
45
48
  end
46
49
  end
47
50
  render PopoverContent.new(**@content_attrs) do
48
- render Calendar.new(input_id: "##{@id}", selected_date: @selected_date, date_format: @date_format, **@calendar_attrs)
51
+ render Calendar.new(input_id: input_selector, selected_date: @selected_date, date_format: @date_format, **@calendar_attrs)
49
52
  end
50
53
  end
51
54
  end
@@ -53,6 +56,50 @@ module PhlexKit
53
56
 
54
57
  private
55
58
 
59
+ # The outlet takes a CSS selector. "#id" is only valid for CSS-identifier
60
+ # ids — anything else (Rails-style "user:due.date" etc) must go through a
61
+ # quoted attribute selector or querySelector throws.
62
+ def input_selector
63
+ return "##{@id}" if @id.match?(/\A[A-Za-z][\w-]*\z/)
64
+
65
+ %([id="#{@id.gsub(/[\\"]/) { |c| "\\#{c}" }}"])
66
+ end
67
+
68
+ # Mirrors the calendar controller's formatDate tokens (calendar_controller.js)
69
+ # so the server-rendered input matches what JS writes after a pick.
70
+ FORMAT_TOKENS = /yyyy|MMMM|MM|dd|HH|mm|ss|EEEE|PPPP|do/
71
+
72
+ def format_selected_date
73
+ return nil unless @selected_date
74
+
75
+ date = @selected_date.respond_to?(:strftime) ? @selected_date : Date.parse(@selected_date.to_s)
76
+ day = date.day
77
+ suffix = day_suffix(day)
78
+ day_name = Date::DAYNAMES[date.wday]
79
+ month_name = Date::MONTHNAMES[date.month]
80
+ map = {
81
+ "yyyy" => date.year.to_s,
82
+ "MMMM" => month_name,
83
+ "MM" => Kernel.format("%02d", date.month),
84
+ "dd" => Kernel.format("%02d", day),
85
+ "HH" => Kernel.format("%02d", date.respond_to?(:hour) ? date.hour : 0),
86
+ "mm" => Kernel.format("%02d", date.respond_to?(:min) ? date.min : 0),
87
+ "ss" => Kernel.format("%02d", date.respond_to?(:sec) ? date.sec : 0),
88
+ "EEEE" => day_name,
89
+ "do" => "#{day}#{suffix}",
90
+ "PPPP" => "#{day_name}, #{month_name} #{day}#{suffix}, #{date.year}"
91
+ }
92
+ @date_format.gsub(FORMAT_TOKENS) { |token| map[token] }
93
+ rescue Date::Error
94
+ @selected_date.to_s
95
+ end
96
+
97
+ def day_suffix(day)
98
+ return "th" if day > 3 && day < 21
99
+
100
+ { 1 => "st", 2 => "nd", 3 => "rd" }.fetch(day % 10, "th")
101
+ end
102
+
56
103
  def trigger_attrs
57
104
  mix({ class: "pk-date-picker-trigger" }, @trigger_attrs)
58
105
  end
@@ -17,12 +17,12 @@
17
17
  border-radius: .75rem; background: var(--pk-surface); color: var(--pk-text);
18
18
  padding: var(--pk-dialog-spacing);
19
19
  font-size: .875rem;
20
- box-shadow: 0 10px 30px rgb(0 0 0 / .35);
20
+ box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
21
21
  }
22
22
  .pk-dialog[open] { display: flex; flex-direction: column; }
23
- .pk-dialog::backdrop { background: rgb(0 0 0 / .1); backdrop-filter: blur(4px); }
23
+ .pk-dialog::backdrop { background: color-mix(in srgb, var(--pk-overlay) 10%, transparent); backdrop-filter: blur(4px); }
24
24
  @supports not (backdrop-filter: blur(1px)) {
25
- .pk-dialog::backdrop { background: rgb(0 0 0 / .5); }
25
+ .pk-dialog::backdrop { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
26
26
  }
27
27
  .pk-dialog.xs { max-width: min(16rem, calc(100% - 2rem)); }
28
28
  .pk-dialog.sm { max-width: min(20rem, calc(100% - 2rem)); }
@@ -51,6 +51,6 @@
51
51
  }
52
52
  .pk-overlay-close:hover { opacity: 1; }
53
53
  @media (min-width: 640px) {
54
- .pk-dialog-header { text-align: left; }
54
+ .pk-dialog-header { text-align: start; }
55
55
  .pk-dialog-footer { flex-direction: row; justify-content: flex-end; }
56
56
  }
@@ -24,10 +24,26 @@ export default class extends Controller {
24
24
  }
25
25
  disconnect() {
26
26
  this.dialogTarget.removeEventListener("close", this.handleClose)
27
- document.body.style.removeProperty("overflow")
27
+ if (this.dialogTarget.open) this.handleClose()
28
+ }
29
+ open(e) {
30
+ e?.preventDefault()
31
+ this.dialogTarget.showModal()
32
+ // Save/restore rather than removeProperty: a host page (or another
33
+ // component) may have set body overflow itself.
34
+ this.previousOverflow = document.body.style.overflow
35
+ document.body.style.overflow = "hidden"
28
36
  }
29
- open(e) { e?.preventDefault(); this.dialogTarget.showModal(); document.body.style.overflow = "hidden" }
30
37
  dismiss() { this.dialogTarget.close() }
31
- backdropClick(e) { if (e.target === this.dialogTarget) this.dismiss() }
32
- handleClose = () => { document.body.style.removeProperty("overflow") }
38
+ // Only a click on the ::backdrop dismisses. e.target is the <dialog> for
39
+ // backdrop clicks, but ALSO for clicks on the dialog's own padding/flex
40
+ // gaps — disambiguate by testing the click point against the dialog's box.
41
+ backdropClick(e) {
42
+ if (e.target !== this.dialogTarget) return
43
+ if (e.detail === 0) return // synthetic/keyboard click — no coordinates
44
+ const r = this.dialogTarget.getBoundingClientRect()
45
+ const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
46
+ if (!inside) this.dismiss()
47
+ }
48
+ handleClose = () => { document.body.style.overflow = this.previousOverflow ?? "" }
33
49
  }
@@ -9,7 +9,7 @@
9
9
  position: fixed;
10
10
  inset: 0;
11
11
  z-index: 50;
12
- background: rgb(0 0 0 / .1);
12
+ background: color-mix(in srgb, var(--pk-overlay) 10%, transparent);
13
13
  backdrop-filter: blur(4px);
14
14
  }
15
15
  .pk-drawer {
@@ -87,8 +87,8 @@
87
87
  padding: 1rem;
88
88
  }
89
89
  @media (min-width: 768px) {
90
- .pk-drawer-header { text-align: left; }
90
+ .pk-drawer-header { text-align: start; }
91
91
  }
92
92
  @supports not (backdrop-filter: blur(1px)) {
93
- .pk-drawer-overlay { background: rgb(0 0 0 / .5); }
93
+ .pk-drawer-overlay { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
94
94
  }
@@ -37,7 +37,7 @@
37
37
  background: var(--pk-surface);
38
38
  color: var(--pk-text);
39
39
  padding: .25rem;
40
- box-shadow: 0 4px 16px rgba(0, 0, 0, .4);
40
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
41
41
  }
42
42
 
43
43
  .pk-dropdown-menu-item {
@@ -54,9 +54,10 @@
54
54
  text-decoration: none; /* href items render as <a> — no UA underline at rest */
55
55
  outline: none;
56
56
  }
57
+ /* Keyboard nav moves real focus — :focus is the highlight (no aria-selected:
58
+ that attribute is unsupported on menuitems and AT ignores it). */
57
59
  .pk-dropdown-menu-item:hover,
58
- .pk-dropdown-menu-item:focus,
59
- .pk-dropdown-menu-item[aria-selected="true"] { background: var(--pk-accent); text-decoration: none; }
60
+ .pk-dropdown-menu-item:focus { background: var(--pk-accent); text-decoration: none; }
60
61
 
61
62
  .pk-dropdown-menu-label { margin: 0; padding: .375rem .5rem; font-size: .875rem; font-weight: 600; }
62
63
  .pk-dropdown-menu-separator { height: 1px; margin: .25rem -.25rem; background: var(--pk-border); }
@@ -75,7 +76,7 @@
75
76
 
76
77
  /* Keyboard hint, pushed to the row's end. */
77
78
  .pk-dropdown-menu-shortcut {
78
- margin-left: auto;
79
+ margin-inline-start: auto;
79
80
  font-size: .75rem;
80
81
  letter-spacing: .1em;
81
82
  color: var(--pk-muted);
@@ -111,7 +112,7 @@
111
112
  The row is its own scoped anchor; the panel flips to the other side at
112
113
  the viewport right edge. */
113
114
  .pk-dropdown-menu-sub { anchor-name: --pk-dropdown-menu-sub; anchor-scope: --pk-dropdown-menu-sub; }
114
- .pk-dropdown-menu-sub-chevron { margin-left: auto; width: 1rem; height: 1rem; }
115
+ .pk-dropdown-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
115
116
  .pk-dropdown-menu-sub-content {
116
117
  position: fixed;
117
118
  position-anchor: --pk-dropdown-menu-sub;