phlex_kit 0.10.0 → 0.11.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +3 -0
  5. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  6. data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
  7. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  8. data/app/components/phlex_kit/badge/badge.css +1 -1
  9. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  10. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  11. data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
  12. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  13. data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
  14. data/app/components/phlex_kit/chart/chart.rb +8 -2
  15. data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
  16. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  17. data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
  18. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  19. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  20. data/app/components/phlex_kit/combobox/combobox_controller.js +15 -1
  21. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  22. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  23. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
  24. data/app/components/phlex_kit/command/command_controller.js +6 -1
  25. data/app/components/phlex_kit/command/command_item.rb +4 -0
  26. data/app/components/phlex_kit/command/command_list.rb +6 -2
  27. data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
  28. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  29. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  30. data/app/components/phlex_kit/data_table/data_table.css +0 -10
  31. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  32. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  33. data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
  34. data/app/components/phlex_kit/dialog/dialog.css +3 -1
  35. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  36. data/app/components/phlex_kit/dialog/dialog_controller.js +13 -2
  37. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
  38. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
  39. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  40. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  41. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  42. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -1
  43. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  44. data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
  45. data/app/components/phlex_kit/icon/icon.css +9 -0
  46. data/app/components/phlex_kit/icon/icon.rb +7 -1
  47. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  48. data/app/components/phlex_kit/marker/marker.rb +9 -2
  49. data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
  50. data/app/components/phlex_kit/menubar/menubar_controller.js +6 -0
  51. data/app/components/phlex_kit/message/message.rb +5 -0
  52. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
  53. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  54. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  55. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  56. data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
  57. data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
  58. data/app/components/phlex_kit/popover/popover_controller.js +23 -1
  59. data/app/components/phlex_kit/progress/progress.css +8 -0
  60. data/app/components/phlex_kit/progress/progress.rb +14 -5
  61. data/app/components/phlex_kit/resizable/resizable_controller.js +3 -0
  62. data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -2
  63. data/app/components/phlex_kit/select/select.css +5 -2
  64. data/app/components/phlex_kit/select/select_controller.js +27 -4
  65. data/app/components/phlex_kit/separator/separator.rb +5 -1
  66. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  67. data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
  68. data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
  69. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  70. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  71. data/app/components/phlex_kit/slider/slider.css +15 -0
  72. data/app/components/phlex_kit/switch/switch.css +5 -0
  73. data/app/components/phlex_kit/table/table.css +2 -1
  74. data/app/components/phlex_kit/table/table.rb +5 -2
  75. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  76. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  77. data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
  78. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  79. data/app/components/phlex_kit/toast/toast.css +2 -1
  80. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  81. data/app/components/phlex_kit/toast/toast_controller.js +7 -4
  82. data/app/components/phlex_kit/toast/toast_region.rb +19 -9
  83. data/app/components/phlex_kit/toast/toaster_controller.js +15 -7
  84. data/app/components/phlex_kit/toggle_group/toggle_group.rb +1 -1
  85. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +5 -1
  86. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
  87. data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
  88. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  89. data/app/components/phlex_kit/typography/heading.rb +7 -1
  90. data/app/components/phlex_kit/typography/list.rb +22 -0
  91. data/app/components/phlex_kit/typography/text.rb +7 -1
  92. data/lib/phlex_kit/base_component.rb +13 -0
  93. data/lib/phlex_kit/version.rb +1 -1
  94. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f20449d41b77f26b6a4272eb5f4ebd116a225af9bf2dcb6bfec5899a5b117a2
4
- data.tar.gz: fd83a994ef1cc33452c19b8520934db31750babacb7508511dffb9c41b08a9dd
3
+ metadata.gz: caadd4e868a63939f22013699b89f9b1557dfa9569022301602c0f1cb07020f0
4
+ data.tar.gz: fa3ba42e9566f171e1fdee7ddcbfc475397bf404152031adbca2fd0cacbbda61
5
5
  SHA512:
