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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f17bf0029690be129f5e5d77449b9ebb5632d22151ed3508789683b135a7e03f
4
- data.tar.gz: 2145e97971f4fba1e283d979f454562839a76bae767c29755d3e9ff01639e306
3
+ metadata.gz: 6b7f736afc68bb09cd4706468a8a92b9b4d6085844e0e6813b12181c5b28a9fc
4
+ data.tar.gz: 480e8e1bc05eb96f3d05a409664737207d76a384cd2ea82d6c401c4298c42c45
5
5
  SHA512:
6
- metadata.gz: 67b7a4e80b51796f3bfc1177be569bf13d064e01b491c5be427baf7275ca87f4fd45d243dfd5f3bdf49fd48735b00fe084ea17e93d48cca62d5b28401c33ca0e
7
- data.tar.gz: fd0c992227463320e3869ac7917b8ac63c7bbbd5bc468388de7ef00cbec003e0f333c37dde32711c8fed263bb95abbdde2b9bef1789990e8070f4b2b6fc16d29
6
+ metadata.gz: 62a50bb438b7c8814f8f32e91619b24f9ba41a3a668991c384b56bcc9fa3f317c9967dc12411a52372476e59b35ad3ef0675f11ad94a0f640d2619d25f02d8e9
7
+ data.tar.gz: 385f3b984cde9d92cd1e3e21dd78c95583e327f7a57a914183c64504029bf72cb515f29eb155311cd8ecb3cbb2cda053af205e31a161ddb2031333e0ad5f9013
@@ -1,10 +1,19 @@
1
1
  module PhlexKit
2
2
  class AccordionContent < BaseComponent
3
- def initialize(**attrs) = (@attrs = attrs)
3
+ # Pass `open: true` alongside AccordionItem(open: true) so the content is
4
+ # readable without JS — the closed-state hidden + height:0 otherwise makes
5
+ # a server-rendered open item unreachable pre-hydration.
6
+ def initialize(open: false, **attrs)
7
+ @open = open
8
+ @attrs = attrs
9
+ end
10
+
4
11
  def view_template(&)
5
12
  # role="region" + aria-labelledby → trigger id (wired by the controller
6
13
  # in connect(), which also assigns the ids), per the APG accordion pattern.
7
- div(**mix({ class: "pk-accordion-content", role: "region", data: { phlex_kit__accordion_target: "content", state: "closed" }, style: "height: 0px;", hidden: true }, @attrs), &)
14
+ base = { class: "pk-accordion-content", role: "region", data: { phlex_kit__accordion_target: "content", state: @open ? "open" : "closed" } }
15
+ base.merge!(style: "height: 0px;", hidden: true) unless @open
16
+ div(**mix(base, @attrs), &)
8
17
  end
9
18
  end
10
19
  end
