phlex_kit 0.9.0 → 0.10.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +8 -0
  3. data/app/components/phlex_kit/alert/alert.rb +1 -1
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +12 -0
  5. data/app/components/phlex_kit/attachment/attachment.css +6 -3
  6. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
  7. data/app/components/phlex_kit/avatar/avatar.css +2 -2
  8. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
  9. data/app/components/phlex_kit/bubble/bubble.css +1 -1
  10. data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
  11. data/app/components/phlex_kit/button_group/button_group.css +16 -16
  12. data/app/components/phlex_kit/calendar/calendar.css +10 -2
  13. data/app/components/phlex_kit/carousel/carousel.css +4 -4
  14. data/app/components/phlex_kit/clipboard/clipboard_controller.js +18 -2
  15. data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
  16. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
  17. data/app/components/phlex_kit/combobox/combobox.css +3 -3
  18. data/app/components/phlex_kit/combobox/combobox.rb +3 -3
  19. data/app/components/phlex_kit/combobox/combobox_controller.js +17 -9
  20. data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
  21. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +3 -1
  22. data/app/components/phlex_kit/command/command.css +2 -2
  23. data/app/components/phlex_kit/command/command_controller.js +57 -34
  24. data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
  25. data/app/components/phlex_kit/command/command_group.rb +5 -2
  26. data/app/components/phlex_kit/dialog/dialog.css +2 -2
  27. data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
  28. data/app/components/phlex_kit/dialog/dialog_controller.js +9 -0
  29. data/app/components/phlex_kit/drawer/drawer.css +4 -4
  30. data/app/components/phlex_kit/empty/empty.css +1 -1
  31. data/app/components/phlex_kit/field/field.css +2 -1
  32. data/app/components/phlex_kit/form_field/form_field_controller.js +7 -0
  33. data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
  34. data/app/components/phlex_kit/marker/marker.rb +6 -0
  35. data/app/components/phlex_kit/masked_input/masked_input_controller.js +17 -3
  36. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  37. data/app/components/phlex_kit/menubar/menubar_controller.js +22 -1
  38. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
  39. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
  40. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +9 -5
  41. data/app/components/phlex_kit/native_select/native_select.css +4 -2
  42. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  43. data/app/components/phlex_kit/pagination/pagination_next.rb +9 -2
  44. data/app/components/phlex_kit/pagination/pagination_previous.rb +9 -2
  45. data/app/components/phlex_kit/popover/popover_controller.js +9 -1
  46. data/app/components/phlex_kit/resizable/resizable_controller.js +49 -5
  47. data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
  48. data/app/components/phlex_kit/scroll_area/scroll_area.rb +20 -4
  49. data/app/components/phlex_kit/select/select.css +0 -1
  50. data/app/components/phlex_kit/select/select_content.rb +9 -3
  51. data/app/components/phlex_kit/select/select_controller.js +32 -2
  52. data/app/components/phlex_kit/select/select_item.rb +5 -1
  53. data/app/components/phlex_kit/separator/separator.rb +7 -1
  54. data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
  55. data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
  56. data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
  57. data/app/components/phlex_kit/tabs/tabs.css +2 -2
  58. data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
  59. data/app/components/phlex_kit/tabs/tabs_trigger.rb +4 -1
  60. data/app/components/phlex_kit/toast/toast.css +1 -1
  61. data/app/components/phlex_kit/toast/toast_controller.js +6 -1
  62. data/app/components/phlex_kit/toast/toaster_controller.js +19 -5
  63. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
  64. data/app/components/phlex_kit/toggle_group/toggle_group.rb +6 -1
  65. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +8 -0
  66. data/app/components/phlex_kit/tooltip/tooltip.css +2 -2
  67. data/app/components/phlex_kit/typography/heading.rb +6 -4
  68. data/lib/phlex_kit/version.rb +1 -1
  69. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f52a630c21854a2d2823951ef321a7749ff01adf9d5f60b0c3483aa635407aaa
