phlex_kit 0.11.0 → 0.13.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 (78) 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 +37 -9
  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/checkbox/checkbox.rb +15 -1
  13. data/app/components/phlex_kit/codeblock/codeblock.rb +5 -1
  14. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  15. data/app/components/phlex_kit/combobox/combobox_controller.js +61 -2
  16. data/app/components/phlex_kit/command/command_controller.js +77 -2
  17. data/app/components/phlex_kit/command/command_dialog_content.rb +5 -0
  18. data/app/components/phlex_kit/command/command_input.rb +3 -2
  19. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  20. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  21. data/app/components/phlex_kit/context_menu/context_menu_controller.js +75 -12
  22. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  23. data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
  24. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  25. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  26. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  27. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
  28. data/app/components/phlex_kit/date_picker/date_picker.rb +13 -2
  29. data/app/components/phlex_kit/date_picker/date_picker_controller.js +26 -0
  30. data/app/components/phlex_kit/dialog/dialog_controller.js +3 -0
  31. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  32. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  33. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +4 -0
  34. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  35. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +78 -18
  36. data/app/components/phlex_kit/form_field/form_field_controller.js +32 -24
  37. data/app/components/phlex_kit/hover_card/hover_card.css +6 -0
  38. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  39. data/app/components/phlex_kit/icon/icon.rb +1 -1
  40. data/app/components/phlex_kit/input/input.css +0 -1
  41. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  42. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  43. data/app/components/phlex_kit/item/item.css +2 -1
  44. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  45. data/app/components/phlex_kit/label/label.css +1 -1
  46. data/app/components/phlex_kit/link/link.rb +11 -1
  47. data/app/components/phlex_kit/marker/marker.css +2 -1
  48. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  49. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  50. data/app/components/phlex_kit/menubar/menubar_controller.js +66 -16
  51. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  52. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  53. data/app/components/phlex_kit/popover/popover_controller.js +19 -0
  54. data/app/components/phlex_kit/progress/progress.css +19 -0
  55. data/app/components/phlex_kit/progress/progress.rb +29 -13
  56. data/app/components/phlex_kit/scroll_area/scroll_area.rb +2 -15
  57. data/app/components/phlex_kit/select/select_controller.js +22 -0
  58. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  59. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  60. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -0
  61. data/app/components/phlex_kit/sheet/sheet_controller.js +17 -2
  62. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  63. data/app/components/phlex_kit/switch/switch.rb +6 -0
  64. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  65. data/app/components/phlex_kit/toast/toast_action.rb +1 -1
  66. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  67. data/app/components/phlex_kit/toast/toast_controller.js +37 -16
  68. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  69. data/app/components/phlex_kit/toast/toast_region.rb +12 -3
  70. data/app/components/phlex_kit/toast/toaster_controller.js +35 -5
  71. data/app/components/phlex_kit/toggle_group/toggle_group.rb +16 -1
  72. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +11 -8
  73. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +15 -5
  74. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  75. data/lib/phlex_kit/base_component.rb +22 -3
  76. data/lib/phlex_kit/version.rb +1 -1
  77. data/lib/phlex_kit.rb +1 -0
  78. metadata +2 -1
@@ -10,6 +10,18 @@ import { Controller } from "@hotwired/stimulus";
10
10
  // sub-rows are skipped via a visibility filter. Escape returns focus to the
11
11
  // trigger; Enter/Space activate the focused row (links, checkbox/radio
12
12
  // labels, and sub triggers alike).
13
+
14
+ // One-shot capture listener swallowing the click an outside mousedown is
15
+ // about to produce (see onMousedownOutside; same helper in menubar/context
16
+ // menu — duplicated per controller by design). {once} self-removes; the DOM
17
+ // dedupes re-arming with the same fn. If the gesture's click never fires
18
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
19
+ // one — rare enough to accept.
20
+ const swallowClick = (ev) => ev.preventDefault();
21
+ function armSwallowClick() {
22
+ window.addEventListener("click", swallowClick, { once: true, capture: true });
23
+ }
24
+
13
25
  export default class extends Controller {
14
26
  static targets = ["trigger", "content", "menuItem"];
15
27
  static values = { open: { type: Boolean, default: false } };
@@ -22,6 +34,18 @@ export default class extends Controller {
22
34
  // The visible control is the caller's button/link inside the trigger
23
35
  // wrapper — that's what AT reads, so the popup wiring belongs on it.
24
36
  this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
37
+ // A Turbo snapshot (or a detach/reattach that mimics one) serializes
38
+ // aria-expanded="true" on sub triggers even though no submenu can be
39
+ // open at connect time — sweep every stale marker back to false
40
+ // (menubar's pattern). Runs before the invoker's own reset below so
41
+ // both are covered by one mental model.
42
+ this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"));
43
+ // Same shape for checkbox/radio rows: their `checked` DOM property
44
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
45
+ // from it — resync every row from its live input.
46
+ this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
47
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked);
48
+ });
25
49
  this.invoker.setAttribute("aria-haspopup", "menu");
