phlex_kit 0.8.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/accordion/accordion_content.rb +11 -2
  3. data/app/components/phlex_kit/accordion/accordion_controller.js +15 -2
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +5 -2
  5. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +80 -6
  6. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +8 -4
  7. data/app/components/phlex_kit/calendar/calendar_body.rb +9 -1
  8. data/app/components/phlex_kit/calendar/calendar_controller.js +63 -1
  9. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  10. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
  11. data/app/components/phlex_kit/combobox/combobox_controller.js +15 -2
  12. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
  13. data/app/components/phlex_kit/context_menu/context_menu_controller.js +51 -1
  14. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
  15. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
  16. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -2
  17. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
  18. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
  19. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +68 -47
  20. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
  21. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
  22. data/app/components/phlex_kit/form_field/form_field_controller.js +14 -1
  23. data/app/components/phlex_kit/form_field/form_field_error.rb +3 -0
  24. data/app/components/phlex_kit/input_otp/input_otp_controller.js +30 -4
  25. data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
  26. data/app/components/phlex_kit/masked_input/masked_input_controller.js +24 -5
  27. data/app/components/phlex_kit/menubar/menubar_controller.js +51 -7
  28. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +3 -1
  29. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +4 -1
  30. data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
  31. data/app/components/phlex_kit/resizable/resizable_controller.js +65 -3
  32. data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
  33. data/app/components/phlex_kit/select/select.rb +8 -7
  34. data/app/components/phlex_kit/select/select_controller.js +10 -4
  35. data/app/components/phlex_kit/select/select_item.rb +0 -1
  36. data/app/components/phlex_kit/sidebar/sidebar.css +19 -7
  37. data/app/components/phlex_kit/slider/slider.css +2 -2
  38. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
  39. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +6 -0
  40. data/app/components/phlex_kit/toast/toast_region.rb +6 -2
  41. data/app/components/phlex_kit/toast/toaster_controller.js +7 -3
  42. data/app/components/phlex_kit/toggle/toggle.rb +3 -1
  43. data/app/javascript/phlex_kit/controllers/index.js +0 -2
  44. data/lib/phlex_kit/version.rb +1 -1
  45. metadata +1 -2
  46. 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() {
@@ -34,65 +43,77 @@ export default class extends Controller {
34
43
 
35
44
  #open() {
36
45
  this.openValue = true;
37
- this.#deselectAll();
38
46
  this.#addEventListeners();
39
47
  this.contentTarget.showPopover();
48
+ this.invoker.setAttribute("aria-expanded", "true");
40
49
  }
41
50
 
42
- 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();
43
55
  this.openValue = false;
44
56
  this.#removeEventListeners();
45
57
  if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover();
58
+ this.invoker.setAttribute("aria-expanded", "false");
46
59
  }
47
60
 
48
- #handleKeydown(e) {
49
- if (this.menuItemTargets.length === 0) return;
50
-
51
- if (e.key === "ArrowDown") {
52
- e.preventDefault();
53
- this.#updateSelectedItem(1);
54
- } else if (e.key === "ArrowUp") {
55
- e.preventDefault();
56
- this.#updateSelectedItem(-1);
57
- } else if (e.key === "Enter" && this.selectedIndex !== -1) {
58
- e.preventDefault();
59
- this.menuItemTargets[this.selectedIndex].click();
60
- } else if (e.key === "Escape") {
61
- this.close();
62
- }
63
- }
64
-
65
- #updateSelectedItem(direction) {
66
- this.menuItemTargets.forEach((item, index) => {
67
- if (item.getAttribute("aria-selected") === "true") this.selectedIndex = index;
68
- });
69
-
70
- if (this.selectedIndex >= 0) {
71
- this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);
72
- }
73
-
74
- this.selectedIndex += direction;
75
-
76
- if (this.selectedIndex < 0) {
77
- this.selectedIndex = this.menuItemTargets.length - 1;
78
- } else if (this.selectedIndex >= this.menuItemTargets.length) {
79
- this.selectedIndex = 0;
80
- }
81
-
82
- 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));
83
69
  }
