phlex_kit 0.11.0 → 0.12.0

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -1
  3. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +35 -7
  5. data/app/components/phlex_kit/attachment/attachment.css +15 -0
  6. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -0
  7. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
  8. data/app/components/phlex_kit/calendar/calendar_controller.js +23 -6
  9. data/app/components/phlex_kit/carousel/carousel_controller.js +36 -2
  10. data/app/components/phlex_kit/chart/chart.rb +6 -4
  11. data/app/components/phlex_kit/chart/chart_controller.js +37 -8
  12. data/app/components/phlex_kit/codeblock/codeblock.rb +5 -1
  13. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  14. data/app/components/phlex_kit/combobox/combobox_controller.js +57 -2
  15. data/app/components/phlex_kit/command/command_controller.js +17 -1
  16. data/app/components/phlex_kit/command/command_input.rb +3 -2
  17. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  18. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  19. data/app/components/phlex_kit/context_menu/context_menu_controller.js +49 -12
  20. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  21. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  22. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  23. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  24. data/app/components/phlex_kit/date_picker/date_picker.rb +13 -2
  25. data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -0
  26. data/app/components/phlex_kit/dialog/dialog_controller.js +3 -0
  27. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  28. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  29. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +4 -0
  30. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  31. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +54 -13
  32. data/app/components/phlex_kit/form_field/form_field_controller.js +32 -24
  33. data/app/components/phlex_kit/hover_card/hover_card.css +6 -0
  34. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  35. data/app/components/phlex_kit/input/input.css +0 -1
  36. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  37. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  38. data/app/components/phlex_kit/item/item.css +2 -1
  39. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  40. data/app/components/phlex_kit/label/label.css +1 -1
  41. data/app/components/phlex_kit/link/link.rb +1 -0
  42. data/app/components/phlex_kit/marker/marker.css +2 -1
  43. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  44. data/app/components/phlex_kit/menubar/menubar_controller.js +40 -16
  45. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  46. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  47. data/app/components/phlex_kit/popover/popover_controller.js +19 -0
  48. data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -14
  49. data/app/components/phlex_kit/select/select_controller.js +22 -0
  50. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  51. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  52. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -0
  53. data/app/components/phlex_kit/sheet/sheet_controller.js +17 -2
  54. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  55. data/app/components/phlex_kit/switch/switch.rb +6 -0
  56. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  57. data/app/components/phlex_kit/toast/toast_action.rb +1 -1
  58. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  59. data/app/components/phlex_kit/toast/toast_controller.js +32 -16
  60. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  61. data/app/components/phlex_kit/toast/toast_region.rb +12 -3
  62. data/app/components/phlex_kit/toast/toaster_controller.js +2 -0
  63. data/app/components/phlex_kit/toggle_group/toggle_group.rb +16 -1
  64. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +11 -8
  65. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +15 -5
  66. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  67. data/lib/phlex_kit/base_component.rb +13 -3
  68. data/lib/phlex_kit/version.rb +1 -1
  69. data/lib/phlex_kit.rb +1 -0
  70. metadata +2 -1
@@ -5,7 +5,15 @@ module PhlexKit
5
5
  class ContextMenu < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-context-menu", data: { controller: "phlex-kit--context-menu" } }, @attrs), &)
8
+ div(**mix({
9
+ class: "pk-context-menu",
10
+ data: {
11
+ controller: "phlex-kit--context-menu",
12
+ # focusout: tabbing (or otherwise moving real focus) out of the
13
+ # menu closes the open [popover=manual] panel (menubar's pattern).
14
+ action: "focusout->phlex-kit--context-menu#onFocusout"
15
+ }
16
+ }, @attrs), &)
9
17
  end
10
18
  end
11
19
  end
