phlex_kit 0.5.1 → 0.6.2

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 (130) 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 +123 -13
  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.rb +1 -1
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu_content.rb +3 -1
  83. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +6 -2
  84. data/app/components/phlex_kit/popover/popover.css +24 -9
  85. data/app/components/phlex_kit/popover/popover.rb +2 -1
  86. data/app/components/phlex_kit/popover/popover_content.rb +6 -4
  87. data/app/components/phlex_kit/popover/popover_controller.js +12 -8
  88. data/app/components/phlex_kit/popover/popover_trigger.rb +1 -1
  89. data/app/components/phlex_kit/resizable/resizable_panel.rb +4 -2
  90. data/app/components/phlex_kit/select/select.css +24 -12
  91. data/app/components/phlex_kit/select/select_content.rb +6 -4
  92. data/app/components/phlex_kit/select/select_controller.js +21 -17
  93. data/app/components/phlex_kit/select/select_trigger.rb +1 -1
  94. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  95. data/app/components/phlex_kit/sheet/sheet_content_controller.js +68 -2
  96. data/app/components/phlex_kit/sheet/sheet_controller.js +28 -3
  97. data/app/components/phlex_kit/sidebar/sidebar.rb +1 -1
  98. data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +1 -1
  99. data/app/components/phlex_kit/spinner/spinner.rb +1 -1
  100. data/app/components/phlex_kit/switch/switch.rb +7 -4
  101. data/app/components/phlex_kit/tabs/tabs.rb +1 -1
  102. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -1
  103. data/app/components/phlex_kit/tabs/tabs_controller.js +60 -7
  104. data/app/components/phlex_kit/tabs/tabs_list.rb +2 -2
  105. data/app/components/phlex_kit/tabs/tabs_trigger.rb +14 -2
  106. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -3
  107. data/app/components/phlex_kit/toast/toast_controller.js +17 -0
  108. data/app/components/phlex_kit/toast/toast_icon.rb +1 -1
  109. data/app/components/phlex_kit/toast/toast_item.rb +1 -1
  110. data/app/components/phlex_kit/toggle/toggle.rb +2 -2
  111. data/app/components/phlex_kit/toggle_group/toggle_group.rb +2 -2
  112. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +1 -1
  113. data/app/components/phlex_kit/tooltip/tooltip.css +40 -22
  114. data/app/components/phlex_kit/tooltip/tooltip.rb +3 -1
  115. data/app/components/phlex_kit/tooltip/tooltip_content.rb +1 -1
  116. data/app/components/phlex_kit/tooltip/tooltip_controller.js +32 -0
  117. data/app/components/phlex_kit/tooltip/tooltip_trigger.rb +8 -3
  118. data/app/components/phlex_kit/typography/text.rb +1 -1
  119. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  120. data/lib/generators/phlex_kit/component/component_generator.rb +28 -0
  121. data/lib/generators/phlex_kit/install/templates/phlex_kit.rb +3 -0
  122. data/lib/phlex_kit/base_component.rb +12 -0
  123. data/lib/phlex_kit/engine.rb +11 -1
  124. data/lib/phlex_kit/icons/lucide.rb +1 -1
  125. data/lib/phlex_kit/icons/phosphor.rb +1 -1
  126. data/lib/phlex_kit/icons/remix.rb +1 -1
  127. data/lib/phlex_kit/icons/tabler.rb +1 -1
  128. data/lib/phlex_kit/version.rb +1 -1
  129. data/lib/phlex_kit.rb +5 -0
  130. 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,43 +1,153 @@
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
+
21
+ disconnect() {
22
+ clearTimeout(this.graceTimer)
23
+ }
24
+
10
25
  toggle(e) {
11
26
  const menu = e.currentTarget.closest("[data-phlex-kit--menubar-target=\"menu\"]")
12
- this.openMenu === menu ? this.close() : this.show(menu)
27
+ this.openMenu === menu ? this.close() : this.show(menu, true)
13
28
  }
14
29
 
15
30
  // Hover: switches between menus while one is open (menubar), or opens
16
31
  // directly when the bar declares data-hover-open (navigation menu).
17
32
  switch(e) {
33
+ clearTimeout(this.graceTimer)
18
34
  const menu = e.currentTarget.closest("[data-phlex-kit--menubar-target=\"menu\"]")
19
- if (this.element.dataset.hoverOpen !== undefined) return this.show(menu)
35
+ if (this.element.dataset.hoverOpen !== undefined) {
36
+ if (!menu || !this.panel(menu)) return this.closeSoon() // link, no panel
37
+ return this.show(menu)
38
+ }
39
+ if (!menu) return this.close()
20
40
  if (this.openMenu && this.openMenu !== menu) this.show(menu)
21
41
  }
22
42
 
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
43
+ // Hover closes go through a short grace period so a diagonal pointer path
44
+ // that clips a sibling link (or briefly exits the nav) on its way to the
45
+ // panel doesn't slam it shut; reaching the panel or a trigger cancels.
46
+ closeSoon() {
47
+ clearTimeout(this.graceTimer)
48
+ this.graceTimer = setTimeout(() => this.close(), 150)
49
+ }
50
+
51
+ cancelClose() {
52
+ clearTimeout(this.graceTimer)
53
+ }
54
+
55
+ show(menu, focus = false) {
56
+ if (this.openMenu !== menu) {
57
+ this.close()
58
+ this.panel(menu)?.showPopover()
59
+ menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "true")
60
+ this.openMenu = menu
61
+ }
62
+ if (focus) this.items(menu)[0]?.focus()
29
63
  }
