phlex_kit 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -8
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -0
  4. data/app/components/phlex_kit/accordion/accordion.rb +1 -1
  5. data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
  6. data/app/components/phlex_kit/accordion/accordion_controller.js +26 -2
  7. data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +20 -4
  8. data/app/components/phlex_kit/accordion/accordion_trigger.rb +18 -2
  9. data/app/components/phlex_kit/alert/alert.rb +1 -1
  10. data/app/components/phlex_kit/alert_dialog/alert_dialog_cancel.rb +1 -1
  11. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +1 -1
  12. data/app/components/phlex_kit/attachment/attachment.rb +2 -2
  13. data/app/components/phlex_kit/attachment/attachment_media.rb +1 -1
  14. data/app/components/phlex_kit/avatar/avatar.rb +1 -1
  15. data/app/components/phlex_kit/badge/badge.rb +1 -1
  16. data/app/components/phlex_kit/button/button.rb +1 -1
  17. data/app/components/phlex_kit/button_group/button_group.rb +1 -1
  18. data/app/components/phlex_kit/calendar/calendar_controller.js +12 -8
  19. data/app/components/phlex_kit/card/card.rb +1 -1
  20. data/app/components/phlex_kit/carousel/carousel.rb +1 -1
  21. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +1 -1
  22. data/app/components/phlex_kit/combobox/combobox.css +21 -12
  23. data/app/components/phlex_kit/combobox/combobox.rb +19 -3
  24. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +7 -2
  25. data/app/components/phlex_kit/combobox/combobox_checkbox.rb +3 -0
  26. data/app/components/phlex_kit/combobox/combobox_controller.js +85 -20
  27. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +11 -2
  28. data/app/components/phlex_kit/combobox/combobox_item.rb +4 -1
  29. data/app/components/phlex_kit/combobox/combobox_list.rb +9 -2
  30. data/app/components/phlex_kit/combobox/combobox_popover.rb +8 -7
  31. data/app/components/phlex_kit/combobox/combobox_radio.rb +3 -0
  32. data/app/components/phlex_kit/combobox/combobox_search_input.rb +8 -3
  33. data/app/components/phlex_kit/command/command.rb +17 -2
  34. data/app/components/phlex_kit/command/command_controller.js +93 -3
  35. data/app/components/phlex_kit/command/command_dialog_content.rb +16 -4
  36. data/app/components/phlex_kit/command/command_input.rb +7 -2
  37. data/app/components/phlex_kit/command/command_item.rb +12 -3
  38. data/app/components/phlex_kit/command/command_list.rb +9 -2
  39. data/app/components/phlex_kit/context_menu/context_menu.css +25 -13
  40. data/app/components/phlex_kit/context_menu/context_menu_content.rb +1 -1
  41. data/app/components/phlex_kit/context_menu/context_menu_controller.js +71 -12
  42. data/app/components/phlex_kit/context_menu/context_menu_item.rb +7 -3
  43. data/app/components/phlex_kit/context_menu/context_menu_label.rb +1 -1
  44. data/app/components/phlex_kit/context_menu/context_menu_trigger.rb +3 -1
  45. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +1 -1
  46. data/app/components/phlex_kit/data_table/data_table_controller.js +12 -1
  47. data/app/components/phlex_kit/data_table/data_table_pagination.rb +12 -10
  48. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +1 -0
  49. data/app/components/phlex_kit/data_table/data_table_sort_head.rb +5 -1
  50. data/app/components/phlex_kit/dialog/dialog_content.rb +12 -4
  51. data/app/components/phlex_kit/dialog/dialog_controller.js +13 -0
  52. data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
  53. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +36 -15
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -3
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +8 -11
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +1 -1
  57. data/app/components/phlex_kit/empty/empty_media.rb +1 -1
  58. data/app/components/phlex_kit/field/field.rb +1 -1
  59. data/app/components/phlex_kit/field/field_legend.rb +1 -1
  60. data/app/components/phlex_kit/hover_card/hover_card.css +27 -8
  61. data/app/components/phlex_kit/hover_card/hover_card.rb +2 -1
  62. data/app/components/phlex_kit/hover_card/hover_card_content.rb +2 -2
  63. data/app/components/phlex_kit/hover_card/hover_card_controller.js +19 -5
  64. data/app/components/phlex_kit/input_group/input_group_addon.rb +1 -1
  65. data/app/components/phlex_kit/input_otp/input_otp.rb +4 -1
  66. data/app/components/phlex_kit/input_otp/input_otp_controller.js +12 -1
  67. data/app/components/phlex_kit/item/item.rb +1 -1
  68. data/app/components/phlex_kit/item/item_media.rb +1 -1
  69. data/app/components/phlex_kit/link/link.rb +1 -1
  70. data/app/components/phlex_kit/marker/marker.rb +1 -1
  71. data/app/components/phlex_kit/menubar/menubar.css +36 -14
  72. data/app/components/phlex_kit/menubar/menubar.rb +1 -1
  73. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +5 -2
  74. data/app/components/phlex_kit/menubar/menubar_content.rb +1 -1
  75. data/app/components/phlex_kit/menubar/menubar_controller.js +100 -12
  76. data/app/components/phlex_kit/menubar/menubar_item.rb +8 -5
  77. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +5 -2
  78. data/app/components/phlex_kit/menubar/menubar_trigger.rb +1 -0
  79. data/app/components/phlex_kit/native_select/native_select.rb +1 -1
  80. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +24 -7
  81. data/app/components/phlex_kit/navigation_menu/navigation_menu_content.rb +3 -1
  82. data/app/components/phlex_kit/popover/popover.css +24 -9
  83. data/app/components/phlex_kit/popover/popover.rb +2 -1
  84. data/app/components/phlex_kit/popover/popover_content.rb +6 -4
  85. data/app/components/phlex_kit/popover/popover_controller.js +12 -8
  86. data/app/components/phlex_kit/popover/popover_trigger.rb +1 -1
  87. data/app/components/phlex_kit/resizable/resizable_panel.rb +4 -2
  88. data/app/components/phlex_kit/select/select.css +24 -12
  89. data/app/components/phlex_kit/select/select_content.rb +6 -4
  90. data/app/components/phlex_kit/select/select_controller.js +21 -17
  91. data/app/components/phlex_kit/select/select_trigger.rb +1 -1
  92. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  93. data/app/components/phlex_kit/sheet/sheet_content_controller.js +68 -2
  94. data/app/components/phlex_kit/sheet/sheet_controller.js +28 -3
  95. data/app/components/phlex_kit/sidebar/sidebar.rb +1 -1
  96. data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +1 -1
  97. data/app/components/phlex_kit/spinner/spinner.rb +1 -1
  98. data/app/components/phlex_kit/switch/switch.rb +7 -4
  99. data/app/components/phlex_kit/tabs/tabs.rb +1 -1
  100. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -1
  101. data/app/components/phlex_kit/tabs/tabs_controller.js +60 -7
  102. data/app/components/phlex_kit/tabs/tabs_list.rb +2 -2
  103. data/app/components/phlex_kit/tabs/tabs_trigger.rb +14 -2
  104. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -3
  105. data/app/components/phlex_kit/toast/toast_controller.js +17 -0
  106. data/app/components/phlex_kit/toast/toast_icon.rb +1 -1
  107. data/app/components/phlex_kit/toast/toast_item.rb +1 -1
  108. data/app/components/phlex_kit/toggle/toggle.rb +2 -2
  109. data/app/components/phlex_kit/toggle_group/toggle_group.rb +2 -2
  110. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +1 -1
  111. data/app/components/phlex_kit/tooltip/tooltip.css +40 -22
  112. data/app/components/phlex_kit/tooltip/tooltip.rb +3 -1
  113. data/app/components/phlex_kit/tooltip/tooltip_content.rb +1 -1
  114. data/app/components/phlex_kit/tooltip/tooltip_controller.js +32 -0
  115. data/app/components/phlex_kit/tooltip/tooltip_trigger.rb +8 -3
  116. data/app/components/phlex_kit/typography/text.rb +1 -1
  117. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  118. data/lib/generators/phlex_kit/component/component_generator.rb +28 -0
  119. data/lib/generators/phlex_kit/install/templates/phlex_kit.rb +3 -0
  120. data/lib/phlex_kit/base_component.rb +12 -0
  121. data/lib/phlex_kit/engine.rb +11 -1
  122. data/lib/phlex_kit/icons/lucide.rb +1 -1
  123. data/lib/phlex_kit/icons/phosphor.rb +1 -1
  124. data/lib/phlex_kit/icons/remix.rb +1 -1
  125. data/lib/phlex_kit/icons/tabler.rb +1 -1
  126. data/lib/phlex_kit/version.rb +1 -1
  127. data/lib/phlex_kit.rb +5 -0
  128. metadata +2 -1