@@ -15,8 +15,11 @@ export default class extends Controller {
15
15
 
16
16
  connect() {
17
17
  this.wireAria()
18
+ // Apply the initial state without animating (the initial valueChanged
19
+ // callback is skipped — it fires before connect()).
18
20
  const d = this.animationDurationValue
19
21
  this.animationDurationValue = 0
22
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", this.openValue ? "true" : "false")
20
23
  this.openValue ? this.open() : this.close()
21
24
  this.animationDurationValue = d
22
25
  }
@@ -34,11 +37,18 @@ export default class extends Controller {
34
37
  if (!root || root.dataset.type !== "single") return
35
38
  root.querySelectorAll('[data-controller~="phlex-kit--accordion"]').forEach((el) => {
36
39
  if (el === this.element) return
40
+ // Skip items of a NESTED accordion — querySelectorAll descends into
41
+ // item content, and opening an outer item collapsed inner ones too.
42
+ if (el.closest(".pk-accordion") !== root) return
37
43
  const ctrl = this.application.getControllerForElementAndIdentifier(el, "phlex-kit--accordion")
38
44
  if (ctrl && ctrl.openValue) ctrl.openValue = false
39
45
  })
40
46
  }
41
- openValueChanged(isOpen) {
47
+ openValueChanged(isOpen, wasOpen) {
48
+ // Stimulus fires the initial value callback BEFORE connect() — before
49
+ // connect zeroes the duration — so a server-rendered open item visibly
50
+ // animated open on every page load. connect() applies the initial state.
51
+ if (wasOpen === undefined) return
42
52
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
43
53
  isOpen ? this.open() : this.close()
44
54
  }
@@ -78,9 +88,12 @@ export default class extends Controller {
78
88
  c.removeAttribute("hidden")
79
89
  c.dataset.state = "open"
80
90
  const h = c.scrollHeight
81
- c.animate([{ height: c.style.height || "0px" }, { height: `${h}px` }],
91
+ const a = c.animate([{ height: c.style.height || "0px" }, { height: `${h}px` }],
82
92
  { duration: this.animationDurationValue, easing: "ease-in-out" })
83
93
  c.style.height = `${h}px`
94
+ // Settle at auto, not the frozen pixel height — with overflow hidden a
95
+ // later content growth (or a resize that rewraps text) silently clipped.
96
+ a.finished.then(() => { if (c.dataset.state === "open") c.style.height = "auto" }).catch(() => {})
84
97
  }
85
98
 
86
99
  hide() {
@@ -13,9 +13,12 @@ module PhlexKit
13
13
 
14
14
  def view_template(&block)
15
15
  template(**mix({ data: { phlex_kit__alert_dialog_target: "content" } }, @attrs)) do
16
- div(data: { controller: "phlex-kit--alert-dialog" }) do
16
+ # keydown on the clone root: focus is trapped inside the panel, so
17
+ # Escape/Tab always bubble here; tabindex="-1" on the panel is the
18
+ # focus fallback when the dialog has no focusable children.
19
+ div(data: { controller: "phlex-kit--alert-dialog", action: "keydown->phlex-kit--alert-dialog#keydown" }) do
17
20
  div(class: "pk-alert-dialog-overlay", "aria-hidden": "true")
18
- div(role: "alertdialog", "aria-modal": "true", class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" "), &block)
21
+ div(role: "alertdialog", "aria-modal": "true", tabindex: "-1", class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" "), &block)
19
22
  end
20
23
  end
21
24
  end
@@ -1,24 +1,98 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Ported from ruby_ui's phlex-kit--alert-dialog controller. open() clones the
4
- // <template> content into <body> as a modal (the clone carries its own instance
5
- // of this controller); dismiss() removes it. The only change from upstream: lock
6
- // body scroll via inline style rather than a Tailwind `overflow-hidden` class.
4
+ // <template> content into <body> as a modal; the clone carries its own
5
+ // instance of this controller, which owns the full modal contract for the
6
+ // clone's lifetime (mirroring sheet_content_controller): scroll lock, initial
7
+ // focus (the Cancel action when present — APG wants the least destructive
8
+ // control), Tab focus trap, Escape-to-dismiss, focus restore to the opener,
9
+ // and aria-labelledby/-describedby wiring to Title/Description. connect and
10
+ // disconnect exactly bracket the clone, so removal by any path restores state.
11
+ const FOCUSABLE =
12
+ 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
13
+
7
14
  export default class extends Controller {
8
15
  static targets = ["content"];
9
16
  static values = { open: { type: Boolean, default: false } };
10
17
 
11
18
  connect() {
12
- if (this.openValue) this.open();
19
+ // The source element holds the <template>; the clone in <body> does not.
20
+ if (this.hasContentTarget) {
21
+ if (this.openValue) this.open();
22
+ return;
23
+ }
24
+ this.opener = document.activeElement;
25
+ this.previousOverflow = document.body.style.overflow;
26
+ document.body.style.overflow = "hidden";
27
+ this.#wireAria();
28
+ const focusables = this.#focusables();
29
+ const cancel = focusables.find((el) => (el.dataset.action || "").includes("#dismiss"));
30
+ (cancel || focusables[0] || this.#panel())?.focus();
31
+ }
32
+
33
+ disconnect() {
34
+ if (this.hasContentTarget) return;
35
+ document.body.style.overflow = this.previousOverflow;
36
+ if (this.opener?.isConnected) this.opener.focus();
13
37
  }
14
38
 
15
39
  open() {
40
+ // Double-open guard: Enter+click (or a double-click) on the trigger must
41
+ // not stack a second clone over the first.
42
+ if (this.clone?.isConnected) return;
16
43
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
17
- document.body.style.overflow = "hidden";
44
+ this.clone = document.body.lastElementChild;
18
45
  }
19
46
 
20
47
  dismiss() {
21
- document.body.style.overflow = "";
22
48
  this.element.remove();
23
49
  }
50
+
51
+ keydown(event) {
52
+ if (event.key === "Escape") {
53
+ event.preventDefault();
54
+ this.dismiss();
55
+ return;
56
+ }
57
+ if (event.key !== "Tab") return;
58
+ const focusables = this.#focusables();
59
+ if (focusables.length === 0) {
60
+ event.preventDefault();
61
+ this.#panel()?.focus();
62
+ return;
63
+ }
64
+ const first = focusables[0];
65
+ const last = focusables[focusables.length - 1];
66
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === this.#panel())) {
67
+ event.preventDefault();
68
+ last.focus();
69
+ } else if (!event.shiftKey && document.activeElement === last) {
70
+ event.preventDefault();
71
+ first.focus();
72
+ }
73
+ }
74
+
75
+ #panel() {
76
+ return this.element.querySelector('[role="alertdialog"]');
77
+ }
78
+
79
+ #focusables() {
80
+ const panel = this.#panel();
81
+ if (!panel) return [];
82
+ return [...panel.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0);
83
+ }
84
+
85
+ #wireAria() {
86
+ const panel = this.#panel();
87
+ if (!panel) return;
88
+ const wire = (attr, selector) => {
89
+ if (panel.getAttribute(attr)) return;
90
+ const el = this.element.querySelector(selector);
91
+ if (!el) return;
92
+ if (!el.id) el.id = `pk-alert-dialog-${Math.random().toString(36).slice(2, 10)}`;
93
+ panel.setAttribute(attr, el.id);
94
+ };
95
+ wire("aria-labelledby", ".pk-alert-dialog-title");
96
+ wire("aria-describedby", ".pk-alert-dialog-description");
97
+ }
24
98
  }