84
70
 
85
- #toggleAriaSelected(element, isSelected) {
86
- if (isSelected) {
87
- element.setAttribute("aria-selected", "true");
88
- } else {
89
- 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;
90
107
  }
91
108
  }
92
109
 
93
- #deselectAll() {
94
- this.menuItemTargets.forEach((item) => this.#toggleAriaSelected(item, false));
95
- 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
+ );
96
117
  }
97
118
 
98
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
  }
@@ -84,11 +84,18 @@ export default class extends Controller {
84
84
  // cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
85
85
  onKeydown(e) {
86
86
  if (!this.openMenu) {
87
- if (e.key !== "ArrowDown") return
88
87
  const menu = e.target.closest("[data-phlex-kit--menubar-target=\"menu\"]")
89
88
  if (!menu) return
90
- e.preventDefault()
91
- 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
+ }
92
99
  return
93
100
  }
94
101
  const items = this.items(this.openMenu)
@@ -114,17 +121,51 @@ export default class extends Controller {
114
121
  e.preventDefault()
115
122
  items[items.length - 1]?.focus()
116
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
117
133
  case "ArrowRight":
118
134
  e.preventDefault()
119
- 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
+ }
120
142
  break
121
- case "ArrowLeft":
143
+ case "ArrowLeft": {
122
144
  e.preventDefault()
123
- 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
+ }
124
152
  break
153
+ }
125
154
  }
126
155
  }
127
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
+
128
169
  // Mirrors a checkbox/radio item's native input state onto the item's
129
170
  // aria-checked (radios also reset their group's siblings).