@@ -1,11 +1,25 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Connects to data-controller="phlex-kit--hover-card". Show on hover/focus with a
4
- // small open/close delay. CSS positions the card under the trigger.
3
+ // Connects to data-controller="phlex-kit--hover-card". Show on hover/focus
4
+ // with a small open/close delay. The panel is a native [popover=manual]
5
+ // (top layer; manual because the controller owns open/close — hover
6
+ // semantics want no light dismiss), anchor-positioned by hover_card.css.
5
7
  export default class extends Controller {
6
- static targets = ["content"]
8
+ static targets = ["trigger", "content"]
7
9
  static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
8
- show() { clearTimeout(this.t); this.t = setTimeout(() => { this.contentTarget.classList.remove("pk-hidden"); this.contentTarget.dataset.state = "open" }, this.openDelayValue) }
9
- hide() { clearTimeout(this.t); this.t = setTimeout(() => { this.contentTarget.classList.add("pk-hidden"); this.contentTarget.dataset.state = "closed" }, this.closeDelayValue) }
10
+ show() {
11
+ clearTimeout(this.t)
12
+ this.t = setTimeout(() => {
13
+ if (!this.contentTarget.matches(":popover-open")) this.contentTarget.showPopover()
14
+ this.contentTarget.dataset.state = "open"
15
+ }, this.openDelayValue)
16
+ }
17
+ hide() {
18
+ clearTimeout(this.t)
19
+ this.t = setTimeout(() => {
20
+ if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
21
+ this.contentTarget.dataset.state = "closed"
22
+ }, this.closeDelayValue)
23
+ }
10
24
  disconnect() { clearTimeout(this.t) }
11
25
  }