26
50
  this.invoker.setAttribute("aria-expanded", "false");
27
51
  // Associate trigger and menu for AT (the dialog controller wires its
@@ -57,16 +81,23 @@ export default class extends Controller {
57
81
  this.subFrames.add(id);
58
82
  }
59
83
 
84
+ // Deliberate (matches Radix's modal dismiss): the dismissing outside
85
+ // click ONLY dismisses the menu — swallowed rather than also acting on
86
+ // whatever was under the pointer (e.g. navigating a link). Armed at
87
+ // MOUSEDOWN: for a focusable click target the same gesture's focusout
88
+ // closes the panel before the click fires, so a click-time swallow
89
+ // silently skipped those targets (audit round 8).
90
+ onMousedownOutside(event) {
91
+ if (!this.contentTarget.matches(":popover-open")) return;
92
+ if (this.element.contains(event.target)) return;
93
+ armSwallowClick();
94
+ }
95
+
60
96
  onClickOutside(event) {
61
97
  // Gate on the live :popover-open state, not the stored flag — a stale
62
98
  // flag is how a close on an already-closed panel becomes an open.
63
99
  if (!this.contentTarget.matches(":popover-open")) return;
64
100
  if (this.element.contains(event.target)) return;
65
-
66
- // Deliberate (matches Radix's modal dismiss): the outside click ONLY
67
- // dismisses the menu — it is swallowed rather than also acting on
68
- // whatever was under the pointer (e.g. navigating a link).
69
- event.preventDefault();
70
101
  this.close();
71
102
  }
72
103
 
@@ -74,6 +105,18 @@ export default class extends Controller {
74
105
  this.contentTarget.matches(":popover-open") ? this.close() : this.#open();
75
106
  }
76
107
 
108
+ // Tabbing (or otherwise moving real focus) out of the menu closes it —
109
+ // menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
110
+ // null when the window itself blurs — leave the menu alone then. The
111
+ // trigger + content are both DOM children of this.element even though the
112
+ // content is a top-layer popover, so one containment check covers both.
113
+ onFocusout(e) {
114
+ if (!this.contentTarget.matches(":popover-open")) return;
115
+ const to = e.relatedTarget;
116
+ if (!to || this.element.contains(to)) return;
117
+ this.close();
118
+ }
119
+
77
120
  #open() {
78
121
  this.openValue = true;
79
122
  this.#addEventListeners();
@@ -102,6 +145,16 @@ export default class extends Controller {
102
145
  }
103
146
 
104
147
  #handleKeydown(e) {
148
+ // This listener is document-level (see #addEventListeners) so it keeps
149
+ // receiving events even once real focus has left the menu (e.g. a click
150
+ // into an unrelated text field the onFocusout listener didn't catch) —
151
+ // scope the roving-nav keys to when focus is actually within the widget.
152
+ // this.element (not just contentTarget) so ArrowDown still works right
153
+ // after a mouse-driven open, when focus is still on the invoker button
154
+ // (#open() doesn't force focus into the first row). Escape stays global
155
+ // while the menu is open (APG).
156
+ if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return;
157
+
105
158
  const items = this.#items();
106
159
  if (items.length === 0) return;
107
160
  const index = items.indexOf(document.activeElement);
@@ -138,20 +191,27 @@ export default class extends Controller {
138
191
  this.invoker.focus();
139
192
  break;
140
193
  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
194
  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();
195
+ // Submenus open inline-end visually LEFT in RTL so the enter key
196
+ // follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
197
+ // (and the reverse in LTR). Same keyboard grammar as context_menu.
198
+ // Runtime dir check is reliable after a dynamic flip.
199
+ const rtl = getComputedStyle(this.element).direction === "rtl";
200
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight";
201
+ if (e.key === enterKey) {
202
+ // On a sub trigger, enter the submenu (focus reveals it via
203
+ // :focus-within, making its rows visible to the roving nav).
204
+ if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
205
+ e.preventDefault();
206
+ this.#enterSub(document.activeElement);
207
+ }
208
+ } else {
209
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
210
+ const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
211
+ if (sub) {
212
+ e.preventDefault();
213
+ sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
214
+ }
155
215
  }
