phlex_kit 0.8.1 → 0.9.1

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +11 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -2
  4. data/app/components/phlex_kit/accordion/accordion_controller.js +8 -2
  5. data/app/components/phlex_kit/alert/alert.css +3 -3
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +12 -8
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
  10. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  11. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  12. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  13. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  14. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  15. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  16. data/app/components/phlex_kit/badge/badge.css +4 -4
  17. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  18. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  19. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  20. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  21. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  22. data/app/components/phlex_kit/button/button.css +6 -6
  23. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  24. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  25. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  26. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  27. data/app/components/phlex_kit/calendar/calendar_body.rb +3 -0
  28. data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
  29. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  30. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  31. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  32. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  34. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  35. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  36. data/app/components/phlex_kit/chart/chart_controller.js +32 -9
  37. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  38. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  39. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  40. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  41. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  42. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  43. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  44. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  45. data/app/components/phlex_kit/combobox/combobox_controller.js +52 -9
  46. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  47. data/app/components/phlex_kit/command/command.css +4 -4
  48. data/app/components/phlex_kit/command/command_controller.js +51 -10
  49. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  50. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  51. data/app/components/phlex_kit/command/command_input.rb +6 -2
  52. data/app/components/phlex_kit/command/command_item.rb +4 -1
  53. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  54. data/app/components/phlex_kit/context_menu/context_menu_controller.js +18 -1
  55. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  56. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  57. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  58. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  59. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  60. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  61. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  62. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  63. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  64. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -3
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  69. data/app/components/phlex_kit/field/field.css +1 -1
  70. data/app/components/phlex_kit/field/field_error.rb +5 -6
  71. data/app/components/phlex_kit/form_field/form_field_controller.js +35 -16
  72. data/app/components/phlex_kit/form_field/form_field_error.rb +5 -1
  73. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  74. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  75. data/app/components/phlex_kit/input/input.css +1 -1
  76. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  77. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  78. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  79. data/app/components/phlex_kit/input_otp/input_otp_controller.js +13 -0
  80. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  81. data/app/components/phlex_kit/item/item.css +1 -1
  82. data/app/components/phlex_kit/item/item_group.rb +4 -2
  83. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  84. data/app/components/phlex_kit/marker/marker.css +1 -1
  85. data/app/components/phlex_kit/masked_input/masked_input_controller.js +10 -4
  86. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  87. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  88. data/app/components/phlex_kit/menubar/menubar_controller.js +70 -7
  89. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  90. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  91. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +4 -1
  92. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  93. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  94. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  95. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +7 -2
  96. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  97. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  98. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  99. data/app/components/phlex_kit/popover/popover.css +1 -1
  100. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  101. data/app/components/phlex_kit/resizable/resizable_controller.js +13 -0
  102. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  103. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  104. data/app/components/phlex_kit/select/select.css +3 -3
  105. data/app/components/phlex_kit/select/select.rb +4 -1
  106. data/app/components/phlex_kit/select/select_controller.js +25 -0
  107. data/app/components/phlex_kit/select/select_item.rb +5 -1
  108. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  109. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  110. data/app/components/phlex_kit/sidebar/sidebar.css +8 -8
  111. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  115. data/app/components/phlex_kit/slider/slider.css +1 -1
  116. data/app/components/phlex_kit/slider/slider.rb +3 -1
  117. data/app/components/phlex_kit/switch/switch.rb +6 -1
  118. data/app/components/phlex_kit/table/table.css +5 -3
  119. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  120. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  121. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  122. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +2 -2
  123. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
  124. data/app/components/phlex_kit/toast/toast.css +32 -14
  125. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  126. data/app/components/phlex_kit/toast/toast_region.rb +13 -3
  127. data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
  128. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  129. data/app/components/phlex_kit/toggle/toggle.rb +6 -2
  130. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  131. data/app/components/phlex_kit/typography/heading.rb +9 -3
  132. data/app/components/phlex_kit/typography/typography.css +4 -3
  133. data/lib/phlex_kit/version.rb +1 -1
  134. metadata +1 -1
@@ -1,7 +1,9 @@
1
1
  module PhlexKit
2
- # Divider between OTP groups. See input_otp.rb.
2
+ # Divider between OTP groups. See input_otp.rb. Purely decorative — the dash
3
+ # is aria-hidden (no separator role) so AT doesn't announce a stray "-"
4
+ # between the digit fields.
3
5
  class InputOtpSeparator < BaseComponent