@@ -5,16 +5,20 @@ module PhlexKit
5
5
  # `.pk-aspect-ratio*` (aspect_ratio.css).
6
6
  class AspectRatio < BaseComponent
7
7
  def initialize(aspect_ratio: "16/9", **attrs)
8
- unless aspect_ratio.is_a?(String) && aspect_ratio.include?("/")
9
- raise ArgumentError, %(aspect_ratio must be a "w/h" string (e.g. "16/9"))
8
+ # Both terms must be positive integers — "16/0" yields Infinity% and
9
+ # "a/b" NaN% padding, silently breaking the layout.
10
+ unless aspect_ratio.is_a?(String) && aspect_ratio.match?(%r{\A[1-9]\d*/[1-9]\d*\z})
11
+ raise ArgumentError, %(aspect_ratio must be a "w/h" string of positive integers (e.g. "16/9"))
10
12
  end
11
13
  @aspect_ratio = aspect_ratio
12
14
  @attrs = attrs
13
15
  end
14
16
 
15
17
  def view_template(&block)
16
- div(class: "pk-aspect-ratio", style: "padding-bottom: #{padding_bottom}%") do
17
- div(**mix({ class: "pk-aspect-ratio-inner" }, @attrs), &block)
18
+ # **mix on the ROOT (kit rule) — caller attrs previously landed on the
19
+ # inner div and could never reach the actual root element.
20
+ div(**mix({ class: "pk-aspect-ratio", style: "padding-bottom: #{padding_bottom}%" }, @attrs)) do
21
+ div(class: "pk-aspect-ratio-inner", &block)
18
22
  end
19
23
  end
20
24
 
@@ -7,7 +7,15 @@ module PhlexKit
7
7
  end
8
8
 
9
9
  def view_template
10
- table(**mix({ class: "pk-calendar-body", data: { phlex_kit__calendar_target: "calendar" } }, @attrs))
10
+ table(**mix({
11
+ class: "pk-calendar-body",
12
+ data: {
13
+ phlex_kit__calendar_target: "calendar",
14
+ # Delegated arrow-key/Home/End navigation for the day grid (the
15
+ # buttons are re-rendered wholesale, so the action lives here).
16
+ action: "keydown->phlex-kit--calendar#onKeydown"
17
+ }
18
+ }, @attrs))
11
19
  end
12
20
  end
13
21
  end
@@ -119,6 +119,7 @@ export default class extends Controller {
119
119
  }
120
120
  this.selectedDatesValue = current;
121
121
  this.updateCalendar();
122
+ this.pushToOutlets();
122
123
  this.dispatchChange();
123
124
  }
124
125
 
@@ -192,7 +193,66 @@ export default class extends Controller {
192
193
  this.titleTarget.textContent = this.monthAndYear();
193
194
  }
194
195
  this.syncDropdowns();
196
+ // innerHTML replacement drops focus — remember the focused day so
197
+ // keyboard selection/month hops don't strand focus on <body>.
198
+ const focusedDay = this.calendarTarget.contains(document.activeElement)
199
+ ? document.activeElement.dataset?.day
200
+ : null;
195
201
  this.calendarTarget.innerHTML = this.calendarHTML();