156
216
  break;
157
217
  }
@@ -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() {
@@ -24,7 +24,7 @@ module PhlexKit
24
24
  # `mix` merges duplicate string attrs ("16 24") — drop a generated attr
25
25
  # whenever the caller supplies their own copy, so theirs wins.
26
26
  %i[width height viewbox aria-hidden].each do |key|
27
- base.delete(key) if @attrs.key?(key) || @attrs.key?(key.to_s)
27
+ base.delete(key) if attr_set?(key)
28
28
  end
29
29
  svg(**mix(base, @attrs)) do |s|
30
30
  icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
@@ -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
  }
@@ -34,7 +34,8 @@ a.pk-item:hover { background: var(--pk-surface-2); }
34
34
  transform: translateY(.125rem);
35
35
  align-self: flex-start;
36
36
  }
37
- .pk-item-media.icon svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
37
+ /* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
38
+ .pk-item-media.icon svg:not([width]) { width: 1rem; height: 1rem; }
38
39
  .pk-item-media.image {
39
40
  width: 2.5rem;
40
41
  height: 2.5rem;
@@ -15,7 +15,8 @@
15
15
  font-weight: 500;
16
16
  }
17
17
  .pk-kbd-group { display: inline-flex; align-items: center; gap: .25rem; font-family: inherit; }
18
- .pk-kbd svg:not([class*="size-"]) { width: .75rem; height: .75rem; }
18
+ /* Glyphs default to size-3; explicit width attrs (Icon size:) win. */
19
+ .pk-kbd svg:not([width]) { width: .75rem; height: .75rem; }
19
20
  /* Inside an (inverted) tooltip the chip tints from the page background. */
20
21
  .pk-tooltip-content .pk-kbd {
21
22
  background: color-mix(in oklab, var(--pk-bg) 10%, transparent);
@@ -9,4 +9,4 @@
9
9
  line-height: 1;
10
10
  user-select: none;
11
11
  }
12
- .pk-label:has(+ :disabled), .pk-label:has(:disabled) { cursor: not-allowed; opacity: .5; }
12
+ .pk-label:has(+ :disabled), .pk-label:has(:disabled), :disabled + .pk-label { cursor: not-allowed; opacity: .5; }
@@ -20,6 +20,7 @@ module PhlexKit
20
20
  }.freeze
21
21
 