4
6
  def initialize(**attrs) = (@attrs = attrs)
5
- def view_template = div(**mix({ class: "pk-input-otp-separator", role: "separator" }, @attrs)) { "-" }
7
+ def view_template = div(**mix({ class: "pk-input-otp-separator", aria_hidden: "true" }, @attrs)) { "-" }
6
8
  end
7
9
  end
@@ -60,7 +60,7 @@ a.pk-item:hover { background: var(--pk-surface-2); }
60
60
  color: var(--pk-muted);
61
61
  font-size: .875rem;
62
62
  line-height: 1.5;
63
- text-align: left;
63
+ text-align: start;
64
64
  overflow: hidden;
65
65
  text-overflow: ellipsis;
66
66
  }
@@ -1,7 +1,9 @@
1
1
  module PhlexKit
2
- # Stack of Items. See item.rb.
2
+ # Stack of Items. See item.rb. No role="list": Items carry no
3
+ # role="listitem" (an href Item is an <a> that must keep its link role), so
4
+ # claiming a list here made AT announce an empty list.
3
5
  class ItemGroup < BaseComponent
4
6
  def initialize(**attrs) = (@attrs = attrs)
5
- def view_template(&) = div(**mix({ class: "pk-item-group", role: "list" }, @attrs), &)
7
+ def view_template(&) = div(**mix({ class: "pk-item-group" }, @attrs), &)
6
8
  end
7
9
  end
@@ -4,7 +4,9 @@ module PhlexKit
4
4
  class ItemSeparator < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template
7
- div(**mix({ class: "pk-item-separator", role: "separator", aria: { hidden: true } }, @attrs))
7
+ # Purely decorative — aria-hidden only (role="separator" would
8
+ # contradict aria-hidden and confuse AT).
9
+ div(**mix({ class: "pk-item-separator", aria: { hidden: true } }, @attrs))
8
10
  end
9
11
  end
10
12
  end
@@ -10,7 +10,7 @@
10
10
  min-height: 1rem;
11
11
  color: var(--pk-muted);
12
12
  font-size: .875rem;
13
- text-align: left;
13
+ text-align: start;
14
14
  }
15
15
  a.pk-marker, button.pk-marker {
16
16
  cursor: pointer;
@@ -12,6 +12,8 @@ export default class extends Controller {
12
12
  // Skip IME composition updates — masking mid-composition mangles the text.
13
13
  this.onInput = (e) => { if (!e.isComposing) this.apply() }
14
14
  this.element.addEventListener("input", this.onInput)
15
+ // A server-prefilled value renders unmasked — format it once up front.
16
+ if (this.element.value) this.apply()
15
17
  }
16
18
  disconnect() { if (this.onInput) this.element.removeEventListener("input", this.onInput) }
17
19
  apply() {
@@ -26,10 +28,14 @@ export default class extends Controller {
26
28
  let out = "", i = 0
27
29
  for (const t of this.mask) {
28
30
  if (i >= raw.length) break
29
- if (t === "#") { if (/\d/.test(raw[i])) out += raw[i++]; else break }
30
- else if (t === "A") { if (/[A-Za-z]/.test(raw[i])) out += raw[i++]; else break }
31
- else if (t === "*") { out += raw[i++] }
32
- else { out += t; if (raw[i] === t) i++ }
31
+ if (t === "#" || t === "A" || t === "*") {
32
+ // Skip wrong-class characters instead of aborting the fill
33
+ // "12a34" against ##/## must yield "12/34", not "12".
34
+ const re = t === "#" ? /\d/ : t === "A" ? /[A-Za-z]/ : /[0-9A-Za-z]/
35
+ while (i < raw.length && !re.test(raw[i])) i++
36
+ if (i >= raw.length) break
37
+ out += raw[i++]
38
+ } else { out += t; if (raw[i] === t) i++ }
33
39
  }
34
40
  el.value = out
35
41
 
@@ -30,6 +30,7 @@
30
30
  }
31
31
  .pk-menubar-trigger:hover,
32
32
  .pk-menubar-trigger[aria-expanded="true"] { background: var(--pk-surface-2); }
33
+ .pk-menubar-trigger:focus-visible { box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent); }
33
34
  .pk-menubar-content {
34
35
  position: fixed;
35
36
  position-anchor: --pk-menubar-menu;
@@ -46,7 +47,7 @@
46
47
  background: var(--pk-surface);
47
48
  color: var(--pk-text);
48
49
  padding: .25rem;
49
- box-shadow: 0 8px 24px rgb(0 0 0 / .3);
50
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
50
51
  }
51
52
  /* Hover bridge over the trigger→panel gap — both sides, since a flip
52
53
  fallback puts the gap below the panel instead of above. */
@@ -82,7 +83,7 @@
82
83
  .pk-menubar-item svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
83
84
  .pk-menubar-item:hover, .pk-menubar-item:focus { background: var(--pk-accent); }
84
85
  .pk-menubar-item[data-disabled="true"] { pointer-events: none; opacity: .5; }
85
- .pk-menubar-item.inset { padding-left: 1.75rem; }
86
+ .pk-menubar-item.inset { padding-inline-start: 1.75rem; }
86
87
  .pk-menubar-item.destructive { color: var(--pk-red); }
87
88
  .pk-menubar-item.destructive > svg { color: var(--pk-red); }
88
89
  .pk-menubar-item.destructive:hover,
@@ -90,9 +91,9 @@
90
91
  background: color-mix(in oklab, var(--pk-red) 12%, transparent);
91
92
  color: var(--pk-red);
92
93
  }