6
- metadata.gz: f08a4051f646822945f9f1bf72b52cfce151509322032dccba9ec7166db4bf5a99bead12adeec4969edf0fc94df5b55dc902424322fa6a351abd18d92c04496b
7
- data.tar.gz: 83b2a4ea332492c19ce059611e534fc57c79fa39e136e9bd00e2cf535fe09c791f75c0c81bc12056f2678f9a1bcf4bd18bd8246b79cd7377e0f990dbd4472c50
6
+ metadata.gz: 76304ba104026fe236758b3fa93014b516ec84e65e6baefd307853da30bb7dc9d38d1f6fff0d2c7639ede3ee9116642beed8a615c24963ea3e0462c0ca89656a
7
+ data.tar.gz: 0c8d0dfd56d4e4b873b35149dd7c362010c97a02a4642de0b72d80606301be27dea505849753aa8754e081c38e49484b3358524267d3df497a12b8c5d9e9ccd0
@@ -44,6 +44,7 @@
44
44
  @import url("form/form.css");
45
45
  @import url("form_field/form_field.css");
46
46
  @import url("hover_card/hover_card.css");
47
+ @import url("icon/icon.css");
47
48
  @import url("input/input.css");
48
49
  @import url("input_group/input_group.css");
49
50
  @import url("input_otp/input_otp.css");
@@ -9,6 +9,13 @@
9
9
  background: none; border: 0; cursor: pointer; color: inherit; transition: color .15s ease, background-color .15s ease, text-decoration-color .15s ease;
10
10
  }
11
11
  .pk-accordion-trigger:hover { text-decoration: underline; }
12
+ /* background/border are stripped above — reinstate the kit focus ring or
13
+ keyboard focus falls back to the UA outline. */
14
+ .pk-accordion-trigger:focus-visible {
15
+ outline: none;
16
+ border-radius: calc(var(--pk-radius) - 2px);
17
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
18
+ }
12
19
  .pk-accordion-trigger-label { flex: 1; }
13
20
  .pk-accordion-icon { display: inline-flex; opacity: .5; transition: transform .2s ease; }
14
21
  .pk-accordion-icon svg { width: 1rem; height: 1rem; }
@@ -52,6 +52,9 @@ export default class extends Controller {
52
52
  // Double-open guard: Enter+click (or a double-click) on the trigger must
53
53
  // not stack a second clone over the first.
54
54
  if (this.clone?.isConnected) return;
55
+ // open: is one-shot — the reflected value sits in the Turbo snapshot, so
56
+ // a cache-restored reconnect would re-open a dialog the user dismissed.
57
+ this.openValue = false;
55
58
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
56
59
  this.clone = document.body.lastElementChild;
57
60
  }
@@ -11,9 +11,14 @@ module PhlexKit
11
11
  div(**mix({
12
12
  class: "pk-attachment-group",
13
13
  role: "group",
14
+ # at-start + at-end = no edge fades. Server-rendered so a group that
15
+ # FITS shows no false "more content" fade pre-hydration / with JS off;
16
+ # the controller's first update() removes them when there's overflow.
14
17
  data: {
15
18
  controller: "phlex-kit--scroll-fade",
16
- phlex_kit__scroll_fade_axis_value: "x"
19
+ phlex_kit__scroll_fade_axis_value: "x",
20
+ at_start: "",
21
+ at_end: ""
17
22
  }
18
23
  }, @attrs), &)
19
24
  end
@@ -4,8 +4,13 @@ module PhlexKit
4
4
  # stay independently clickable. Give it an aria-label; pass `as: :a` +
5
5
  # `href:` for a link. See attachment.rb.
6
6
  class AttachmentTrigger < BaseComponent
7
+ AS_TAGS = %i[button a].freeze
8
+
7
9
  def initialize(as: :button, href: nil, **attrs)
8
10
  @as = as.to_sym
11
+ unless AS_TAGS.include?(@as)
12
+ raise ArgumentError, "unknown AttachmentTrigger as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
13
+ end
9
14
  @href = href
10
15
  @attrs = attrs
11
16
  end
@@ -4,12 +4,19 @@ module PhlexKit
4
4
  # svg glyph (hidden automatically on xs/sm avatars). Recolor via style/class
5
5
  # — defaults to the brand fill. See avatar.rb.
6
6
  class AvatarBadge < BaseComponent
7
- def initialize(**attrs)
7
+ # label: gives the color-only presence dot an accessible alternative
8
+ # (a .pk-sr-only span, like AttachmentAction's). No default — a purely
9
+ # decorative dot stays silent.
10
+ def initialize(label: nil, **attrs)
11
+ @label = label
8
12
  @attrs = attrs
9
13
  end
10
14
 
11
15
  def view_template(&block)
