phlex_kit 0.8.0 → 0.9.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 (148) 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_content.rb +11 -2
  5. data/app/components/phlex_kit/accordion/accordion_controller.js +23 -4
  6. data/app/components/phlex_kit/alert/alert.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +11 -4
  9. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +123 -6
  10. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +10 -4
  11. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  12. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  13. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  14. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  15. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  16. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  17. data/app/components/phlex_kit/badge/badge.css +4 -4
  18. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  19. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  20. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  21. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  22. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  23. data/app/components/phlex_kit/button/button.css +6 -6
  24. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  25. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  26. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  27. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  28. data/app/components/phlex_kit/calendar/calendar_body.rb +12 -1
  29. data/app/components/phlex_kit/calendar/calendar_controller.js +151 -12
  30. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  31. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  32. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  34. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  35. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  36. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  37. data/app/components/phlex_kit/chart/chart_controller.js +37 -10
  38. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  39. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  40. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  41. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
  42. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  43. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  44. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  45. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  46. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  47. data/app/components/phlex_kit/combobox/combobox_controller.js +67 -11
  48. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  49. data/app/components/phlex_kit/command/command.css +4 -4
  50. data/app/components/phlex_kit/command/command_controller.js +51 -10
  51. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  52. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  53. data/app/components/phlex_kit/command/command_input.rb +6 -2
  54. data/app/components/phlex_kit/command/command_item.rb +4 -1
  55. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  56. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
  57. data/app/components/phlex_kit/context_menu/context_menu_controller.js +69 -2
  58. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
  59. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
  60. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  61. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  62. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  63. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  64. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  65. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  66. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  67. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  68. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  69. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +6 -5
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  72. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
  73. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
  74. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +99 -45
  75. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  76. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
  77. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
  78. data/app/components/phlex_kit/field/field.css +1 -1
  79. data/app/components/phlex_kit/field/field_error.rb +5 -6
  80. data/app/components/phlex_kit/form_field/form_field_controller.js +44 -12
  81. data/app/components/phlex_kit/form_field/form_field_error.rb +8 -1
  82. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  83. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  84. data/app/components/phlex_kit/input/input.css +1 -1
  85. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  86. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  87. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  88. data/app/components/phlex_kit/input_otp/input_otp_controller.js +43 -4
  89. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  90. data/app/components/phlex_kit/item/item.css +1 -1
  91. data/app/components/phlex_kit/item/item_group.rb +4 -2
  92. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  93. data/app/components/phlex_kit/marker/marker.css +1 -1
  94. data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
  95. data/app/components/phlex_kit/masked_input/masked_input_controller.js +34 -9
  96. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  97. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  98. data/app/components/phlex_kit/menubar/menubar_controller.js +119 -12
  99. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  100. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  101. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +7 -2
  102. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  103. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  104. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  105. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +11 -3
  106. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  107. data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
  108. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  109. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  110. data/app/components/phlex_kit/popover/popover.css +1 -1
  111. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  112. data/app/components/phlex_kit/resizable/resizable_controller.js +78 -3
  113. data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
  114. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  115. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  116. data/app/components/phlex_kit/select/select.css +3 -3
  117. data/app/components/phlex_kit/select/select.rb +11 -7
  118. data/app/components/phlex_kit/select/select_controller.js +35 -4
  119. data/app/components/phlex_kit/select/select_item.rb +5 -2
  120. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  121. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  122. data/app/components/phlex_kit/sidebar/sidebar.css +27 -15
  123. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  124. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  125. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  126. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  127. data/app/components/phlex_kit/slider/slider.css +3 -3
  128. data/app/components/phlex_kit/slider/slider.rb +3 -1
  129. data/app/components/phlex_kit/switch/switch.rb +6 -1
  130. data/app/components/phlex_kit/table/table.css +5 -3
  131. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  132. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  133. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  134. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
  135. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +49 -7
  136. data/app/components/phlex_kit/toast/toast.css +32 -14
  137. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  138. data/app/components/phlex_kit/toast/toast_region.rb +19 -5
  139. data/app/components/phlex_kit/toast/toaster_controller.js +20 -6
  140. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  141. data/app/components/phlex_kit/toggle/toggle.rb +9 -3
  142. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  143. data/app/components/phlex_kit/typography/heading.rb +9 -3
  144. data/app/components/phlex_kit/typography/typography.css +4 -3
  145. data/app/javascript/phlex_kit/controllers/index.js +0 -2
  146. data/lib/phlex_kit/version.rb +1 -1
  147. metadata +1 -2
  148. data/app/components/phlex_kit/select/select_item_controller.js +0 -14
@@ -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;
@@ -1,7 +1,7 @@
1
1
  module PhlexKit