4
- data.tar.gz: c0a594263a95f57bf56b20955054369cc251d58fb6ecbc2bd22b45aa3b4dd278
3
+ metadata.gz: 9f20449d41b77f26b6a4272eb5f4ebd116a225af9bf2dcb6bfec5899a5b117a2
4
+ data.tar.gz: fd83a994ef1cc33452c19b8520934db31750babacb7508511dffb9c41b08a9dd
5
5
  SHA512:
6
- metadata.gz: 5c1b054a1ce8c3c1f01e4530dccdda7e23b013e2e0b3ccd966959d9419b784c462a2da725c9eefbb78b2d71675edc2d553de42745675c768c5621b31c0a41685
7
- data.tar.gz: ee5a88c2c3615582fd51ca3b75d9574aba7dfedadca8e1fe2879370e052b311f2904a6ca3469ceba6fd256392becec583d3ae14c0ccb8df6c6196ab2b7cddd17
6
+ metadata.gz: f08a4051f646822945f9f1bf72b52cfce151509322032dccba9ec7166db4bf5a99bead12adeec4969edf0fc94df5b55dc902424322fa6a351abd18d92c04496b
7
+ data.tar.gz: 83b2a4ea332492c19ce059611e534fc57c79fa39e136e9bd00e2cf535fe09c791f75c0c81bc12056f2678f9a1bcf4bd18bd8246b79cd7377e0f990dbd4472c50
@@ -1,6 +1,14 @@
1
1
  /* Kit utilities used by the interactive controllers (Stimulus toggles these). */
2
2
  .pk-hidden { display: none !important; }
3
3
 
4
+ /* Kit-wide screen-reader-only utility — used by many components
5
+ (breadcrumb, dialog, sheet, sidebar, …), so it lives here rather
6
+ than in any one component's stylesheet. */
7
+ .pk-sr-only {
8
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
9
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
10
+ }
11
+
4
12
  /* Shimmer — shadcn's shimmer util: a highlight band sweeping across text, for
5
13
  loading / "thinking…" states. Pure CSS, built on currentColor: the text keeps
6
14
  its own color (any color) and the highlight derives from it via relative
@@ -15,7 +15,7 @@ module PhlexKit
15
15
  # shadcn ships default/destructive; success/warning are kit extras kept from
16
16
  # ruby_ui, restyled to the same text-tint grammar. `VARIANTS.fetch` fails loud.
17
17
  class Alert < BaseComponent
18
- # variant => extra class appended after the base `ui-alert` (nil = neutral).
18
+ # variant => extra class appended after the base `pk-alert` (nil = neutral).
19
19
  VARIANTS = {
20
20
  default: nil,
21
21
  warning: "warning",
@@ -68,6 +68,10 @@ export default class extends Controller {
68
68
  }
69
69
 
70
70
  keydown(event) {
71
+ // Every open clone has its own document-level listener; with stacked
72
+ // alert dialogs (a trigger inside a panel) only the topmost clone may
73
+ // handle the keyboard, or one Escape would close every layer at once.
74
+ if (!this.#topmost()) return;
71
75
  if (event.key === "Escape") {
72
76
  event.preventDefault();
73
77
  this.dismiss();
@@ -97,6 +101,14 @@ export default class extends Controller {
97
101
  }
98
102
  }
99
103
 
104
+ // Clones are appended as direct <body> children whose direct child is the
105
+ // [role="alertdialog"] panel (source elements only hold a <template>, whose
106
+ // content never matches querySelector). The last such clone is the topmost.
107
+ #topmost() {
108
+ const clones = document.body.querySelectorAll(':scope > [data-controller~="phlex-kit--alert-dialog"] > [role="alertdialog"]');
109
+ return clones.length > 0 && clones[clones.length - 1].parentElement === this.element;
110
+ }
111
+
100
112
  #panel() {
101
113
  return this.element.querySelector('[role="alertdialog"]');
102
114
  }
@@ -142,21 +142,24 @@
142
142
  /* Scrollable, snapping row with an edge fade (horizontal scroll-fade). */