12
- span(**mix({ class: "pk-avatar-badge" }, @attrs), &block)
16
+ span(**mix({ class: "pk-avatar-badge" }, @attrs)) do
17
+ span(class: "pk-sr-only") { @label } if @label
18
+ block&.call
19
+ end
13
20
  end
14
21
  end
15
22
  end
@@ -15,7 +15,7 @@
15
15
  overflow: hidden;
16
16
  white-space: nowrap;
17
17
  height: 1.25rem;
18
- border-radius: 2rem;
18
+ border-radius: 999px; /* fully-round pill (matches avatar/progress), not a --pk-radius derivative */
19
19
  border: 1px solid transparent;
20
20
  padding: .125rem .5rem;
21
21
  font-size: .75rem;
@@ -3,13 +3,11 @@ module PhlexKit
3
3
  # the child BubbleContent via a data-slot selector; align: :end flips it to the
4
4
  # trailing edge. Compose Bubble > BubbleContent (+ BubbleReactions).
5
5
  class Bubble < BaseComponent
6
- VARIANTS = %i[default secondary muted tinted outline ghost destructive].freeze
7
- ALIGNS = %i[start end].freeze
6
+ VARIANTS = %i[default secondary muted tinted outline ghost destructive].to_h { |v| [ v, v ] }.freeze
7
+ ALIGNS = %i[start end].to_h { |v| [ v, v ] }.freeze
8
8
  def initialize(variant: :default, align: :start, **attrs)
9
- @variant = variant.to_sym
10
- raise KeyError, "unknown Bubble variant #{@variant}" unless VARIANTS.include?(@variant)
11
- @align = align.to_sym
12
- raise KeyError, "unknown Bubble align #{@align}" unless ALIGNS.include?(@align)
9
+ @variant = fetch_option(VARIANTS, variant.to_sym, :variant)
10
+ @align = fetch_option(ALIGNS, align.to_sym, :align)
13
11
  @attrs = attrs
14
12
  end
15
13
  def view_template(&)
@@ -1,12 +1,10 @@
1
1
  module PhlexKit
2
2
  class BubbleReactions < BaseComponent
3
- SIDES = %i[top bottom].freeze
4
- ALIGNS = %i[start end].freeze
3
+ SIDES = %i[top bottom].to_h { |v| [ v, v ] }.freeze
4
+ ALIGNS = %i[start end].to_h { |v| [ v, v ] }.freeze
5
5
  def initialize(side: :bottom, align: :end, **attrs)
6
- @side = side.to_sym
7
- raise KeyError, "unknown BubbleReactions side #{@side}" unless SIDES.include?(@side)
8
- @align = align.to_sym
9
- raise KeyError, "unknown BubbleReactions align #{@align}" unless ALIGNS.include?(@align)
6
+ @side = fetch_option(SIDES, side.to_sym, :side)
7
+ @align = fetch_option(ALIGNS, align.to_sym, :align)
10
8
  @attrs = attrs
11
9
  end
12
10
  def view_template(&)
