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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: caadd4e868a63939f22013699b89f9b1557dfa9569022301602c0f1cb07020f0
4
- data.tar.gz: fa3ba42e9566f171e1fdee7ddcbfc475397bf404152031adbca2fd0cacbbda61
3
+ metadata.gz: 4e3d6272f0ef818c51cc952c6a2021a1f2b6a036afdab70b617f05c64ada4144
4
+ data.tar.gz: aa43b4e8826c3dfe52d96440987195815e836de43a795f9628dfce1cbc3bcbd8
5
5
  SHA512:
6
- metadata.gz: 76304ba104026fe236758b3fa93014b516ec84e65e6baefd307853da30bb7dc9d38d1f6fff0d2c7639ede3ee9116642beed8a615c24963ea3e0462c0ca89656a
7
- data.tar.gz: 0c8d0dfd56d4e4b873b35149dd7c362010c97a02a4642de0b72d80606301be27dea505849753aa8754e081c38e49484b3358524267d3df497a12b8c5d9e9ccd0
6
+ metadata.gz: b655fda7c0a0b013e006b2586b5b2eec5714106a7877f4b012ed6567af45fedd0320cb8cec8360fc05288924af71e809ce56ce911d2dbb72d7fceb52adf938c2
7
+ data.tar.gz: 90070ed31d24e9f46f311345a19eaa2ef79b760a9de08cb28c174ee6126e68aa6cd4122fbe8f915d61aaf800801d1dd781b81e86fc7e4aa90595e54be8631435
@@ -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 attr_set?(:role)
34
+ panel_attrs[:"aria-modal"] = "true" unless aria_key_set?(:modal)
35
+ panel_attrs[:tabindex] = "-1" unless attr_set?(: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; }
@@ -144,9 +144,11 @@ export default class extends Controller {
144
144
  const end = this.parseDate(this.rangeEndValue);
145
145
 
146
146
  if (!start || (start && end)) {
147
- // begin a fresh range
147
+ // begin a fresh range. undefined (not null) clears the value: Stimulus
148
+ // removes the attribute for undefined, while null goes through
149
+ // setAttribute and serializes as the literal string "null".
148
150
  this.rangeStartValue = this.isoDate(candidate);
149
- this.rangeEndValue = null;
151
+ this.rangeEndValue = undefined;
150
152
  } else if (candidate < start) {
151
153
  this.rangeStartValue = this.isoDate(candidate);
152
154
  } else {
@@ -182,9 +184,10 @@ export default class extends Controller {
182
184
  return;
183
185
  }
184
186
 
185
- // update the viewDateValue to the selected date's month (this re-renders)
187
+ // update the viewDateValue to the selected date's month
186
188
  const newViewDate = new Date(selectedDate);
187
189
  newViewDate.setDate(2); // avoid month-length/timezone edges
190
+ this._viewRendered = true;
188
191
  this.viewDateValue = this.isoDate(newViewDate);
189
192
 
190
193
  this.updateCalendar();
@@ -197,6 +200,13 @@ export default class extends Controller {
197
200
  }
198
201
 
199
202
  viewDateValueChanged(value, prevValue) {
203
+ // Month-cross paths render synchronously right after writing
204
+ // viewDateValue (focus must land in the NEW grid); this async echo
205
+ // (MutationObserver) would render the same view a second time.
206
+ if (this._viewRendered) {
207
+ this._viewRendered = false;
208
+ return;
209
+ }
200
210
  this.updateCalendar();
201
211
  }
202
212
 
@@ -243,7 +253,10 @@ export default class extends Controller {
243
253
  return this.isoDate(date);
244
254
  }
245
255
 
246
- updateCalendar() {
256
+ // `focusDay` overrides the day to refocus after re-render: on a keyboard
257
+ // month cross the target day lives in the NEW grid, not the pre-render
258
+ // activeElement, so onKeydown passes it explicitly.
259
+ updateCalendar(focusDay = null) {
247
260
  if (this.hasTitleTarget) {
248
261
  this.titleTarget.textContent = this.monthAndYear();
249
262
  }
@@ -251,9 +264,11 @@ export default class extends Controller {
251
264
  this.syncNavButtons();
252
265
  // innerHTML replacement drops focus — remember the focused day so
253
266
  // 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;
267
+ const focusedDay =
268
+ focusDay ||
269
+ (this.calendarTarget.contains(document.activeElement)
270
+ ? document.activeElement.dataset?.day
271
+ : null);
257
272
  this.calendarTarget.innerHTML = this.calendarHTML();
258
273
  this.ensureGridTabStop(focusedDay);
259
274
  }
@@ -282,7 +297,11 @@ export default class extends Controller {
282
297
  // boundary re-renders the grid on the target month. Enter/Space activate
283
298
  // natively (the days are real <button>s).
284
299
  onKeydown(e) {
285
- const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
300
+ // In RTL the day grid mirrors: the physical LEFT arrow advances one day.
301
+ // The week steps (Up/Down) and the PageUp/Down/Home/End jumps are
302
+ // unaffected. Runtime dir check is reliable after a dynamic flip.
303
+ const rtl = getComputedStyle(this.element).direction === "rtl";
304
+ const STEPS = { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1, ArrowUp: -7, ArrowDown: 7 };
286
305
  const day = e.target.closest?.("[data-day]");
287
306
  if (!day) return;
288
307
 
@@ -319,7 +338,16 @@ export default class extends Controller {
319
338
  }
320
339
 
321
340
  if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
322
- this.viewDateValue = iso; // re-renders the grid on the target month
341
+ // Crossing a month re-renders the grid on the target month. Stimulus
342
+ // value-changed callbacks fire via MutationObserver (async), so the grid
343
+ // is NOT re-rendered synchronously after this assignment — querying it
344
+ // below would hit the OLD month, and the target day (outside it) would be
345
+ // missing → early return, stranding focus on <body>. Drive the re-render
346
+ // ourselves and hand it the target day so ensureGridTabStop focuses it.
347
+ this._viewRendered = true;
348
+ this.viewDateValue = iso;
349
+ this.updateCalendar(iso);
350
+ return;
323
351
  }
324
352
  const next = this.calendarTarget.querySelector(`[data-day="${iso}"]:not([disabled])`);
325
353
  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 attr_set?(: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
 
@@ -8,11 +8,25 @@ module PhlexKit
8
8
  # `name:`/`value:`/`checked:`/`**on(...)` pass through via `mix`. Styled by
9
9
  # `.pk-checkbox` (checkbox.css).
10
10
  class Checkbox < BaseComponent
11
- def initialize(**attrs)
11
+ # include_hidden mirrors Rails' check_box (and PhlexKit::Switch): an
12
+ # unchecked box posts nothing, so a paired hidden field carries the
13
+ # unchecked value. Emitted only when a `name:` is present — and never for
14
+ # array-style names ("ids[]"), where an unchecked "0" would inject a
15
+ # bogus element into the collection param (use a single blank hidden for
16
+ # the whole collection instead, as Rails' collection helpers do).
17
+ def initialize(include_hidden: true, unchecked_value: "0", **attrs)
18
+ @include_hidden = include_hidden
19
+ @unchecked_value = unchecked_value
12
20
  @attrs = attrs
13
21
  end
14
22
 
15
23
  def view_template
24
+ if @include_hidden && @attrs[:name] && !@attrs[:name].to_s.end_with?("[]")
25
+ # Disabled in lockstep with the checkbox (Rails' check_box idiom) —
26
+ # a disabled checkbox must not still post its unchecked value.
27
+ input(type: "hidden", name: @attrs[:name], value: @unchecked_value,
28
+ disabled: @attrs[:disabled] ? true : nil)
29
+ end
16
30
  input(**mix({
17
31
  type: :checkbox,
18
32
  class: "pk-checkbox",
@@ -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 attr_set?(:tabindex)
19
+ base[:role] = "region" unless attr_set?(: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
 
@@ -324,6 +354,10 @@ export default class extends Controller {
324
354
  if (!this.isOpen()) return
325
355
  if (this.element.contains(event.target)) return
326
356
 
357
+ // Kit-wide dismiss contract: the combobox is POPOVER-family (shadcn
358
+ // builds it on Popover light-dismiss), so the dismissing outside click
359
+ // deliberately clicks through — no preventDefault. Only the MENU
360
+ // overlays (dropdown/context/menubar/select) swallow theirs.
327
361
  this.closePopover()
328
362
  }
329
363
 
@@ -349,6 +383,12 @@ export default class extends Controller {
349
383
  this.applyFilter(field.value.toLowerCase())
350
384
  }
351
385
 
386
+ // Strips combining diacritical marks after an NFD decomposition, so "é"
387
+ // (query or candidate) matches "e" instead of never comparing equal.
388
+ normalizeDiacritics(s) {
389
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "")
390
+ }
391
+
352
392
  applyFilter(filterTerm) {
353
393
  if (this.hasToggleAllTarget) {
354
394
  if (filterTerm) this.toggleAllTarget.parentElement.classList.add("pk-hidden")
@@ -363,10 +403,12 @@ export default class extends Controller {
363
403
  // aria-current="true" stayed the Enter target while invisible.
364
404
  this.itemTargets.forEach(item => item.ariaCurrent = "false")
365
405
 
406
+ const normalizedFilterTerm = this.normalizeDiacritics(filterTerm)
407
+
366
408
  this.inputTargets.forEach((input) => {
367
- const text = this.inputContent(input).toLowerCase()
409
+ const text = this.normalizeDiacritics(this.inputContent(input).toLowerCase())
368
410
 
369
- if (text.indexOf(filterTerm) > -1) {
411
+ if (text.indexOf(normalizedFilterTerm) > -1) {
370
412
  input.parentElement.classList.remove("pk-hidden")
371
413
  resultCount++
372
414
  } else {
@@ -395,6 +437,12 @@ export default class extends Controller {
395
437
  keyDownPressed(e) {
396
438
  if (e) e.preventDefault()
397
439
 
440
+ // APG: ArrowDown on a closed combobox opens it and highlights the first
441
+ // option, rather than walking aria-current through invisible options.
442
+ // openPopover() resets selectedItemIndex to null, so the branch below
443
+ // still lands on 0.
444
+ if (!this.isOpen()) this.openPopover()
445
+
398
446
  if (this.selectedItemIndex !== null) {
399
447
  this.selectedItemIndex++
400
448
  } else {
@@ -407,6 +455,11 @@ export default class extends Controller {
407
455
  keyUpPressed(e) {
408
456
  if (e) e.preventDefault()
409
457
 
458
+ // Same as keyDownPressed: ArrowUp on a closed combobox opens it and
459
+ // highlights the last option (wrapSelectedInputIndex turns -1 into the
460
+ // final index).
461
+ if (!this.isOpen()) this.openPopover()
462
+
410
463
  if (this.selectedItemIndex !== null) {
411
464
  this.selectedItemIndex--
412
465
  } else {
@@ -439,6 +492,12 @@ export default class extends Controller {
439
492
  }
440
493
 
441
494
  keyEnterPressed(event) {
495
+ // APG: Enter on a CLOSED combobox is a no-op — it must not activate
496
+ // whatever option happened to keep aria-current="true" from before the
497
+ // popover closed. Bail before preventDefault so a closed field keeps
498
+ // Enter's ordinary default behaviour.
499
+ if (!this.isOpen()) return
500
+
442
501
  event.preventDefault()
443
502
  const option = this.itemTargets.find(item => item.ariaCurrent === "true")
444
503