93
- .pk-menubar-shortcut { margin-left: auto; font-size: .75rem; color: var(--pk-muted); letter-spacing: .1em; }
94
+ .pk-menubar-shortcut { margin-inline-start: auto; font-size: .75rem; color: var(--pk-muted); letter-spacing: .1em; }
94
95
  .pk-menubar-label { padding: .25rem .375rem; font-size: .875rem; font-weight: 500; }
95
- .pk-menubar-label.inset { padding-left: 1.75rem; }
96
+ .pk-menubar-label.inset { padding-inline-start: 1.75rem; }
96
97
  .pk-menubar-separator { height: 1px; margin: .25rem -0.25rem; background: var(--pk-border); }
97
98
  .pk-menubar-group { display: contents; }
98
99
 
@@ -105,10 +106,10 @@
105
106
  pointer-events: none;
106
107
  }
107
108
  .pk-menubar-checkbox-item,
108
- .pk-menubar-radio-item { position: relative; padding-left: 1.75rem; }
109
+ .pk-menubar-radio-item { position: relative; padding-inline-start: 1.75rem; }
109
110
  .pk-menubar-item-indicator {
110
111
  position: absolute;
111
- left: .375rem;
112
+ inset-inline-start: .375rem;
112
113
  display: inline-flex;
113
114
  align-items: center;
114
115
  justify-content: center;
@@ -120,14 +121,14 @@
120
121
  .pk-menubar-radio-item .pk-menubar-item-indicator svg { width: .5rem; height: .5rem; }
121
122
  .pk-menubar-item:has(.pk-menubar-item-input:checked) .pk-menubar-item-indicator { visibility: visible; }
122
123
  .pk-menubar-item:has(.pk-menubar-item-input:focus-visible) {
123
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
124
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
124
125
  }
125
126
 
126
127
  /* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS.
127
128
  The row is its own scoped anchor; the panel flips to the other side at
128
129
  the viewport right edge. */
129
130
  .pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
130
- .pk-menubar-sub-chevron { margin-left: auto; width: 1rem; height: 1rem; }
131
+ .pk-menubar-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
131
132
  .pk-menubar-sub-content {
132
133
  position: fixed;
133
134
  position-anchor: --pk-menubar-sub;
@@ -148,7 +149,7 @@
148
149
  background: var(--pk-surface);
149
150
  color: var(--pk-text);
150
151
  padding: .25rem;
151
- box-shadow: 0 8px 24px rgb(0 0 0 / .3);
152
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
152
153
  }
153
154
  .pk-menubar-sub:hover > .pk-menubar-sub-content,
154
155
  .pk-menubar-sub:focus-within > .pk-menubar-sub-content { display: block; }
@@ -1,9 +1,15 @@
1
1
  module PhlexKit
2
2
  # Dropdown panel of a MenubarMenu. See menubar.rb.
3
3
  class MenubarContent < BaseComponent
4
- def initialize(**attrs) = (@attrs = attrs)
4
+ def initialize(**attrs)
5
+ # Default id so the controller can wire the trigger's aria-controls to
6
+ # this panel in menuTargetConnected (same pattern as accordion).
7
+ @id = attrs.delete(:id) || "pk-menubar-content-#{SecureRandom.hex(4)}"
8
+ @attrs = attrs
9
+ end
10
+
5
11
  def view_template(&)
6
- div(**mix({ class: "pk-menubar-content", popover: "manual", role: "menu" }, @attrs), &)
12
+ div(**mix({ id: @id, class: "pk-menubar-content", popover: "manual", role: "menu" }, @attrs), &)
7
13
  end
8
14
  end
9
15
  end
@@ -11,6 +11,8 @@ import { Controller } from "@hotwired/stimulus"
11
11
  // (skipping [data-disabled]); ArrowLeft/ArrowRight jump to the sibling menus;
12
12
  // Escape closes and returns focus to the trigger.
13
13
  // Connects to data-controller="phlex-kit--menubar"
14
+ let uid = 0
15
+
14
16
  export default class extends Controller {
15
17
  static targets = ["menu"]
16
18
 
@@ -21,13 +23,38 @@ export default class extends Controller {
21
23
  return this.menuTargets.find((menu) => this.panel(menu)?.matches(":popover-open")) ?? null
22
24
  }
23
25
 
26
+ initialize() {
27
+ // menuTargetConnected fires BEFORE connect() — state it reads must be
28
+ // set here. Roving tabindex applies only in menubar mode: a nav's
29
+ // triggers and links stay natural tab stops.
30
+ this.roving = this.element.dataset.hoverOpen === undefined
31
+ }
32
+
33
+ // Pair each trigger with its panel for AT (aria-controls), and fold the
34
+ // trigger into the roving tabindex (first trigger 0, rest -1).
35
+ menuTargetConnected(menu) {
36
+ const trigger = this.trigger(menu)
37
+ const panel = this.panel(menu)
38
+ if (trigger && panel) {
39
+ if (!panel.id) panel.id = `pk-menu-panel-${++uid}`
40
+ trigger.setAttribute("aria-controls", panel.id)
41
+ }
42
+ if (this.roving) this.applyRoving()
43
+ }
44
+
45
+ menuTargetDisconnected() {
46
+ if (this.roving) this.applyRoving()
47
+ }
48
+
24
49
  disconnect() {
25
50
  clearTimeout(this.graceTimer)
26
51
  }
27
52
 
28
53
  toggle(e) {
29
54
  const menu = e.currentTarget.closest("[data-phlex-kit--menubar-target=\"menu\"]")
30
- this.openMenu === menu ? this.close() : this.show(menu, true)
55
+ // detail 0 = keyboard-activated click (Enter/Space): only then does the
56
+ // first item get force-focused — a mouse open leaves focus on the trigger.
57
+ this.openMenu === menu ? this.close() : this.show(menu, e.detail === 0)
31
58
  }
32
59
 
33
60
  // Hover: switches between menus while one is open (menubar), or opens
@@ -58,10 +85,15 @@ export default class extends Controller {
58
85
  show(menu, focus = false) {
59
86
  if (this.openMenu !== menu) {
60
87
  this.close()
61
- this.panel(menu)?.showPopover()
62
- menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "true")
88
+ if (this.panel(menu)) {
89
+ this.panel(menu).showPopover()
90
+ menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "true")
91
+ }
63
92
  }
64
- if (focus) this.items(menu)[0]?.focus()
93
+ if (this.roving) this.applyRoving(this.trigger(menu))
94
+ // No panel items (e.g. a panel-less nav link item reached via arrows):
95
+ // land on the trigger itself instead of stranding focus.
96
+ if (focus) (this.items(menu)[0] ?? this.trigger(menu))?.focus()
65
97
  }
66
98
 
67
99
  close(opts = {}) {
@@ -94,7 +126,9 @@ export default class extends Controller {
94
126
  e.preventDefault()
95
127
  const menus = this.menuTargets
96
128
  const next = menus[(menus.indexOf(menu) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length]
97
- next?.querySelector("[aria-expanded], a, button, [tabindex]")?.focus()
129
+ const target = next ? this.trigger(next) : null
130
+ target?.focus()
131
+ if (this.roving && target) this.applyRoving(target)
98
132
  }
99
133
  return
100
134
  }
@@ -176,12 +210,38 @@ export default class extends Controller {
176
210
  group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
177
211
  }
178
212
 
213
+ // Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
214
+ // sub trigger's aria-expanded. rAF: on mouseleave/focusout the pseudo-class
215
+ // state isn't settled until the event finishes dispatching.
216
+ syncSub(e) {
217
+ const sub = e.currentTarget
218
+ requestAnimationFrame(() => {
219
+ sub.querySelector(":scope > [aria-haspopup]")
220
+ ?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
221
+ })
222
+ }
223
+
179
224
  shift(dir) {
180
225
  const menus = this.menuTargets
181
226
  const index = menus.indexOf(this.openMenu)
182
227
  this.show(menus[(index + dir + menus.length) % menus.length], true)
183
228
  }
184
229
 
230
+ // The bar's single tab stop (APG menubar): `current` keeps tabindex 0,
231
+ // every other trigger gets -1.
232
+ applyRoving(current = null) {
233
+ const triggers = this.menuTargets.map((menu) => this.trigger(menu)).filter(Boolean)
234
+ if (triggers.length === 0) return
235
+ if (!current || !triggers.includes(current)) {
236
+ current = triggers.find((t) => t.getAttribute("tabindex") === "0") ?? triggers[0]
237
+ }
238
+ triggers.forEach((t) => t.setAttribute("tabindex", t === current ? "0" : "-1"))
239
+ }
240
+
241
+ trigger(menu) {
242
+ return menu?.querySelector("[aria-expanded], a, button, [tabindex]")
243
+ }
244
+
185
245
  panel(menu) {
186
246
  return menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")
187
247
  }
@@ -192,7 +252,10 @@ export default class extends Controller {
192
252
  // getClientRects also skips rows inside a CLOSED sub panel (its
193
253
  // display:none comes from the hover/focus-within CSS, not .pk-hidden) —
194
254
  // focus() on those silently fails and the roving nav jams there.
195
- return [...panel.querySelectorAll("[role^=\"menuitem\"]")]
196
- .filter((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
255
+ // Nav panels hold plain links (no menu roles, matching Radix/shadcn)
256
+ // fall back to them so arrow keys navigate there too.
257
+ const rows = [...panel.querySelectorAll("[role^=\"menuitem\"]")]
258
+ const list = rows.length ? rows : [...panel.querySelectorAll("a[href], button")]
259
+ return list.filter((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
197
260
  }
198
261
  }
@@ -3,7 +3,10 @@ module PhlexKit
3
3
  class MenubarMenu < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
5
  def view_template(&)
6
- div(**mix({ class: "pk-menubar-menu", data: { phlex_kit__menubar_target: "menu" } }, @attrs), &)
6
+ # role="none": role="menubar" only allows menuitem children in the
7
+ # accessibility tree — this layout wrapper must be presentational so
8
+ # the tree flattens to menubar > menuitem (axe: required-children).
9
+ div(**mix({ class: "pk-menubar-menu", role: "none", data: { phlex_kit__menubar_target: "menu" } }, @attrs), &)
7
10
  end
8
11
  end
9
12
  end
@@ -4,7 +4,12 @@ module PhlexKit
4
4
  class MenubarSub < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-menubar-sub" }, @attrs), &)
7
+ # syncSub mirrors the CSS-driven open state (:hover / :focus-within)
8
+ # onto the sub trigger's aria-expanded.
9
+ div(**mix({
10
+ class: "pk-menubar-sub",
11
+ data: { action: "mouseenter->phlex-kit--menubar#syncSub mouseleave->phlex-kit--menubar#syncSub focusin->phlex-kit--menubar#syncSub focusout->phlex-kit--menubar#syncSub" }
12
+ }, @attrs), &)
8
13
  end
9
14
  end
10
15
  end
@@ -9,7 +9,10 @@ module PhlexKit
9
9
  # -1: the roving focus reaches it via arrows (focus-within opens the
10
10
  # sub); tabindex 0 made it a stray tab stop inside the open panel.
11
11
  tabindex: "-1",
12
- aria: { haspopup: "menu" }
12
+ # expanded starts false; the sub is revealed by CSS (:hover /
13
+ # :focus-within), so MenubarSub's syncSub actions mirror that state
14
+ # onto this attribute.
15
+ aria: { haspopup: "menu", expanded: "false" }
13
16
  }, @attrs)) do
14
17
  block&.call
15
18
  render Icon.new(:chevron_right, size: nil, class: "pk-menubar-sub-chevron")
@@ -11,7 +11,7 @@
11
11
  opacity: 0;
12
12
  pointer-events: none;
13
13
  transition: opacity .15s ease, transform .15s ease;
14
- box-shadow: 0 2px 8px rgb(0 0 0 / .25);
14
+ box-shadow: 0 2px 8px color-mix(in srgb, var(--pk-shadow-color) 25%, transparent);
15
15
  }
16
16
  .pk-message-scroller-jump[data-active="true"] {
17
17
  opacity: 1;
@@ -65,8 +65,19 @@ export default class extends Controller {
65
65
  });
66
66
  }
