phlex_kit 0.7.0 → 0.8.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +10 -2
  3. data/app/assets/stylesheets/phlex_kit/themes/claude.css +1 -1
  4. data/app/assets/stylesheets/phlex_kit/themes/neutral.css +1 -1
  5. data/app/assets/stylesheets/phlex_kit/themes/zinc.css +1 -1
  6. data/app/components/phlex_kit/accordion/accordion_content.rb +11 -2
  7. data/app/components/phlex_kit/accordion/accordion_controller.js +15 -2
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +5 -2
  9. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +80 -6
  10. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +8 -4
  11. data/app/components/phlex_kit/calendar/calendar_body.rb +9 -1
  12. data/app/components/phlex_kit/calendar/calendar_controller.js +63 -1
  13. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  14. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
  15. data/app/components/phlex_kit/combobox/combobox_controller.js +15 -2
  16. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
  17. data/app/components/phlex_kit/context_menu/context_menu_controller.js +51 -1
  18. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
  19. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
  20. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -2
  21. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
  22. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
  23. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +71 -48
  24. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
  25. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
  26. data/app/components/phlex_kit/form_field/form_field_controller.js +14 -1
  27. data/app/components/phlex_kit/form_field/form_field_error.rb +3 -0
  28. data/app/components/phlex_kit/input_otp/input_otp_controller.js +30 -4
  29. data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
  30. data/app/components/phlex_kit/masked_input/masked_input_controller.js +24 -5
  31. data/app/components/phlex_kit/menubar/menubar_controller.js +56 -11
  32. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +3 -1
  33. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +4 -1
  34. data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
  35. data/app/components/phlex_kit/popover/popover.css +3 -3
  36. data/app/components/phlex_kit/popover/popover_controller.js +29 -6
  37. data/app/components/phlex_kit/popover/popover_trigger.rb +4 -1
  38. data/app/components/phlex_kit/resizable/resizable_controller.js +65 -3
  39. data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
  40. data/app/components/phlex_kit/select/select.rb +8 -7
  41. data/app/components/phlex_kit/select/select_controller.js +29 -14
  42. data/app/components/phlex_kit/select/select_item.rb +0 -1
  43. data/app/components/phlex_kit/sidebar/sidebar.css +19 -7
  44. data/app/components/phlex_kit/slider/slider.css +2 -2
  45. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
  46. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +6 -0
  47. data/app/components/phlex_kit/toast/toast_region.rb +6 -2
  48. data/app/components/phlex_kit/toast/toaster_controller.js +7 -3
  49. data/app/components/phlex_kit/toggle/toggle.rb +3 -1
  50. data/app/javascript/phlex_kit/controllers/index.js +0 -2
  51. data/lib/phlex_kit/version.rb +1 -1
  52. metadata +1 -2
  53. data/app/components/phlex_kit/select/select_item_controller.js +0 -14
@@ -4,14 +4,23 @@ import { Controller } from "@hotwired/stimulus";
4
4
  // @floating-ui/dom dependency: the panel is a native [popover=manual] in the
5
5
  // top layer, anchor-positioned with viewport-edge flipping by
6
6
  // dropdown_menu.css (manual so this controller keeps owning click-outside,
