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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: caadd4e868a63939f22013699b89f9b1557dfa9569022301602c0f1cb07020f0
4
- data.tar.gz: fa3ba42e9566f171e1fdee7ddcbfc475397bf404152031adbca2fd0cacbbda61
3
+ metadata.gz: 832d7d74c2bcda4dcddef6ab391ee7afff8c15a3f011b57392dfb0e5d73b9922
4
+ data.tar.gz: 730dd9af5257a9350c551d9b5573d919cd04dd462a19f38d55558aa282d16822
5
5
  SHA512:
6
- metadata.gz: 76304ba104026fe236758b3fa93014b516ec84e65e6baefd307853da30bb7dc9d38d1f6fff0d2c7639ede3ee9116642beed8a615c24963ea3e0462c0ca89656a
7
- data.tar.gz: 0c8d0dfd56d4e4b873b35149dd7c362010c97a02a4642de0b72d80606301be27dea505849753aa8754e081c38e49484b3358524267d3df497a12b8c5d9e9ccd0
6
+ metadata.gz: 545d9af3c5b4ef67ff2793618825b17fcfff6a42fd7a7e97ca2b294d447d71ded63858094dc32f938dcdabd9c39ac4c8696bdf68da4b74cf1692bf62770d8081
7
+ data.tar.gz: 2a98a887feff3cf7a2144baeb9f380738880f2c100e6ed3ecc3ab2a4a1d94038efe06531b725eede6e1e419c4f9a282b109b6cb2db49328f32962819a17417c3
@@ -22,7 +22,9 @@
22
22
  gap: 1rem;
23
23
  width: calc(100% - 2rem);
24
24
  max-width: 32rem;
25
- max-height: 100vh;
25
+ /* Breathing room like dialog.css's max-height 100vh touched the screen
26
+ edges and overflowed behind mobile browser chrome (dvh tracks it). */
27
+ max-height: calc(100dvh - 2rem);
26
28
  overflow-y: auto;
27
29
  border: 1px solid var(--pk-border);
28
30
  border-radius: var(--pk-radius);
@@ -20,9 +20,20 @@ module PhlexKit
20
20
  # mousedown on the overlay is prevented so a stray click can't move
21
21
  # focus out of the trap. tabindex="-1" on the panel is the focus
22
22
  # fallback when the dialog has no focusable children.
23
- div(data: { controller: "phlex-kit--alert-dialog" }) do
23
+ # data-pk-overlay-clone is a common marker read across clone-based
24
+ # overlay families (see #topmost in alert_dialog_controller.js) so
25
+ # Escape resolves to whichever overlay is actually topmost, even when
26
+ # a different overlay type (e.g. a Sheet) is nested/stacked on top.
27
+ div(data: { controller: "phlex-kit--alert-dialog", pk_overlay_clone: "" }) do
24
28
  div(class: "pk-alert-dialog-overlay", "aria-hidden": "true", data: { action: "mousedown->phlex-kit--alert-dialog#overlayMousedown" })
25
- div(**mix({ role: "alertdialog", "aria-modal": "true", tabindex: "-1", class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" ") }, @attrs), &block)
29
+ panel_attrs = { class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" ") }
30
+ # Defaults only when the caller didn't supply their own — `mix`
31
+ # would fuse role="alertdialog dialog" / aria-modal="true false" /
32
+ # tabindex="-1 0" instead of overriding.
33
+ panel_attrs[:role] = "alertdialog" unless @attrs.key?(:role) || @attrs.key?("role")
34
+ panel_attrs[:"aria-modal"] = "true" unless aria_key_set?(:modal)
35
+ panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
36
+ div(**mix(panel_attrs, @attrs), &block)
26
37
  end
27
38
  end
28
39
  end