130
171
  syncChecked(e) {
@@ -148,7 +189,10 @@ export default class extends Controller {
148
189
  items(menu) {
149
190
  const panel = this.panel(menu)
150
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.
151
195
  return [...panel.querySelectorAll("[role^=\"menuitem\"]")]
152
- .filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
196
+ .filter((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
153
197
  }
154
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
@@ -1,13 +1,25 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // PhlexKit's stand-in for react-resizable-panels behind shadcn's Resizable:
4
- // dragging a handle rebalances the flex-grow of the two panels around it.
4
+ // dragging (or arrow-keying) a handle rebalances the flex-grow of the two
5
+ // panels around it. The handle is a focusable role="separator", so it also
6
+ // carries the ARIA value contract: aria-valuenow tracks the leading panel's
7
+ // share of the pair, and aria-orientation follows the group direction (a
8
+ // handle in a horizontal group is a vertical separator).
5
9
  // Connects to data-controller="phlex-kit--resizable"
6
10
  export default class extends Controller {
7
11
  static targets = ["panel", "handle"]
8
12
  static values = { direction: { type: String, default: "horizontal" } }
9
13
 
14
+ handleTargetConnected(handle) {
15
+ handle.setAttribute("aria-orientation", this.directionValue === "horizontal" ? "vertical" : "horizontal")
16
+ handle.setAttribute("aria-valuemin", "0")
17
+ handle.setAttribute("aria-valuemax", "100")
18
+ this.syncValuenow(handle)
19
+ }
20
+
10
21
  start(e) {
22
+ if (e.button !== 0 || !e.isPrimary) return
11
23
  const handle = e.currentTarget
12
24
  const prev = handle.previousElementSibling
13
25
  const next = handle.nextElementSibling
@@ -20,6 +32,10 @@ export default class extends Controller {
20
32
  startPos: horizontal ? e.clientX : e.clientY,
21
33
  prevSize: sizeOf(prev),
22
34
  nextSize: sizeOf(next),
35
+ // The drag redistributes the PAIR's combined flex-grow, not a fixed
36
+ // constant: normalizing to 2 rescaled the pair against untouched
37
+ // siblings, so in a 25/25/50 group the 50 panel ballooned on first drag.
38
+ pairGrow: this.growOf(prev) + this.growOf(next) || 2,
23
39
  }
24
40
  try { handle.setPointerCapture(e.pointerId) } catch {}
25
41
 
@@ -27,8 +43,9 @@ export default class extends Controller {
27
43
  const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
28
44
  const total = drag.prevSize + drag.nextSize
29
45
  const prevSize = Math.min(Math.max(drag.prevSize + delta, 0), total)
30
- prev.style.flexGrow = (prevSize / total) * 2
31
- next.style.flexGrow = ((total - prevSize) / total) * 2
46
+ prev.style.flexGrow = (prevSize / total) * drag.pairGrow
47
+ next.style.flexGrow = ((total - prevSize) / total) * drag.pairGrow
48
+ this.syncValuenow(handle)
32
49
  }
33
50
  const onUp = () => {
34
51
  handle.removeEventListener("pointermove", onMove)
@@ -39,4 +56,49 @@ export default class extends Controller {
39
56
  handle.addEventListener("pointerup", onUp)
40
57
  handle.addEventListener("pointercancel", onUp)
41
58
  }
59
+
60
+ // Arrow keys resize by 5% of the pair per press (matching the drag axis);
61
+ // Home/End collapse the leading panel to its min/max.
62
+ keydown(e) {
63
+ const handle = e.currentTarget
64
+ const prev = handle.previousElementSibling
65
+ const next = handle.nextElementSibling
66
+ if (!prev || !next) return
67
+
68
+ const horizontal = this.directionValue === "horizontal"
69
+ const steps = horizontal
70
+ ? { ArrowLeft: -0.05, ArrowRight: 0.05 }
71
+ : { ArrowUp: -0.05, ArrowDown: 0.05 }
72
+
73
+ const pairGrow = this.growOf(prev) + this.growOf(next) || 2
74
+ let share = this.growOf(prev) / (pairGrow || 1)
75
+ if (e.key in steps) {
76
+ share = Math.min(1, Math.max(0, share + steps[e.key]))
77
+ } else if (e.key === "Home") {
78
+ share = 0
79
+ } else if (e.key === "End") {
80
+ share = 1
81
+ } else {
82
+ return
83
+ }
84
+ e.preventDefault()
85
+
86
+ prev.style.flexGrow = share * pairGrow
87
+ next.style.flexGrow = (1 - share) * pairGrow
88
+ this.syncValuenow(handle)
89
+ }
90
+
91
+ growOf(el) {
92
+ const g = parseFloat(getComputedStyle(el).flexGrow)
93
+ return Number.isFinite(g) ? g : 1
94
+ }
95
+
96
+ syncValuenow(handle) {
97
+ const prev = handle.previousElementSibling
98
+ const next = handle.nextElementSibling
99
+ if (!prev || !next) return
100
+ const pair = this.growOf(prev) + this.growOf(next)
101
+ const share = pair > 0 ? this.growOf(prev) / pair : 0.5
102
+ handle.setAttribute("aria-valuenow", String(Math.round(share * 100)))
103
+ }
42
104
  }
@@ -12,10 +12,12 @@ module PhlexKit
12
12
  class: "pk-resizable-handle",
13
13
  role: "separator",
14
14
  tabindex: "0",
15
+ # Correct for the default horizontal group; the controller re-stamps
16
+ # orientation (and aria-valuenow/min/max) per group direction on connect.
15
17
  aria: { orientation: "vertical" },
16
18
  data: {
17
19
  phlex_kit__resizable_target: "handle",
18
- action: "pointerdown->phlex-kit--resizable#start"
20
+ action: "pointerdown->phlex-kit--resizable#start keydown->phlex-kit--resizable#keydown"
19
21
  }
20
22
  }, @attrs)) do
21
23
  span(class: "pk-resizable-handle-grip", aria: { hidden: "true" }) if @with_handle
@@ -2,11 +2,13 @@ module PhlexKit
2
2
  # Custom dropdown select, ported from ruby_ui's RubyUI::Select — the styled
3
3
  # popover (Image #2), NOT the native <select> (that's PhlexKit::NativeSelect). Unlike
4
4
  # the rest of the kit this component IS JS-driven: it keeps ruby_ui's Stimulus
5
- # wiring (`phlex-kit--select` / `phlex-kit--select-item`, in
6
- # app/javascript/controllers/ruby_ui/), since the open/close, selection, and
7
- # keyboard nav are the point. The one change from upstream: the controller drops
8
- # the `@floating-ui/dom` dependency and positions the panel with plain CSS
9
- # (opens directly below the trigger). Tailwind → vanilla `.pk-select-*` (select.css).
5
+ # wiring (`phlex-kit--select`), since the open/close, selection, and keyboard
6
+ # nav are the point. Two changes from upstream: the controller drops the
7
+ # `@floating-ui/dom` dependency and positions the panel with plain CSS (opens
8
+ # directly below the trigger), and selection flips aria-selected via the
9
+ # instance-scoped itemTargets rather than upstream's document-scoped
10
+ # `.pk-select-item` outlet (which clobbered every other Select on the page).
11
+ # Tailwind → vanilla `.pk-select-*` (select.css).
10
12
  #
11
13
  # Multi-part. The hidden SelectInput carries the form value/name; SelectTrigger +
12
14
  # SelectValue are the closed-state button; SelectContent > SelectGroup >
@@ -34,8 +36,7 @@ module PhlexKit
34
36
  data: {
35
37
  controller: "phlex-kit--select",
36
38
  phlex_kit__select_open_value: "false",
37
- action: "click@window->phlex-kit--select#clickOutside",
38
- phlex_kit__select_phlex_kit__select_item_outlet: ".pk-select-item"
39
+ action: "click@window->phlex-kit--select#clickOutside"
39
40
  }
40
41
  }, @attrs), &block)