7
- // Escape, and arrow/enter keyboard nav all unchanged from upstream).
7
+ // Escape, and keyboard nav). Keyboard nav moves REAL focus over the rows
8
+ // (upstream stamped aria-selected on menuitems, which AT ignores): :focus
9
+ // styles the highlight and :focus-within opens/closes submenus, so hidden
10
+ // sub-rows are skipped via a visibility filter. Escape returns focus to the
11
+ // trigger; Enter/Space activate the focused row (links, checkbox/radio
12
+ // labels, and sub triggers alike).
8
13
  export default class extends Controller {
9
14
  static targets = ["trigger", "content", "menuItem"];
10
15
  static values = { open: { type: Boolean, default: false } };
11
16
 
12
17
  connect() {
13
18
  this.boundHandleKeydown = this.#handleKeydown.bind(this);
14
- this.selectedIndex = -1;
19
+ // The visible control is the caller's button/link inside the trigger
20
+ // wrapper — that's what AT reads, so the popup wiring belongs on it.
21
+ this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
22
+ this.invoker.setAttribute("aria-haspopup", "menu");
23
+ this.invoker.setAttribute("aria-expanded", "false");
15
24
  }
16
25
 
17
26
  disconnect() {
@@ -19,7 +28,9 @@ export default class extends Controller {
19
28
  }
20
29
 
21
30
  onClickOutside(event) {
22
- if (!this.openValue) return;
31
+ // Gate on the live :popover-open state, not the stored flag — a stale
32
+ // flag is how a close on an already-closed panel becomes an open.
33
+ if (!this.contentTarget.matches(":popover-open")) return;
23
34
  if (this.element.contains(event.target)) return;
24
35
 
25
36
  event.preventDefault();
@@ -32,65 +43,77 @@ export default class extends Controller {
32
43
 
33
44
  #open() {
34
45
  this.openValue = true;
35
- this.#deselectAll();
36
46
  this.#addEventListeners();
37
47
  this.contentTarget.showPopover();
48
+ this.invoker.setAttribute("aria-expanded", "true");
38
49
  }
39
50
 
40
- close() {
51
+ close(event) {
52
+ // Menu rows default to href="#"; without this an Enter/click on a
53
+ // non-link row scrolls to top and appends # to the URL.
54
+ if (event?.target?.closest?.('a[href="#"]')) event.preventDefault();
41
55
  this.openValue = false;
42
56
  this.#removeEventListeners();
43
57
  if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover();
58
+ this.invoker.setAttribute("aria-expanded", "false");
44
59
  }
45
60
 
46
- #handleKeydown(e) {
47
- if (this.menuItemTargets.length === 0) return;
48
-
49
- if (e.key === "ArrowDown") {
50
- e.preventDefault();
51
- this.#updateSelectedItem(1);
52
- } else if (e.key === "ArrowUp") {
53
- e.preventDefault();
54
- this.#updateSelectedItem(-1);
55
- } else if (e.key === "Enter" && this.selectedIndex !== -1) {
56
- e.preventDefault();
57
- this.menuItemTargets[this.selectedIndex].click();
58
- } else if (e.key === "Escape") {
59
- this.close();
60
- }
61
- }
62
-
63
- #updateSelectedItem(direction) {
64
- this.menuItemTargets.forEach((item, index) => {
65
- if (item.getAttribute("aria-selected") === "true") this.selectedIndex = index;
66
- });
67
-
68
- if (this.selectedIndex >= 0) {
69
- this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);
70
- }
71
-
72
- this.selectedIndex += direction;
73
-
74
- if (this.selectedIndex < 0) {
75
- this.selectedIndex = this.menuItemTargets.length - 1;
76
- } else if (this.selectedIndex >= this.menuItemTargets.length) {
77
- this.selectedIndex = 0;
78
- }
79
-
80
- this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);
61
+ // Mirrors a checkbox/radio row's native input state onto the row's
62
+ // aria-checked (radios also reset their group's siblings).
63
+ syncChecked(event) {
64
+ const input = event.target;
65
+ const group = input.name
66
+ ? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
67
+ : [input];
68
+ group.forEach((i) => i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked));
81
69
  }
82
70
 
83
- #toggleAriaSelected(element, isSelected) {
84
- if (isSelected) {
85
- element.setAttribute("aria-selected", "true");
86
- } else {
87
- element.removeAttribute("aria-selected");
71
+ #handleKeydown(e) {
72
+ const items = this.#items();
73
+ if (items.length === 0) return;
74
+ const index = items.indexOf(document.activeElement);
75
+
76
+ switch (e.key) {
77
+ case "ArrowDown":
78
+ e.preventDefault();
79
+ items[(index + 1) % items.length]?.focus();
80
+ break;
81
+ case "ArrowUp":
82
+ e.preventDefault();
83
+ items[index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length]?.focus();
84
+ break;
85
+ case "Home":
86
+ e.preventDefault();
87
+ items[0]?.focus();
88
+ break;
89
+ case "End":
90
+ e.preventDefault();
91
+ items[items.length - 1]?.focus();
92
+ break;
93
+ case "Enter":
94
+ case " ":
95
+ // Explicit click so label rows (checkbox/radio) activate too —
96
+ // labels have no native keyboard activation.
97
+ if (index >= 0) {
98
+ e.preventDefault();
99
+ items[index].click();
100
+ }
101
+ break;
102
+ case "Escape":
103
+ e.preventDefault();
104
+ this.close();
105
+ this.invoker.focus();
106
+ break;
88
107
  }
89
108
  }