@@ -33,6 +33,17 @@ export default class extends Controller {
33
33
  // Outstanding syncSub rAF handles — cancelled on disconnect (menubar's
34
34
  // pattern).
35
35
  this.subFrames = new Set()
36
+ // A Turbo snapshot (or a detach/reattach that mimics one) serializes
37
+ // aria-expanded="true" on sub triggers even though no submenu can be
38
+ // open at connect time — sweep every stale marker back to false
39
+ // (menubar's pattern).
40
+ this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
41
+ // Same shape for checkbox/radio rows: their `checked` DOM property
42
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
43
+ // from it — resync every row from its live input.
44
+ this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
45
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
46
+ })
36
47
  }
37
48
 
38
49
  disconnect() {
@@ -56,6 +67,18 @@ export default class extends Controller {
56
67
  this.subFrames.add(id)
57
68
  }
58
69
 
70
+ // Tabbing (or otherwise moving real focus) out of the menu closes it —
71
+ // menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
72
+ // null when the window itself blurs — leave the menu alone then. The
73
+ // trigger + content are both DOM children of this.element even though the
74
+ // content is a top-layer popover, so one containment check covers both.
75
+ onFocusout(e) {
76
+ if (!this.contentTarget.matches(":popover-open")) return
77
+ const to = e.relatedTarget
78
+ if (!to || this.element.contains(to)) return
79
+ this.close()
80
+ }
81
+
59
82
  open(e) {
60
83
  e.preventDefault()
61
84
  let x = e.clientX
@@ -93,6 +116,13 @@ export default class extends Controller {
93
116
  }
94
117
 
95
118
  keydown(e) {
119
+ // This listener stays attached document-wide while the menu is open (see
120
+ // open()/close()) so it keeps receiving events even once real focus has
121
+ // left the menu — scope the roving-nav keys to when focus is actually
122
+ // within the widget (this.element, not just contentTarget, so a focused
123
+ // trigger still counts). Escape stays global while the menu is open (APG).
124
+ if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return
125
+
96
126
  const items = this.items()
97
127
  const index = items.indexOf(document.activeElement)
98
128
  switch (e.key) {
@@ -126,19 +156,26 @@ export default class extends Controller {
126
156
  }
127
157
  break
128
158
  case "ArrowRight":
129
- // On a sub trigger, enter the submenu (focus reveals it via
130
- // :focus-within, making its rows visible to the roving nav).
131
- if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
132
- e.preventDefault()
133
- this.enterSub(document.activeElement)
134
- }
135
- break
136
159
  case "ArrowLeft": {
137
- // Inside a submenu, step back to its trigger (closes it on focus-out).
138
- const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
139
- if (sub) {
140
- e.preventDefault()
141
- sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
160
+ // Submenus open inline-end visually LEFT in RTL so the enter key
161
+ // follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
162
+ // (and the reverse in LTR). Runtime dir check is reliable after a flip.
163
+ const rtl = getComputedStyle(this.element).direction === "rtl"
164
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
165
+ if (e.key === enterKey) {
166
+ // On a sub trigger, enter the submenu (focus reveals it via
167
+ // :focus-within, making its rows visible to the roving nav).
168
+ if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
169
+ e.preventDefault()
170
+ this.enterSub(document.activeElement)
171
+ }
172
+ } else {
173
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
174
+ const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
175
+ if (sub) {
176
+ e.preventDefault()
177
+ sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
178
+ }
142
179
  }
143
180
  break
144
181
  }
@@ -92,7 +92,7 @@
92
92
  .pk-data-table-page-disabled {
93
93
  display: inline-flex;
94
94
  align-items: center;
95
- height: 2.25rem;
95
+ height: 2rem;
96
96
  padding: 0 .75rem;
97
97
  font-size: .875rem;
98
98
  opacity: .5;
@@ -70,7 +70,10 @@ module PhlexKit
70
70
  def edge_item(disabled:, href:, label:, name:)
71
71
  if disabled
72
72
  li do
73
- span(class: "pk-data-table-page-disabled", aria: { disabled: "true", label: name }) { label }
73
+ # role-less span + aria-label is invalid ARIA (aria-label on a
74
+ # role-less element is not perceivable) — role + aria-disabled
75
+ # mirrors BreadcrumbPage's disabled-control pattern.
76
+ span(class: "pk-data-table-page-disabled", role: "link", aria: { disabled: "true", label: name }) { label }
74
77
  end
75
78
  else
76
79
  render PaginationItem.new(href: href, aria: { label: name }) { label }
@@ -4,21 +4,26 @@ module PhlexKit
4
4
  # (search/sort/direction/...) survive via `preserved_params:` hidden inputs,
5
5
  # mirroring DataTableSearch. See data_table.rb.
6
6
  class DataTablePerPageSelect < BaseComponent
7
- def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, **attrs)
7
+ # method:/action: are kit-owned (a GET form targeting `path:`) named
8
+ # kwargs so a caller override lands in @method/@action directly instead
9
+ # of fusing with the generated defaults via `mix` ("get post").
10
+ def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, method: "get", action: nil, **attrs)
8
11
  @path = path
9
12
  @name = name
10
13
  @value = value
11
14
  @frame_id = frame_id
12
15
  @options = options
13
16
  @preserved_params = preserved_params
17
+ @method = method
18
+ @action = action || @path
14
19
  @attrs = attrs
15
20
  end
16
21
 
17
22
  def view_template
18
23
  form_attrs = {
19
24
  class: "pk-data-table-per-page",
20
- action: @path,
21
- method: "get",
25
+ action: @action,
26
+ method: @method,
22
27
  # Reuse the search controller for its CSP-safe #submitNow action.
23
28
  data: { controller: "phlex-kit--data-table-search" }
24
29
  }
@@ -3,7 +3,10 @@ module PhlexKit
3
3
  # params survive via `preserved_params:` hidden inputs; focus/caret survive the
4
4
  # frame swap (see the search controller). See data_table.rb.
5
5
  class DataTableSearch < BaseComponent
6
- def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, **attrs)
6
+ # method:/action: are kit-owned (a GET form targeting `path:`) named
7
+ # kwargs so a caller override lands in @method/@action directly instead
8
+ # of fusing with the generated defaults via `mix` ("get post").
9
+ def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, method: "get", action: nil, **attrs)
7
10
  @path = path
