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
@@ -1,5 +1,12 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
+ // Strips combining diacritical marks after an NFD decomposition, so "é"
4
+ // (query or candidate) matches "e" — the scorer otherwise treats accented
5
+ // characters as never equal to their bare ASCII counterparts.
6
+ function normalizeDiacritics(s) {
7
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
8
+ }
9
+
3
10
  // Fuzzy subsequence score: every query character must appear in order in the
4
11
  // value (else null = no match). Consecutive runs compound, word-starts score
5
12
  // extra, and a verbatim substring outranks scattered hits — so "set" beats
@@ -7,6 +14,8 @@ import { Controller } from "@hotwired/stimulus";
7
14
  // what fuse.js gave ruby_ui.
8
15
  function fuzzyScore(query, value) {
9
16
  if (!query) return 0;
17
+ query = normalizeDiacritics(query);
18
+ value = normalizeDiacritics(value);
10
19
  let qi = 0;
11
20
  let score = 0;
12
21
  let streak = 0;
@@ -40,6 +49,7 @@ export default class extends Controller {
40
49
  // itemTargetConnected uses must be initialized here (mirrors
41
50
  // combobox_controller.js).
42
51
  this.itemIdCounter = 0;
52
+ this.orderCounter = 0;
43
53
  this.searchEntries = [];
44
54
  this.selectedIndex = -1;
45
55
  }
@@ -50,10 +60,17 @@ export default class extends Controller {
50
60
  // dismiss() can hand focus back instead of dropping it on <body>.
51
61
  this.previouslyFocused = document.activeElement;
52
62
 
63
+ // A Turbo snapshot serializes stale aria-selected on rows / aria-
64
+ // activedescendant on the input even though the live JS highlight state
65
+ // doesn't survive restore — normalize here so a restored page doesn't
66
+ // announce a stale highlight (mirrors select_controller.js's connect()).
67
+ this.itemTargets.forEach((item) => item.removeAttribute("aria-selected"));
68
+
53
69
  if (!this.hasInputTarget) {
54
70
  return;
55
71
  }
56
72
 
73
+ this.inputTarget.removeAttribute("aria-activedescendant");
57
74
  this.generateItemIds();
58
75
  // Only the cloned dialog overlay grabs focus on connect — an inline
59
76
  // palette connecting at page load must not steal it.
@@ -67,6 +84,13 @@ export default class extends Controller {
67
84
  return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
68
85
  }
69
86
 
87
+ // Clones are appended as direct <body> children; the last stamped clone is
88
+ // the visually topmost overlay across all clone-based overlay families.
89
+ isTopmostClone() {
90
+ const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
91
+ return clones.length > 0 && clones[clones.length - 1] === this.element;
92
+ }
93
+
70
94
  // ARIA plumbing: aria-controls is wired to the listbox (mirrors
71
95
  // select_controller.js); the per-result ids come from itemTargetConnected.
72
96
  generateItemIds() {
@@ -87,6 +111,10 @@ export default class extends Controller {
87
111
  // arrival (mirrors combobox_controller.js).
88
112
  itemTargetConnected(item) {
89
113
  if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
114
+ // Original position, stamped once — filter() reorders rows by fuzzy
115
+ // score and resetVisibility() restores this order. Survives the
116
+ // disconnect/reconnect a DOM move itself triggers.
117
+ if (!item.dataset.pkOrder) item.dataset.pkOrder = String(this.orderCounter++);
90
118
  this.searchEntries.push({
91
119
  value: (item.dataset.value || "").toLowerCase(),
92
120
  text: (item.dataset.text || "").toLowerCase(),
@@ -103,7 +131,22 @@ export default class extends Controller {
103
131
  this.selectedIndex = -1;
104
132
  }
105
133
 
106
- dismiss() {
134
+ dismiss(e) {
135
+ if (e?.type === "keydown" && this.isDialogClone()) {
136
+ // The backdrop's Escape binding is window-scoped, so it fires even
137
+ // with another overlay (alert dialog, sheet) stacked ON TOP of the
138
+ // palette — those clones are later <body> siblings carrying the same
139
+ // [data-pk-overlay-clone] marker. Only the topmost clone may act on
140
+ // Escape (mirrors alert_dialog_controller.js#topmost).
141
+ if (!this.isTopmostClone()) return;
142
+ // The input's Escape binding is element-scoped and fires BEFORE lower
143
+ // overlays' document/window listeners — stop the keydown here, or by
144
+ // the time they run this clone (and its marker) is already removed and
145
+ // their own topmost check would wrongly elect them (mirrors
146
+ // sheet_content_controller.js's Escape guard).
147
+ e.stopPropagation();
148
+ }
149
+
107
150
  // Cloned dialog overlay: tear the clone down and hand focus back.
108
151
  if (this.isDialogClone()) {
109
152
  // Dispatched BEFORE teardown: the command-dialog controller listens on
@@ -174,7 +217,7 @@ export default class extends Controller {
174
217
  }
175
218
 
176
219
  focusInput() {
177
- this.inputTarget?.focus();
220
+ if (this.hasInputTarget) this.inputTarget.focus();
178
221
  }
179
222
 
180
223
  filter(e) {
@@ -194,6 +237,9 @@ export default class extends Controller {
194
237
  results.forEach((result) =>
195
238
  this.toggleVisibility([result.item.element], true),
196
239
  );
240
+ // cmdk parity: the best match lists first. Within each parent only —
241
+ // groups keep their identity, matches never jump between them.
242
+ this.reorderWithinParents(results.map((r) => r.item.element));
197
243
 
198
244
  this.announceResultCount(results.length);
199
245
  this.toggleVisibility(this.emptyTargets, results.length === 0);
@@ -232,6 +278,35 @@ export default class extends Controller {
232
278
  this.toggleVisibility(this.groupTargets, true);
233
279
  this.toggleVisibility(this.separatorTargets, true);
234
280
  this.toggleVisibility(this.emptyTargets, false);
281
+ // Undo any fuzzy-score reordering: back to server-rendered order.
282
+ this.reorderWithinParents(
283
+ [...this.itemTargets].sort((a, b) => Number(a.dataset.pkOrder) - Number(b.dataset.pkOrder)),
284
+ );
285
+ }
286
+
287
+ // Re-inserts `orderedItems` in the given sequence, each within its own
288
+ // parent, at the position of that parent's first item row — headings,
289
+ // separators and empty-state siblings keep their places. (Items are
290
+ // assumed contiguous within a parent; a separator BETWEEN items of one
291
+ // parent would drift below them.) The moves retrigger item target
292
+ // disconnect/connect, which is safe: ids and pkOrder are stamped once,
293
+ // and filter()/dismiss() have already reset the highlight.
294
+ reorderWithinParents(orderedItems) {
295
+ const itemSet = new Set(this.itemTargets);
296
+ const byParent = new Map();
297
+ orderedItems.forEach((el) => {
298
+ const parent = el.parentElement;
299
+ if (!parent) return;
300
+ if (!byParent.has(parent)) byParent.set(parent, []);
301
+ byParent.get(parent).push(el);
302
+ });
303
+ byParent.forEach((els, parent) => {
304
+ const firstItem = [...parent.children].find((c) => itemSet.has(c));
305
+ const placeholder = document.createComment("");
306
+ parent.insertBefore(placeholder, firstItem || null);
307
+ els.forEach((el) => parent.insertBefore(el, placeholder));
308
+ placeholder.remove();
309
+ });
235
310
  }
236
311
 
237
312
  // Upstream builds a Fuse index here; this keeps the same result shape —
@@ -17,9 +17,14 @@ module PhlexKit
17
17
  template(data: { phlex_kit__command_dialog_target: "content" }) do
18
18
  # The keydown action is the focus trap: Tab cycles within the cloned
19
19
  # overlay instead of escaping to the page underneath.
20
+ # data-pk-overlay-clone is the common marker stamped by every
21
+ # clone-based overlay family (see alert_dialog_controller.js#topmost):
22
+ # it lets stacked overlays of DIFFERENT types resolve which one is
23
+ # topmost, so one Escape never closes both layers at once.
20
24
  div(data: {
21
25
  controller: "phlex-kit--command",
22
26
  phlex_kit__command_dialog_instance: true,
27
+ pk_overlay_clone: "",
23
28
  action: "keydown->phlex-kit--command#trapFocus"
24
29
  }) do
25
30
  backdrop
@@ -11,10 +11,11 @@ module PhlexKit
11
11
  # `autofocus:` defaults off so an inline palette doesn't steal page focus
12
12
  # on load; the dialog clone needs no autofocus either — the command
13
13
  # controller's connect() focuses the input when the clone is inserted.
14
- def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, **attrs)
14
+ def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, value: "", **attrs)
15
15
  @placeholder = placeholder
16
16
  @list_id = list_id
17
17
  @autofocus = autofocus
18
+ @value = value
18
19
  @attrs = attrs
19
20
  end
20
21
 
@@ -31,7 +32,7 @@ module PhlexKit
31
32
  spellcheck: "false",
32
33
  autofocus: @autofocus,
33
34
  role: "combobox",
34
- value: "",
35
+ value: @value,
35
36
  aria: { autocomplete: "list", expanded: "true", controls: @list_id },
36
37
  data: {
37
38
  phlex_kit__command_target: "input",
@@ -72,6 +72,9 @@
72
72
  the viewport right edge. */
73
73
  .pk-context-menu-sub { anchor-name: --pk-context-menu-sub; anchor-scope: --pk-context-menu-sub; }
74
74
  .pk-context-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
75
+ /* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
76
+ [dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
77
+ [dir="rtl"] .pk-context-menu-sub-chevron { transform: scaleX(-1); }
75
78
  .pk-context-menu-sub-content {
76
79
  position: fixed;
77
80
  position-anchor: --pk-context-menu-sub;
@@ -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
@@ -8,6 +8,18 @@ import { Controller } from "@hotwired/stimulus"
8
8
  // native [popover=manual] in the top layer (context_menu.css); it is placed
9
9
  // in viewport coordinates after showPopover() — a hidden popover has no
10
10
  // size — and clamped so it never overflows the viewport (no floating-ui).
11
+
12
+ // One-shot capture listener swallowing the click an outside mousedown is
13
+ // about to produce (see onDocMousedown; same helper in dropdown/menubar —
14
+ // duplicated per controller by design). {once} self-removes; the DOM
15
+ // dedupes re-arming with the same fn. If the gesture's click never fires
16
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
17
+ // one — rare enough to accept.
18
+ const swallowClick = (ev) => ev.preventDefault()
19
+ function armSwallowClick() {
20
+ window.addEventListener("click", swallowClick, { once: true, capture: true })
21
+ }
22
+
11
23
  export default class extends Controller {
12
24
  static targets = ["content", "trigger", "menuItem"]
13
25
 
@@ -22,6 +34,17 @@ export default class extends Controller {
22
34
  if (e.type === "contextmenu" && this.element.contains(e.target)) return
23
35
  this.close()
24
36
  }
37
+ // Kit-wide dismiss contract (Radix modal-menu parity): a plain click
38
+ // outside only dismisses — swallowed, like dropdown/menubar/select.
39
+ // Armed at MOUSEDOWN: the gesture's focusout close() removes onDoc
40
+ // before the click even fires. Right-button mousedowns are ignored so a
41
+ // contextmenu elsewhere stays click-through (that's how a second kit —
42
+ // or the native — context menu opens in the same gesture).
43
+ this.onDocMousedown = (e) => {
44
+ if (this.contentTarget.contains(e.target)) return
45
+ if (e.button !== 0) return
46
+ armSwallowClick()
47
+ }
25
48
  this.onKey = (e) => this.keydown(e)
26
49
  // Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
27
50
  // stale data-state="open" (the panel itself can't resurrect, but host
@@ -33,9 +56,21 @@ export default class extends Controller {
33
56
  // Outstanding syncSub rAF handles — cancelled on disconnect (menubar's
34
57
  // pattern).
35
58
  this.subFrames = new Set()
59
+ // A Turbo snapshot (or a detach/reattach that mimics one) serializes
60
+ // aria-expanded="true" on sub triggers even though no submenu can be
61
+ // open at connect time — sweep every stale marker back to false
62
+ // (menubar's pattern).
63
+ this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
64
+ // Same shape for checkbox/radio rows: their `checked` DOM property
65
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
66
+ // from it — resync every row from its live input.
67
+ this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
68
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
69
+ })
36
70
  }
37
71
 
38
72
  disconnect() {
73
+ document.removeEventListener("mousedown", this.onDocMousedown)
39
74
  document.removeEventListener("click", this.onDoc)
40
75
  document.removeEventListener("contextmenu", this.onDoc)
41
76
  document.removeEventListener("keydown", this.onKey)
@@ -56,6 +91,18 @@ export default class extends Controller {
56
91
  this.subFrames.add(id)
57
92
  }
58
93
 
94
+ // Tabbing (or otherwise moving real focus) out of the menu closes it —
95
+ // menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
96
+ // null when the window itself blurs — leave the menu alone then. The
97
+ // trigger + content are both DOM children of this.element even though the
98
+ // content is a top-layer popover, so one containment check covers both.
99
+ onFocusout(e) {
100
+ if (!this.contentTarget.matches(":popover-open")) return
101
+ const to = e.relatedTarget
102
+ if (!to || this.element.contains(to)) return
103
+ this.close()
104
+ }
105
+
59
106
  open(e) {
60
107
  e.preventDefault()
61
108
  let x = e.clientX
@@ -72,6 +119,7 @@ export default class extends Controller {
72
119
  c.style.left = `${x}px`
73
120
  c.style.top = `${y}px`
74
121
  c.dataset.state = "open"
122
+ document.addEventListener("mousedown", this.onDocMousedown)
75
123
  document.addEventListener("click", this.onDoc)
76
124
  document.addEventListener("contextmenu", this.onDoc)
77
125
  document.addEventListener("keydown", this.onKey)
@@ -86,6 +134,7 @@ export default class extends Controller {
86
134
  if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault()
87
135
  if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
88
136
  this.contentTarget.dataset.state = "closed"
137
+ document.removeEventListener("mousedown", this.onDocMousedown)
89
138
  document.removeEventListener("click", this.onDoc)
90
139
  document.removeEventListener("contextmenu", this.onDoc)
91
140
  document.removeEventListener("keydown", this.onKey)
@@ -93,6 +142,13 @@ export default class extends Controller {
93
142
  }
94
143
 
95
144
  keydown(e) {
145
+ // This listener stays attached document-wide while the menu is open (see
146
+ // open()/close()) so it keeps receiving events even once real focus has
147
+ // left the menu — scope the roving-nav keys to when focus is actually
148
+ // within the widget (this.element, not just contentTarget, so a focused
149
+ // trigger still counts). Escape stays global while the menu is open (APG).
150
+ if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return
151
+
96
152
  const items = this.items()
97
153
  const index = items.indexOf(document.activeElement)
98
154
  switch (e.key) {
@@ -126,19 +182,26 @@ export default class extends Controller {
126
182
  }
127
183
  break
128
184
  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
185
  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()
186
+ // Submenus open inline-end visually LEFT in RTL so the enter key
187
+ // follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
188
+ // (and the reverse in LTR). Runtime dir check is reliable after a flip.
189
+ const rtl = getComputedStyle(this.element).direction === "rtl"
190
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
191
+ if (e.key === enterKey) {
192
+ // On a sub trigger, enter the submenu (focus reveals it via
193
+ // :focus-within, making its rows visible to the roving nav).
194
+ if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
195
+ e.preventDefault()
196
+ this.enterSub(document.activeElement)
197
+ }
198
+ } else {
199
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
200
+ const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
201
+ if (sub) {
202
+ e.preventDefault()
203
+ sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
204
+ }
142
205
  }
143
206
  break
144
207
  }
@@ -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;
@@ -66,7 +66,13 @@ export default class extends Controller {
66
66
  }
67
67
 
68
68
  if (this.hasSelectionSummaryTarget) {
69
- this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`;
69
+ // The component stamps its (possibly localized) %{selected}/%{total}
70
+ // template as data-format; live updates interpolate the same string
71
+ // the server rendered.
72
+ const format = this.selectionSummaryTarget.dataset.format || "%{selected} of %{total} row(s) selected.";
73
+ this.selectionSummaryTarget.textContent = format
74
+ .replace("%{selected}", String(selected))
75
+ .replace("%{total}", String(total));
70
76
  }
71
77
 
72
78
  if (this.hasBulkActionsTarget) {
@@ -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,
@@ -1,9 +1,15 @@
1
1
  module PhlexKit
2
2
  # "N of M row(s) selected." line, kept current by the controller.
3
+ # `format:` localizes it — a template with %{selected} and %{total}
4
+ # placeholders, used for the server render AND stamped on the element so
5
+ # the controller's live updates interpolate the same string.
3
6
  # See data_table.rb.
4
7
  class DataTableSelectionSummary < BaseComponent
5
- def initialize(total_on_page: 0, **attrs)
8
+ DEFAULT_FORMAT = "%{selected} of %{total} row(s) selected."
9
+
10
+ def initialize(total_on_page: 0, format: DEFAULT_FORMAT, **attrs)
6
11
  @total_on_page = total_on_page
12
+ @format = format
7
13
  @attrs = attrs
8
14
  end
9
15
 
@@ -11,9 +17,12 @@ module PhlexKit
11
17
  div(**mix({
12
18
  class: "pk-data-table-selection-summary",
13
19
  role: "status", # live region — selection changes are announced to AT
14
- data: { phlex_kit__data_table_target: "selectionSummary" }
20
+ data: {
21
+ phlex_kit__data_table_target: "selectionSummary",
22
+ format: @format
23
+ }
15
24
  }, @attrs)) do
16
- plain "0 of #{@total_on_page} row(s) selected."
25
+ plain Kernel.format(@format, selected: 0, total: @total_on_page)
17
26
  end
18
27
  end
19
28
  end
@@ -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,26 @@
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 by assigning undefined (Stimulus
17
+ // removes the attribute), so an unset rangeEnd reads back as null here.
18
+ if (mode === "range" && !rangeEnd) return
19
+ this.popoverController()?.close()
20
+ }
21
+
22
+ popoverController() {
23
+ const popoverEl = this.element.querySelector(".pk-popover")
24
+ return popoverEl && this.application.getControllerForElementAndIdentifier(popoverEl, "phlex-kit--popover")
25
+ }
26
+ }
@@ -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 attr_set?(:role)
22
+ panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
+ panel_attrs[:tabindex] = "-1" unless attr_set?(: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,11 @@ 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
+ # mousedown@window arms the modal-menu click swallow before the
24
+ # gesture's focusout closes the panel (see the controller).
25
+ action: "mousedown@window->phlex-kit--dropdown-menu#onMousedownOutside click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
22
26
  }
23
27
  }, @attrs), &block)
24
28
  end