2
2
  # Text input with an inline mask. Ported from ruby_ui's RubyUI::MaskedInput —
3
3
  # ruby_ui uses the `maska` JS lib; PhlexKit ships a small dependency-free mask
4
- # controller (#=digit, A=letter, *=any) driven by a data-mask attribute. Swap in
4
+ # controller (#=digit, A=letter, *=any alphanumeric) driven by a data-mask attribute. Swap in
5
5
  # maska by replacing masked_input_controller.js if you need its full feature set.
6
6
  class MaskedInput < BaseComponent
7
7
  def initialize(**attrs) = (@attrs = attrs)
@@ -1,26 +1,51 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--masked-input". Lightweight, dependency-
4
- // free mask driven by a data-mask pattern (# = digit, A = letter, * = any). Swap
5
- // in `maska` (ruby_ui's choice) here if you need a fuller mask engine.
4
+ // free mask driven by a data-mask pattern (# = digit, A = letter, * = any
5
+ // alphanumeric formatting characters are stripped before matching, so *
6
+ // cannot match punctuation; swap in `maska` (ruby_ui's choice) if you need a
7
+ // fuller mask engine).
6
8
  export default class extends Controller {
7
9
  connect() {
8
10
  this.mask = this.element.getAttribute("data-mask") || ""
9
11
  if (!this.mask) return
10
- this.onInput = () => this.apply()
12
+ // Skip IME composition updates — masking mid-composition mangles the text.
13
+ this.onInput = (e) => { if (!e.isComposing) this.apply() }
11
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()
12
17
  }
13
18
  disconnect() { if (this.onInput) this.element.removeEventListener("input", this.onInput) }
14
19
  apply() {
15
- const raw = this.element.value.replace(/[^0-9A-Za-z]/g, "")
20
+ const el = this.element
21
+ // The whole value is rewritten below, which throws the caret to the end —
22
+ // remember how many maskable chars sit before it and re-seat it after
23
+ // the same count in the masked output (mid-field edits keep their place).
24
+ const caret = el.selectionStart ?? el.value.length
25
+ const rawBefore = el.value.slice(0, caret).replace(/[^0-9A-Za-z]/g, "").length
26
+
27
+ const raw = el.value.replace(/[^0-9A-Za-z]/g, "")
16
28
  let out = "", i = 0
17
29
  for (const t of this.mask) {
18
30
  if (i >= raw.length) break
19
- if (t === "#") { if (/\d/.test(raw[i])) out += raw[i++]; else break }
20
- else if (t === "A") { if (/[A-Za-z]/.test(raw[i])) out += raw[i++]; else break }
21
- else if (t === "*") { out += raw[i++] }
22
- 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++ }
39
+ }
40
+ el.value = out
41
+
42
+ if (document.activeElement === el) {
43
+ let pos = 0, seen = 0
44
+ while (pos < out.length && seen < rawBefore) {
45
+ if (/[0-9A-Za-z]/.test(out[pos])) seen++
46
+ pos++
47
+ }
48
+ el.setSelectionRange(pos, pos)
23
49
  }
24
- this.element.value = out
25
50
  }
26
51
  }