67
67
 
68
+ // The MutationObserver only sees DOM changes — late image loads and
69
+ // viewport resizes also move the live edge, so keep the pin through both.
70
+ this.onContentGrowth = this.onContentGrowth.bind(this);
71
+ this.resizeObserver = new ResizeObserver(this.onContentGrowth);
72
+ if (this.hasContentTarget) this.resizeObserver.observe(this.contentTarget);
73
+ if (this.hasViewportTarget) {
74
+ this.resizeObserver.observe(this.viewportTarget);
75
+ // load doesn't bubble — capture catches images/media inside messages.
76
+ this.viewportTarget.addEventListener("load", this.onContentGrowth, true);
77
+ }
78
+
68
79
  // Apply the opening position after layout settles.
69
- requestAnimationFrame(() => {
80
+ this.initialFrame = requestAnimationFrame(() => {
70
81
  this.applyDefaultPosition();
71
82
  this.updateButton();
72
83
  });
@@ -78,9 +89,22 @@ export default class extends Controller {
78
89
  this.viewportTarget.removeEventListener("wheel", this.onWheel);
79
90
  this.viewportTarget.removeEventListener("touchstart", this.onTouchStart);
80
91
  this.viewportTarget.removeEventListener("keydown", this.onKeydown);
92
+ this.viewportTarget.removeEventListener("load", this.onContentGrowth, true);
81
93
  }
82
94
  this.observer?.disconnect();
95
+ this.resizeObserver?.disconnect();
83
96
  if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
97
+ if (this.initialFrame) cancelAnimationFrame(this.initialFrame);
98
+ }
99
+
100
+ // Content/viewport geometry changed (image decoded, panel resized, …):
101
+ // re-pin to the live edge when following, otherwise just refresh the button.
102
+ onContentGrowth() {
103
+ if (this.programmatic) return;
104
+ if (this.autoScrollValue && this.following && !this.isAtEnd()) {
105
+ this.scrollToEnd("auto");
106
+ }
107
+ this.updateButton();
84
108
  }
85
109
 
86
110
  // --- Reader intent -------------------------------------------------------
@@ -91,17 +115,37 @@ export default class extends Controller {
91
115
  this.updateButton();
92
116
  }
93
117
 
94
- // Any upward wheel is a deliberate move away from the live edge.
118
+ // Any upward wheel is a deliberate move away from the live edge. Any wheel
119
+ // during a programmatic scroll hands control straight back to the reader.
95
120
  onWheel(event) {
121
+ if (this.programmatic) {
122
+ this.interrupt();
123
+ return;
124
+ }
96
125
  if (event.deltaY < 0) this.release();
97
126
  }
98
127
 
99
128
  onTouchStart() {
100
129
  // A touch that turns into an upward drag surfaces through onScroll; this
101
130
  // just makes the release feel immediate when the reader grabs the list.
131
+ if (this.programmatic) {
132
+ this.interrupt();
133
+ return;
134
+ }
102
135
  if (!this.isAtEnd()) this.release();
103
136
  }
104
137
 
138
+ // Cancel an in-flight programmatic scroll and release the pin — the
139
+ // reader's wheel/touch always wins over our 300ms animation.
140
+ interrupt() {
141
+ if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
142
+ this.programmatic = false;
143
+ this.element.removeAttribute("data-autoscrolling");
144
+ this.viewportTarget?.removeAttribute("data-autoscrolling");
145
+ this.following = false;
146
+ this.updateButton();
147
+ }
148
+
105
149
  onKeydown(event) {
106
150
  const navKeys = ["ArrowUp", "PageUp", "Home", "ArrowDown", "PageDown", "End", " "];
107
151
  if (navKeys.includes(event.key)) this.release();
@@ -61,7 +61,7 @@
61
61
  .pk-native-select-icon {
62
62
  position: absolute;
63
63
  top: 50%;
64
- right: .625rem;
64
+ inset-inline-end: .625rem;
65
65
  transform: translateY(-50%);
66
66
  display: flex;
67
67
  pointer-events: none;
@@ -57,7 +57,7 @@
57
57
  background: var(--pk-surface);
58
58
  color: var(--pk-text);
59
59
  padding: .25rem;
60
- box-shadow: 0 8px 24px rgb(0 0 0 / .3);
60
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
61
61
  }
62
62
  .pk-navigation-menu-content .pk-navigation-menu-link {
63
63
  display: flex;
@@ -68,7 +68,12 @@
68
68
  align-items: flex-start;
69
69
  gap: .25rem;
70
70
  }
71
- /* Gap bridges on both sides — a flip fallback puts the gap below. */
71
+ /* Gap bridges on both sides — a flip fallback puts the gap below. They only
72
+ exist while the panel is open (the closed panel is display:none) and are
73
+ already sized to the actual gap; the ~7px hit-area spill on the non-gap
74
+ side is the accepted cost: CSS has no selector for which position-try
75
+ fallback applied, so we can't scope the bridge to the active side, and
76
+ pointer-events:none would defeat the mouseleave continuity they exist for. */
72
77
  .pk-navigation-menu-content::before {
73
78
  content: "";
74
79
  position: absolute;
@@ -15,7 +15,12 @@ module PhlexKit
15
15
  data: {
16
16
  controller: "phlex-kit--menubar",
17
17
  hover_open: true,
18
- action: "click@window->phlex-kit--menubar#onClickOutside keydown.esc->phlex-kit--menubar#close mouseleave->phlex-kit--menubar#closeSoon"
18
+ # onKeydown (not keydown.esc->close): the shared handler drives
19
+ # arrow-key navigation over the panels AND closes on Escape with
20
+ # focus returned to the trigger (bare #close as an action received
21
+ # the event as `opts`, so refocus never happened — focus fell to
22
+ # <body> when the panel hid).
23
+ action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown mouseleave->phlex-kit--menubar#closeSoon"
19
24
  }
20
25
  }, @attrs), &)