@@ -159,7 +159,9 @@ export default class extends Controller {
159
159
 
160
160
  toggleMultipleDay(iso) {
161
161
  const day = this.isoDate(this.parseDate(iso));
162
- const current = [...this.selectedDatesValue];
162
+ // Normalized copy: server-supplied entries may be datetime strings
163
+ // (Time#to_s) — indexOf against the bare isoDate must still match.
164
+ const current = this.normalizeDates(this.selectedDatesValue);
163
165
  const index = current.indexOf(day);
164
166
  if (index >= 0) {
165
167
  current.splice(index, 1);
@@ -303,8 +305,18 @@ export default class extends Controller {
303
305
  }
304
306
  e.preventDefault();
305
307
 
306
- const iso = this.isoDate(target);
307
- if (this.isDateDisabled(iso)) return; // min/max/booked boundary: stay put
308
+ let iso = this.isoDate(target);
309
+ if (this.isDateDisabled(iso)) {
310
+ // A min/max boundary holds focus in place; an isolated booked date is
311
+ // SKIPPED in the travel direction (APG date-grid) — arrows only, the
312
+ // month/row jumps (PageUp/Home/End) still stay put.
313
+ if (!(e.key in STEPS) || this.outOfRange(target)) return;
314
+ do {
315
+ target.setDate(target.getDate() + STEPS[e.key]);
316
+ if (this.outOfRange(target)) return;
317
+ } while (this.isBooked(target));
318
+ iso = this.isoDate(target);
319
+ }
308
320
 
309
321
  if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
310
322
  this.viewDateValue = iso; // re-renders the grid on the target month
@@ -412,7 +424,7 @@ export default class extends Controller {
412
424
  );
413
425
  }
414
426
  if (this.modeValue === "multiple") {
415
- return this.selectedDatesValue.includes(this.isoDate(day));
427
+ return this.normalizeDates(this.selectedDatesValue).includes(this.isoDate(day));
416
428
  }
417
429
  const selectedDate = this.selectedDate();
418
430
  return selectedDate && day.toDateString() === selectedDate.toDateString();
@@ -428,7 +440,17 @@ export default class extends Controller {
428
440
  }
429
441
 
430
442
  isBooked(day) {
431
- return this.disabledDatesValue.includes(this.isoDate(day));
443
+ return this.normalizeDates(this.disabledDatesValue).includes(this.isoDate(day));
444
+ }
445
+
446
+ // disabled_dates / selected_dates entries may be Time#to_s or datetime ISO
447
+ // strings — every other date input tolerates those via parseDate, so the
448
+ // membership arrays must be normalized the same way (exact string equality
449
+ // silently no-oped for anything but bare yyyy-MM-dd).
450
+ normalizeDates(values) {
451
+ return values
452
+ .map((d) => { const parsed = this.parseDate(d); return parsed ? this.isoDate(parsed) : null; })
453
+ .filter(Boolean);
432
454
  }
433
455
 
434
456
  monthAndYear() {
@@ -575,14 +597,16 @@ export default class extends Controller {
575
597
  isDateDisabled(date) {
576
598
  const candidate = this.parseDate(date);
577
599
  if (!candidate) return false;
600
+ return this.outOfRange(candidate) || this.isBooked(candidate);
601
+ }
578
602
 
603
+ // min/max bounds only (not booked entries) — the keyboard skip needs to
604
+ // distinguish a hard boundary from a hole in the middle of the grid.
605
+ outOfRange(candidate) {
579
606
  const minDate = this.minDate();
580
607
  if (minDate && this.startOfDay(candidate) < this.startOfDay(minDate)) return true;
581
-
582
608
  const maxDate = this.maxDate();
583
- if (maxDate && this.startOfDay(candidate) > this.startOfDay(maxDate)) return true;
584
-
585
- return this.disabledDatesValue.includes(this.isoDate(candidate));
609
+ return !!(maxDate && this.startOfDay(candidate) > this.startOfDay(maxDate));
586
610
  }
587
611
 
588
612
  parseDate(value) {
@@ -35,12 +35,13 @@ module PhlexKit
35
35
  end
36
36
 
37
37
  # Arrow keys follow the scroll axis: left/right for horizontal, up/down
38
- # for vertical.
38
+ # for vertical. Horizontal arrows route through keyNext/keyPrev, which
39
+ # flip in RTL (the next slide sits to the physical left there).
39
40
  def keyboard_action
40
41
  if @orientation == :vertical
41
42
  "keydown.down->phlex-kit--carousel#scrollNext:prevent keydown.up->phlex-kit--carousel#scrollPrev:prevent"
42
43
  else
43
- "keydown.right->phlex-kit--carousel#scrollNext:prevent keydown.left->phlex-kit--carousel#scrollPrev:prevent"
44
+ "keydown.right->phlex-kit--carousel#keyNext:prevent keydown.left->phlex-kit--carousel#keyPrev:prevent"
44
45
  end
45
46
  end
46
47
  end
@@ -73,6 +73,16 @@ export default class extends Controller {
73
73
  this._goTo(this.index - 1);
74
74
  }
75
75
 
76
+ // Physical arrow keys (horizontal axis only): in RTL the next slide sits
77
+ // to the physical LEFT, so the right arrow must travel toward "previous".
78
+ keyNext() {
79
+ this._rtl() ? this.scrollPrev() : this.scrollNext();
80
+ }
81
+
82
+ keyPrev() {
83
+ this._rtl() ? this.scrollNext() : this.scrollPrev();
84
+ }
85
+
76
86
  get slides() {
77
87
  return this.track ? Array.from(this.track.children) : [];
78
88
  }
@@ -129,6 +139,8 @@ export default class extends Controller {
129
139
  const drag = this.drag;
130
140
  if (!drag) return;
131
141
  drag.delta = this._pointerPos(e) - drag.start;
142
+ // Normalize to scroll-axis space: in RTL a rightward drag reveals next.
143
+ if (this._rtl()) drag.delta = -drag.delta;
132
144
  if (!drag.moved && Math.abs(drag.delta) > 5) {
133
145
  drag.moved = true;
134
146
  this.track.style.transition = "none";
@@ -183,13 +195,22 @@ export default class extends Controller {
183
195
  return this.options.axis === "y" ? e.clientY : e.clientX;
184
196
  }
185
197
 
198
+ // In RTL the horizontal track lays slides right-to-left: later slides have
199
+ // SMALLER offsetLeft and the track must translate in +x to reveal them.
200
+ // All geometry below works in a direction-neutral "offset along the scroll
201
+ // axis" space; _rtl() flips the signs at the physical boundaries only.
202
+ _rtl() {
203
+ return this.options.axis !== "y" && getComputedStyle(this.element).direction === "rtl";
204
+ }
205
+
186
206
  _offsetOf(index) {
187
207
  const slide = this.slides[index];
188
208
  const first = this.slides[0];
189
209
  if (!slide || !first) return 0;
190
- const raw = this.options.axis === "y"
210
+ let raw = this.options.axis === "y"
191
211
  ? slide.offsetTop - first.offsetTop
192
212
  : slide.offsetLeft - first.offsetLeft;
213
+ if (this._rtl()) raw = -raw;
193
214
  // Never scroll past the content edge: with multi-up layouts the last
194
215
  // slides' offsets overshoot the max scrollable offset (track − viewport).
195
216
  return Math.min(Math.max(0, raw), this._maxOffset());
@@ -211,6 +232,6 @@ export default class extends Controller {
211
232
  _translateTo(offset) {
212
233
  this.track.style.transform = this.options.axis === "y"
213
234
  ? `translate3d(0, ${-offset}px, 0)`
214
- : `translate3d(${-offset}px, 0, 0)`;
235
+ : `translate3d(${this._rtl() ? offset : -offset}px, 0, 0)`;
215
236
  }
216
237
  }
@@ -19,13 +19,19 @@ module PhlexKit
19
19
  end
20
20
 
21
21
  def view_template(&)
22
- canvas(**mix({
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",
24
28
  data: {
25
29
  controller: "phlex-kit--chart",
26
30
  phlex_kit__chart_options_value: JSON.generate(@options)
27
31
  }
28
- }, @attrs), &)
32
+ }
33
+ base[:aria] = { label: "Chart" } unless aria_labelled?
34
+ canvas(**mix(base, @attrs), &)
29
35
  end
30
36
  end
31
37
  end
@@ -3,7 +3,8 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="phlex-kit--clipboard". Copies the source text and
4
4
  // flashes a success/error popover (CSS-positioned, no @floating-ui). Ported from ruby_ui.
5
5
  export default class extends Controller {
6
- static targets = ["trigger", "source", "successPopover", "errorPopover"]
6
+ // No "trigger" target: the click->#copy action alone wires the trigger.
7
+ static targets = ["source", "successPopover", "errorPopover"]
7
8
 
8
9
  connect() {
9
10
  // Turbo snapshots at turbo:before-cache, BEFORE disconnect — hide the
@@ -28,10 +29,16 @@ export default class extends Controller {
28
29
  // A source usually wraps one element (input/code/…), but bare text is
29
30
  // legal too — fall back to the source's own text instead of erroring.
30
31
  const el = this.sourceTarget.children[0]
32
+ // TEXTAREA reads .value like INPUT — innerText would return the initial
33
+ // content, not the user-edited value.
31
34
  const text = el
32
- ? (el.tagName === "INPUT" ? el.value : el.innerText)
35
+ ? (["INPUT", "TEXTAREA"].includes(el.tagName) ? el.value : el.innerText)
33
36
  : this.sourceTarget.textContent.trim()
34
37
  if (!text) { this.show(this.errorPopoverTarget); return }
38
+ // navigator.clipboard is undefined in insecure contexts (plain-HTTP
39
+ // hosts, some webviews) — that's the error popover's job, not an
40
+ // unhandled TypeError's.
41
+ if (!navigator.clipboard) { this.show(this.errorPopoverTarget); return }
35
42
  navigator.clipboard.writeText(text)
36
43
  .then(() => this.show(this.successPopoverTarget))
37
44
  .catch(() => this.show(this.errorPopoverTarget))
@@ -1,6 +1,6 @@
1
1
  module PhlexKit
2
2
  class ClipboardTrigger < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
- def view_template(&) = div(**mix({ class: "pk-clipboard-trigger", data: { phlex_kit__clipboard_target: "trigger", action: "click->phlex-kit--clipboard#copy" } }, @attrs), &)
4
+ def view_template(&) = div(**mix({ class: "pk-clipboard-trigger", data: { action: "click->phlex-kit--clipboard#copy" } }, @attrs), &)
5
5
  end
6
6
  end
@@ -12,7 +12,13 @@ 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
- div(**mix({ class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }, @attrs)) do
15
+ base = { class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }
16
+ # A region landmark without a name is an axe violation — default one
17
+ # from the syntax; a caller aria: { label: } replaces it (never fused).
18
+ unless aria_labelled?
19
+ base[:aria] = { label: @syntax ? "#{@syntax} code" : "Code" }
20
+ end
21
+ div(**mix(base, @attrs)) do
16
22
  pre do
17
23
  if block
18
24
  code(&block)
@@ -7,7 +7,10 @@ module PhlexKit
7
7
  @attrs = attrs
8
8
  end
9
9
  def view_template(&)
10
- div(**mix({ class: "pk-collapsible", data: { controller: "phlex-kit--collapsible", phlex_kit__collapsible_open_value: @open } }, @attrs), &)
10
+ # data-state is the CSS styling hook (chevron rotation) it must be
11
+ # server-rendered or an open: true collapsible renders a closed-pointing
12
+ # chevron until Stimulus connects (and always, with JS off).
13
+ div(**mix({ class: "pk-collapsible", data: { controller: "phlex-kit--collapsible", phlex_kit__collapsible_open_value: @open, state: @open ? "open" : "closed" } }, @attrs), &)
11
14
  end
12
15
  end
13
16
  end
@@ -30,7 +30,8 @@ module PhlexKit
30
30
  "keydown.down->phlex-kit--combobox#keyDownPressed",
31
31
  "keydown.up->phlex-kit--combobox#keyUpPressed",
32
32
  "keydown.enter->phlex-kit--combobox#keyEnterPressed",
33
- "keydown.esc->phlex-kit--combobox#closePopover:prevent"
33
+ # no :prevent — closePopover only swallows Escape while open.
34
+ "keydown.esc->phlex-kit--combobox#closePopover"
34
35
  ].join(" ")
35
36
  }
36
37
  }, @attrs)) do
@@ -49,7 +50,10 @@ module PhlexKit
49
50
  action: [
50
51
  "keyup->phlex-kit--combobox#filterItems",
51
52
  "input->phlex-kit--combobox#filterItems",
52
- "keydown.backspace->phlex-kit--combobox#handleBadgeInputBackspace"
53
+ # plain keydown — "backspace" is not a Stimulus key filter
54
+ # (using it throws "unknown key filter" on every keystroke and
55
+ # the handler never ran); the method guards on e.key itself.
56
+ "keydown->phlex-kit--combobox#handleBadgeInputBackspace"
53
57
  ].join(" ")
54
58
  }
55
59
  )
@@ -45,6 +45,11 @@ export default class extends Controller {
45
45
  }
46
46
 
47
47
  connect() {
48
+ // A Turbo snapshot serializes aria-expanded / aria-activedescendant even
49
+ // though :popover-open does not survive the restore — normalize here so
50
+ // a restored page doesn't announce an open listbox over a closed combobox.
51
+ this.setExpanded(false)
52
+ this.clearActiveDescendant()
48
53
  this.generateItemIds()
49
54
  this.updateTriggerContent()
50
55
  }
@@ -217,6 +222,8 @@ export default class extends Controller {
217
222
  }
218
223
 
219
224
  handleBadgeInputBackspace(e) {
225
+ // Bound as a bare keydown: "backspace" is not a Stimulus key filter.
226
+ if (e.key !== "Backspace") return
220
227
  if (this.badgeInputTarget.value !== "") return
221
228
 
222
229
  const checked = this.checkedInputs()
@@ -278,7 +285,14 @@ export default class extends Controller {
278
285
  }
279
286
  }
280
287
 
281
- closePopover() {
288
+ closePopover(e) {
289
+ // Escape must keep its browser default (e.g. cancelling an enclosing
290
+ // <dialog>) when the popover is already closed — only swallow the key
291
+ // when this close actually consumes it.
292
+ if (e?.type === "keydown") {
293
+ if (!this.isOpen()) return
294
+ e.preventDefault()
295
+ }
282
296
  // Hiding the popover while focus is inside it (search input, option) drops
283
297
  // focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
284
298
  // only when focus would actually be orphaned — a click elsewhere on the
@@ -36,7 +36,10 @@ module PhlexKit
36
36
  "keydown.down->phlex-kit--combobox#keyDownPressed",
37
37
  "keydown.up->phlex-kit--combobox#keyUpPressed",
38
38
  "keydown.enter->phlex-kit--combobox#keyEnterPressed",
39
- "keydown.esc->phlex-kit--combobox#closePopover:prevent"
39
+ # no :prevent — closePopover only swallows Escape while the
40
+ # popover is open, so a closed field keeps the key's default
41
+ # (e.g. cancelling an enclosing <dialog>).
42
+ "keydown.esc->phlex-kit--combobox#closePopover"
40
43
  ].join(" ")
41
44
  }
42
45
  }, @attrs)) do