22
22
  SIZES = {
23
+ xs: "xs",
23
24
  sm: "sm",
24
25
  md: nil,
25
26
  lg: "lg",
@@ -35,11 +36,20 @@ module PhlexKit
35
36
  end
36
37
 
37
38
  def view_template(&block)
38
- a(**mix({ href: @href, class: classes }, @attrs), &block)
39
+ base = { href: @href, class: classes }
40
+ # target="_blank" hands the new page a window.opener reference in older
41
+ # browsers (modern ones imply noopener) — default the safe rel unless
42
+ # the caller supplied their own (`mix` would fuse "noopener external").
43
+ base[:rel] = "noopener" if blank_target? && !attr_set?(:rel)
44
+ a(**mix(base, @attrs), &block)
39
45
  end
40
46
 
41
47
  private
42
48
 
49
+ def blank_target?
50
+ (@attrs[:target] || @attrs["target"]).to_s == "_blank"
51
+ end
52
+
43
53
  def classes
44
54
  [ "pk-button", fetch_option(VARIANTS, @variant, :variant), fetch_option(SIZES, @size, :size), ("icon" if @icon) ].compact.join(" ")
45
55
  end
@@ -25,7 +25,8 @@ a.pk-marker, button.pk-marker {
25
25
  font-size: .875rem;
26
26
  }
27
27
  a.pk-marker:hover, button.pk-marker:hover { color: var(--pk-text); }
28
- .pk-marker svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
28
+ /* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
29
+ .pk-marker svg:not([width]) { width: 1rem; height: 1rem; }
29
30
  .pk-marker-icon { display: inline-flex; flex: none; width: 1rem; height: 1rem; }
30
31
  .pk-marker-content { min-width: 0; overflow-wrap: break-word; }
31
32
  .pk-marker-content a { text-decoration: underline; text-underline-offset: 3px; color: inherit; }
@@ -129,6 +129,9 @@
129
129
  the viewport right edge. */
130
130
  .pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
131
131
  .pk-menubar-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
132
+ /* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
133
+ [dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
134
+ [dir="rtl"] .pk-menubar-sub-chevron { transform: scaleX(-1); }
132
135
  .pk-menubar-sub-content {
133
136
  position: fixed;
134
137
  position-anchor: --pk-menubar-sub;
@@ -18,7 +18,9 @@ module PhlexKit
18
18
  controller: "phlex-kit--menubar",
19
19
  # focusout: tabbing out of the bar closes the open [popover=manual]
20
20
  # panel (which no focus trap holds open).
21
- action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
21
+ # mousedown@window arms the modal-menu click swallow (deliberately
22
+ # NOT bound by navigation_menu.rb — nav menus are non-modal).
23
+ action: "mousedown@window->phlex-kit--menubar#onMousedownOutside click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
22
24
  }
23
25
  }, @attrs), &)
24
26
  end
@@ -13,6 +13,17 @@ import { Controller } from "@hotwired/stimulus"
13
13
  // Connects to data-controller="phlex-kit--menubar"
14
14
  let uid = 0
15
15
 
16
+ // One-shot capture listener swallowing the click an outside mousedown is
17
+ // about to produce (see onMousedownOutside; same helper in dropdown/context
18
+ // menu — duplicated per controller by design). {once} self-removes; the DOM
19
+ // dedupes re-arming with the same fn. If the gesture's click never fires
20
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
21
+ // one — rare enough to accept.
22
+ const swallowClick = (ev) => ev.preventDefault()
23
+ function armSwallowClick() {
24
+ window.addEventListener("click", swallowClick, { once: true, capture: true })
25
+ }
26
+
16
27
  export default class extends Controller {
17
28
  static targets = ["menu"]
18
29
 
@@ -48,6 +59,12 @@ export default class extends Controller {
48
59
  // (only on elements that already carry it: plain nav links must not
49
60
  // grow an aria-expanded). Covers the trigger and CSS-revealed subs.
50
61
  menu.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
62
+ // Same shape for checkbox/radio rows: their `checked` DOM property
63
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
64
+ // from it — resync every row from its live input.
65
+ menu.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
66
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
67
+ })
51
68
  if (this.roving) this.applyRoving()
52
69
  }
53
70
 
@@ -107,8 +124,15 @@ export default class extends Controller {
107
124
  if (focus) (this.items(menu)[0] ?? this.trigger(menu))?.focus()
108
125
  }
109
126
 
127
+ // Also wired directly as the click handler on plain nav links (see
128
+ // navigation_menu_link.rb) — `opts` is then the click Event rather than an
129
+ // options hash. Guard the default href="#" (dropdown_menu_controller.js's
130
+ // close() does the same) so a link without a real destination doesn't
131
+ // scroll-to-top/append a hash; a link WITH a real href is left to navigate
132
+ // normally, closing whatever panel is open on the way out.
110
133
  close(opts = {}) {
111
134
  clearTimeout(this.graceTimer)
135
+ if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault?.()
112
136
  const menu = this.openMenu
113
137
  if (!menu) return
114
138
  const panel = this.panel(menu)
@@ -118,6 +142,21 @@ export default class extends Controller {
118
142
  if (opts.refocus === true) trigger?.focus()
119
143
  }
120
144
 
145
+ // Kit-wide dismiss contract (Radix parity): MENU overlays (dropdown,
146
+ // context, menubar, select) are modal — the dismissing outside click is
147
+ // swallowed rather than also acting on what was under the pointer. The
148
+ // decision happens at MOUSEDOWN: the same gesture's focusout closes the
149
+ // panel before the click event fires, so a click-time openMenu check
150
+ // would already be null (that race made the old swallow silently skip
151
+ // focusable click targets). Only menubar.rb binds this action — the
152
+ // navigation menu (hover-open mode) is non-modal like Radix's and its
153
+ // dismissing click clicks through.
154
+ onMousedownOutside(e) {
155
+ if (this.element.contains(e.target)) return
156
+ if (!this.openMenu) return
157
+ armSwallowClick()
158
+ }
159
+
121
160
  onClickOutside(e) {
122
161
  if (this.element.contains(e.target)) return
123
162
  this.close()
@@ -147,10 +186,14 @@ export default class extends Controller {
147
186
  e.preventDefault()
148
187
  this.show(menu, true)
149
188
  } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
150
- // Closed bar: left/right move focus between the triggers (APG).
189
+ // Closed bar: left/right move focus between the triggers (APG). In RTL
190
+ // the bar mirrors, so the physical LEFT arrow moves to the next
191
+ // trigger. Runtime dir check is reliable after a dynamic flip.
151
192
  e.preventDefault()
193
+ const rtl = getComputedStyle(this.element).direction === "rtl"
194
+ const step = (e.key === "ArrowRight" ? 1 : -1) * (rtl ? -1 : 1)
152
195
  const menus = this.menuTargets
153
- const next = menus[(menus.indexOf(menu) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length]
196
+ const next = menus[(menus.indexOf(menu) + step + menus.length) % menus.length]
154
197
  const target = next ? this.trigger(next) : null
155
198
  target?.focus()
156
199
  if (this.roving && target) this.applyRoving(target)
@@ -190,23 +233,30 @@ export default class extends Controller {
190
233
  }
191
234
  break
192
235
  case "ArrowRight":
193
- e.preventDefault()
194
- // On a sub trigger, enter the submenu (focus reveals it via
195
- // :focus-within) instead of jumping to the next top-level menu.
196
- if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
197
- this.enterSub(document.activeElement)
198
- } else {
199
- this.shift(1)
200
- }
201
- break
202
236
  case "ArrowLeft": {
203
237
  e.preventDefault()
204
- // Inside a submenu, step back to its trigger instead of switching menus.
205
- const sub = document.activeElement?.closest(".pk-menubar-sub-content")
206
- if (sub) {
207
- sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
238
+ // Both the submenu (opens inline-end = visually LEFT in RTL) and the
239
+ // top-level menu traversal follow visual direction: the "enter/next"
240
+ // key is ArrowLeft in RTL, ArrowRight in LTR. Runtime dir check is
241
+ // reliable after a dynamic flip.
242
+ const rtl = getComputedStyle(this.element).direction === "rtl"
243
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
244
+ if (e.key === enterKey) {
245
+ // On a sub trigger, enter the submenu (focus reveals it via
246
+ // :focus-within) instead of jumping to the next top-level menu.
247
+ if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
248
+ this.enterSub(document.activeElement)
249
+ } else {
250
+ this.shift(1)
251
+ }
208
252
  } else {
209
- this.shift(-1)
253
+ // Inside a submenu, step back to its trigger instead of switching menus.
254
+ const sub = document.activeElement?.closest(".pk-menubar-sub-content")
255
+ if (sub) {
256
+ sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
257
+ } else {
258
+ this.shift(-1)
259
+ }
210
260
  }
211
261
  break
212
262
  }
@@ -33,6 +33,20 @@
33
33
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
34
34
  }
35
35
  .pk-native-select-field:disabled { cursor: not-allowed; opacity: .5; }
36
+ /* [size] (a multi-row list box, NativeSelect.new(size: N)) or [multiple]
37
+ renders as a scrollable option list, not a single-line control — the fixed
38
+ 2rem row height squashes it and there's no single line for the absolute
39
+ chevron to sit in, so auto-height the field and hide the chevron.
40
+ Note: [size="1"] is a single-line dropdown, not a list box — exclude it. */
41
+ .pk-native-select-field[size]:not([size="1"]),
42
+ .pk-native-select-field[multiple] {
43
+ height: auto;
44
+ padding-inline-end: .625rem;
45
+ }
46
+ .pk-native-select:has(.pk-native-select-field[size]:not([size="1"])) .pk-native-select-icon,
47
+ .pk-native-select:has(.pk-native-select-field[multiple]) .pk-native-select-icon {
48
+ display: none;
49
+ }
36
50
  .pk-native-select-field.sm {
37
51
  height: 1.75rem;
38
52
  padding-top: .125rem;
@@ -11,7 +11,14 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&)
14
- a(**mix({ class: "pk-navigation-menu-link", href: @href, data: { action: "mouseenter->phlex-kit--menubar#switch" } }, @attrs), &)
14
+ a(**mix({
15
+ class: "pk-navigation-menu-link",
16
+ href: @href,
17
+ # click: closes the open panel on real activation (and swallows the
18
+ # default href="#" the way dropdown_menu_controller.js's close() does
19
+ # — see menubar_controller.js#close).
20
+ data: { action: "mouseenter->phlex-kit--menubar#switch click->phlex-kit--menubar#close" }
21
+ }, @attrs), &)
15
22
  end
16
23
  end
17
24
  end