143
143
  .pk-attachment-group {
144
144
  --pk-scroll-fade-size: 2rem;
145
+ /* Gradient directions can't be logical — follow the inline axis by hand. */
146
+ --pk-scroll-fade-dir: to right;
145
147
  display: flex;
146
148
  gap: .5rem;
147
149
  overflow-x: auto;
148
150
  scroll-snap-type: x mandatory;
149
151
  padding: 2px; /* room for card rings */
150
- mask-image: linear-gradient(to right,
152
+ mask-image: linear-gradient(var(--pk-scroll-fade-dir),
151
153
  transparent 0, black var(--pk-scroll-fade-size),
152
154
  black calc(100% - var(--pk-scroll-fade-size)), transparent 100%);
153
155
  }
156
+ .pk-attachment-group:dir(rtl) { --pk-scroll-fade-dir: to left; }
154
157
  .pk-attachment-group[data-at-start] {
155
- mask-image: linear-gradient(to right,
158
+ mask-image: linear-gradient(var(--pk-scroll-fade-dir),
156
159
  black calc(100% - var(--pk-scroll-fade-size)), transparent 100%);
157
160
  }
158
161
  .pk-attachment-group[data-at-end] {
159
- mask-image: linear-gradient(to right, transparent 0, black var(--pk-scroll-fade-size));
162
+ mask-image: linear-gradient(var(--pk-scroll-fade-dir), transparent 0, black var(--pk-scroll-fade-size));
160
163
  }
161
164
  .pk-attachment-group[data-at-start][data-at-end] { mask-image: none; }
162
165
  .pk-attachment-group > .pk-attachment { flex: none; scroll-snap-align: start; }
@@ -29,8 +29,13 @@ export default class extends Controller {
29
29
  update() {
30
30
  const el = this.element
31
31
  if (this.axisValue === "x") {
32
- el.toggleAttribute("data-at-start", el.scrollLeft <= 1)
33
- el.toggleAttribute("data-at-end", el.scrollLeft + el.clientWidth >= el.scrollWidth - 1)
32
+ // Math.abs: in RTL scrollLeft runs 0..negative, so the raw value would
33
+ // read as permanently "at start" abs tracks the logical inline start.
34
+ const scrolled = Math.abs(el.scrollLeft)
35
+ // <= 2, not <= 1: scroll-snap + the 2px ring padding leave the group
36
+ // resting at scrollLeft 2, which must still count as "at start".
37
+ el.toggleAttribute("data-at-start", scrolled <= 2)
38
+ el.toggleAttribute("data-at-end", scrolled + el.clientWidth >= el.scrollWidth - 1)
34
39
  } else {
35
40
  el.toggleAttribute("data-at-start", el.scrollTop <= 1)
36
41
  el.toggleAttribute("data-at-end", el.scrollTop + el.clientHeight >= el.scrollHeight - 1)
@@ -67,8 +67,8 @@
67
67
  modifier, glyph hidden when there's no room (shadcn's AvatarBadge). */
68
68
  .pk-avatar-badge {
69
69
  position: absolute;
70
- right: 0;
71
- bottom: 0;
70
+ inset-inline-end: 0;
71
+ inset-block-end: 0;
72
72
  z-index: 10;
73
73
  display: inline-flex;
74
74
  align-items: center;
@@ -12,7 +12,3 @@
12
12
  .pk-breadcrumb-separator > svg { width: .875rem; height: .875rem; }
13
13
  .pk-breadcrumb-ellipsis { display: flex; height: 1.25rem; width: 1.25rem; align-items: center; justify-content: center; }
14
14
  .pk-breadcrumb-ellipsis > svg { width: 1rem; height: 1rem; }
15
- .pk-sr-only {
16
- position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
17
- overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
18
- }
@@ -14,7 +14,7 @@
14
14
  .pk-message[data-align="end"] .pk-bubble { align-self: flex-end; }
15
15
  .pk-bubble-content {
16
16
  width: fit-content; max-width: 100%; min-width: 0; overflow: hidden;
17
- overflow-wrap: break-word; border: 1px solid transparent; border-radius: .75rem;
17
+ overflow-wrap: break-word; border: 1px solid transparent; border-radius: calc(var(--pk-radius) + 2px);
18
18
  padding: .5rem .75rem; font-size: .875rem; line-height: 1.625;
19
19
  }
20
20
  .pk-bubble[data-align="end"] > .pk-bubble-content { align-self: flex-end; }
@@ -1,8 +1,9 @@
1
1
  module PhlexKit
2
2
  class BubbleContent < BaseComponent
3
- # `as:` is dispatched dynamically — whitelist the sensible text containers
4
- # so it can't reach arbitrary (including private) methods.
5
- AS_TAGS = %i[div p blockquote].freeze
3
+ # `as:` is dispatched dynamically — whitelist the documented containers
4
+ # (incl. the interactive button/a contract bubble.css styles) so it can't
5
+ # reach arbitrary (including private) methods.
6
+ AS_TAGS = %i[div p blockquote button a].freeze
6
7
  def initialize(as: :div, **attrs)
7
8
  @as = as.to_sym
8
9
  raise ArgumentError, "unknown BubbleContent as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})" unless AS_TAGS.include?(@as)
@@ -12,25 +12,25 @@
12
12
 
13
13
  /* horizontal (default) */
14
14
  .pk-button-group:not(.vertical) > :not(:first-child) {
15
- border-top-left-radius: 0;
16
- border-bottom-left-radius: 0;
15
+ border-start-start-radius: 0;
16
+ border-end-start-radius: 0;
17
17
  border-inline-start: 0;
18
18
  }
19
19
  .pk-button-group:not(.vertical) > :not(:last-child) {
20
- border-top-right-radius: 0;
21
- border-bottom-right-radius: 0;
20
+ border-start-end-radius: 0;
21
+ border-end-end-radius: 0;
22
22
  }
23
23
 
24
24
  /* vertical */
25
25
  .pk-button-group.vertical { flex-direction: column; }
26
26
  .pk-button-group.vertical > :not(:first-child) {
27
- border-top-left-radius: 0;
28
- border-top-right-radius: 0;
27
+ border-start-start-radius: 0;
28
+ border-start-end-radius: 0;
29
29
  border-top: 0;
30
30
  }
31
31
  .pk-button-group.vertical > :not(:last-child) {
32
- border-bottom-left-radius: 0;
33
- border-bottom-right-radius: 0;
32
+ border-end-start-radius: 0;
33
+ border-end-end-radius: 0;
34
34
  }
35
35
 
36
36
  /* Nested groups keep their own corners — undo the squaring the parent applied. */
@@ -45,25 +45,25 @@
45
45
  absolutely-positioned panels are untouched. */
46
46
  .pk-button-group:not(.vertical) > :not(:first-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
47
47
  .pk-button-group:not(.vertical) > :not(:first-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
48
- border-top-left-radius: 0;
49
- border-bottom-left-radius: 0;
48
+ border-start-start-radius: 0;
49
+ border-end-start-radius: 0;
50
50
  border-inline-start: 0;
51
51
  }
52
52
  .pk-button-group:not(.vertical) > :not(:last-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
53
53
  .pk-button-group:not(.vertical) > :not(:last-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
54
- border-top-right-radius: 0;
55
- border-bottom-right-radius: 0;
54
+ border-start-end-radius: 0;
55
+ border-end-end-radius: 0;
56
56
  }
57
57
  .pk-button-group.vertical > :not(:first-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
58
58
  .pk-button-group.vertical > :not(:first-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
59
- border-top-left-radius: 0;
60
- border-top-right-radius: 0;
59
+ border-start-start-radius: 0;
60
+ border-start-end-radius: 0;
61
61
  border-top: 0;
62
62
  }
63
63
  .pk-button-group.vertical > :not(:last-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
64
64
  .pk-button-group.vertical > :not(:last-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
65
- border-bottom-left-radius: 0;
66
- border-bottom-right-radius: 0;
65
+ border-end-start-radius: 0;
66
+ border-end-end-radius: 0;
67
67
  }
68
68
  /* .pk-select defaults to width:100%; as a group segment it hugs its trigger
69
69
  and lets siblings (the input) take the leftover flex space — shadcn's
@@ -155,6 +155,14 @@
155
155
  background: var(--pk-accent);
156
156
  }
157
157
  .pk-calendar-cell:has(> .range-cap) { background: var(--pk-accent); }
158
- .pk-calendar-cell:has(> .range-start.range-cap) { border-radius: var(--pk-cell-radius) 0 0 var(--pk-cell-radius); }
159
- .pk-calendar-cell:has(> .range-end.range-cap) { border-radius: 0 var(--pk-cell-radius) var(--pk-cell-radius) 0; }
158
+ .pk-calendar-cell:has(> .range-start.range-cap) {
159
+ border-radius: 0;
160
+ border-start-start-radius: var(--pk-cell-radius);
161
+ border-end-start-radius: var(--pk-cell-radius);
162
+ }
163
+ .pk-calendar-cell:has(> .range-end.range-cap) {
164
+ border-radius: 0;
165
+ border-start-end-radius: var(--pk-cell-radius);
166
+ border-end-end-radius: var(--pk-cell-radius);
167
+ }
160
168
  .pk-calendar-cell:has(> .range-start.range-end) { border-radius: var(--pk-cell-radius); }
@@ -16,14 +16,14 @@
16
16
  transition: transform .3s ease;
17
17
  will-change: transform;
18
18
  }
19
- .pk-carousel.is-horizontal .pk-carousel-track { margin-left: -1rem; }
19
+ .pk-carousel.is-horizontal .pk-carousel-track { margin-inline-start: -1rem; }
20
20
  .pk-carousel.is-vertical .pk-carousel-track { flex-direction: column; margin-top: -1rem; }
21
21
 
22
22
  .pk-carousel-item {
23
23
  min-width: 0;
24
24
  flex: 0 0 100%;
25
25
  }
26
- .pk-carousel.is-horizontal .pk-carousel-item { padding-left: 1rem; }
26
+ .pk-carousel.is-horizontal .pk-carousel-item { padding-inline-start: 1rem; }
27
27
  .pk-carousel.is-vertical .pk-carousel-item { padding-top: 1rem; }
28
28
 
29
29
  /* Edge buttons (an outline icon .pk-button; same specificity, later in the
@@ -34,8 +34,8 @@
34
34
  height: 2rem;
35
35
  border-radius: 9999px;
36
36
  }
37
- .pk-carousel.is-horizontal .pk-carousel-previous { left: -3rem; top: 50%; transform: translateY(-50%); }
38
- .pk-carousel.is-horizontal .pk-carousel-next { right: -3rem; top: 50%; transform: translateY(-50%); }
37
+ .pk-carousel.is-horizontal .pk-carousel-previous { inset-inline-start: -3rem; top: 50%; transform: translateY(-50%); }
38
+ .pk-carousel.is-horizontal .pk-carousel-next { inset-inline-end: -3rem; top: 50%; transform: translateY(-50%); }
39
39
  .pk-carousel.is-vertical .pk-carousel-previous { top: -3rem; left: 50%; transform: translateX(-50%) rotate(90deg); }
40
40
  .pk-carousel.is-vertical .pk-carousel-next { bottom: -3rem; left: 50%; transform: translateX(-50%) rotate(90deg); }
41
41
  .pk-carousel-previous svg, .pk-carousel-next svg { width: 1rem; height: 1rem; }
@@ -5,17 +5,33 @@ import { Controller } from "@hotwired/stimulus"
5
5
  export default class extends Controller {
6
6
  static targets = ["trigger", "source", "successPopover", "errorPopover"]
7
7
 
8
+ connect() {
9
+ // Turbo snapshots at turbo:before-cache, BEFORE disconnect — hide the
10
+ // flash now or a restore shows a permanently stuck "Copied!" popover.
11
+ document.addEventListener("turbo:before-cache", this.beforeCache)
12
+ }
13
+
8
14
  disconnect() {
9
15
  // Don't leave the 1.5s hide timer running into a dead/recycled element
10
16
  // (Turbo can detach and re-attach this subtree mid-flash).
17
+ clearTimeout(this.timer)
18
+ document.removeEventListener("turbo:before-cache", this.beforeCache)
19
+ this.hideAll()
20
+ }
21
+
22
+ beforeCache = () => {
11
23
  clearTimeout(this.timer)
12
24
  this.hideAll()
13
25
  }
14
26
 
15
27
  copy() {
28
+ // A source usually wraps one element (input/code/…), but bare text is
29
+ // legal too — fall back to the source's own text instead of erroring.
16
30
  const el = this.sourceTarget.children[0]
17
- if (!el) { this.show(this.errorPopoverTarget); return }
18
- const text = el.tagName === "INPUT" ? el.value : el.innerText
31
+ const text = el
32
+ ? (el.tagName === "INPUT" ? el.value : el.innerText)
33
+ : this.sourceTarget.textContent.trim()
34
+ if (!text) { this.show(this.errorPopoverTarget); return }
19
35
  navigator.clipboard.writeText(text)
20
36
  .then(() => this.show(this.successPopoverTarget))
21
37
  .catch(() => this.show(this.errorPopoverTarget))
@@ -6,10 +6,19 @@ export default class extends Controller {
6
6
  static values = { open: { type: Boolean, default: false } }
7
7
 
8
8
  connect() {
9
- // The trigger renders aria-expanded from its own `open:` kwarg but can't
10
- // know the content's id at render time wire aria-controls here.
11
- if (this.hasTriggerTarget && this.hasContentTarget && this.contentTarget.id) {
12
- this.triggerTarget.setAttribute("aria-controls", this.contentTarget.id)
9
+ // aria-expanded must live on the element AT reads state from — the
10
+ // focused control, not the plain wrapper div. The trigger wrapper renders
11
+ // aria-expanded server-side (it can't reach into its block), so resolve
12
+ // the nested focusable control here, relocate the state onto it, and keep
13
+ // toggling there. Wrapper-only triggers keep the wrapper as carrier.
14
+ if (this.hasTriggerTarget) {
15
+ this.control = this.triggerTarget.querySelector("button, [role='button'], a[href], input") || this.triggerTarget
16
+ if (this.control !== this.triggerTarget) this.triggerTarget.removeAttribute("aria-expanded")
17
+ // The trigger can't know the content's id at render time — wire
18
+ // aria-controls here.
19
+ if (this.hasContentTarget && this.contentTarget.id) {
20
+ this.control.setAttribute("aria-controls", this.contentTarget.id)
21
+ }
13
22
  }
14
23
  this.openValue ? this.open() : this.close()
15
24
  }
@@ -18,13 +27,13 @@ export default class extends Controller {
18
27
 
19
28
  open() {
20
29
  if (this.hasContentTarget) { this.contentTarget.classList.remove("pk-hidden"); this.openValue = true }
21
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true")
30
+ if (this.control) this.control.setAttribute("aria-expanded", "true")
22
31
  this.element.dataset.state = "open";
23
32
  }
24
33
 
25
34
  close() {
26
35
  if (this.hasContentTarget) { this.contentTarget.classList.add("pk-hidden"); this.openValue = false }
27
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false")
36
+ if (this.control) this.control.setAttribute("aria-expanded", "false")
28
37
  this.element.dataset.state = "closed";
29
38
  }
30
39
  }
@@ -4,8 +4,11 @@ module PhlexKit
4
4
  # control (a <button>, e.g. PhlexKit::Button) inside it for keyboard
5
5
  # operability and semantics, the same composition pattern as
6
6
  # AttachmentTrigger. Pass `open:` matching the parent Collapsible's so
7
- # aria-expanded is correct before JS; the controller keeps it in sync and
8
- # wires aria-controls to the content's id in connect().
7
+ # aria-expanded is correct before JS. The wrapper carries the server-rendered
8
+ # aria-expanded only because the component can't reach into its block; on
9
+ # connect the controller relocates it (plus aria-controls) onto the nested
10
+ # focusable control — AT reads state from the focused element, not the
11
+ # wrapper — falling back to the wrapper when no control is nested.
9
12
  class CollapsibleTrigger < BaseComponent
10
13
  def initialize(open: false, **attrs)
11
14
  @open = open
@@ -152,7 +152,7 @@
152
152
  margin: 0;
153
153
  accent-color: var(--pk-brand);
154
154
  }
155
- .pk-combobox-checkbox { border-radius: .125rem; }
155
+ .pk-combobox-checkbox { border-radius: calc(var(--pk-radius) - 8px); }
156
156
  .pk-combobox-checkbox:disabled, .pk-combobox-radio:disabled { cursor: not-allowed; opacity: .5; }
157
157
  .pk-combobox-checkbox:focus-visible, .pk-combobox-radio:focus-visible {
158
158
  outline: none;
@@ -296,7 +296,7 @@
296
296
  width: .875rem;
297
297
  height: .875rem;
298
298
  border: 0;
299
- border-radius: .125rem;
299
+ border-radius: calc(var(--pk-radius) - 8px);
300
300
  padding: 0;
301
301
  background: transparent;
302
302
  color: var(--pk-muted);
@@ -318,7 +318,7 @@
318
318
  flex: none;
319
319
  margin-inline-start: auto;
320
320
  border: 0;
321
- border-radius: .125rem;
321
+ border-radius: calc(var(--pk-radius) - 8px);
322
322
  padding: 0;
323
323
  background: transparent;
324
324
  color: var(--pk-muted);
@@ -5,9 +5,9 @@ module PhlexKit
5
5
  # toggle-all live in the phlex-kit--combobox controller. Like PhlexKit::Select, the
6
6
  # @floating-ui/dom + native-popover positioning is replaced with CSS (panel
7
7
  # anchored below the trigger, click-outside via window action). Compose
8
- # Combobox(term:) > trigger + Popover(SearchInput + List(ListGroup >
9
- # Item(Checkbox|Radio + text + ItemIndicator)) + EmptyState [+
10
- # ToggleAllCheckbox]). Three trigger flavours: ComboboxTrigger (button +
8
+ # Combobox(term:) > trigger + Popover(SearchInput + List([Item(
9
+ # ToggleAllCheckbox) first +] ListGroup > Item(Checkbox|Radio + text +
10
+ # ItemIndicator) + EmptyState)). Three trigger flavours: ComboboxTrigger (button +
11
11
  # popover search), ComboboxInputTrigger (the field is the filter), and
12
12
  # ComboboxBadgeTrigger (multi-select chips + ComboboxClearButton) — the
13
13
  # latter two shipped unfinished in ruby_ui (no controller support) and are
@@ -174,6 +174,11 @@ export default class extends Controller {
174
174
  this.inputTargets.forEach((input) => {
175
175
  input.parentElement.setAttribute("aria-selected", input.checked ? "true" : "false")
176
176
  })
177
+ // The toggle-all row is an option too (first row inside the listbox) —
178
+ // keep its aria-selected in step with its checkbox.
179
+ if (this.hasToggleAllTarget && this.toggleAllTarget.parentElement.getAttribute("role") === "option") {
180
+ this.toggleAllTarget.parentElement.setAttribute("aria-selected", this.toggleAllTarget.checked ? "true" : "false")
181
+ }
177
182
  }
178
183
 
179
184
  renderBadges(checked) {
@@ -284,7 +289,7 @@ export default class extends Controller {
284
289
  // aria-activedescendant holds an element id; on close it must be removed,
285
290
  // not left pointing at a hidden option.
286
291
  this.clearActiveDescendant()
287
- if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
292
+ if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
288
293
  this.updateTriggerContent() // reflect the selection into an input trigger
289
294
 
290
295
  if (focusWasInside) {
@@ -398,20 +403,23 @@ export default class extends Controller {
398
403
  }
399
404
 
400
405
  focusSelectedInput() {
401
- const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("pk-hidden"))
402
- if (visibleInputs.length === 0) return
406
+ // Walk the option rows themselves, not the inner selection inputs: the
407
+ // toggle-all row is an option without an "input" target and must be
408
+ // arrow-reachable like any other option.
409
+ const visibleItems = this.itemTargets.filter(item => !item.classList.contains("pk-hidden"))
410
+ if (visibleItems.length === 0) return
403
411
 
404
- this.wrapSelectedInputIndex(visibleInputs.length)
412
+ this.wrapSelectedInputIndex(visibleItems.length)
405
413
 
406
- visibleInputs.forEach((input, index) => {
414
+ visibleItems.forEach((item, index) => {
407
415
  if (index == this.selectedItemIndex) {
408
- input.parentElement.ariaCurrent = "true"
416
+ item.ariaCurrent = "true"
409
417
  // Focus stays in the filter field; the highlighted option is exposed
410
418
  // via aria-activedescendant (option ids come from generateItemIds).
411
- this.setActiveDescendant(input.parentElement.id)
412
- input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
419
+ this.setActiveDescendant(item.id)
420
+ item.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
413
421
  } else {
414
- input.parentElement.ariaCurrent = "false"
422
+ item.ariaCurrent = "false"
415
423
  }
416
424
  })
417
425
  }
@@ -26,7 +26,9 @@ module PhlexKit
26
26
  class: "pk-combobox-search-input",
27
27
  data: {
28
28
  phlex_kit__combobox_target: "searchInput",
29
- action: "keyup->phlex-kit--combobox#filterItems search->phlex-kit--combobox#filterItems"
29
+ # `input` covers context-menu paste and IME composition, which
30
+ # fire no keyup/search (mirrors ComboboxInputTrigger).
31
+ action: "keyup->phlex-kit--combobox#filterItems input->phlex-kit--combobox#filterItems search->phlex-kit--combobox#filterItems"
30
32
  }
31
33
  }, @attrs))
32
34
  end
@@ -1,6 +1,8 @@
1
1
  module PhlexKit
2
2
  # "Select all" checkbox for a multi-select combobox — wrap it in a
3
- # PhlexKit::ComboboxItem above the list. Hidden while a filter term is active.
3
+ # PhlexKit::ComboboxItem as the FIRST row inside the ComboboxList (the item
4
+ # carries role="option", which is only valid inside the listbox, and arrow-key
5
+ # nav walks the list's rows). Hidden while a filter term is active.
4
6
  # See combobox.rb.
5
7
  class ComboboxToggleAllCheckbox < BaseComponent
6
8
  def initialize(**attrs)
@@ -8,7 +8,7 @@
8
8
  .pk-command {
9
9
  background: var(--pk-surface);
10
10
  color: var(--pk-text);
11
- border-radius: .75rem;
11
+ border-radius: calc(var(--pk-radius) + 2px);
12
12
  padding: .25rem;
13
13
  }
14
14
  .pk-command-dialog-trigger { display: inline-block; }
@@ -34,7 +34,7 @@
34
34
  width: 100%;
35
35
  max-width: 32rem;
36
36
  border: 1px solid var(--pk-border);
37
- border-radius: .75rem;
37
+ border-radius: calc(var(--pk-radius) + 2px);
38
38
  background: var(--pk-surface);
39
39
  color: var(--pk-text);
40
40
  box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);