@@ -3,13 +3,17 @@ module PhlexKit
3
3
  # role and an id the controller points the combobox element's aria-controls
4
4
  # at (and derives option ids from). See combobox.rb.
5
5
  class ComboboxList < BaseComponent
6
- def initialize(**attrs)
6
+ # id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
7
+ # caller id with the generated one into an invalid two-token id, breaking
8
+ # aria-controls (the documented list_id: wiring) and derived option ids.
9
+ def initialize(id: nil, **attrs)
10
+ @id = id || "pk-combobox-list-#{SecureRandom.hex(4)}"
7
11
  @attrs = attrs
8
12
  end
9
13
 
10
14
  def view_template(&)
11
15
  div(**mix({
12
- id: "pk-combobox-list-#{SecureRandom.hex(4)}",
16
+ id: @id,
13
17
  class: "pk-combobox-list",
14
18
  role: "listbox",
15
19
  data: { phlex_kit__combobox_target: "list" }
@@ -13,6 +13,10 @@ module PhlexKit
13
13
  input(**mix({
14
14
  type: :checkbox,
15
15
  class: "pk-combobox-checkbox",
16
+ # The option label is the interactive surface (ARIA option); the real
17
+ # input must not be a second tab stop inside it — same as
18
+ # ComboboxCheckbox/ComboboxRadio.
19
+ tabindex: "-1",
16
20
  data: {
17
21
  phlex_kit__combobox_target: "toggleAll",
18
22
  action: "change->phlex-kit--combobox#toggleAllItems"
@@ -98,6 +98,9 @@ export default class extends Controller {
98
98
  this.searchEntries = this.searchEntries.filter(
99
99
  (entry) => entry.element !== item,
100
100
  );
101
+ // The highlight index is positional — after a removal it points at the
102
+ // wrong (or a missing) row, so Enter would activate the wrong item.
103
+ this.selectedIndex = -1;
101
104
  }
102
105
 
103
106
  dismiss() {
@@ -266,7 +269,9 @@ export default class extends Controller {
266
269
  this.updateSelectedItem(visibleItems, -1);
267
270
  } else if (e.key === "Enter" && this.selectedIndex !== -1) {
268
271
  e.preventDefault();
269
- visibleItems[this.selectedIndex].click();
272
+ // Optional-chained: a stale selectedIndex after dynamic item removal
273
+ // (turbo stream re-render) must not throw.
274
+ visibleItems[this.selectedIndex]?.click();
270
275
  }
271
276
  }
272
277
 
@@ -18,6 +18,10 @@ module PhlexKit
18
18
  class: "pk-command-item",
19
19
  href: @href,
20
20
  role: "option",
21
+ # Options in an aria-activedescendant listbox are not tab stops —
22
+ # keyboard access is arrows/Enter from the input (cmdk parity); a
23
+ # tabbable anchor per row also made disabled items focusable.
24
+ tabindex: "-1",
21
25
  aria: { disabled: (@disabled ? "true" : nil) },
22
26
  data: {
23
27
  phlex_kit__command_target: "item",
@@ -3,13 +3,17 @@ module PhlexKit
3
3
  # listbox role and an id the controller points the input's aria-controls at
4
4
  # (and derives result ids from, for aria-activedescendant). See command.rb.
5
5
  class CommandList < BaseComponent
6
- def initialize(**attrs)
6
+ # id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
7
+ # caller id with the generated one into an invalid two-token id, breaking
8
+ # aria-controls (the documented list_id: wiring) and derived result ids.
9
+ def initialize(id: nil, **attrs)
10
+ @id = id || "pk-command-list-#{SecureRandom.hex(4)}"
7
11
  @attrs = attrs
8
12
  end
9
13
 
10
14
  def view_template(&)
11
15
  div(**mix({
12
- id: "pk-command-list-#{SecureRandom.hex(4)}",
16
+ id: @id,
13
17
  class: "pk-command-list",
14
18
  role: "listbox",
15
19
  data: { phlex_kit__command_target: "list" }
@@ -23,12 +23,37 @@ export default class extends Controller {
23
23
  this.close()
24
24
  }
25
25
  this.onKey = (e) => this.keydown(e)
26
+ // Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
27
+ // stale data-state="open" (the panel itself can't resurrect, but host
28
+ // CSS keyed on the state hook would misfire after a restore).
29
+ this.onBeforeCache = () => {
30
+ if (this.contentTarget.matches(":popover-open")) this.close()
31
+ }
32
+ document.addEventListener("turbo:before-cache", this.onBeforeCache)
33
+ // Outstanding syncSub rAF handles — cancelled on disconnect (menubar's
34
+ // pattern).
35
+ this.subFrames = new Set()
26
36
  }
27
37
 
28
38
  disconnect() {
29
39
  document.removeEventListener("click", this.onDoc)
30
40
  document.removeEventListener("contextmenu", this.onDoc)
31
41
  document.removeEventListener("keydown", this.onKey)
42
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
43
+ this.subFrames.forEach((id) => cancelAnimationFrame(id))
44
+ this.subFrames.clear()
45
+ }
46
+
47
+ // Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
48
+ // sub trigger's aria-expanded — same wiring as dropdown_menu/menubar.
49
+ syncSub(e) {
50
+ const sub = e.currentTarget
51
+ const id = requestAnimationFrame(() => {
52
+ this.subFrames.delete(id)
53
+ sub.querySelector(":scope > [aria-haspopup]")
54
+ ?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
55
+ })
56
+ this.subFrames.add(id)
32
57
  }
33
58
 
34
59
  open(e) {
@@ -5,7 +5,9 @@ module PhlexKit
5
5
  class ContextMenuSub < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-context-menu-sub" }, @attrs), &)
8
+ # syncSub mirrors the CSS-driven reveal state onto the sub trigger's
9
+ # aria-expanded (same wiring as DropdownMenuSub / MenubarSub).
10
+ div(**mix({ class: "pk-context-menu-sub", data: { action: "mouseenter->phlex-kit--context-menu#syncSub mouseleave->phlex-kit--context-menu#syncSub focusin->phlex-kit--context-menu#syncSub focusout->phlex-kit--context-menu#syncSub" } }, @attrs), &)
9
11
  end
10
12
  end
11
13
  end
@@ -9,7 +9,9 @@ module PhlexKit
9
9
  # -1: roving focus reaches it via arrows (focus-within opens the sub);
10
10
  # tabindex 0 made it a stray tab stop inside the menu.
11
11
  tabindex: "-1",
12
- aria: { haspopup: "menu" },
12
+ # expanded starts false and is mirrored live by the controller's
13
+ # syncSub (the reveal itself is pure CSS :hover/:focus-within).
14
+ aria: { haspopup: "menu", expanded: "false" },
13
15
  data: { phlex_kit__context_menu_target: "menuItem" }
14
16
  }, @attrs)) do
15
17
  block&.call
@@ -23,16 +23,6 @@
23
23
  /* "Columns" dropdown rows. */
24
24
  .pk-data-table-column-toggle { position: relative; }
25
25
  .pk-data-table-column-toggle-icon { width: 1rem; height: 1rem; margin-inline-start: .25rem; }
26
- .pk-data-table-column-option {
27
- display: flex;
28
- align-items: center;
29
- gap: .5rem;
30
- border-radius: calc(var(--pk-radius) - 4px);
31
- padding: .375rem .5rem;
32
- font-size: .875rem;
33
- cursor: pointer;
34
- }
35
- .pk-data-table-column-option:hover { background: var(--pk-accent); }
36
26
 
37
27
  /* Sortable header link. */
38
28
  .pk-data-table-sort-head { color: var(--pk-text); white-space: nowrap; }