@@ -17,7 +17,7 @@ module PhlexKit
17
17
  end
18
18
 
19
19
  def view_template(&)
20
- div(**mix({ class: "pk-input-group-addon #{ALIGNS.fetch(@align)}", role: "group" }, @attrs), &)
20
+ div(**mix({ class: "pk-input-group-addon #{fetch_option(ALIGNS, @align, :align)}", role: "group" }, @attrs), &)
21
21
  end
22
22
  end
23
23
  end
@@ -6,16 +6,19 @@ module PhlexKit
6
6
  # InputOtpGroup(InputOtpSlot…) [+ InputOtpSeparator + more groups]; slot
7
7
  # count must total `length:`. `.pk-input-otp*` (input_otp.css).
8
8
  class InputOtp < BaseComponent
9
- def initialize(length: 6, name: nil, value: nil, **attrs)
9
+ def initialize(length: 6, name: nil, value: nil, label: "One-time code", **attrs)
10
10
  @length = length
11
11
  @name = name
12
12
  @value = value
13
+ @label = label
13
14
  @attrs = attrs
14
15
  end
15
16
 
16
17
  def view_template(&block)
17
18
  div(**mix({
18
19
  class: "pk-input-otp",
20
+ role: "group",
21
+ aria: { label: @label },
19
22
  data: {
20
23
  controller: "phlex-kit--input-otp",
21
24
  phlex_kit__input_otp_length_value: @length
@@ -10,6 +10,13 @@ export default class extends Controller {
10
10
  static values = { length: Number }
11
11
 
12
12
  connect() {
13
+ // Slots compose without knowing their position — label them here so AT
14
+ // announces "Digit N of M" instead of anonymous edit fields.
15
+ this.slotTargets.forEach((slot, i) => {
16
+ if (!slot.hasAttribute("aria-label")) {
17
+ slot.setAttribute("aria-label", `Digit ${i + 1} of ${this.slotTargets.length}`)
18
+ }
19
+ })
13
20
  this.syncValue()
14
21
  }
15
22
 
@@ -39,7 +46,11 @@ export default class extends Controller {
39
46
 
40
47
  onPaste(e) {
41
48
  e.preventDefault()
42
- const chars = (e.clipboardData?.getData("text") || "").replace(/\s/g, "").split("")
49
+ let text = (e.clipboardData?.getData("text") || "").replace(/\s/g, "")
50
+ // Numeric slots reject non-digits on typing; pasted text like
51
+ // "code: 123456" should distribute only the digits.
52
+ if (e.target.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
53
+ const chars = text.split("")
43
54
  if (!chars.length) return
44
55
  const start = this.slotTargets.indexOf(e.target)
45
56
  this.slotTargets.slice(start).forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
@@ -17,7 +17,7 @@ module PhlexKit
17
17
  end
18
18
 
19
19
  def view_template(&)
20
- classes = [ "pk-item", VARIANTS.fetch(@variant), SIZES.fetch(@size) ].compact.join(" ")
20
+ classes = [ "pk-item", fetch_option(VARIANTS, @variant, :variant), fetch_option(SIZES, @size, :size) ].compact.join(" ")
21
21
  if @href
22
22
  a(**mix({ class: classes, href: @href }, @attrs), &)
23
23
  else
@@ -11,7 +11,7 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&)
14
- div(**mix({ class: [ "pk-item-media", VARIANTS.fetch(@variant) ].compact.join(" ") }, @attrs), &)
14
+ div(**mix({ class: [ "pk-item-media", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ") }, @attrs), &)
15
15
  end
16
16
  end
17
17
  end
@@ -41,7 +41,7 @@ module PhlexKit
41
41
  private
42
42
 
43
43
  def classes
44
- [ "pk-button", VARIANTS.fetch(@variant), SIZES.fetch(@size), ("icon" if @icon) ].compact.join(" ")
44
+ [ "pk-button", fetch_option(VARIANTS, @variant, :variant), fetch_option(SIZES, @size, :size), ("icon" if @icon) ].compact.join(" ")
45
45
  end
46
46
  end
47
47
  end
@@ -15,7 +15,7 @@ module PhlexKit
15
15
  end
16
16
 
17
17
  def view_template(&)
18
- classes = [ "pk-marker", VARIANTS.fetch(@variant) ].compact.join(" ")
18
+ classes = [ "pk-marker", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
19
19
  if @href
20
20
  a(**mix({ class: classes, href: @href }, @attrs), &)
21
21
  elsif @as == :button
@@ -13,7 +13,7 @@
13
13
  background: var(--pk-surface);
14
14
  padding: 3px;
15
15
  }
16
- .pk-menubar-menu { position: relative; }
16
+ .pk-menubar-menu { anchor-name: --pk-menubar-menu; anchor-scope: --pk-menubar-menu; }
17
17
  .pk-menubar-trigger {
18
18
  display: flex;
19
19
  align-items: center;
@@ -31,12 +31,16 @@
31
31
  .pk-menubar-trigger:hover,
32
32
  .pk-menubar-trigger[aria-expanded="true"] { background: var(--pk-surface-2); }
33
33
  .pk-menubar-content {
34
- position: absolute;
35
- top: 100%;
36
- left: 0;
37
- z-index: 50;
34
+ position: fixed;
35
+ position-anchor: --pk-menubar-menu;
36
+ inset: auto;
37
+ position-area: block-end span-inline-end;
38
+ position-try-fallbacks: flip-block;
39
+ /* the [popover] UA style sets overflow:auto, which would clip the
40
+ negative-offset ::before/::after bridges below */
41
+ overflow: visible;
38
42
  min-width: 9rem;
39
- margin-top: .5rem;
43
+ margin: .5rem 0 0;
40
44
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
41
45
  border-radius: var(--pk-radius);
42
46
  background: var(--pk-surface);
@@ -44,7 +48,8 @@
44
48
  padding: .25rem;
45
49
  box-shadow: 0 8px 24px rgb(0 0 0 / .3);
46
50
  }
47
- /* Hover bridge over the trigger→panel gap. */
51
+ /* Hover bridge over the trigger→panel gap — both sides, since a flip
52
+ fallback puts the gap below the panel instead of above. */
48
53
  .pk-menubar-content::before {
49
54
  content: "";
50
55
  position: absolute;
@@ -53,6 +58,14 @@
53
58
  right: 0;
54
59
  height: calc(.5rem + 1px);
55
60
  }
61
+ .pk-menubar-content::after {
62
+ content: "";
63
+ position: absolute;
64
+ top: 100%;
65
+ left: 0;
66
+ right: 0;
67
+ height: calc(.5rem + 1px);
68
+ }
56
69
  .pk-menubar-item {
57
70
  display: flex;
58
71
  align-items: center;
@@ -110,16 +123,22 @@
110
123
  box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
111
124
  }
112
125
 
113
- /* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS. */
114
- .pk-menubar-sub { position: relative; }
126
+ /* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS.
127
+ The row is its own scoped anchor; the panel flips to the other side at
128
+ the viewport right edge. */
129
+ .pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
115
130
  .pk-menubar-sub-chevron { margin-left: auto; width: 1rem; height: 1rem; }
116
131
  .pk-menubar-sub-content {
117
- position: absolute;
118
- top: -0.25rem;
119
- left: 100%;
120
- z-index: 60;
132
+ position: fixed;
133
+ position-anchor: --pk-menubar-sub;
134
+ inset: auto;
135
+ position-area: inline-end span-block-end;
136
+ margin-top: -0.25rem;
137
+ position-try-fallbacks: flip-inline;
121
138
  min-width: 9rem;
122
- padding-left: .25rem; /* hover bridge over the gap */
139
+ /* hover bridge over the gap — symmetric because flip tactics flip
140
+ margins/insets but not padding, so the flipped side needs one too */
141
+ padding: 0 .25rem;
123
142
  display: none;
124
143
  }
125
144
  .pk-menubar-sub-viewport {
@@ -135,3 +154,6 @@
135
154
  .pk-menubar-sub:focus-within > .pk-menubar-sub-content { display: block; }
136
155
  .pk-menubar-sub:hover > .pk-menubar-sub-trigger,
137
156
  .pk-menubar-sub:focus-within > .pk-menubar-sub-trigger { background: var(--pk-accent); }
157
+ /* Hiding must survive author display values (a host class like
158
+ display:grid would beat the UA [popover] { display: none }). */
159
+ .pk-menubar-content:not(:popover-open) { display: none; }
@@ -16,7 +16,7 @@ module PhlexKit
16
16
  role: "menubar",
17
17
  data: {
18
18
  controller: "phlex-kit--menubar",
19
- action: "click@window->phlex-kit--menubar#onClickOutside keydown.esc->phlex-kit--menubar#close"
19
+ action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown"
20
20
  }
21
21
  }, @attrs), &)
22
22
  end
@@ -13,9 +13,12 @@ module PhlexKit
13
13
  def view_template(&block)
14
14
  label(**mix({
15
15
  class: "pk-menubar-item pk-menubar-checkbox-item",
16
- role: "menuitemcheckbox"
16
+ role: "menuitemcheckbox",
17
+ tabindex: "-1",
18
+ aria: { checked: @checked ? "true" : "false" }
17
19
  }, @attrs)) do
18
- input(type: :checkbox, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked)
20
+ input(type: :checkbox, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked,
21
+ tabindex: "-1", data: { action: "change->phlex-kit--menubar#syncChecked" })
19
22
  span(class: "pk-menubar-item-indicator", aria: { hidden: "true" }) do
20
23
  render Icon.new(:check, size: nil)
21
24
  end
@@ -3,7 +3,7 @@ module PhlexKit
3
3
  class MenubarContent < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
5
  def view_template(&)
6
- div(**mix({ class: "pk-menubar-content pk-hidden", role: "menu" }, @attrs), &)
6
+ div(**mix({ class: "pk-menubar-content", popover: "manual", role: "menu" }, @attrs), &)
7
7
  end
8
8
  end
9
9
  end
@@ -1,15 +1,26 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // PhlexKit's stand-in for Radix behind shadcn's Menubar and NavigationMenu:
4
- // click (or hover, for nav menus) opens a menu's panel; while one is open,
3
+ // PhlexKit's stand-in for Radix behind shadcn's Menubar and NavigationMenu.
4
+ // Panels are native [popover=manual] elements (manual: this controller owns
5
+ // open/close), anchor-positioned per menu with viewport-edge flipping by
6
+ // menubar.css / navigation_menu.css.
7
+ // Click (or hover, for nav menus) opens a menu's panel; while one is open,
5
8
  // hovering a sibling trigger switches to it; Escape/outside/item click closes.
9
+ // Keyboard (menubar mode): ArrowDown on a trigger opens its menu focusing the
10
+ // first item; ArrowDown/ArrowUp/Home/End rove over the open menu's items
11
+ // (skipping [data-disabled]); ArrowLeft/ArrowRight jump to the sibling menus;
12
+ // Escape closes and returns focus to the trigger.
6
13
  // Connects to data-controller="phlex-kit--menubar"
7
14
  export default class extends Controller {
8
15
  static targets = ["menu"]
9
16
 
17
+ initialize() {
18
+ this.openMenu = null
19
+ }
20
+
10
21
  toggle(e) {
11
22
  const menu = e.currentTarget.closest("[data-phlex-kit--menubar-target=\"menu\"]")
12
- this.openMenu === menu ? this.close() : this.show(menu)
23
+ this.openMenu === menu ? this.close() : this.show(menu, true)
13
24
  }
14
25
 
15
26
  // Hover: switches between menus while one is open (menubar), or opens
@@ -20,24 +31,101 @@ export default class extends Controller {
20
31
  if (this.openMenu && this.openMenu !== menu) this.show(menu)
21
32
  }
22
33
 
23
- show(menu) {
24
- if (this.openMenu === menu) return
25
- this.close()
26
- menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")?.classList.remove("pk-hidden")
27
- menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "true")
28
- this.openMenu = menu
34
+ show(menu, focus = false) {
35
+ if (this.openMenu !== menu) {
36
+ this.close()
37
+ this.panel(menu)?.showPopover()
38
+ menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "true")
39
+ this.openMenu = menu
40
+ }
41
+ if (focus) this.items(menu)[0]?.focus()
29
42
  }
30
43
 
31
- close() {
44
+ close(opts = {}) {
32
45
  const menu = this.openMenu
33
46
  if (!menu) return
34
- menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")?.classList.add("pk-hidden")
35
- menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "false")
47
+ const panel = this.panel(menu)
48
+ if (panel?.matches(":popover-open")) panel.hidePopover()
49
+ const trigger = menu.querySelector("[aria-expanded]")
50
+ trigger?.setAttribute("aria-expanded", "false")
36
51
  this.openMenu = null
52
+ if (opts.refocus === true) trigger?.focus()
37
53
  }
38
54
 
39
55
  onClickOutside(e) {
40
56
  if (this.element.contains(e.target)) return
41
57
  this.close()
42
58
  }
59
+
60
+ // Single keydown listener on the bar (wired via data-action, so no manual
61
+ // cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
62
+ onKeydown(e) {
63
+ if (!this.openMenu) {
64
+ if (e.key !== "ArrowDown") return
65
+ const menu = e.target.closest("[data-phlex-kit--menubar-target=\"menu\"]")
66
+ if (!menu) return
67
+ e.preventDefault()
68
+ this.show(menu, true)
69
+ return
70
+ }
71
+ const items = this.items(this.openMenu)
72
+ const index = items.indexOf(document.activeElement)
73
+ switch (e.key) {
74
+ case "Escape":
75
+ e.preventDefault()
76
+ this.close({ refocus: true })
77
+ break
78
+ case "ArrowDown":
79
+ e.preventDefault()
80
+ items[(index + 1) % items.length]?.focus()
81
+ break
82
+ case "ArrowUp":
83
+ e.preventDefault()
84
+ items[index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length]?.focus()
85
+ break
86
+ case "Home":
87
+ e.preventDefault()
88
+ items[0]?.focus()
89
+ break
90
+ case "End":
91
+ e.preventDefault()
92
+ items[items.length - 1]?.focus()
93
+ break
94
+ case "ArrowRight":
95
+ e.preventDefault()
96
+ this.shift(1)
97
+ break
98
+ case "ArrowLeft":
99
+ e.preventDefault()
100
+ this.shift(-1)
101
+ break
102
+ }
103
+ }
104
+
105
+ // Mirrors a checkbox/radio item's native input state onto the item's
106
+ // aria-checked (radios also reset their group's siblings).
107
+ syncChecked(e) {
108
+ const input = e.target
109
+ const group = input.name
110
+ ? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
111
+ : [input]
112
+ group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
113
+ }
114
+
115
+ shift(dir) {
116
+ const menus = this.menuTargets
117
+ const index = menus.indexOf(this.openMenu)
118
+ this.show(menus[(index + dir + menus.length) % menus.length], true)
119
+ }
120
+
121
+ panel(menu) {
122
+ return menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")
123
+ }
124
+
125
+ items(menu) {
126
+ const panel = this.panel(menu)
127
+ if (!panel) return []
128
+ return [...panel.querySelectorAll("[role^=\"menuitem\"]")]
129
+ .filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
130
+ }
43
131
  }
@@ -1,16 +1,18 @@
1
1
  module PhlexKit
2
2
  # Menu row (an <a> — pass href:, or as: :div for buttons). `variant:
3
3
  # :destructive` tints red; `inset: true` aligns with checkbox/radio rows.
4
- # Closes the bar. See menubar.rb.
4
+ # Closes the bar. `disabled: true` marks the row inert (skipped by roving
5
+ # focus, pointer-events off). See menubar.rb.
5
6
  class MenubarItem < BaseComponent
6
7
  VARIANTS = { default: nil, destructive: "destructive" }.freeze
7
8
 
8
- def initialize(as: :a, href: "#", shortcut: nil, variant: :default, inset: false, **attrs)
9
+ def initialize(as: :a, href: "#", shortcut: nil, variant: :default, inset: false, disabled: false, **attrs)
9
10
  @as = as.to_sym
10
11
  @href = href
11
12
  @shortcut = shortcut
12
13
  @variant = variant.to_sym
13
14
  @inset = inset
15
+ @disabled = disabled
14
16
  @attrs = attrs
15
17
  end
16
18
 
@@ -18,10 +20,11 @@ module PhlexKit
18
20
  base = {
19
21
  role: "menuitem",
20
22
  tabindex: "-1",
21
- class: [ "pk-menubar-item", VARIANTS.fetch(@variant), ("inset" if @inset) ].compact.join(" "),
22
- data: { action: "click->phlex-kit--menubar#close" }
23
+ class: [ "pk-menubar-item", fetch_option(VARIANTS, @variant, :variant), ("inset" if @inset) ].compact.join(" "),
24
+ data: @disabled ? { disabled: "true" } : { action: "click->phlex-kit--menubar#close" }
23
25
  }
24
- base[:href] = @href unless @as == :div
26
+ base[:aria] = { disabled: "true" } if @disabled
27
+ base[:href] = @href unless @as == :div || @disabled
25
28
  send(@as, **mix(base, @attrs)) do
26
29
  block&.call
27
30
  span(class: "pk-menubar-shortcut") { @shortcut } if @shortcut
@@ -13,9 +13,12 @@ module PhlexKit
13
13
  def view_template(&block)
14
14
  label(**mix({
15
15
  class: "pk-menubar-item pk-menubar-radio-item",
16
- role: "menuitemradio"
16
+ role: "menuitemradio",
17
+ tabindex: "-1",
18
+ aria: { checked: @checked ? "true" : "false" }
17
19
  }, @attrs)) do
18
- input(type: :radio, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked)
20
+ input(type: :radio, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked,
21
+ tabindex: "-1", data: { action: "change->phlex-kit--menubar#syncChecked" })
19
22
  span(class: "pk-menubar-item-indicator", aria: { hidden: "true" }) do
20
23
  # A filled selection dot is geometry, not icon-library vocabulary.
21
24
  svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "currentColor") do |s|
@@ -6,6 +6,7 @@ module PhlexKit
6
6
  button(**mix({
7
7
  type: :button,
8
8
  class: "pk-menubar-trigger",
9
+ role: "menuitem",
9
10
  aria: { haspopup: "menu", expanded: "false" },
10
11
  data: { action: "click->phlex-kit--menubar#toggle mouseenter->phlex-kit--menubar#switch" }
11
12
  }, @attrs), &)
@@ -42,7 +42,7 @@ module PhlexKit
42
42
  private
43
43
 
44
44
  def classes
45
- [ "pk-native-select-field", SIZES.fetch(@size) ].compact.join(" ")
45
+ [ "pk-native-select-field", fetch_option(SIZES, @size, :size) ].compact.join(" ")
46
46
  end
47
47
  end
48
48
  end
@@ -9,7 +9,7 @@
9
9
  padding: 0;
10
10
  list-style: none;
11
11
  }
12
- .pk-navigation-menu-item { position: relative; }
12
+ .pk-navigation-menu-item { anchor-name: --pk-nav-item; anchor-scope: --pk-nav-item; }
13
13
  .pk-navigation-menu-trigger, .pk-navigation-menu-link {
14
14
  display: inline-flex;
15
15
  align-items: center;
@@ -35,15 +35,20 @@
35
35
  .pk-navigation-menu-chevron { width: .75rem; height: .75rem; transition: transform .2s ease; }
36
36
  .pk-navigation-menu-trigger[aria-expanded="true"] .pk-navigation-menu-chevron { transform: rotate(180deg); }
37
37
  .pk-navigation-menu-content {
38
- position: absolute;
38
+ position: fixed;
39
+ position-anchor: --pk-nav-item;
40
+ inset: auto;
41
+ position-area: block-end span-inline-end;
42
+ position-try-fallbacks: flip-block;
43
+ /* the [popover] UA style sets overflow:auto, which would clip the
44
+ negative-offset ::before bridge below */
45
+ overflow: visible;
39
46
  /* invisible bridge over the gap so hovering toward the panel doesn't
40
- mouseleave the nav and close it */
47
+ mouseleave the nav and close it (the panel stays a DOM descendant of
48
+ the nav even in the top layer, so :hover/mouseleave still track it) */
41
49
  --pk-nav-gap: .375rem;
42
- top: 100%;
43
- left: 0;
44
- z-index: 50;
45
50
  min-width: 16rem;
46
- margin-top: var(--pk-nav-gap);
51
+ margin: var(--pk-nav-gap) 0 0;
47
52
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
48
53
  border-radius: var(--pk-radius);
49
54
  background: var(--pk-surface);
@@ -60,6 +65,7 @@
60
65
  align-items: flex-start;
61
66
  gap: .25rem;
62
67
  }
68
+ /* Gap bridges on both sides — a flip fallback puts the gap below. */
63
69
  .pk-navigation-menu-content::before {
64
70
  content: "";
65
71
  position: absolute;
@@ -68,3 +74,14 @@
68
74
  right: 0;
69
75
  height: calc(var(--pk-nav-gap) + 1px);
70
76
  }
77
+ .pk-navigation-menu-content::after {
78
+ content: "";
79
+ position: absolute;
80
+ top: 100%;
81
+ left: 0;
82
+ right: 0;
83
+ height: calc(var(--pk-nav-gap) + 1px);
84
+ }
85
+ /* Hiding must survive author display values (a host class like
86
+ display:grid would beat the UA [popover] { display: none }). */
87
+ .pk-navigation-menu-content:not(:popover-open) { display: none; }
@@ -3,7 +3,9 @@ module PhlexKit
3
3
  class NavigationMenuContent < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
5
  def view_template(&)
6
- div(**mix({ class: "pk-navigation-menu-content pk-hidden", role: "menu" }, @attrs), &)
6
+ # No role="menu" here the panel holds plain links, not menuitems
7
+ # (Radix/shadcn ship no menu role on NavigationMenu content either).
8
+ div(**mix({ class: "pk-navigation-menu-content", popover: "manual" }, @attrs), &)
7
9
  end
8
10
  end
9
11
  end
@@ -1,12 +1,21 @@
1
- /* Co-located with popover.rb. CSS-positioned panel matched to nova: w-72,
2
- rounded-lg, p-2.5 with a gap-2.5 column, foreground-tint ring, text-sm;
3
- Header (title + muted description) stacks at gap-0.5. `align: :end`
4
- flips the panel to the trigger's end edge. Tokens global. */
5
- .pk-popover { position: relative; display: inline-block; }
6
- .pk-popover-trigger { display: inline-block; }
1
+ /* Co-located with popover.rb. Panel matched to nova: w-72, rounded-lg,
2
+ p-2.5 with a gap-2.5 column, foreground-tint ring, text-sm; Header
3
+ (title + muted description) stacks at gap-0.5. `align: :end` flips the
4
+ panel to the trigger's end edge. Tokens global.
5
+ The panel is a native [popover] in the top layer, anchor-positioned to
6
+ the trigger (Baseline 2026); position-try-fallbacks flips it above the
7
+ trigger when it would overflow the viewport bottom. anchor-scope keeps
8
+ sibling popovers from resolving to each other's anchors. */
9
+ .pk-popover { display: inline-block; anchor-scope: --pk-popover; }
10
+ .pk-popover-trigger { display: inline-block; anchor-name: --pk-popover; }
7
11
  .pk-popover-content {
8
- position: absolute; z-index: 50; top: calc(100% + .25rem); left: 0;
9
- display: flex; flex-direction: column; gap: .625rem;
12
+ position: fixed;
13
+ position-anchor: --pk-popover;
14
+ inset: auto;
15
+ position-area: block-end span-inline-end;
16
+ margin: .25rem 0 0;
17
+ position-try-fallbacks: flip-block;
18
+ flex-direction: column; gap: .625rem;
10
19
  width: 18rem;
11
20
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
12
21
  border-radius: var(--pk-radius);
@@ -14,7 +23,13 @@
14
23
  font-size: .875rem;
15
24
  box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
16
25
  }
17
- .pk-popover-content.end { left: auto; right: 0; }
26
+ /* display must be gated on :popover-open an unconditional author
27
+ `display: flex` would beat the UA's [popover] { display: none }. */
28
+ .pk-popover-content:popover-open { display: flex; }
29
+ .pk-popover-content.end { position-area: block-end span-inline-start; }
18
30
  .pk-popover-header { display: flex; flex-direction: column; gap: .125rem; font-size: .875rem; }
19
31
  .pk-popover-title { margin: 0; font-size: .875rem; font-weight: 500; }
20
32
  .pk-popover-description { margin: 0; color: var(--pk-muted); }
33
+ /* Hiding must survive author display values (a host class like
34
+ display:grid would beat the UA [popover] { display: none }). */
35
+ .pk-popover-content:not(:popover-open) { display: none; }
@@ -1,5 +1,6 @@
1
1
  module PhlexKit
2
- # Click-triggered floating panel, positioned with CSS (no @floating-ui). Ported
2
+ # Click-triggered floating panel native [popover] + CSS anchor
3
+ # positioning with viewport-edge flipping (no @floating-ui). Ported
3
4
  # from ruby_ui's RubyUI::Popover. Compose Popover > (PopoverTrigger +
4
5
  # PopoverContent). phlex-kit--popover.
5
6
  class Popover < BaseComponent
@@ -1,6 +1,8 @@
1
1
  module PhlexKit
2
- # The floating panel of a PhlexKit::Popover. `align: :end` flips it to the
3
- # trigger's end edge (their align prop). See popover.rb.
2
+ # The floating panel of a PhlexKit::Popover a native [popover=auto]
3
+ # element (top layer, browser light dismiss) anchor-positioned to the
4
+ # trigger with viewport-edge flipping (popover.css). `align: :end` flips it
5
+ # to the trigger's end edge (their align prop). See popover.rb.
4
6
  class PopoverContent < BaseComponent
5
7
  ALIGNS = { start: nil, end: "end" }.freeze
6
8
 
@@ -10,8 +12,8 @@ module PhlexKit
10
12
  end
11
13
 
12
14
  def view_template(&)
13
- classes = [ "pk-popover-content", "pk-hidden", ALIGNS.fetch(@align) ].compact.join(" ")
14
- div(**mix({ class: classes, data: { phlex_kit__popover_target: "content", state: "closed" } }, @attrs), &)
15
+ classes = [ "pk-popover-content", fetch_option(ALIGNS, @align, :align) ].compact.join(" ")
16
+ div(**mix({ class: classes, popover: "auto", data: { phlex_kit__popover_target: "content", state: "closed" } }, @attrs), &)
15
17
  end
16
18
  end
17
19
  end
@@ -1,12 +1,16 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Connects to data-controller="phlex-kit--popover". Click to toggle; click
4
- // outside to close. CSS positions the panel under the trigger.
3
+ // Connects to data-controller="phlex-kit--popover". The panel is a native
4
+ // [popover=auto] the browser owns light dismiss + Escape; CSS anchor
5
+ // positioning places it (popover.css). Light dismiss fires on pointerdown
6
+ // outside the panel, so a click on the trigger would close-then-reopen:
7
+ // armToggle records the open state at pointerdown and toggle skips the
8
+ // reopen.
5
9
  export default class extends Controller {
6
- static targets = ["content"]
7
- connect() { this.onDoc = (e) => { if (!this.element.contains(e.target)) this.hide() } }
8
- disconnect() { document.removeEventListener("click", this.onDoc) }
9
- toggle(e) { e?.preventDefault(); this.contentTarget.classList.contains("pk-hidden") ? this.show() : this.hide() }
10
- show() { this.contentTarget.classList.remove("pk-hidden"); this.contentTarget.dataset.state = "open"; document.addEventListener("click", this.onDoc) }
11
- hide() { this.contentTarget.classList.add("pk-hidden"); this.contentTarget.dataset.state = "closed"; document.removeEventListener("click", this.onDoc) }
10
+ static targets = ["trigger", "content"]
11
+ armToggle() { this.wasOpen = this.contentTarget.matches(":popover-open") }
12
+ toggle(e) { e?.preventDefault(); if (this.wasOpen) return; this.contentTarget.showPopover() }
13
+ contentTargetConnected(el) {
14
+ el.addEventListener("toggle", (e) => { el.dataset.state = e.newState === "open" ? "open" : "closed" })
15
+ }
12
16
  }