202
+ this.ensureGridTabStop(focusedDay);
203
+ }
204
+
205
+ // Roving tabindex: exactly one day button is tabbable — the previously
206
+ // focused day (refocused across re-renders), else the selected day (its
207
+ // template ships tabindex="0"), else today, else the first enabled day of
208
+ // the current month. Without this, a calendar with no selection has no
209
+ // keyboard entry point at all.
210
+ ensureGridTabStop(focusedDay) {
211
+ const days = [...this.calendarTarget.querySelectorAll(".pk-calendar-day:not([disabled])")];
212
+ if (!days.length) return;
213
+ const focused = focusedDay ? days.find((d) => d.dataset.day === focusedDay) : null;
214
+ const stop =
215
+ focused ||
216
+ days.find((d) => d.getAttribute("tabindex") === "0") ||
217
+ days.find((d) => d.classList.contains("today")) ||
218
+ days.find((d) => !d.classList.contains("other")) ||
219
+ days[0];
220
+ days.forEach((d) => d.setAttribute("tabindex", d === stop ? "0" : "-1"));
221
+ if (focused) stop.focus({ preventScroll: true });
222
+ }
223
+
224
+ // Arrow keys move by day/week, Home/End jump within the rendered week row;
225
+ // crossing a month boundary re-renders the grid on the target month.
226
+ // Enter/Space activate natively (the days are real <button>s).
227
+ onKeydown(e) {
228
+ const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
229
+ const day = e.target.closest?.("[data-day]");
230
+ if (!day) return;
231
+
232
+ let target;
233
+ if (e.key in STEPS) {
234
+ target = this.parseDate(day.dataset.day);
235
+ target.setDate(target.getDate() + STEPS[e.key]);
236
+ } else if (e.key === "Home" || e.key === "End") {
237
+ const rowDays = [...day.closest("tr").querySelectorAll("[data-day]")];
238
+ target = this.parseDate(rowDays[e.key === "Home" ? 0 : rowDays.length - 1].dataset.day);
239
+ } else {
240
+ return;
241
+ }
242
+ e.preventDefault();
243
+
244
+ const iso = this.isoDate(target);
245
+ if (this.isDateDisabled(iso)) return; // min/max/booked boundary: stay put
246
+
247
+ if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
248
+ this.viewDateValue = iso; // re-renders the grid on the target month
249
+ }
250
+ const next = this.calendarTarget.querySelector(`[data-day="${iso}"]:not([disabled])`);
251
+ if (!next) return;
252
+ this.calendarTarget.querySelectorAll(".pk-calendar-day").forEach((d) => {
253
+ d.setAttribute("tabindex", d === next ? "0" : "-1");
254
+ });
255
+ next.focus({ preventScroll: true });
196
256
  }
197
257
 