8
11
  @name = name
9
12
  @value = value
@@ -11,11 +14,13 @@ module PhlexKit
11
14
  @placeholder = placeholder
12
15
  @debounce = debounce
13
16
  @preserved_params = preserved_params
17
+ @method = method
18
+ @action = action || @path
14
19
  @attrs = attrs
15
20
  end
16
21
 
17
22
  def view_template
18
- form(**mix({ class: "pk-data-table-search", method: "get", action: @path, data: form_data }, @attrs)) do
23
+ form(**mix({ class: "pk-data-table-search", method: @method, action: @action, data: form_data }, @attrs)) do
19
24
  render Input.new(
20
25
  type: :search,
21
26
  name: @name,
@@ -5,7 +5,12 @@ module PhlexKit
5
5
  # controller and the calendar's outlet points at its id, so picking a day
6
6
  # writes the formatted date into the field. Upstream's popover_options
7
7
  # (hover trigger via @floating-ui) are gone — the kit popover is click-only,
8
- # CSS-positioned. Tailwind → vanilla `.pk-date-picker*` (date_picker.css).
8
+ # CSS-positioned. The phlex-kit--date-picker controller on the root div
9
+ # listens for the calendar's change event and closes the popover once a
10
+ # selection is COMPLETE (single: any pick; range: the second end only;
11
+ # multiple: never), returning focus to the input — see
12
+ # date_picker_controller.js and popover_controller.js#close.
13
+ # Tailwind → vanilla `.pk-date-picker*` (date_picker.css).
9
14
  class DatePicker < BaseComponent
10
15
  def initialize(
11
16
  id: nil,
@@ -45,7 +50,13 @@ module PhlexKit
45
50
  end
46
51
 
47
52
  def view_template
48
- div(**mix({ class: "pk-date-picker" }, @attrs)) do
53
+ div(**mix({
54
+ class: "pk-date-picker",
55
+ data: {
56
+ controller: "phlex-kit--date-picker",
57
+ action: "phlex-kit--calendar:change->phlex-kit--date-picker#onCalendarChange"
58
+ }
59
+ }, @attrs)) do
49
60
  render Popover.new do
50
61
  render PopoverTrigger.new(**trigger_attrs) do
51
62
  div(class: "pk-date-picker-field") do
@@ -0,0 +1,28 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Bridges the nested Calendar's change event to the wrapping Popover's
4
+ // close(), so picking a date collapses the panel instead of leaving the
5
+ // user to click outside/Escape after every pick (shadcn's DatePicker
6
+ // behavior). Only closes on a COMPLETE selection: single mode closes on
7
+ // any pick, range mode only once the second end (rangeEnd) is set — closing
8
+ // on the first end would strand the user before they can pick the second —
9
+ // and multiple mode never auto-closes since picking more than one date is
10
+ // the entire point. See date_picker.rb for the data-action wiring and
11
+ // popover_controller.js for close()/focusTrigger().
12
+ export default class extends Controller {
13
+ onCalendarChange(e) {
14
+ const { mode, rangeEnd } = e.detail
15
+ if (mode === "multiple") return
16
+ // calendar_controller.js clears rangeEnd via `this.rangeEndValue = null`,
17
+ // which goes through Element#setAttribute — the DOM stringifies a null
18
+ // argument to the literal "null", so a genuinely-unset rangeEnd reads
19
+ // back as that string, not JS null/undefined/"".
20
+ if (mode === "range" && (!rangeEnd || rangeEnd === "null")) return
21
+ this.popoverController()?.close()
22
+ }
23
+
24
+ popoverController() {
25
+ const popoverEl = this.element.querySelector(".pk-popover")
26
+ return popoverEl && this.application.getControllerForElementAndIdentifier(popoverEl, "phlex-kit--popover")
27
+ }
28
+ }
@@ -37,6 +37,9 @@ export default class extends Controller {
37
37
  }
38
38
  open(e) {
39
39
  e?.preventDefault()
40
+ // open: is one-shot — the reflected value sits in the Turbo snapshot, so
41
+ // a cache-restored reconnect would re-open a dismissed dialog.
42
+ this.openValue = false
40
43
  this.dialogTarget.showModal()
41
44
  // Save/restore rather than removeProperty: a host page (or another
42
45
  // component) may have set body overflow itself.
@@ -12,7 +12,7 @@ module PhlexKit
12
12
  end
13
13
 
14
14
  def view_template(&)
15
- div(**mix({ data: { controller: "phlex-kit--sheet", phlex_kit__sheet_open_value: @open } }, @attrs), &)
15
+ div(**mix({ data: { controller: "phlex-kit--sheet", phlex_kit__sheet_open_value: @open.to_s } }, @attrs), &)
16
16
  end
17
17
  end
18
18
  end
@@ -12,9 +12,16 @@ module PhlexKit
12
12
 
13
13
  def view_template(&block)
14
14
  template(data: { phlex_kit__sheet_target: "content" }) do
15
- div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown" }) do
16
- div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close" })
17
- div(**mix({ class: "pk-drawer #{fetch_option(SIDES, @side, :side)}", role: "dialog", aria: { modal: "true" }, tabindex: "-1", data: { phlex_kit__sheet_content_target: "panel" } }, @attrs)) do
15
+ div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown", pk_overlay_clone: "" }) do
16
+ div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close mousedown->phlex-kit--sheet-content#overlayMousedown" })
17
+ panel_attrs = { class: "pk-drawer #{fetch_option(SIDES, @side, :side)}", data: { phlex_kit__sheet_content_target: "panel" } }
18
+ # Defaults only when the caller didn't supply their own — `mix`
19
+ # would fuse role="dialog region" / aria-modal="true false" /
20
+ # tabindex="-1 0" instead of overriding.
21
+ panel_attrs[:role] = "dialog" unless @attrs.key?(:role) || @attrs.key?("role")
22
+ panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
+ panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
24
+ div(**mix(panel_attrs, @attrs)) do
18
25
  div(class: "pk-drawer-handle", aria: { hidden: "true" })
19
26
  yield if block
20
27
  end
@@ -116,6 +116,10 @@
116
116
  the viewport right edge. */
117
117
  .pk-dropdown-menu-sub { anchor-name: --pk-dropdown-menu-sub; anchor-scope: --pk-dropdown-menu-sub; }
118
118
  .pk-dropdown-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
119
+ /* The chevron points toward where the submenu opens (inline-end). In RTL that
120
+ is visually LEFT — mirror it. Ancestor [dir="rtl"] (not :dir()) so Chrome
121
+ re-styles it on a dynamic dir flip too. */
122
+ [dir="rtl"] .pk-dropdown-menu-sub-chevron { transform: scaleX(-1); }
119
123
  .pk-dropdown-menu-sub-content {
120
124
  position: fixed;
121
125
  position-anchor: --pk-dropdown-menu-sub;
@@ -18,7 +18,9 @@ module PhlexKit
18
18
  data: {
19
19
  controller: "phlex-kit--dropdown-menu",
20
20
  phlex_kit__dropdown_menu_open_value: @open,
21
- action: "click@window->phlex-kit--dropdown-menu#onClickOutside"
21
+ # focusout: tabbing (or otherwise moving real focus) out of the
22
+ # menu closes the open [popover=manual] panel (menubar's pattern).
23
+ action: "click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
22
24
  }
23
25
  }, @attrs), &block)
24
26
  end
@@ -22,6 +22,18 @@ export default class extends Controller {
22
22
  // The visible control is the caller's button/link inside the trigger
23
23
  // wrapper — that's what AT reads, so the popup wiring belongs on it.
24
24
  this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
25
+ // A Turbo snapshot (or a detach/reattach that mimics one) serializes
26
+ // aria-expanded="true" on sub triggers even though no submenu can be
27
+ // open at connect time — sweep every stale marker back to false
28
+ // (menubar's pattern). Runs before the invoker's own reset below so
29
+ // both are covered by one mental model.
30
+ this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"));
31
+ // Same shape for checkbox/radio rows: their `checked` DOM property
32
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
33
+ // from it — resync every row from its live input.
34
+ this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
35
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked);
36
+ });
25
37
  this.invoker.setAttribute("aria-haspopup", "menu");