21
26
  end
@@ -6,7 +6,7 @@
6
6
  .pk-pagination-content { display: flex; flex-direction: row; align-items: center; gap: .125rem; list-style: none; margin: 0; padding: 0; }
7
7
  .pk-pagination-ellipsis { display: flex; height: 2rem; width: 2rem; align-items: center; justify-content: center; }
8
8
  .pk-pagination-ellipsis > svg { width: 1rem; height: 1rem; }
9
- .pk-button.pk-pagination-previous { padding-left: .375rem; }
10
- .pk-button.pk-pagination-next { padding-right: .375rem; }
9
+ .pk-button.pk-pagination-previous { padding-inline-start: .375rem; }
10
+ .pk-button.pk-pagination-next { padding-inline-end: .375rem; }
11
11
  .pk-pagination-label { display: none; }
12
12
  @media (min-width: 640px) { .pk-pagination-label { display: block; } }
@@ -3,7 +3,9 @@ module PhlexKit
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template
5
5
  li do
6
- span(**mix({ class: "pk-pagination-ellipsis", aria: { hidden: true } }, @attrs)) do
6
+ # No aria-hidden on the wrapper it would swallow the sr-only text.
7
+ # The Icon renders its own aria-hidden="true"; only the glyph is hidden.
8
+ span(**mix({ class: "pk-pagination-ellipsis" }, @attrs)) do
7
9
  render Icon.new(:ellipsis, size: nil)