198
258
  syncDropdowns() {
@@ -404,8 +464,10 @@ export default class extends Controller {
404
464
  PPPP: `${dayOfWeek}, ${monthName} ${day}${daySuffix}, ${year}`,
405
465
  };
406
466
 
467
+ // Longest tokens first: MMMM must precede MM or the alternation eats
468
+ // "MMMM" as two "MM"s and renders the month number twice.
407
469
  const formattedDate = format.replace(
408
- /yyyy|MM|dd|HH|mm|ss|EEEE|MMMM|do|PPPP/g,
470
+ /yyyy|MMMM|MM|dd|HH|mm|ss|EEEE|PPPP|do/g,
409
471
  (matched) => map[matched],
410
472
  );
411
473
  return formattedDate;
@@ -59,8 +59,12 @@ export default class extends Controller {
59
59
  defaultThemeColor(index) {
60
60
  const color = this.seriesColor(index)
61
61
  const translucentFill = ["line", "radar"].includes(this.optionsValue.type)
62
+ // color-mix, not hex + "33": the token may be any CSS color — the shipped
63
+ // zinc/neutral themes use oklch(...), and appending an alpha suffix to
64
+ // those produced invalid colors (fills silently broke).
65
+ const translucent = `color-mix(in oklab, ${color} 20%, transparent)`
62
66
  return {
63
- backgroundColor: this.isCircular() ? undefined : (translucentFill ? color + "33" : color),
67
+ backgroundColor: this.isCircular() ? undefined : (translucentFill ? translucent : color),
64
68
  hoverBackgroundColor: color,
65
69
  borderColor: color,
66
70
  borderWidth: 2,
@@ -6,7 +6,9 @@ module PhlexKit
6
6
  @attrs = attrs
7
7
  end
8
8
  def view_template(&block)
9
- div(class: "pk-clipboard-popover pk-hidden", data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
9
+ # role="status": the shown/hidden confirmation is a live region, so
10
+ # screen readers announce "Copied!" instead of a silent visual flash.
11
+ div(class: "pk-clipboard-popover pk-hidden", role: "status", data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
10
12
  div(**mix({ class: "pk-clipboard-popover-inner" }, @attrs), &block)
11
13
  end
12
14
  end
@@ -120,7 +120,13 @@ export default class extends Controller {
120
120
 
121
121
  toggleAllItems() {
122
122
  const isChecked = this.toggleAllTarget.checked
123
- this.inputTargets.forEach(input => input.checked = isChecked)
123
+ this.inputTargets.forEach(input => {
124
+ if (input.checked === isChecked) return
125
+ input.checked = isChecked
126
+ // Programmatic .checked flips fire no event — host listeners and the
127
+ // form-field validation must still hear select-all (uncheck() does this).
128
+ input.dispatchEvent(new Event("change", { bubbles: true }))
129
+ })
124
130
  this.updateTriggerContent()
125
131
  }
126
132
 
@@ -207,7 +213,11 @@ export default class extends Controller {
207
213
  e.preventDefault()
208
214
  e.stopPropagation() // the button sits inside the trigger's click-to-open area
209
215
  }
210
- this.inputTargets.forEach(input => input.checked = false)
216
+ this.inputTargets.forEach(input => {
217
+ if (!input.checked) return
218
+ input.checked = false
219
+ input.dispatchEvent(new Event("change", { bubbles: true })) // see toggleAllItems
220
+ })
211
221
  if (this.hasToggleAllTarget) this.toggleAllTarget.checked = false
212
222
  this.updateTriggerContent()
213
223
  }
@@ -287,6 +297,9 @@ export default class extends Controller {
287
297
 
288
298
  this.selectedItemIndex = null
289
299
  this.clearActiveDescendant()
300
+ // Also drop the highlight itself: a filtered-out option that kept
301
+ // aria-current="true" stayed the Enter target while invisible.
302
+ this.itemTargets.forEach(item => item.ariaCurrent = "false")
290
303
 
291
304
  this.inputTargets.forEach((input) => {
292
305
  const text = this.inputContent(input).toLowerCase()
@@ -14,9 +14,13 @@ module PhlexKit
14
14
  def view_template(&block)
15
15
  label(**mix({
16
16
  class: "pk-context-menu-item pk-context-menu-checkbox-item",
17
- role: "menuitemcheckbox"
17
+ role: "menuitemcheckbox",
18
+ tabindex: "-1",
19
+ aria: { checked: @checked ? "true" : "false" },
20
+ data: { phlex_kit__context_menu_target: "menuItem" }
18
21
  }, @attrs)) do
19
- input(type: :checkbox, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked)
22
+ input(type: :checkbox, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
23
+ tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
20
24
  span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
21
25
  render Icon.new(:check, size: nil)
22
26
  end
@@ -74,14 +74,64 @@ export default class extends Controller {
74
74
  e.preventDefault()
75
75
  items[items.length - 1]?.focus()
76
76
  break
77
+ case "Enter":
78
+ case " ":
79
+ // Explicit click so label rows (checkbox/radio) activate — labels
80
+ // have no native keyboard activation (and this gives links Space).
81
+ if (index >= 0) {
82
+ e.preventDefault()
83
+ items[index].click()
84
+ }
85
+ break
86
+ case "ArrowRight":
87
+ // On a sub trigger, enter the submenu (focus reveals it via
88
+ // :focus-within, making its rows visible to the roving nav).
89
+ if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
90
+ e.preventDefault()
91
+ this.enterSub(document.activeElement)
92
+ }
93
+ break
94
+ case "ArrowLeft": {
95
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
96
+ const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
97
+ if (sub) {
98
+ e.preventDefault()
99
+ sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
100
+ }
101
+ break
102
+ }
77
103
  }
78
104
  }
79
105
 
106
+ enterSub(trigger) {
107
+ trigger.focus()
108
+ const panel = trigger.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-content")
109
+ if (!panel) return
110
+ const first = [...panel.querySelectorAll("[role^=\"menuitem\"]")]
111
+ .find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
112
+ first?.focus()
113
+ }
114
+
115
+ // Mirrors a checkbox/radio row's native input state onto the row's
116
+ // aria-checked (radios also reset their group's siblings).
117
+ syncChecked(e) {
118
+ const input = e.target
119
+ const group = input.name
120
+ ? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
121
+ : [input]
122
+ group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
123
+ }
124
+
80
125
  anchor() {
81
126
  return this.hasTriggerTarget ? this.triggerTarget : this.element
82
127
  }
83
128
 
84
129
  items() {
85
- return this.menuItemTargets.filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
130
+ // getClientRects also skips rows inside a CLOSED sub panel (its
131
+ // display:none comes from the hover/focus-within CSS, not .pk-hidden) —
132
+ // focus() on those silently fails and the roving nav jams there.
133
+ return this.menuItemTargets.filter(
134
+ (el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0
135
+ )
86
136
  }
87
137
  }
@@ -13,9 +13,13 @@ module PhlexKit
13
13
  def view_template(&block)
14
14
  label(**mix({
15
15
  class: "pk-context-menu-item pk-context-menu-radio-item",
16
- role: "menuitemradio"
16
+ role: "menuitemradio",
17
+ tabindex: "-1",
18
+ aria: { checked: @checked ? "true" : "false" },
19
+ data: { phlex_kit__context_menu_target: "menuItem" }
17
20
  }, @attrs)) do
18
- input(type: :radio, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked)
21
+ input(type: :radio, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
22
+ tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
19
23
  span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
20
24
  # A filled selection dot is geometry, not icon-library vocabulary —
21
25
  # identical across icon_library settings (matches the dropdown).
@@ -6,8 +6,11 @@ module PhlexKit
6
6
  div(**mix({
7
7
  class: "pk-context-menu-item pk-context-menu-sub-trigger",
8
8
  role: "menuitem",
9
- tabindex: "0",
10
- aria: { haspopup: "menu" }
9
+ # -1: roving focus reaches it via arrows (focus-within opens the sub);
10
+ # tabindex 0 made it a stray tab stop inside the menu.
11
+ tabindex: "-1",
12
+ aria: { haspopup: "menu" },
13
+ data: { phlex_kit__context_menu_target: "menuItem" }
11
14
  }, @attrs)) do
12
15
  block&.call
13
16
  render Icon.new(:chevron_right, size: nil, class: "pk-context-menu-sub-chevron")
@@ -54,9 +54,10 @@
54
54
  text-decoration: none; /* href items render as <a> — no UA underline at rest */
55
55
  outline: none;
56
56
  }
57
+ /* Keyboard nav moves real focus — :focus is the highlight (no aria-selected:
58
+ that attribute is unsupported on menuitems and AT ignores it). */
57
59
  .pk-dropdown-menu-item:hover,
58
- .pk-dropdown-menu-item:focus,
59
- .pk-dropdown-menu-item[aria-selected="true"] { background: var(--pk-accent); text-decoration: none; }
60
+ .pk-dropdown-menu-item:focus { background: var(--pk-accent); text-decoration: none; }
60
61
 
61
62
  .pk-dropdown-menu-label { margin: 0; padding: .375rem .5rem; font-size: .875rem; font-weight: 600; }
62
63
  .pk-dropdown-menu-separator { height: 1px; margin: .25rem -.25rem; background: var(--pk-border); }
@@ -14,9 +14,13 @@ module PhlexKit
14
14
  def view_template(&block)
15
15
  label(**mix({
16
16
  class: "pk-dropdown-menu-item pk-dropdown-menu-checkbox-item",
17
- role: "menuitemcheckbox"
17
+ role: "menuitemcheckbox",
18
+ tabindex: "-1",
19
+ aria: { checked: @checked ? "true" : "false" },
20
+ data: { phlex_kit__dropdown_menu_target: "menuItem" }
18
21
  }, @attrs)) do
19
- input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked)
22
+ input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
23
+ tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
20
24
  span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) { check_icon }
21
25
  yield if block
22
26
  end
@@ -14,7 +14,9 @@ module PhlexKit
14
14
 
15
15
  def view_template(&block)
16
16
  div(class: wrapper_classes, popover: "manual", data: { phlex_kit__dropdown_menu_target: "content" }) do
17
- div(**mix({ class: "pk-dropdown-menu-viewport" }, @attrs), &block)
17
+ # role="menu" on the items' direct parent — the rows render
18
+ # role="menuitem", which is invalid ARIA without a menu ancestor.
19
+ div(**mix({ class: "pk-dropdown-menu-viewport", role: "menu" }, @attrs), &block)
18
20
  end
19
21
  end
20
22