26
38
  this.invoker.setAttribute("aria-expanded", "false");
27
39
  // Associate trigger and menu for AT (the dialog controller wires its
@@ -74,6 +86,18 @@ export default class extends Controller {
74
86
  this.contentTarget.matches(":popover-open") ? this.close() : this.#open();
75
87
  }
76
88
 
89
+ // Tabbing (or otherwise moving real focus) out of the menu closes it —
90
+ // menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
91
+ // null when the window itself blurs — leave the menu alone then. The
92
+ // trigger + content are both DOM children of this.element even though the
93
+ // content is a top-layer popover, so one containment check covers both.
94
+ onFocusout(e) {
95
+ if (!this.contentTarget.matches(":popover-open")) return;
96
+ const to = e.relatedTarget;
97
+ if (!to || this.element.contains(to)) return;
98
+ this.close();
99
+ }
100
+
77
101
  #open() {
78
102
  this.openValue = true;
79
103
  this.#addEventListeners();
@@ -102,6 +126,16 @@ export default class extends Controller {
102
126
  }
103
127
 
104
128
  #handleKeydown(e) {
129
+ // This listener is document-level (see #addEventListeners) so it keeps
130
+ // receiving events even once real focus has left the menu (e.g. a click
131
+ // into an unrelated text field the onFocusout listener didn't catch) —
132
+ // scope the roving-nav keys to when focus is actually within the widget.
133
+ // this.element (not just contentTarget) so ArrowDown still works right
134
+ // after a mouse-driven open, when focus is still on the invoker button
135
+ // (#open() doesn't force focus into the first row). Escape stays global
136
+ // while the menu is open (APG).
137
+ if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return;
138
+
105
139
  const items = this.#items();