41
42
  end
@@ -8,7 +8,6 @@ import { Controller } from "@hotwired/stimulus";
8
8
  export default class extends Controller {
9
9
  static targets = ["trigger", "content", "input", "value", "item"];
10
10
  static values = { open: Boolean };
11
- static outlets = ["phlex-kit--select-item"];
12
11
 
13
12
  connect() {
14
13
  this.generateItemsIds();
@@ -17,13 +16,20 @@ export default class extends Controller {
17
16
  selectItem(event) {
18
17
  event.preventDefault();
19
18
 
20
- this.phlexKitSelectItemOutlets.forEach((item) => item.handleSelectItem(event));
19
+ // currentTarget, not target: the click may land on a child of the item
20
+ // (target.dataset.value would be undefined). itemTargets, not a
21
+ // document-scoped outlet: outlets match `.pk-select-item` page-wide and
22
+ // clobbered every other Select's aria-selected.
23
+ const item = event.currentTarget;
24
+ this.itemTargets.forEach((el) => {
25
+ el.setAttribute("aria-selected", el === item ? "true" : "false");
26
+ });
21
27
 
22
28
  const oldValue = this.inputTarget.value;
23
- const newValue = event.target.dataset.value;
29
+ const newValue = item.dataset.value;
24
30
 
25
31
  this.inputTarget.value = newValue;
26
- this.valueTarget.innerText = event.target.innerText;
32
+ this.valueTarget.innerText = item.innerText;
27
33
 
28
34
  this.dispatchOnChange(oldValue, newValue);
29
35
  this.closeContent();
@@ -21,7 +21,6 @@ module PhlexKit
21
21
  "aria-selected": (@selected ? "true" : "false"),
22
22
  data: {
23
23
  value: @value,
24
- controller: "phlex-kit--select-item",
25
24
  action: "click->phlex-kit--select#selectItem keydown.enter->phlex-kit--select#selectItem " \
26
25
  "keydown.down->phlex-kit--select#handleKeyDown keydown.up->phlex-kit--select#handleKeyUp " \
27
26
  "keydown.esc->phlex-kit--select#handleEsc",