8
10
  span(class: "pk-sr-only") { "More pages" }
9
11
  end
@@ -21,7 +21,7 @@
21
21
  border-radius: var(--pk-radius);
22
22
  background: var(--pk-surface); color: var(--pk-text); padding: .625rem;
23
23
  font-size: .875rem;
24
- box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
24
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
25
25
  }
26
26
  /* display must be gated on :popover-open — an unconditional author
27
27
  `display: flex` would beat the UA's [popover] { display: none }. */
@@ -7,6 +7,8 @@
7
7
  position: relative;
8
8
  flex: none;
9
9
  background: var(--pk-border);
10
+ /* without this, touch drags scroll the page instead of resizing */
11
+ touch-action: none;
10
12
  }
11
13
  .pk-resizable-group.horizontal > .pk-resizable-handle { width: 1px; cursor: col-resize; }
12
14
  .pk-resizable-group.vertical > .pk-resizable-handle { height: 1px; cursor: row-resize; }
@@ -19,7 +21,7 @@
19
21
  }
20
22
  .pk-resizable-handle:focus-visible {
21
23
  outline: none;
22
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
24
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
23
25
  }
24
26
 
25
27
  /* Visible grip pill (their withHandle / .cn-resizable-handle-icon). */
@@ -11,6 +11,17 @@ export default class extends Controller {
11
11
  static targets = ["panel", "handle"]
12
12
  static values = { direction: { type: String, default: "horizontal" } }
13
13
 
14
+ disconnect() {
15
+ // a disconnect mid-drag must drop the handle's move/up listeners
16
+ if (this._activeDrag) {
17
+ const { handle, onMove, onUp } = this._activeDrag
18
+ handle.removeEventListener("pointermove", onMove)
19
+ handle.removeEventListener("pointerup", onUp)
20
+ handle.removeEventListener("pointercancel", onUp)
21
+ this._activeDrag = null
22
+ }
23
+ }
24
+
14
25
  handleTargetConnected(handle) {
15
26
  handle.setAttribute("aria-orientation", this.directionValue === "horizontal" ? "vertical" : "horizontal")
16
27
  handle.setAttribute("aria-valuemin", "0")
@@ -51,10 +62,12 @@ export default class extends Controller {
51
62
  handle.removeEventListener("pointermove", onMove)
52
63
  handle.removeEventListener("pointerup", onUp)
53
64
  handle.removeEventListener("pointercancel", onUp)
65
+ this._activeDrag = null
54
66
  }
55
67
  handle.addEventListener("pointermove", onMove)
56
68
  handle.addEventListener("pointerup", onUp)
57
69
  handle.addEventListener("pointercancel", onUp)
70
+ this._activeDrag = { handle, onMove, onUp } // for disconnect() cleanup
58
71
  }
59
72
 
60
73
  // Arrow keys resize by 5% of the pair per press (matching the drag axis);
@@ -10,7 +10,9 @@ module PhlexKit
10
10
  end
11
11
 
12
12
  def view_template(&)
13
- style = @default_size ? "flex-grow: #{Kernel.format("%g", @default_size)}" : nil
13
+ # Trailing ";" matters: Phlex's mix joins string attrs with a space, so
14
+ # a caller style: would otherwise fuse into one invalid declaration.
15
+ style = @default_size ? "flex-grow: #{Kernel.format("%g", @default_size)};" : nil
14
16
  div(**mix({
15
17
  class: "pk-resizable-panel",
16
18
  style: style,
@@ -1,7 +1,9 @@
1
1
  module PhlexKit
2
2
  # Styled scroll container, ported from shadcn/ui's ScrollArea. Radix's custom
3
3
  # scrollbars are replaced with native thin scrollbars themed via CSS — no JS.
4
- # Constrain it with a height/width from the caller. `.pk-scroll-area`
4
+ # Constrain it with a height/width from the caller. tabindex=0 makes the
5
+ # scrollable region keyboard-focusable (WCAG); role=region gives that focus
6
+ # stop a role — pass `aria: { label: ... }` to name it. `.pk-scroll-area`
5
7
  # (scroll_area.css).
6
8
  class ScrollArea < BaseComponent
7
9
  def initialize(**attrs)
@@ -9,7 +11,7 @@ module PhlexKit
9
11
  end
10
12
 
11
13
  def view_template(&)
12
- div(**mix({ class: "pk-scroll-area", tabindex: "0" }, @attrs), &)
14
+ div(**mix({ class: "pk-scroll-area", tabindex: "0", role: "region" }, @attrs), &)
13
15
  end
14
16
  end
15
17
  end
@@ -44,7 +44,7 @@
44
44
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
45
45
  }
46
46
  .pk-select-trigger:disabled { cursor: not-allowed; opacity: .5; }
47
- .pk-select-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-left: .5rem; opacity: .5; }
47
+ .pk-select-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-inline-start: .5rem; opacity: .5; }
48
48
  .pk-select-value { overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
49
49
 
50
50
  /* Dropdown panel — anchored directly below the trigger, flips above at the
@@ -75,7 +75,7 @@
75
75
  background: var(--pk-surface);
76
76
  color: var(--pk-text);
77
77
  padding: .25rem;
78
- box-shadow: 0 4px 16px rgba(0, 0, 0, .4);
78
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
79
79
  }
80
80
 
81
81
  .pk-select-label { margin: 0; padding: .25rem .375rem; font-size: .75rem; font-weight: 400; color: var(--pk-muted); }
@@ -102,7 +102,7 @@
102
102
  .pk-select-item-check {
103
103
  visibility: hidden;
104
104
  position: absolute;
105
- right: .5rem;
105
+ inset-inline-end: .5rem;
106
106
  display: flex;
107
107
  align-items: center;
108
108
  justify-content: center;