106
140
  if (items.length === 0) return;
107
141
  const index = items.indexOf(document.activeElement);
@@ -138,20 +172,27 @@ export default class extends Controller {
138
172
  this.invoker.focus();
139
173
  break;
140
174
  case "ArrowRight":
141
- // On a sub trigger, enter the submenu (focus reveals it via
142
- // :focus-within, making its rows visible to the roving nav).
143
- // Same keyboard grammar as context_menu.
144
- if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
145
- e.preventDefault();
146
- this.#enterSub(document.activeElement);
147
- }
148
- break;
149
175
  case "ArrowLeft": {
150
- // Inside a submenu, step back to its trigger (closes it on focus-out).
151
- const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
152
- if (sub) {
153
- e.preventDefault();
154
- sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
176
+ // Submenus open inline-end visually LEFT in RTL so the enter key
177
+ // follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
178
+ // (and the reverse in LTR). Same keyboard grammar as context_menu.
179
+ // Runtime dir check is reliable after a dynamic flip.
180
+ const rtl = getComputedStyle(this.element).direction === "rtl";
181
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight";
182
+ if (e.key === enterKey) {
183
+ // On a sub trigger, enter the submenu (focus reveals it via
184
+ // :focus-within, making its rows visible to the roving nav).
185
+ if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
186
+ e.preventDefault();
187
+ this.#enterSub(document.activeElement);
188
+ }
189
+ } else {
190
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
191
+ const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
192
+ if (sub) {
193
+ e.preventDefault();
194
+ sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
195
+ }
155
196
  }
156
197
  break;
157
198
  }