@@ -29,7 +29,17 @@ export default class extends Controller {
29
29
  this.previousOverflow = document.body.style.overflow;
30
30
  document.body.style.overflow = "hidden";
31
31
  this.onKeydown = (event) => this.keydown(event);
32
- this.beforeCache = () => this.element.remove();
32
+ // Turbo snapshots synchronously right after dispatching this event —
33
+ // BEFORE Stimulus disconnect() runs (it fires via MutationObserver, too
34
+ // late). Restore inert + scroll lock here so the snapshot never captures
35
+ // a fully-inerted, scroll-locked page; disconnect()'s later restore
36
+ // becomes a harmless no-op (#restoreInert clears `this.inerted`, and
37
+ // reassigning the same overflow value is idempotent).
38
+ this.beforeCache = () => {
39
+ this.#restoreInert();
40
+ document.body.style.overflow = this.previousOverflow;
41
+ this.element.remove();
42
+ };
33
43
  document.addEventListener("keydown", this.onKeydown);
34
44
  document.addEventListener("turbo:before-cache", this.beforeCache);
35
45
  this.#inertOthers();
@@ -40,7 +50,16 @@ export default class extends Controller {
40
50
  }
41
51
 
42
52
  disconnect() {
43
- if (this.hasContentTarget) return;
53
+ if (this.hasContentTarget) {
54
+ // The source element (holding the template) can disconnect while a
55
+ // clone it spawned is still live in <body> — e.g. Turbo replacing this
56
+ // region out from under an open dialog. Remove the orphan rather than
57
+ // leaving a modal nothing can dismiss. Idempotent: if the clone
58
+ // already removed itself (Cancel, Escape, its own before-cache),
59
+ // this.clone is disconnected and the isConnected check no-ops.
60
+ if (this.clone?.isConnected) this.clone.remove();
61
+ return;
62
+ }
44
63
  document.removeEventListener("keydown", this.onKeydown);
45
64
  document.removeEventListener("turbo:before-cache", this.beforeCache);
46
65
  this.#restoreInert();
@@ -76,6 +95,12 @@ export default class extends Controller {
76
95
  // handle the keyboard, or one Escape would close every layer at once.
77
96
  if (!this.#topmost()) return;
78
97
  if (event.key === "Escape") {
98
+ // A native <dialog> nested inside this panel (e.g. a Dialog opened
99
+ // from within AlertDialogContent) owns its own Escape handling; this
100
+ // document-level listener still sees the keydown since it bubbles all
101
+ // the way up — ignore it so one Escape doesn't also dismiss the alert
102
+ // dialog underneath (mirrors sheet_content_controller's guard).
103
+ if (event.target.closest("dialog[open]")) return;
79
104
  event.preventDefault();
80
105
  this.dismiss();
81
106
  return;
@@ -104,12 +129,15 @@ export default class extends Controller {
104
129
  }
105
130
  }
106
131
 
107
- // Clones are appended as direct <body> children whose direct child is the
108
- // [role="alertdialog"] panel (source elements only hold a <template>, whose
109
- // content never matches querySelector). The last such clone is the topmost.
132
+ // Clones are appended as direct <body> children. Other clone-based overlay
133
+ // families (sheet/drawer content) stamp the same [data-pk-overlay-clone]
134
+ // marker on their own clone root, so this checks z-stacking across overlay
135
+ // TYPES, not just other alert dialogs — a sheet opened from inside an
136
+ // alert dialog (or vice versa) is a later sibling and must absorb Escape
137
+ // first; only the last overlay clone overall may act on it.
110
138
  #topmost() {
111
- const clones = document.body.querySelectorAll(':scope > [data-controller~="phlex-kit--alert-dialog"] > [role="alertdialog"]');
112
- return clones.length > 0 && clones[clones.length - 1].parentElement === this.element;
139
+ const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
140
+ return clones.length > 0 && clones[clones.length - 1] === this.element;
113
141
  }
114
142
 
115
143
  #panel() {
@@ -118,6 +118,21 @@
118
118
  -webkit-text-fill-color: transparent;
119
119
  animation: pk-shimmer 2s linear infinite;
120
120
  }
121
+ /* Same two fallback arms as _tokens.css's .pk-shimmer (:49-54): never let the
122
+ filename disappear under reduced motion or without relative color syntax
123
+ (Safari <16.4 / Chrome <119). */
124
+ @media (prefers-reduced-motion: reduce) {
125
+ .pk-attachment[data-state="uploading"] .pk-attachment-title,
126
+ .pk-attachment[data-state="processing"] .pk-attachment-title {
127
+ animation: none; background-image: none; -webkit-text-fill-color: currentColor;
128
+ }
129
+ }
130
+ @supports not (color: oklch(from white l c h)) {
131
+ .pk-attachment[data-state="uploading"] .pk-attachment-title,
132
+ .pk-attachment[data-state="processing"] .pk-attachment-title {
133
+ animation: none; background-image: none; -webkit-text-fill-color: currentColor;
134
+ }
135
+ }
121
136
  .pk-attachment[data-state="error"] { border-color: color-mix(in oklab, var(--pk-red) 40%, transparent); }
122
137
  .pk-attachment[data-state="error"] .pk-attachment-media { color: var(--pk-red); background: color-mix(in oklab, var(--pk-red) 12%, transparent); }
123
138
  .pk-attachment[data-state="error"] .pk-attachment-description { color: var(--pk-red); }
@@ -11,6 +11,9 @@ module PhlexKit
11
11
  unless AS_TAGS.include?(@as)
12
12
  raise ArgumentError, "unknown AttachmentTrigger as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
13
13
  end
14
+ if @as == :a && href.nil?
15
+ raise ArgumentError, "AttachmentTrigger requires href: when as: :a"
16
+ end
14
17
  @href = href
15
18
  @attrs = attrs
16
19
  end
@@ -3,6 +3,7 @@
3
3
  display: flex; flex-wrap: wrap; align-items: center;
4
4
  gap: .375rem; word-break: break-word;
5
5
  font-size: .875rem; color: var(--pk-muted);
6
+ list-style: none; margin: 0; padding: 0;
6
7
  }
7
8
  .pk-breadcrumb-item { display: inline-flex; align-items: center; gap: .25rem; }
8
9
  .pk-breadcrumb-link { color: inherit; text-decoration: none; transition: color .15s ease; }
@@ -243,7 +243,10 @@ export default class extends Controller {
243
243
  return this.isoDate(date);
244
244
  }
245
245
 
246
- updateCalendar() {
246
+ // `focusDay` overrides the day to refocus after re-render: on a keyboard
247
+ // month cross the target day lives in the NEW grid, not the pre-render
248
+ // activeElement, so onKeydown passes it explicitly.
249
+ updateCalendar(focusDay = null) {
247
250
  if (this.hasTitleTarget) {
248
251
  this.titleTarget.textContent = this.monthAndYear();
249
252
  }
@@ -251,9 +254,11 @@ export default class extends Controller {
251
254
  this.syncNavButtons();
252
255
  // innerHTML replacement drops focus — remember the focused day so
253
256
  // keyboard selection/month hops don't strand focus on <body>.
254
- const focusedDay = this.calendarTarget.contains(document.activeElement)
255
- ? document.activeElement.dataset?.day
256
- : null;
257
+ const focusedDay =
258
+ focusDay ||
259
+ (this.calendarTarget.contains(document.activeElement)
260
+ ? document.activeElement.dataset?.day
261
+ : null);
257
262
  this.calendarTarget.innerHTML = this.calendarHTML();
258
263
  this.ensureGridTabStop(focusedDay);
259
264
  }
@@ -282,7 +287,11 @@ export default class extends Controller {
282
287
  // boundary re-renders the grid on the target month. Enter/Space activate
283
288
  // natively (the days are real <button>s).
284
289
  onKeydown(e) {
285
- const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
290
+ // In RTL the day grid mirrors: the physical LEFT arrow advances one day.
291
+ // The week steps (Up/Down) and the PageUp/Down/Home/End jumps are
292
+ // unaffected. Runtime dir check is reliable after a dynamic flip.
293
+ const rtl = getComputedStyle(this.element).direction === "rtl";
294
+ const STEPS = { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1, ArrowUp: -7, ArrowDown: 7 };
286
295
  const day = e.target.closest?.("[data-day]");
287
296
  if (!day) return;
288
297
 
@@ -319,7 +328,15 @@ export default class extends Controller {
319
328
  }
320
329
 
321
330
  if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
322
- this.viewDateValue = iso; // re-renders the grid on the target month
331
+ // Crossing a month re-renders the grid on the target month. Stimulus
332
+ // value-changed callbacks fire via MutationObserver (async), so the grid
333
+ // is NOT re-rendered synchronously after this assignment — querying it
334
+ // below would hit the OLD month, and the target day (outside it) would be
335
+ // missing → early return, stranding focus on <body>. Drive the re-render
336
+ // ourselves and hand it the target day so ensureGridTabStop focuses it.
337
+ this.viewDateValue = iso;
338
+ this.updateCalendar(iso);
339
+ return;
323
340
  }
324
341
  const next = this.calendarTarget.querySelector(`[data-day="${iso}"]:not([disabled])`);
325
342
  if (!next) return;
@@ -10,6 +10,11 @@ const DEFAULT_OPTIONS = {
10
10
  const DRAG_DISTANCE_THRESHOLD = 0.25;
11
11
  const DRAG_VELOCITY_THRESHOLD = 0.5;
12
12
 
13
+ // Subpixel slop when comparing the current offset to the max scrollable
14
+ // offset — layout rounding can leave a fraction of a px short of the true
15
+ // max, which would otherwise read as "can still scroll".
16
+ const OFFSET_EPSILON = 0.5;
17
+
13
18
  // Ported from ruby_ui's carousel controller with the embla-carousel dependency
14
19
  // removed: a minimal translate-based engine (scrollNext/scrollPrev, loop, x/y
15
20
  // axis from the options value) moves the track directly, so only
@@ -36,6 +41,7 @@ export default class extends Controller {
36
41
  this._onPointerMove = this._onPointerMove.bind(this);
37
42
  this._onPointerUp = this._onPointerUp.bind(this);
38
43
  this._onClickCapture = this._onClickCapture.bind(this);
44
+ this._onDragStart = this._onDragStart.bind(this);
39
45
  window.addEventListener("resize", this._onResize);
40
46
  // window resize misses container-only resizes (flex/grid reflow,
41
47
  // sidebar toggles) — observe the viewport itself.
@@ -45,6 +51,10 @@ export default class extends Controller {
45
51
  }
46
52
  this.viewportTarget.addEventListener("pointerdown", this._onPointerDown);
47
53
  this.viewportTarget.addEventListener("click", this._onClickCapture, true);
54
+ // Firefox starts a native image drag on pointerdown-over-<img>, which
55
+ // cancels the pointer sequence mid-swipe; -webkit-user-drag (CSS) is
56
+ // WebKit-only, so suppress it here for every browser.
57
+ this.viewportTarget.addEventListener("dragstart", this._onDragStart);
48
58
  this._update();
49
59
  }
50
60
 
@@ -54,6 +64,7 @@ export default class extends Controller {
54
64
  this._resizeObserver = null;
55
65
  this.viewportTarget.removeEventListener("pointerdown", this._onPointerDown);
56
66
  this.viewportTarget.removeEventListener("click", this._onClickCapture, true);
67
+ this.viewportTarget.removeEventListener("dragstart", this._onDragStart);
57
68
  // a disconnect mid-drag must drop the move/up listeners and drag state
58
69
  this.viewportTarget.removeEventListener("pointermove", this._onPointerMove);
59
70
  this.viewportTarget.removeEventListener("pointerup", this._onPointerUp);
@@ -66,6 +77,14 @@ export default class extends Controller {
66
77
  }
67
78
 
68
79
  scrollNext() {
80
+ // Non-loop multi-up: the track can hit its max scrollable offset before
81
+ // the index reaches the last slide (_offsetOf clamps). Once there, index
82
+ // still has room to climb but there's nothing left to reveal — bail so
83
+ // neither clicks (button already disables via _update) nor ArrowRight
84
+ // (keyNext, which routes here) produce dead no-op advances.
85
+ if (!this.options.loop && this._offsetOf(this.index) >= this._maxOffset() - OFFSET_EPSILON) {
86
+ return;
87
+ }
69
88
  this._goTo(this.index + 1);
70
89
  }
71
90
 
@@ -105,8 +124,13 @@ export default class extends Controller {
105
124
 
106
125
  _update() {
107
126
  this._applyTransform();
108
- const last = this.slides.length - 1;
109
- const canNext = this.options.loop ? this.slides.length > 1 : this.index < last;
127
+ const canNext = this.options.loop
128
+ ? this.slides.length > 1
129
+ // Offset-based, not index-based: with multi-up layouts the track
130
+ // reaches its max scrollable offset (_maxOffset) before this.index
131
+ // reaches the last slide, and _offsetOf clamps to that max — an
132
+ // index-based check left Next enabled for several dead clicks.
133
+ : this._offsetOf(this.index) < this._maxOffset() - OFFSET_EPSILON;
110
134
  const canPrev = this.options.loop ? this.slides.length > 1 : this.index > 0;
111
135
  this.nextButtonTargets.forEach((button) => (button.disabled = !canNext));
112
136
  this.prevButtonTargets.forEach((button) => (button.disabled = !canPrev));
@@ -189,6 +213,16 @@ export default class extends Controller {
189
213
  e.stopPropagation();
190
214
  }
191
215
 
216
+ // Firefox-only: a pointer drag that starts on an <img> slide fires a
217
+ // native HTML5 dragstart, which cancels the in-flight pointer sequence
218
+ // (pointercancel) and aborts the swipe. Chrome/Safari never fire it here
219
+ // because carousel.css already sets -webkit-user-drag: none.
220
+ _onDragStart(e) {
221
+ if (e.target.closest("img")) {
222
+ e.preventDefault();
223
+ }
224
+ }
225
+
192
226
  // --- geometry ---
193
227
 
194
228
  _pointerPos(e) {
@@ -21,15 +21,17 @@ module PhlexKit
21
21
  def view_template(&)
22
22
  base = {
23
23
  class: "pk-chart",
24
- # Canvas content is invisible to AT — announce it as an image with at
25
- # least a generic name; pass aria: { label: "…" } (or block fallback
26
- # content) to describe the actual data.
27
- role: "img",
28
24
  data: {
29
25
  controller: "phlex-kit--chart",
30
26
  phlex_kit__chart_options_value: JSON.generate(@options)
31
27
  }
32
28
  }
29
+ # Defaults only when the caller didn't supply their own — `mix` would
30
+ # fuse role="img figure" instead of overriding.
31
+ # Canvas content is invisible to AT — announce it as an image with at
32
+ # least a generic name; pass aria: { label: "…" } (or block fallback
33
+ # content) to describe the actual data.
34
+ base[:role] = "img" unless @attrs.key?(:role) || @attrs.key?("role")
33
35
  base[:aria] = { label: "Chart" } unless aria_labelled?
34
36
  canvas(**mix(base, @attrs), &)
35
37
  end
@@ -26,7 +26,7 @@ export default class extends Controller {
26
26
  }
27
27
 
28
28
  disconnect() {
29
- this.themeObserver?.disconnect()
29
+ this.disconnectThemeObservers()
30
30
  this.chart?.destroy()
31
31
  if (!this.chart) {
32
32
  this.dispatch("disconnect", { detail: { canvas: this.element } })
@@ -44,9 +44,14 @@ export default class extends Controller {
44
44
  }
45
45
 
46
46
  // Kit tokens are plain color values (hex / color-mix results), not the hsl
47
- // triplets ruby_ui reads — return them as-is.
47
+ // triplets ruby_ui reads — return them as-is. Resolved from this.element,
48
+ // not document.documentElement: a .pk-dark island (_tokens.css:123-129)
49
+ // sets the --pk-* custom properties at the SUBTREE root, and per-element
50
+ // getComputedStyle correctly picks up the nearest ancestor's values —
51
+ // reading from the document root would ignore the island and always
52
+ // return the page-level theme.
48
53
  getThemeColor(name) {
49
- return getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim()
54
+ return getComputedStyle(this.element).getPropertyValue(`--${name}`).trim()
50
55
  }
51
56
 
52
57
  // shadcn-style series colors: dataset N takes --pk-chart-(N%5 + 1). Line/area
@@ -80,6 +85,13 @@ export default class extends Controller {
80
85
 
81
86
  setDefaultColorsForChart() {
82
87
  const Chart = this.chartLibrary()
88
+ // Chart.defaults is a library-wide singleton (chart.js has no per-instance
89
+ // defaults object), so it can only ever reflect ONE theme — the
90
+ // documented caveat. It's resolved from the document root, not
91
+ // this.element: a chart inside a .pk-dark island still gets per-dataset
92
+ // colors from the island (getThemeColor/seriesColor, above), just not
93
+ // these library-global defaults (grid/tooltip/legend chrome).
94
+ //
83
95
  // One computed-style resolve for all nine tokens (this runs on every
84
96
  // connect; assigning identical values to Chart.defaults is free — charts
85
97
  // read defaults at construction — so the style resolves are the only cost
@@ -122,20 +134,37 @@ export default class extends Controller {
122
134
  // unrelated class flip is the expensive path.
123
135
  initThemeObserver() {
124
136
  this._themeKey = this.currentThemeKey()
125
- this.themeObserver = new MutationObserver(() => {
137
+ const onMutate = () => {
126
138
  const key = this.currentThemeKey()
127
139
  if (key === this._themeKey) return
128
140
  this._themeKey = key
129
141
  this.refreshChart()
130
- })
142
+ }
143
+ this.themeObserver = new MutationObserver(onMutate)
131
144
  this.themeObserver.observe(document.documentElement, { attributeFilter: ["data-theme", "class"] })
145
+
146
+ // A .pk-dark island (_tokens.css:123-129) re-themes a subtree without
147
+ // touching the document root, so the root-only observer above misses it.
148
+ // Watch the nearest island ancestor too, if this chart lives inside one.
149
+ const island = this.element.closest(".pk-dark")
150
+ if (island && island !== document.documentElement) {
151
+ this.islandObserver = new MutationObserver(onMutate)
152
+ this.islandObserver.observe(island, { attributeFilter: ["data-theme", "class"] })
153
+ }
154
+ }
155
+
156
+ disconnectThemeObservers() {
157
+ this.themeObserver?.disconnect()
158
+ this.islandObserver?.disconnect()
132
159
  }
133
160
 
134
161
  // Cheap fingerprint of the resolved theme: two tokens that differ between
135
- // every light/dark pair. One style resolve per mutation vs a full chart
136
- // rebuild.
162
+ // every light/dark pair. Resolved from this.element (island-aware, see
163
+ // getThemeColor) so a mutation on an ancestor .pk-dark island is detected
164
+ // even though the document root never changes. One style resolve per
165
+ // mutation vs a full chart rebuild.
137
166
  currentThemeKey() {
138
- const styles = getComputedStyle(document.documentElement)
167
+ const styles = getComputedStyle(this.element)
139
168
  return `${styles.getPropertyValue("--pk-bg")}|${styles.getPropertyValue("--pk-text")}`
140
169
  }
141
170
 
@@ -12,7 +12,11 @@ module PhlexKit
12
12
  # tabindex=0 + role=region: the block scrolls (overflow:auto), so it must
13
13
  # be keyboard-focusable (WCAG 2.1.1 scrollable-region-focusable). Pass
14
14
  # `aria: { label: ... }` to name the region for AT.
15
- base = { class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }
15
+ # Defaults only when the caller didn't supply their own `mix` would
16
+ # fuse tabindex="0 -1" / role="region article" instead of overriding.
17
+ base = { class: "pk-codeblock", data: { syntax: @syntax } }
18
+ base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
19
+ base[:role] = "region" unless @attrs.key?(:role) || @attrs.key?("role")
16
20
  # A region landmark without a name is an axe violation — default one
17
21
  # from the syntax; a caller aria: { label: } replaces it (never fused).
18
22
  unless aria_labelled?
@@ -107,7 +107,7 @@
107
107
  padding: .25rem 0;
108
108
  border-bottom: 1px solid var(--pk-border);
109
109
  }
110
- .pk-combobox-group::before {
110
+ .pk-combobox-group[data-label]::before {
111
111
  content: attr(data-label);
112
112
  padding: .375rem .5rem;
113
113
  font-size: .75rem;
@@ -50,6 +50,10 @@ export default class extends Controller {
50
50
  // a restored page doesn't announce an open listbox over a closed combobox.
51
51
  this.setExpanded(false)
52
52
  this.clearActiveDescendant()
53
+ // Mirrors select_controller.js's connect(): a Turbo snapshot also
54
+ // serializes the keyboard-highlighted item's aria-current even though
55
+ // the popover itself does not survive the restore.
56
+ this.itemTargets.forEach((item) => item.removeAttribute("aria-current"))
53
57
  this.generateItemIds()
54
58
  this.updateTriggerContent()
55
59
  }
@@ -208,7 +212,19 @@ export default class extends Controller {
208
212
  remove.textContent = "×"
209
213
  remove.addEventListener("click", (e) => {
210
214
  e.stopPropagation() // don't reopen the popover via the trigger's click action
215
+ // Removing a focused chip drops focus with it (the remove button is
216
+ // torn out of the DOM by the renderBadges() rebuild below) — hand
217
+ // focus to the badge input instead of stranding it on <body>.
218
+ const wasFocused = document.activeElement === remove
211
219
  this.uncheck(input)
220
+ // badgeInput sits inside the trigger wrapper, so focusing it bubbles a
221
+ // focusin the trigger's own focusin->openPopover action would otherwise
222
+ // reopen — same guard closePopover() uses for its own focus restore.
223
+ if (wasFocused && this.hasBadgeInputTarget) {
224
+ this.refocusing = true
225
+ this.badgeInputTarget.focus()
226
+ this.refocusing = false
227
+ }
212
228
  })
213
229
 
214
230
  badge.append(remove)
@@ -235,6 +251,10 @@ export default class extends Controller {
235
251
  }
236
252
 
237
253
  clearAll(e) {
254
+ // The clear button hides itself once nothing is checked (updateTriggerContent
255
+ // below) — if it held focus, hand focus to the badge input rather than
256
+ // stranding it on a now-hidden element.
257
+ const wasFocused = this.hasClearButtonTarget && document.activeElement === this.clearButtonTarget
238
258
  if (e) {
239
259
  e.preventDefault()
240
260
  e.stopPropagation() // the button sits inside the trigger's click-to-open area
@@ -246,6 +266,11 @@ export default class extends Controller {
246
266
  })
247
267
  if (this.hasToggleAllTarget) this.toggleAllTarget.checked = false
248
268
  this.updateTriggerContent()
269
+ if (wasFocused && this.hasBadgeInputTarget) {
270
+ this.refocusing = true
271
+ this.badgeInputTarget.focus()
272
+ this.refocusing = false
273
+ }
249
274
  }
250
275
 
251
276
  togglePopover(event) {
@@ -303,6 +328,11 @@ export default class extends Controller {
303
328
  // aria-activedescendant holds an element id; on close it must be removed,
304
329
  // not left pointing at a hidden option.
305
330
  this.clearActiveDescendant()
331
+ // Also drop the highlight itself — otherwise a closed combobox still
332
+ // exposes an aria-current="true" option that Enter would go on to
333
+ // activate (APG: Enter on a closed combobox must be a no-op).
334
+ this.selectedItemIndex = null
335
+ this.itemTargets.forEach(item => item.ariaCurrent = "false")
306
336
  if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
307
337
  this.updateTriggerContent() // reflect the selection into an input trigger
308
338
 
@@ -349,6 +379,12 @@ export default class extends Controller {
349
379
  this.applyFilter(field.value.toLowerCase())
350
380
  }
351
381
 
382
+ // Strips combining diacritical marks after an NFD decomposition, so "é"
383
+ // (query or candidate) matches "e" instead of never comparing equal.
384
+ normalizeDiacritics(s) {
385
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "")
386
+ }
387
+
352
388
  applyFilter(filterTerm) {
353
389
  if (this.hasToggleAllTarget) {
354
390
  if (filterTerm) this.toggleAllTarget.parentElement.classList.add("pk-hidden")
@@ -363,10 +399,12 @@ export default class extends Controller {
363
399
  // aria-current="true" stayed the Enter target while invisible.
364
400
  this.itemTargets.forEach(item => item.ariaCurrent = "false")
365
401
 
402
+ const normalizedFilterTerm = this.normalizeDiacritics(filterTerm)
403
+
366
404
  this.inputTargets.forEach((input) => {
367
- const text = this.inputContent(input).toLowerCase()
405
+ const text = this.normalizeDiacritics(this.inputContent(input).toLowerCase())
368
406
 
369
- if (text.indexOf(filterTerm) > -1) {
407
+ if (text.indexOf(normalizedFilterTerm) > -1) {
370
408
  input.parentElement.classList.remove("pk-hidden")
371
409
  resultCount++
372
410
  } else {
@@ -395,6 +433,12 @@ export default class extends Controller {
395
433
  keyDownPressed(e) {
396
434
  if (e) e.preventDefault()
397
435
 
436
+ // APG: ArrowDown on a closed combobox opens it and highlights the first
437
+ // option, rather than walking aria-current through invisible options.
438
+ // openPopover() resets selectedItemIndex to null, so the branch below
439
+ // still lands on 0.
440
+ if (!this.isOpen()) this.openPopover()
441
+
398
442
  if (this.selectedItemIndex !== null) {
399
443
  this.selectedItemIndex++
400
444
  } else {
@@ -407,6 +451,11 @@ export default class extends Controller {
407
451
  keyUpPressed(e) {
408
452
  if (e) e.preventDefault()
409
453
 
454
+ // Same as keyDownPressed: ArrowUp on a closed combobox opens it and
455
+ // highlights the last option (wrapSelectedInputIndex turns -1 into the
456
+ // final index).
457
+ if (!this.isOpen()) this.openPopover()
458
+
410
459
  if (this.selectedItemIndex !== null) {
411
460
  this.selectedItemIndex--
412
461
  } else {
@@ -439,6 +488,12 @@ export default class extends Controller {
439
488
  }
440
489
 
441
490
  keyEnterPressed(event) {
491
+ // APG: Enter on a CLOSED combobox is a no-op — it must not activate
492
+ // whatever option happened to keep aria-current="true" from before the
493
+ // popover closed. Bail before preventDefault so a closed field keeps
494
+ // Enter's ordinary default behaviour.
495
+ if (!this.isOpen()) return
496
+
442
497
  event.preventDefault()
443
498
  const option = this.itemTargets.find(item => item.ariaCurrent === "true")
444
499
 
@@ -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;
@@ -50,10 +59,17 @@ export default class extends Controller {
50
59
  // dismiss() can hand focus back instead of dropping it on <body>.
51
60
  this.previouslyFocused = document.activeElement;
52
61
 
62
+ // A Turbo snapshot serializes stale aria-selected on rows / aria-
63
+ // activedescendant on the input even though the live JS highlight state
64
+ // doesn't survive restore — normalize here so a restored page doesn't
65
+ // announce a stale highlight (mirrors select_controller.js's connect()).
66
+ this.itemTargets.forEach((item) => item.removeAttribute("aria-selected"));
67
+
53
68
  if (!this.hasInputTarget) {
54
69
  return;
55
70
  }
56
71
 
72
+ this.inputTarget.removeAttribute("aria-activedescendant");
57
73
  this.generateItemIds();
58
74
  // Only the cloned dialog overlay grabs focus on connect — an inline
59
75
  // palette connecting at page load must not steal it.
@@ -174,7 +190,7 @@ export default class extends Controller {
174
190
  }
175
191
 
176
192
  focusInput() {
177
- this.inputTarget?.focus();
193
+ if (this.hasInputTarget) this.inputTarget.focus();
178
194
  }
179
195
 
180
196
  filter(e) {
@@ -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;