@@ -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 = {}) {
@@ -84,11 +116,20 @@ export default class extends Controller {
84
116
  // cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
85
117
  onKeydown(e) {
86
118
  if (!this.openMenu) {
87
- if (e.key !== "ArrowDown") return
88
119
  const menu = e.target.closest("[data-phlex-kit--menubar-target=\"menu\"]")
89
120
  if (!menu) return
90
- e.preventDefault()
91
- this.show(menu, true)
121
+ if (e.key === "ArrowDown") {
122
+ e.preventDefault()
123
+ this.show(menu, true)
124
+ } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
125
+ // Closed bar: left/right move focus between the triggers (APG).
126
+ e.preventDefault()
127
+ const menus = this.menuTargets
128
+ const next = menus[(menus.indexOf(menu) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length]
129
+ const target = next ? this.trigger(next) : null
130
+ target?.focus()
131
+ if (this.roving && target) this.applyRoving(target)
132
+ }
92
133
  return
93
134
  }
94
135
  const items = this.items(this.openMenu)
@@ -114,17 +155,51 @@ export default class extends Controller {
114
155
  e.preventDefault()
115
156
  items[items.length - 1]?.focus()
116
157
  break
158
+ case "Enter":
159
+ case " ":
160
+ // Explicit click so label rows (checkbox/radio) activate — labels
161
+ // have no native keyboard activation (and this gives links Space).
162
+ if (index >= 0) {
163
+ e.preventDefault()
164
+ items[index].click()
165
+ }
166
+ break
117
167
  case "ArrowRight":
118
168
  e.preventDefault()
119
- this.shift(1)
169
+ // On a sub trigger, enter the submenu (focus reveals it via
170
+ // :focus-within) instead of jumping to the next top-level menu.
171
+ if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
172
+ this.enterSub(document.activeElement)
173
+ } else {
174
+ this.shift(1)
175
+ }
120
176
  break
121
- case "ArrowLeft":
177
+ case "ArrowLeft": {
122
178
  e.preventDefault()
123
- this.shift(-1)
179
+ // Inside a submenu, step back to its trigger instead of switching menus.
180
+ const sub = document.activeElement?.closest(".pk-menubar-sub-content")
181
+ if (sub) {
182
+ sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
183
+ } else {
184
+ this.shift(-1)
185
+ }
124
186
  break
187
+ }
125
188
  }
126
189
  }
127
190
 
191
+ // Focus the first row of a sub trigger's panel. The panel opens on
192
+ // :focus-within, so focus the trigger first, then hop into the revealed
193
+ // panel's first visible item.
194
+ enterSub(trigger) {
195
+ trigger.focus()
196
+ const panel = trigger.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-content")
197
+ if (!panel) return
198
+ const first = [...panel.querySelectorAll("[role^=\"menuitem\"]")]
199
+ .find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
200
+ first?.focus()
201
+ }
202
+
128
203
  // Mirrors a checkbox/radio item's native input state onto the item's
129
204
  // aria-checked (radios also reset their group's siblings).
130
205
  syncChecked(e) {
@@ -135,12 +210,38 @@ export default class extends Controller {
135
210
  group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
136
211
  }
137
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
+
138
224
  shift(dir) {
139
225
  const menus = this.menuTargets
140
226
  const index = menus.indexOf(this.openMenu)
141
227
  this.show(menus[(index + dir + menus.length) % menus.length], true)
142
228
  }
143
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
+
144
245
  panel(menu) {
145
246
  return menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")
146
247
  }
@@ -148,7 +249,13 @@ export default class extends Controller {
148
249
  items(menu) {
149
250
  const panel = this.panel(menu)
150
251
  if (!panel) return []
151
- return [...panel.querySelectorAll("[role^=\"menuitem\"]")]
152
- .filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
252
+ // getClientRects also skips rows inside a CLOSED sub panel (its
253
+ // display:none comes from the hover/focus-within CSS, not .pk-hidden)
254
+ // focus() on those silently fails and the roving nav jams there.
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)
153
260
  }
154
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
@@ -6,8 +6,13 @@ module PhlexKit
6
6
  div(**mix({
7
7
  class: "pk-menubar-item pk-menubar-sub-trigger",
8
8
  role: "menuitem",
9
- tabindex: "0",
10
- aria: { haspopup: "menu" }
9
+ # -1: the roving focus reaches it via arrows (focus-within opens the
10
+ # sub); tabindex 0 made it a stray tab stop inside the open panel.
11
+ tabindex: "-1",
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" }
11
16
  }, @attrs)) do
12
17
  block&.call
13
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;
@@ -39,7 +39,10 @@
39
39
  position-anchor: --pk-nav-item;
40
40
  inset: auto;
41
41
  position-area: block-end span-inline-end;
42
- position-try-fallbacks: flip-block;
42
+ /* flip-inline too: a 16rem panel on an item near the right viewport edge
43
+ otherwise clips (same bug class popover fixed in #38). The hover
44
+ bridges span the block-axis gap, which flip-inline doesn't change. */
45
+ position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
43
46
  /* the [popover] UA style sets overflow:auto, which would clip the
44
47
  negative-offset ::before bridge below */
45
48
  overflow: visible;
@@ -54,7 +57,7 @@
54
57
  background: var(--pk-surface);
55
58
  color: var(--pk-text);
56
59
  padding: .25rem;
57
- 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);
58
61
  }
59
62
  .pk-navigation-menu-content .pk-navigation-menu-link {
60
63
  display: flex;
@@ -65,7 +68,12 @@
65
68
  align-items: flex-start;
66
69
  gap: .25rem;
67
70
  }
68
- /* 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. */
69
77
  .pk-navigation-menu-content::before {
70
78
  content: "";
71
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 @@ module PhlexKit
6
6
  button(**mix({
7
7
  type: :button,
8
8
  class: "pk-navigation-menu-trigger",
9
- aria: { haspopup: "menu", expanded: "false" },
9
+ aria: { expanded: "false" }, # no haspopup="menu": the panel deliberately has no menu role (matching Radix/shadcn)
10
10
  data: { action: "click->phlex-kit--menubar#toggle mouseenter->phlex-kit--menubar#switch" }
11
11
  }, @attrs)) do
12
12
  block&.call
@@ -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; } }