@@ -59,42 +59,50 @@ export default class extends Controller {
59
59
  }
60
60
 
61
61
  this.shouldValidateValue = true;
62
- this.#setErrorMessage(error);
62
+ this.#setErrorMessage();
63
63
  }
64
64
 
65
- onInput(event) {
66
- this.#setErrorMessage(event);
65
+ onInput(_event) {
66
+ this.#setErrorMessage();
67
67
  }
68
68
 
69
- onChange(event) {
70
- this.#setErrorMessage(event);
69
+ onChange(_event) {
70
+ this.#setErrorMessage();
71
71
  }
72
72
 
73
- // The event's own control, when it's one of ours — radio/checkbox groups
74
- // register several input targets and validity/aria-invalid must land on
75
- // the control that fired, not always the first.
76
- #inputFor(event) {
77
- if (event && this.inputTargets.includes(event.target)) return event.target;
78
- return this.inputTarget;
79
- }
80
-
81
- #setErrorMessage(event) {
73
+ // A FormField can carry several input targets (radio/checkbox groups, or
74
+ // independent controls like two required checkboxes sharing one error).
75
+ // Recompute across ALL of them on every validity event — deciding from the
76
+ // event's own target alone left a shared error stuck on/off based on
77
+ // whichever control happened to fire last, out of step with the others'
78
+ // own (correctly-updated) aria-invalid.
79
+ #setErrorMessage() {
82
80
  if (!this.shouldValidateValue || !this.hasErrorTarget) return;
83
81
 
84
- const input = this.#inputFor(event);
85
82
  // aria-invalid drives the red ring for LIVE validation too (input.css
86
- // only matched server-rendered attrs); aria-describedby ties the message
87
- // to the control for AT.
88
- if (input.validity.valid) {
89
- input.removeAttribute("aria-invalid");
83
+ // only matched server-rendered attrs); each control reflects its own
84
+ // validity regardless of which one fired the event. Radio buttons
85
+ // sharing a `name` share native validity, so they naturally agree here.
86
+ let firstInvalid = null;
87
+ this.inputTargets.forEach((input) => {
88
+ if (input.validity.valid) {
89
+ input.removeAttribute("aria-invalid");
90
+ } else {
91
+ input.setAttribute("aria-invalid", "true");
92
+ firstInvalid ||= input;
93
+ }
94
+ // Clear the shared error's id everywhere first — it's re-added below,
95
+ // only on the one control whose message is currently shown.
90
96
  this.#describeBy(input, false);
97
+ });
98
+
99
+ if (firstInvalid) {
100
+ this.#describeBy(firstInvalid, true);
101
+ this.errorTarget.textContent = this.#getValidationMessage(firstInvalid);
102
+ this.errorTarget.classList.remove("pk-hidden");
103
+ } else {
91
104
  this.errorTarget.textContent = "";
92
105
  this.errorTarget.classList.add("pk-hidden");
93
- } else {
94
- input.setAttribute("aria-invalid", "true");
95
- this.#describeBy(input, true);
96
- this.errorTarget.textContent = this.#getValidationMessage(input);
97
- this.errorTarget.classList.remove("pk-hidden");
98
106
  }
99
107
  }
100
108
 
@@ -15,12 +15,18 @@
15
15
  position-area: block-end span-inline-end;
16
16
  margin: .25rem 0 0;
17
17
  position-try-fallbacks: flip-block;
18
+ flex-direction: column; gap: .125rem;
18
19
  width: 16rem;
19
20
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
20
21
  border-radius: var(--pk-radius); background: var(--pk-surface);
21
22
  color: var(--pk-text); padding: .625rem; font-size: .875rem;
22
23
  box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
23
24
  }