30
64
 
31
- close() {
65
+ close(opts = {}) {
66
+ clearTimeout(this.graceTimer)
32
67
  const menu = this.openMenu
33
68
  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")
69
+ const panel = this.panel(menu)
70
+ if (panel?.matches(":popover-open")) panel.hidePopover()
71
+ const trigger = menu.querySelector("[aria-expanded]")
72
+ trigger?.setAttribute("aria-expanded", "false")
36
73
  this.openMenu = null
74
+ if (opts.refocus === true) trigger?.focus()
37
75
  }
38
76
 
39
77
  onClickOutside(e) {
40
78
  if (this.element.contains(e.target)) return
41
79
  this.close()
42
80
  }
81
+
82
+ // Single keydown listener on the bar (wired via data-action, so no manual
83
+ // cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
84
+ onKeydown(e) {
85
+ if (!this.openMenu) {
86
+ if (e.key !== "ArrowDown") return
87
+ const menu = e.target.closest("[data-phlex-kit--menubar-target=\"menu\"]")
88
+ if (!menu) return
89
+ e.preventDefault()
90
+ this.show(menu, true)
91
+ return
92
+ }
93
+ const items = this.items(this.openMenu)
94
+ const index = items.indexOf(document.activeElement)
95
+ switch (e.key) {
96
+ case "Escape":
97
+ e.preventDefault()
98
+ this.close({ refocus: true })
99
+ break
100
+ case "ArrowDown":
101
+ e.preventDefault()
102
+ items[(index + 1) % items.length]?.focus()
103
+ break
104
+ case "ArrowUp":
105
+ e.preventDefault()
106
+ items[index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length]?.focus()
107
+ break
108
+ case "Home":
109
+ e.preventDefault()
110
+ items[0]?.focus()
111
+ break
112
+ case "End":
113
+ e.preventDefault()
114
+ items[items.length - 1]?.focus()
115
+ break
116
+ case "ArrowRight":
117
+ e.preventDefault()
118
+ this.shift(1)
119
+ break
120
+ case "ArrowLeft":
121
+ e.preventDefault()
122
+ this.shift(-1)
123
+ break
124
+ }
125
+ }
126
+
127
+ // Mirrors a checkbox/radio item's native input state onto the item's
128
+ // aria-checked (radios also reset their group's siblings).
129
+ syncChecked(e) {
130
+ const input = e.target
131
+ const group = input.name
132
+ ? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
133
+ : [input]
134
+ group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
135
+ }
136
+
137
+ shift(dir) {
138
+ const menus = this.menuTargets
139
+ const index = menus.indexOf(this.openMenu)
140
+ this.show(menus[(index + dir + menus.length) % menus.length], true)
141
+ }
142
+
143
+ panel(menu) {
144
+ return menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")
145
+ }
146
+
147
+ items(menu) {
148
+ const panel = this.panel(menu)
149
+ if (!panel) return []
150
+ return [...panel.querySelectorAll("[role^=\"menuitem\"]")]
151
+ .filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
152
+ }
43
153
  }
@@ -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; }
@@ -15,7 +15,7 @@ module PhlexKit
15
15
  data: {
16
16
  controller: "phlex-kit--menubar",
17
17
  hover_open: true,
18
- action: "click@window->phlex-kit--menubar#onClickOutside keydown.esc->phlex-kit--menubar#close mouseleave->phlex-kit--menubar#close"
18
+ action: "click@window->phlex-kit--menubar#onClickOutside keydown.esc->phlex-kit--menubar#close mouseleave->phlex-kit--menubar#closeSoon"
19
19
  }
20
20
  }, @attrs), &)
21
21
  end
@@ -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", data: { action: "mouseenter->phlex-kit--menubar#cancelClose" } }, @attrs), &)
7
9
  end
8
10
  end
9
11
  end
@@ -1,5 +1,9 @@
1
1
  module PhlexKit
2
- # Plain link item (top-level or inside a panel). See navigation_menu.rb.
2
+ # Plain link item (top-level or inside a panel). Hover fires #switch like a
3
+ # trigger: for a TOP-LEVEL link the closest menu item has no panel, so an
4
+ # open sibling panel closes; for a link INSIDE a panel the closest item IS
5
+ # the open menu, so switch is a no-op and the panel stays. See
6
+ # navigation_menu.rb.
3
7
  class NavigationMenuLink < BaseComponent
4
8
  def initialize(href: "#", **attrs)
5
9
  @href = href
@@ -7,7 +11,7 @@ module PhlexKit
7
11
  end
8
12
 
9
13
  def view_template(&)
10
- a(**mix({ class: "pk-navigation-menu-link", href: @href }, @attrs), &)
14
+ a(**mix({ class: "pk-navigation-menu-link", href: @href, data: { action: "mouseenter->phlex-kit--menubar#switch" } }, @attrs), &)
11
15
  end
12
16
  end
13
17
  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