90
109
 
91
- #deselectAll() {
92
- this.menuItemTargets.forEach((item) => this.#toggleAriaSelected(item, false));
93
- this.selectedIndex = -1;
110
+ // Rows inside a closed submenu are display:none (revealed by CSS on
111
+ // hover/focus-within) focus() on them silently fails and the roving
112
+ // navigation would jam at the submenu boundary.
113
+ #items() {
114
+ return this.menuItemTargets.filter(
115
+ (el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0,
116
+ );
94
117
  }
95
118
 
96
119
  #addEventListeners() {
@@ -13,9 +13,13 @@ module PhlexKit
13
13
  def view_template(&block)
14
14
  label(**mix({
15
15
  class: "pk-dropdown-menu-item pk-dropdown-menu-radio-item",
16
- role: "menuitemradio"
16
+ role: "menuitemradio",
17
+ tabindex: "-1",
18
+ aria: { checked: @checked ? "true" : "false" },
19
+ data: { phlex_kit__dropdown_menu_target: "menuItem" }
17
20
  }, @attrs)) do
18
- input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked)
21
+ input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
22
+ tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
19
23
  span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) do
20
24
  # Deliberately NOT a PhlexKit::Icon: a filled selection dot is
21
25
  # geometry, not icon-library vocabulary — it stays identical across
@@ -7,8 +7,11 @@ module PhlexKit
7
7
  div(**mix({
8
8
  class: "pk-dropdown-menu-item pk-dropdown-menu-sub-trigger",
9
9
  role: "menuitem",
10
- tabindex: "0",
11
- aria: { haspopup: "menu" }
10
+ # -1: roving focus reaches it via arrows (focus-within opens the sub);
11
+ # tabindex 0 made it a stray tab stop inside the menu.
12
+ tabindex: "-1",
13
+ aria: { haspopup: "menu" },
14
+ data: { phlex_kit__dropdown_menu_target: "menuItem" }
12
15
  }, @attrs)) do
13
16
  block&.call
14
17
  render Icon.new(:chevron_right, size: nil, class: "pk-dropdown-menu-sub-chevron")
@@ -23,6 +23,11 @@ export default class extends Controller {
23
23
  }
24
24
 