25
+ /* display must be gated on :popover-open — an unconditional author
26
+ `display: flex` would beat the UA's [popover] { display: none } (bit the
27
+ Basic docs example's inline style, which relied on this instead of the
28
+ component doing it). */
29
+ .pk-hover-card-content:popover-open { display: flex; }
24
30
  .pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
25
31
  /* Gap margins are logical to match the logical position-area (and tooltip's
26
32
  pattern) — physical margins landed on the wrong side in RTL. */
@@ -10,6 +10,29 @@ import { Controller } from "@hotwired/stimulus"
10
10
  export default class extends Controller {
11
11
  static targets = ["trigger", "content"]
12
12
  static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
13
+
14
+ connect() {
15
+ // Turbo snapshots BEFORE disconnect, so a stale data-state="open" would
16
+ // otherwise resurrect host CSS keyed on the state hook after a cache
17
+ // restore — close now (popover_controller.js's contentTargetConnected /
18
+ // context_menu_controller.js's turbo:before-cache pattern combined).
19
+ this.onBeforeCache = () => {
20
+ if (this.contentTarget.matches(":popover-open")) {
21
+ clearTimeout(this.t)
22
+ this.#close()
23
+ }
24
+ }
25
+ document.addEventListener("turbo:before-cache", this.onBeforeCache)
26
+ }
27
+
28
+ contentTargetConnected(el) {
29
+ // A reconnect (Turbo cache restore, or the detach/reattach it's tested
30
+ // with) can hand us markup whose data-state was serialized while open —
31
+ // :popover-open does not survive the restore, so resync before anything
32
+ // reads the hook.
33
+ el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
34
+ }
35
+
13
36
  show() {
14
37
  clearTimeout(this.t)
15
38
  this.t = setTimeout(() => {
@@ -25,6 +48,7 @@ export default class extends Controller {
25
48
  disconnect() {
26
49
  clearTimeout(this.t)
27
50
  this.#unbindEscape()
51
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
28
52
  }
29
53
 
30
54
  #close() {
@@ -38,7 +38,6 @@
38
38
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
39
39
  }
40
40
  .pk-input:disabled {
41
- pointer-events: none;
42
41
  cursor: not-allowed;
43
42
  opacity: .5;
44
43
  background: color-mix(in oklab, var(--pk-input) 80%, transparent);
@@ -15,15 +15,18 @@ module PhlexKit
15
15
  end
16
16
 
17
17
  def view_template(&block)
18
- div(**mix({
18
+ base = {
19
19
  class: "pk-input-otp",
20
20
  role: "group",
21
- aria: { label: @label },
22
21
  data: {
23
22
  controller: "phlex-kit--input-otp",
24
23
  phlex_kit__input_otp_length_value: @length
25
24
  }
26
- }, @attrs)) do
25
+ }
26
+ # Default label only when the caller didn't supply their own — `mix`
27
+ # would fuse aria-label into a two-token value instead of overriding.
28
+ base[:aria] = { label: @label } unless aria_labelled?
29
+ div(**mix(base, @attrs)) do
27
30
  input(type: :hidden, name: @name, value: @value, data: { phlex_kit__input_otp_target: "value" }) if @name
28
31
  yield if block
29
32
  end
@@ -30,20 +30,32 @@ export default class extends Controller {
30
30
  const chars = this.valueTarget.value.split("")
31
31
  this.slotTargets.forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
32
32
  }
33
+ // Track each slot's last-known-good value so a rejected keystroke (see
34
+ // onInput) can restore it instead of leaving the slot blanked.
35
+ this.slotTargets.forEach((slot) => { slot.dataset.otpValue = slot.value })
33
36
  this.syncValue()
34
37
  }
35
38
 
36
39
  onInput(e) {
37
40
  const slot = e.target
41
+ const isNumeric = slot.getAttribute("inputmode") === "numeric"
42
+ const priorValue = slot.dataset.otpValue || ""
38
43
  let text = slot.value.replace(/\s/g, "")
39
44
  // Typing rejects the same characters paste strips — a letter keyed into
40
45
  // an inputmode=numeric slot must not land.
41
- if (slot.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
46
+ if (isNumeric) text = text.replace(/\D/g, "")
42
47
 
43
48
  if (text.length > 1) {
44
49
  if (e.data && e.data.length === 1) {
50
+ if (isNumeric && !/\d/.test(e.data)) {
51
+ // Rejected keystroke into an already-filled/selected slot: restore
52
+ // the prior digit rather than blanking it, and don't advance focus.
53
+ slot.value = priorValue
54
+ return
55
+ }
45
56
  // one keystroke into an already-filled slot: keep the newest char
46
57
  slot.value = e.data
58
+ slot.dataset.otpValue = e.data
47
59
  this.focusSlot(this.slotTargets.indexOf(slot) + 1)
48
60
  this.syncValue()
49
61
  } else {
@@ -55,7 +67,16 @@ export default class extends Controller {
55
67
  return
56
68
  }
57
69
 
70
+ if (!text && e.data) {
71
+ // A keystroke was fully rejected by numeric filtering (e.g. a letter
72
+ // typed into a filled/selected slot) — restore the slot's prior value
73
+ // instead of blanking it, and don't advance focus.
74
+ slot.value = priorValue
75
+ return
76
+ }
77
+
58
78
  slot.value = text
79
+ slot.dataset.otpValue = text
59
80
  if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
60
81
  this.syncValue()
61
82
  }
@@ -66,6 +87,7 @@ export default class extends Controller {
66
87
  e.preventDefault()
67
88
  const prev = this.slotTargets[index - 1]
68
89
  prev.value = ""
90
+ prev.dataset.otpValue = ""
69
91
  prev.focus()
70
92
  this.syncValue()
71
93
  } else if (e.key === "ArrowLeft" && index > 0) {
@@ -92,7 +114,7 @@ export default class extends Controller {
92
114
  fillFrom(slot, text) {
93
115
  const chars = text.split("")
94
116
  const start = this.slotTargets.indexOf(slot)
95
- this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
117
+ this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) { s.value = chars[i]; s.dataset.otpValue = chars[i] } })
96
118
  this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
97
119
  this.syncValue()
98
120
  }