25
25
  onInvalid(error) {
26
+ // Only suppress the native validation bubble when we can show our own
27
+ // message — a FormField without a FormFieldError previously ate the
28
+ // bubble AND crashed on the missing target, so a required-but-empty
29
+ // form gave zero feedback and never submitted.
30
+ if (!this.hasErrorTarget) return;
26
31
  error.preventDefault();
27
32
 
28
33
  this.shouldValidateValue = true;
@@ -38,12 +43,20 @@ export default class extends Controller {
38
43
  }
39
44
 
40
45
  #setErrorMessage() {
41
- if (!this.shouldValidateValue) return;
46
+ if (!this.shouldValidateValue || !this.hasErrorTarget) return;
42
47
 
48
+ // aria-invalid drives the red ring for LIVE validation too (input.css
49
+ // only matched server-rendered attrs); aria-describedby ties the message
50
+ // to the control for AT.
51
+ if (this.errorTarget.id) {
52
+ this.inputTarget.setAttribute("aria-describedby", this.errorTarget.id);
53
+ }
43
54
  if (this.inputTarget.validity.valid) {
55
+ this.inputTarget.removeAttribute("aria-invalid");
44
56
  this.errorTarget.textContent = "";
45
57
  this.errorTarget.classList.add("pk-hidden");
46
58
  } else {
59
+ this.inputTarget.setAttribute("aria-invalid", "true");
47
60
  this.errorTarget.textContent = this.#getValidationMessage();
48
61
  this.errorTarget.classList.remove("pk-hidden");
49
62
  }
@@ -10,8 +10,11 @@ module PhlexKit
10
10
  end
11
11
 
12
12
  def view_template(&block)
13
+ # role="alert": the controller swaps this text live on invalid/input —
14
+ # without a live region the announcement never reaches screen readers.
13
15
  p(**mix({
14
16
  class: "pk-form-field-error",
17
+ role: "alert",
15
18
  data: { phlex_kit__form_field_target: "error" }
16
19
  }, @attrs), &block)
17
20
  end
@@ -22,7 +22,27 @@ export default class extends Controller {
22
22
 
23
23
  onInput(e) {
24
24
  const slot = e.target
25
- slot.value = slot.value.replace(/\s/g, "").slice(-1)
25
+ let text = slot.value.replace(/\s/g, "")
26
+ // Typing rejects the same characters paste strips — a letter keyed into
27
+ // an inputmode=numeric slot must not land.
28
+ if (slot.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
29
+
30
+ if (text.length > 1) {
31
+ if (e.data && e.data.length === 1) {
32
+ // one keystroke into an already-filled slot: keep the newest char
33
+ slot.value = e.data
34
+ this.focusSlot(this.slotTargets.indexOf(slot) + 1)
35
+ this.syncValue()
36
+ } else {
37
+ // OTP autofill (autocomplete="one-time-code") delivers the whole
38
+ // code into the focused slot — distribute it like a paste instead
39
+ // of discarding all but one digit.
40
+ this.fillFrom(slot, text)
41
+ }
42
+ return
43
+ }
44
+
45
+ slot.value = text
26
46
  if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
27
47
  this.syncValue()
28
48
  }
@@ -50,10 +70,16 @@ export default class extends Controller {
50
70
  // Numeric slots reject non-digits on typing; pasted text like
51
71
  // "code: 123456" should distribute only the digits.
52
72
  if (e.target.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
73
+ if (!text.length) return
74
+ this.fillFrom(e.target, text)
75
+ }
76
+
77
+ // Distribute a multi-character string across the slots starting at `slot`
78
+ // (shared by paste and autofill), then park focus after the last filled one.
79
+ fillFrom(slot, text) {
53
80
  const chars = text.split("")
54
- if (!chars.length) return
55
- const start = this.slotTargets.indexOf(e.target)
56
- this.slotTargets.slice(start).forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
81
+ const start = this.slotTargets.indexOf(slot)
82
+ this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
57
83
  this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
58
84
  this.syncValue()
59
85
  }
@@ -1,7 +1,7 @@
1
1
  module PhlexKit
2
2
  # Text input with an inline mask. Ported from ruby_ui's RubyUI::MaskedInput —
3
3
  # ruby_ui uses the `maska` JS lib; PhlexKit ships a small dependency-free mask
4
- # controller (#=digit, A=letter, *=any) driven by a data-mask attribute. Swap in
4
+ # controller (#=digit, A=letter, *=any alphanumeric) driven by a data-mask attribute. Swap in
5
5
  # maska by replacing masked_input_controller.js if you need its full feature set.
6
6
  class MaskedInput < BaseComponent
7
7
  def initialize(**attrs) = (@attrs = attrs)
@@ -1,18 +1,28 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--masked-input". Lightweight, dependency-
4
- // free mask driven by a data-mask pattern (# = digit, A = letter, * = any). Swap
5
- // in `maska` (ruby_ui's choice) here if you need a fuller mask engine.
4
+ // free mask driven by a data-mask pattern (# = digit, A = letter, * = any
5
+ // alphanumeric formatting characters are stripped before matching, so *
6
+ // cannot match punctuation; swap in `maska` (ruby_ui's choice) if you need a
7
+ // fuller mask engine).
6
8
  export default class extends Controller {
7
9
  connect() {
8
10
  this.mask = this.element.getAttribute("data-mask") || ""
9
11
  if (!this.mask) return
10
- this.onInput = () => this.apply()
12
+ // Skip IME composition updates — masking mid-composition mangles the text.
13
+ this.onInput = (e) => { if (!e.isComposing) this.apply() }
11
14
  this.element.addEventListener("input", this.onInput)
12
15
  }
13
16
  disconnect() { if (this.onInput) this.element.removeEventListener("input", this.onInput) }
14
17
  apply() {
15
- const raw = this.element.value.replace(/[^0-9A-Za-z]/g, "")
18
+ const el = this.element
19
+ // The whole value is rewritten below, which throws the caret to the end —
20
+ // remember how many maskable chars sit before it and re-seat it after
21
+ // the same count in the masked output (mid-field edits keep their place).
22
+ const caret = el.selectionStart ?? el.value.length
23
+ const rawBefore = el.value.slice(0, caret).replace(/[^0-9A-Za-z]/g, "").length
24
+
25
+ const raw = el.value.replace(/[^0-9A-Za-z]/g, "")
16
26
  let out = "", i = 0
17
27
  for (const t of this.mask) {
18
28
  if (i >= raw.length) break
@@ -21,6 +31,15 @@ export default class extends Controller {
21
31
  else if (t === "*") { out += raw[i++] }
22
32
  else { out += t; if (raw[i] === t) i++ }
23
33
  }
24
- this.element.value = out
34
+ el.value = out
35
+
36
+ if (document.activeElement === el) {
37
+ let pos = 0, seen = 0
38
+ while (pos < out.length && seen < rawBefore) {
39
+ if (/[0-9A-Za-z]/.test(out[pos])) seen++
40
+ pos++
41
+ }
42
+ el.setSelectionRange(pos, pos)
43
+ }
25
44
  }
26
45
  }
@@ -14,8 +14,11 @@ import { Controller } from "@hotwired/stimulus"
14
14
  export default class extends Controller {
15
15
  static targets = ["menu"]
16
16
 
17
- initialize() {
18
- this.openMenu = null
17
+ // Which menu is open derives from the live :popover-open state, never a
18
+ // stored field — a stale field would make toggle() call showPopover() on
19
+ // an already-open panel (InvalidStateError) or strand an open one.
20
+ get openMenu() {
21
+ return this.menuTargets.find((menu) => this.panel(menu)?.matches(":popover-open")) ?? null
19
22
  }
20
23
 
21
24
  disconnect() {
@@ -57,7 +60,6 @@ export default class extends Controller {
57
60
  this.close()
58
61
  this.panel(menu)?.showPopover()
59
62
  menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "true")
60
- this.openMenu = menu
61
63
  }
62
64
  if (focus) this.items(menu)[0]?.focus()
63
65
  }
@@ -70,7 +72,6 @@ export default class extends Controller {
70
72
  if (panel?.matches(":popover-open")) panel.hidePopover()
71
73
  const trigger = menu.querySelector("[aria-expanded]")
72
74
  trigger?.setAttribute("aria-expanded", "false")
73
- this.openMenu = null
74
75
  if (opts.refocus === true) trigger?.focus()
75
76
  }
76
77
 
@@ -83,11 +84,18 @@ export default class extends Controller {
83
84
  // cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
84
85
  onKeydown(e) {
85
86
  if (!this.openMenu) {
86
- if (e.key !== "ArrowDown") return
87
87
  const menu = e.target.closest("[data-phlex-kit--menubar-target=\"menu\"]")
88
88
  if (!menu) return
89
- e.preventDefault()
90
- this.show(menu, true)
89
+ if (e.key === "ArrowDown") {
90
+ e.preventDefault()
91
+ this.show(menu, true)
92
+ } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
93
+ // Closed bar: left/right move focus between the triggers (APG).
94
+ e.preventDefault()
95
+ const menus = this.menuTargets
96
+ const next = menus[(menus.indexOf(menu) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length]
97
+ next?.querySelector("[aria-expanded], a, button, [tabindex]")?.focus()
98
+ }
91
99
  return
92
100
  }
93
101
  const items = this.items(this.openMenu)
@@ -113,17 +121,51 @@ export default class extends Controller {
113
121
  e.preventDefault()
114
122
  items[items.length - 1]?.focus()
115
123
  break
124
+ case "Enter":
125
+ case " ":
126
+ // Explicit click so label rows (checkbox/radio) activate — labels
127
+ // have no native keyboard activation (and this gives links Space).
128
+ if (index >= 0) {
129
+ e.preventDefault()
130
+ items[index].click()
131
+ }
132
+ break
116
133
  case "ArrowRight":
117
134
  e.preventDefault()
118
- this.shift(1)
135
+ // On a sub trigger, enter the submenu (focus reveals it via
136
+ // :focus-within) instead of jumping to the next top-level menu.
137
+ if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
138
+ this.enterSub(document.activeElement)
139
+ } else {
140
+ this.shift(1)
141
+ }
119
142
  break
120
- case "ArrowLeft":
143
+ case "ArrowLeft": {
121
144
  e.preventDefault()
122
- this.shift(-1)
145
+ // Inside a submenu, step back to its trigger instead of switching menus.
146
+ const sub = document.activeElement?.closest(".pk-menubar-sub-content")
147
+ if (sub) {
148
+ sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
149
+ } else {
150
+ this.shift(-1)
151
+ }
123
152
  break
153
+ }
124
154
  }
125
155
  }
126
156
 
157
+ // Focus the first row of a sub trigger's panel. The panel opens on
158
+ // :focus-within, so focus the trigger first, then hop into the revealed
159
+ // panel's first visible item.
160
+ enterSub(trigger) {
161
+ trigger.focus()
162
+ const panel = trigger.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-content")
163
+ if (!panel) return
164
+ const first = [...panel.querySelectorAll("[role^=\"menuitem\"]")]
165
+ .find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
166
+ first?.focus()
167
+ }
168
+
127
169
  // Mirrors a checkbox/radio item's native input state onto the item's
128
170
  // aria-checked (radios also reset their group's siblings).
129
171
  syncChecked(e) {
@@ -147,7 +189,10 @@ export default class extends Controller {
147
189
  items(menu) {
148
190
  const panel = this.panel(menu)
149
191
  if (!panel) return []
192
+ // getClientRects also skips rows inside a CLOSED sub panel (its
193
+ // display:none comes from the hover/focus-within CSS, not .pk-hidden) —
194
+ // focus() on those silently fails and the roving nav jams there.
150
195
  return [...panel.querySelectorAll("[role^=\"menuitem\"]")]
151
- .filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
196
+ .filter((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
152
197
  }
153
198
  }
@@ -6,7 +6,9 @@ module PhlexKit
6
6
  div(**mix({
7
7
  class: "pk-menubar-item pk-menubar-sub-trigger",
8
8
  role: "menuitem",
9
- tabindex: "0",
9
+ # -1: the roving focus reaches it via arrows (focus-within opens the
10
+ # sub); tabindex 0 made it a stray tab stop inside the open panel.
11
+ tabindex: "-1",
10
12
  aria: { haspopup: "menu" }
11
13
  }, @attrs)) do
12
14
  block&.call
@@ -39,7 +39,10 @@
39
39
  position-anchor: --pk-nav-item;
40
40
  inset: auto;
41
41
  position-area: block-end span-inline-end;
42
- position-try-fallbacks: flip-block;
42
+ /* flip-inline too: a 16rem panel on an item near the right viewport edge
43
+ otherwise clips (same bug class popover fixed in #38). The hover
44
+ bridges span the block-axis gap, which flip-inline doesn't change. */
45
+ position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
43
46
  /* the [popover] UA style sets overflow:auto, which would clip the
44
47
  negative-offset ::before bridge below */
45
48
  overflow: visible;
@@ -6,7 +6,7 @@ module PhlexKit
6
6
  button(**mix({
7
7
  type: :button,
8
8
  class: "pk-navigation-menu-trigger",
9
- aria: { haspopup: "menu", expanded: "false" },
9
+ aria: { expanded: "false" }, # no haspopup="menu": the panel deliberately has no menu role (matching Radix/shadcn)
10
10
  data: { action: "click->phlex-kit--menubar#toggle mouseenter->phlex-kit--menubar#switch" }
11
11
  }, @attrs)) do
12
12
  block&.call
@@ -3,8 +3,8 @@
3
3
  (title + muted description) stacks at gap-0.5. `align: :end` flips the
4
4
  panel to the trigger's end edge. Tokens global.
5
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
6
+ the trigger (Baseline 2026); position-try-fallbacks flips it on either
7
+ axis (or both) when it would overflow a viewport edge. anchor-scope keeps
8
8
  sibling popovers from resolving to each other's anchors. */
9
9
  .pk-popover { display: inline-block; anchor-scope: --pk-popover; }
10
10
  .pk-popover-trigger { display: inline-block; anchor-name: --pk-popover; }
@@ -14,7 +14,7 @@
14
14
  inset: auto;
15
15
  position-area: block-end span-inline-end;
16
16
  margin: .25rem 0 0;
17
- position-try-fallbacks: flip-block;
17
+ position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
18
18
  flex-direction: column; gap: .625rem;
19
19
  width: 18rem;
20
20
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
@@ -2,14 +2,37 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--popover". The panel is a native
4
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
+ // positioning places it (popover.css). Toggling is native too: connect()
6
+ // points the trigger's button at the panel via popoverTargetElement, so the
7
+ // browser handles mouse + keyboard activation and exposes the expanded
8
+ // state to assistive tech — no imperative toggle, no invoker/light-dismiss
9
+ // race. Button-less triggers (the date picker's input) fall back to a
10
+ // click toggle; wasOpen records the open state at pointerdown because
11
+ // light dismiss fires first and the click would otherwise reopen.
9
12
  export default class extends Controller {
10
13
  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() }
14
+
15
+ connect() {
16
+ const invoker = this.triggerTarget.querySelector("button")
17
+ if (invoker) {
18
+ invoker.popoverTargetElement = this.contentTarget
19
+ } else {
20
+ this.triggerTarget.addEventListener("pointerdown", this.armToggle)
21
+ this.triggerTarget.addEventListener("click", this.toggle)
22
+ }
23
+ }
24
+
25
+ disconnect() {
26
+ this.triggerTarget.removeEventListener("pointerdown", this.armToggle)
27
+ this.triggerTarget.removeEventListener("click", this.toggle)
28
+ }
29
+
30
+ armToggle = () => { this.wasOpen = this.contentTarget.matches(":popover-open") }
31
+ toggle = () => {
32
+ if (this.wasOpen) { this.wasOpen = false; return }
33
+ this.contentTarget.togglePopover()
34
+ }
35
+
13
36
  contentTargetConnected(el) {
14
37
  el.addEventListener("toggle", (e) => { el.dataset.state = e.newState === "open" ? "open" : "closed" })
15
38
  }
@@ -1,8 +1,11 @@
1
1
  module PhlexKit
2
+ # Wrapper the panel anchors to. Carries no actions: the controller wires
3
+ # the button inside it to the panel via popoverTargetElement, so the
4
+ # browser owns toggling (mouse + keyboard). See popover.rb.
2
5
  class PopoverTrigger < BaseComponent
3
6
  def initialize(**attrs) = (@attrs = attrs)
4
7
  def view_template(&)
5
- div(**mix({ class: "pk-popover-trigger", data: { phlex_kit__popover_target: "trigger", action: "pointerdown->phlex-kit--popover#armToggle click->phlex-kit--popover#toggle" } }, @attrs), &)
8
+ div(**mix({ class: "pk-popover-trigger", data: { phlex_kit__popover_target: "trigger" } }, @attrs), &)
6
9
  end
7
10
  end
8
11
  end