phlex_kit 0.13.0 → 0.14.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
  3. data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +1 -1
  4. data/app/components/phlex_kit/accordion/accordion_trigger.rb +1 -1
  5. data/app/components/phlex_kit/alert/alert.rb +5 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
  8. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
  9. data/app/components/phlex_kit/attachment/attachment.css +7 -1
  10. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
  11. data/app/components/phlex_kit/avatar/avatar.css +4 -0
  12. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
  13. data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
  16. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  17. data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
  18. data/app/components/phlex_kit/calendar/calendar.css +5 -0
  19. data/app/components/phlex_kit/calendar/calendar_controller.js +26 -8
  20. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
  21. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
  22. data/app/components/phlex_kit/carousel/carousel.css +7 -0
  23. data/app/components/phlex_kit/carousel/carousel.rb +8 -4
  24. data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
  25. data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
  26. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  27. data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
  28. data/app/components/phlex_kit/checkbox/checkbox.rb +5 -1
  29. data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
  30. data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
  31. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
  32. data/app/components/phlex_kit/combobox/combobox.rb +1 -1
  33. data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
  34. data/app/components/phlex_kit/combobox/combobox_controller.js +56 -5
  35. data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
  36. data/app/components/phlex_kit/command/command_controller.js +9 -1
  37. data/app/components/phlex_kit/command/command_dialog_content.rb +10 -6
  38. data/app/components/phlex_kit/command/command_empty.rb +5 -3
  39. data/app/components/phlex_kit/command/command_separator.rb +1 -1
  40. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
  41. data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
  42. data/app/components/phlex_kit/context_menu/context_menu_controller.js +17 -2
  43. data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
  44. data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
  45. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
  46. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
  47. data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
  49. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
  50. data/app/components/phlex_kit/data_table/data_table.css +4 -0
  51. data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
  52. data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
  53. data/app/components/phlex_kit/drawer/drawer.css +4 -0
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +10 -1
  57. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
  58. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
  59. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
  60. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
  62. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
  63. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
  64. data/app/components/phlex_kit/empty/empty.rb +1 -0
  65. data/app/components/phlex_kit/field/field.rb +4 -1
  66. data/app/components/phlex_kit/field/field_error.rb +4 -1
  67. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
  68. data/app/components/phlex_kit/icon/icon.rb +5 -1
  69. data/app/components/phlex_kit/input_group/input_group.css +6 -3
  70. data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
  71. data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
  72. data/app/components/phlex_kit/item/item.css +9 -1
  73. data/app/components/phlex_kit/item/item_separator.rb +1 -1
  74. data/app/components/phlex_kit/label/label.css +6 -1
  75. data/app/components/phlex_kit/menubar/menubar.rb +5 -3
  76. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
  77. data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
  78. data/app/components/phlex_kit/menubar/menubar_controller.js +11 -1
  79. data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
  80. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
  81. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  82. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
  83. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
  84. data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
  85. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
  86. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
  87. data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
  88. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
  89. data/app/components/phlex_kit/pagination/pagination.css +4 -0
  90. data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
  91. data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
  92. data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
  93. data/app/components/phlex_kit/select/select.rb +1 -1
  94. data/app/components/phlex_kit/select/select_content.rb +7 -5
  95. data/app/components/phlex_kit/select/select_controller.js +10 -0
  96. data/app/components/phlex_kit/select/select_separator.rb +1 -1
  97. data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
  98. data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
  99. data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
  100. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
  101. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
  102. data/app/components/phlex_kit/switch/switch.rb +4 -1
  103. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
  104. data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
  105. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
  106. data/app/components/phlex_kit/toast/toast_controller.js +46 -1
  107. data/app/components/phlex_kit/toast/toaster_controller.js +29 -1
  108. data/app/components/phlex_kit/toggle/toggle.rb +1 -1
  109. data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
  110. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
  111. data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
  112. data/app/components/phlex_kit/typography/typography.css +5 -2
  113. data/lib/phlex_kit/version.rb +1 -1
  114. metadata +1 -1
@@ -5,7 +5,10 @@ module PhlexKit
5
5
  class DropdownMenuGroup < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-dropdown-menu-group", role: "group" }, @attrs), &)
8
+ base = { class: "pk-dropdown-menu-group" }
9
+ # Default only when the caller didn't supply their own — `mix` fuses.
10
+ base[:role] = "group" unless attr_set?(:role)
11
+ div(**mix(base, @attrs), &)
9
12
  end
10
13
  end
11
14
  end
@@ -13,11 +13,12 @@ module PhlexKit
13
13
 
14
14
  def view_template(&block)
15
15
  base = {
16
- role: "menuitem",
17
- tabindex: "-1",
18
16
  class: [ "pk-dropdown-menu-item", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" "),
19
17
  data: { phlex_kit__dropdown_menu_target: "menuItem", action: "click->phlex-kit--dropdown-menu#close" }
20
18
  }
19
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
20
+ base[:role] = "menuitem" unless attr_set?(:role)
21
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
21
22
  base[:href] = @href unless @as == :div
22
23
  send(@as, **mix(base, @attrs), &block)
23
24
  end
@@ -4,7 +4,10 @@ module PhlexKit
4
4
  class DropdownMenuRadioGroup < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-dropdown-menu-group", role: "radiogroup" }, @attrs), &)
7
+ base = { class: "pk-dropdown-menu-group" }
8
+ # Default only when the caller didn't supply their own — `mix` fuses.
9
+ base[:role] = "radiogroup" unless attr_set?(:role)
10
+ div(**mix(base, @attrs), &)
8
11
  end
9
12
  end
10
13
  end
@@ -11,13 +11,15 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&block)
14
- label(**mix({
14
+ base = {
15
15
  class: "pk-dropdown-menu-item pk-dropdown-menu-radio-item",
16
- role: "menuitemradio",
17
- tabindex: "-1",
18
- aria: { checked: @checked ? "true" : "false" },
19
16
  data: { phlex_kit__dropdown_menu_target: "menuItem" }
20
- }, @attrs)) do
17
+ }
18
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
19
+ base[:role] = "menuitemradio" unless attr_set?(:role)
20
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
21
+ base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
22
+ label(**mix(base, @attrs)) do
21
23
  input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
22
24
  tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
23
25
  span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) do
@@ -6,7 +6,12 @@ module PhlexKit
6
6
  end
7
7
 
8
8
  def view_template
9
- div(**mix({ role: "separator", "aria-orientation": "horizontal", class: "pk-dropdown-menu-separator" }, @attrs))
9
+ base = { class: "pk-dropdown-menu-separator" }
10
+ # Defaults only when the caller didn't supply their own — `mix` fuses,
11
+ # and aria_key_set? covers the aria: hash spelling the flat key missed.
12
+ base[:role] = "separator" unless attr_set?(:role)
13
+ base[:"aria-orientation"] = "horizontal" unless aria_key_set?(:orientation)
14
+ div(**mix(base, @attrs))
10
15
  end
11
16
  end
12
17
  end
@@ -4,7 +4,10 @@ module PhlexKit
4
4
  class DropdownMenuSubContent < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-dropdown-menu-sub-content", role: "menu" }, @attrs)) do
7
+ base = { class: "pk-dropdown-menu-sub-content" }
8
+ # Default only when the caller didn't supply their own — `mix` fuses.
9
+ base[:role] = "menu" unless attr_set?(:role)
10
+ div(**mix(base, @attrs)) do
8
11
  div(class: "pk-dropdown-menu-viewport", &)
9
12
  end
10
13
  end
@@ -4,17 +4,20 @@ module PhlexKit
4
4
  class DropdownMenuSubTrigger < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&block)
7
- div(**mix({
7
+ base = {
8
8
  class: "pk-dropdown-menu-item pk-dropdown-menu-sub-trigger",
9
- role: "menuitem",
10
- # -1: roving focus reaches it via arrows (focus-within opens the sub);
11
- # tabindex 0 made it a stray tab stop inside the menu.
12
- tabindex: "-1",
13
- # expanded starts false and is mirrored live by the controller's
14
- # syncSub (the reveal itself is pure CSS :hover/:focus-within).
15
- aria: { haspopup: "menu", expanded: "false" },
16
9
  data: { phlex_kit__dropdown_menu_target: "menuItem" }
17
- }, @attrs)) do
10
+ }
11
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
12
+ base[:role] = "menuitem" unless attr_set?(:role)
13
+ # -1: roving focus reaches it via arrows (focus-within opens the sub);
14
+ # tabindex 0 made it a stray tab stop inside the menu.
15
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
16
+ base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
17
+ # expanded starts false and is mirrored live by the controller's
18
+ # syncSub (the reveal itself is pure CSS :hover/:focus-within).
19
+ base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
20
+ div(**mix(base, @attrs)) do
18
21
  block&.call
19
22
  render Icon.new(:chevron_right, size: nil, class: "pk-dropdown-menu-sub-chevron")
20
23
  end
@@ -1,4 +1,5 @@
1
1
  module PhlexKit
2
+ # Empty-state container, ported from shadcn/ui's Empty.
2
3
  class Empty < BaseComponent
3
4
  def initialize(**attrs) = (@attrs = attrs)
4
5
  def view_template(&) = div(**mix({ class: "pk-empty" }, @attrs), &)
@@ -29,7 +29,10 @@ module PhlexKit
29
29
  data = { slot: "field", orientation: fetch_option(ORIENTATIONS, @orientation, :orientation) }
30
30
  data[:invalid] = "true" if @invalid
31
31
  data[:disabled] = "true" if @disabled
32
- div(**mix({ class: "pk-field", role: "group", data: data }, @attrs), &)
32
+ base = { class: "pk-field", data: data }
33
+ # Default only when the caller didn't supply their own — `mix` fuses.
34
+ base[:role] = "group" unless attr_set?(:role)
35
+ div(**mix(base, @attrs), &)
33
36
  end
34
37
  end
35
38
  end
@@ -13,7 +13,10 @@ module PhlexKit
13
13
  def view_template(&block)
14
14
  return if @errors.empty? && !block
15
15
 
16
- div(**mix({ class: "pk-field-error", role: "alert", data: { slot: "field-error" } }, @attrs)) do
16
+ base = { class: "pk-field-error", data: { slot: "field-error" } }
17
+ # Default only when the caller didn't supply their own — `mix` fuses.
18
+ base[:role] = "alert" unless attr_set?(:role)
19
+ div(**mix(base, @attrs)) do
17
20
  if @errors.length == 1
18
21
  plain @errors.first
19
22
  elsif @errors.length > 1
@@ -49,11 +49,28 @@ export default class extends Controller {
49
49
  error.preventDefault();
50
50
 
51
51
  // preventDefault also cancels the browser's focus-first-invalid — restore
52
- // it for the first invalid control of the submit pass (all invalid events
53
- // fire in the same task, so the microtask reset scopes "first" correctly).
54
- if (!this._focusedInvalid) {
55
- this._focusedInvalid = true;
56
- queueMicrotask(() => { this._focusedInvalid = false; });
52
+ // it for the FIRST invalid control of the submit pass. Coordinate across
53
+ // sibling FormField controllers via a flag on the shared form: invalid
54
+ // events fire in document order, so the first control to claim wins. A
55
+ // per-controller flag alone let each FormField focus its own control, so
56
+ // the LAST invalid field ended up focused, inverting native behavior. No
57
+ // shared JS util (kit rule) — the form element itself is the coordination
58
+ // point. The reset runs on a macrotask, NOT queueMicrotask: a microtask
59
+ // checkpoint can drain between two invalid events (the JS stack empties
60
+ // after each listener), clearing the flag mid-pass so the next field
61
+ // re-claims. setTimeout survives the whole synchronous validation pass and
62
+ // resets before the next submit. A control with no form (rare) falls back
63
+ // to the per-controller flag.
64
+ const form = error.target.form;
65
+ const alreadyClaimed = form ? form.dataset.pkInvalidFocused : this._focusedInvalid;
66
+ if (!alreadyClaimed) {
67
+ if (form) {
68
+ form.dataset.pkInvalidFocused = "1";
69
+ setTimeout(() => { delete form.dataset.pkInvalidFocused; }, 0);
70
+ } else {
71
+ this._focusedInvalid = true;
72
+ setTimeout(() => { this._focusedInvalid = false; }, 0);
73
+ }
57
74
  error.target.focus({ preventScroll: true });
58
75
  error.target.scrollIntoView({ block: "nearest" });
59
76
  }
@@ -23,9 +23,13 @@ module PhlexKit
23
23
  base = base_attrs(icon)
24
24
  # `mix` merges duplicate string attrs ("16 24") — drop a generated attr
25
25
  # whenever the caller supplies their own copy, so theirs wins.
26
- %i[width height viewbox aria-hidden].each do |key|
26
+ %i[width height viewbox].each do |key|
27
27
  base.delete(key) if attr_set?(key)
28
28
  end
29
+ # aria-hidden needs the aria-aware guard: the aria: hash and underscore
30
+ # spellings must also suppress the default (a caller-supplied
31
+ # accessible name must not be hidden from AT by our default).
32
+ base.delete(:"aria-hidden") if aria_key_set?(:hidden) || aria_labelled?
29
33
  svg(**mix(base, @attrs)) do |s|
30
34
  icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
31
35
  end
@@ -34,7 +34,10 @@
34
34
  border-color: color-mix(in oklab, var(--pk-red) 50%, transparent);
35
35
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
36
36
  }
37
- .pk-input-group:has(:disabled) {
37
+ /* Group-disabled presentation follows the CONTROL only (the direct-child
38
+ input/textarea/select) — a disabled addon button must not dim a group whose
39
+ input is still editable. */
40
+ .pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) {
38
41
  opacity: .5;
39
42
  background: color-mix(in oklab, var(--pk-input) 80%, transparent);
40
43
  }
@@ -97,14 +100,14 @@
97
100
  .pk-input-group-text svg { pointer-events: none; }
98
101
 
99
102
  :root[data-theme="light"] .pk-input-group { background: transparent; }
100
- :root[data-theme="light"] .pk-input-group:has(:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
103
+ :root[data-theme="light"] .pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
101
104
  :root[data-theme="light"] .pk-input-group:has([aria-invalid="true"]) {
102
105
  border-color: var(--pk-red);
103
106
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
104
107
  }
105
108
  @media (prefers-color-scheme: light) {
106
109
  :root[data-theme="system"] .pk-input-group { background: transparent; }
107
- :root[data-theme="system"] .pk-input-group:has(:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
110
+ :root[data-theme="system"] .pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
108
111
  :root[data-theme="system"] .pk-input-group:has([aria-invalid="true"]) {
109
112
  border-color: var(--pk-red);
110
113
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
@@ -90,12 +90,15 @@ export default class extends Controller {
90
90
  prev.dataset.otpValue = ""
91
91
  prev.focus()
92
92
  this.syncValue()
93
- } else if (e.key === "ArrowLeft" && index > 0) {
93
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
94
+ // The slot row mirrors in RTL (all logical CSS), so the physical
95
+ // arrows flip: ArrowLeft moves toward the NEXT slot there. Runtime
96
+ // dir check — reliable after a dynamic flip, unlike :dir().
97
+ const rtl = getComputedStyle(this.element).direction === "rtl"
98
+ const delta = (e.key === "ArrowLeft") !== rtl ? -1 : 1
99
+ if (delta === -1 && index === 0) return
94
100
  e.preventDefault()
95
- this.focusSlot(index - 1)
96
- } else if (e.key === "ArrowRight") {
97
- e.preventDefault()
98
- this.focusSlot(index + 1)
101
+ this.focusSlot(index + delta)
99
102
  }
100
103
  }
101
104
 
@@ -113,7 +116,12 @@ export default class extends Controller {
113
116
  // (shared by paste and autofill), then park focus after the last filled one.
114
117
  fillFrom(slot, text) {
115
118
  const chars = text.split("")
116
- const start = this.slotTargets.indexOf(slot)
119
+ // A full-length (or longer) code is distributed from the FIRST slot no
120
+ // matter which one is focused — pasting a complete code into a middle
121
+ // slot otherwise silently truncated it (only the slots from focus onward
122
+ // got filled) and merged the run with whatever digits sat before it.
123
+ // A shorter (partial) paste still fills from the focused slot.
124
+ const start = chars.length >= this.slotTargets.length ? 0 : this.slotTargets.indexOf(slot)
117
125
  this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) { s.value = chars[i]; s.dataset.otpValue = chars[i] } })
118
126
  this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
119
127
  this.syncValue()
@@ -6,12 +6,13 @@ module PhlexKit
6
6
  end
7
7
 
8
8
  def view_template
9
- input(**mix({
10
- type: :text,
11
- inputmode: "numeric",
12
- autocomplete: "one-time-code",
13
- maxlength: "1",
14
- class: "pk-input-otp-slot",
9
+ base = { type: :text, class: "pk-input-otp-slot" }
10
+ # Defaults only when the caller didn't supply their own — `mix` fuses
11
+ # (e.g. inputmode: "text" for alphanumeric codes).
12
+ base[:inputmode] = "numeric" unless attr_set?(:inputmode)
13
+ base[:autocomplete] = "one-time-code" unless attr_set?(:autocomplete)
14
+ base[:maxlength] = "1" unless attr_set?(:maxlength)
15
+ input(**mix(base, {
15
16
  data: {
16
17
  phlex_kit__input_otp_target: "slot",
17
18
  action: [
@@ -16,6 +16,11 @@
16
16
  text-decoration: none;
17
17
  }
18
18
  a.pk-item:hover { background: var(--pk-surface-2); }
19
+ a.pk-item:focus-visible {
20
+ outline: none;
21
+ border-color: var(--pk-ring);
22
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
23
+ }
19
24
  .pk-item.outline { border-color: var(--pk-border); }
20
25
  .pk-item.muted { background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent); }
21
26
  .pk-item.xs { gap: .5rem; padding: .5rem .625rem; }
@@ -62,8 +67,11 @@ a.pk-item:hover { background: var(--pk-surface-2); }
62
67
  font-size: .875rem;
63
68
  line-height: 1.5;
64
69
  text-align: start;
70
+ /* shadcn's ItemDescription line-clamps to 2 lines (line-clamp-2). */
71
+ display: -webkit-box;
72
+ -webkit-box-orient: vertical;
73
+ -webkit-line-clamp: 2;
65
74
  overflow: hidden;
66
- text-overflow: ellipsis;
67
75
  }
68
76
  .pk-item.xs .pk-item-description { font-size: .75rem; }
69
77
  .pk-item-actions { display: flex; align-items: center; gap: .5rem; flex: none; }
@@ -6,7 +6,7 @@ module PhlexKit
6
6
  def view_template
7
7
  # Purely decorative — aria-hidden only (role="separator" would
8
8
  # contradict aria-hidden and confuse AT).
9
- div(**mix({ class: "pk-item-separator", aria: { hidden: true } }, @attrs))
9
+ div(**mix({ class: "pk-item-separator", aria: { hidden: "true" } }, @attrs))
10
10
  end
11
11
  end
12
12
  end
@@ -9,4 +9,9 @@
9
9
  line-height: 1;
10
10
  user-select: none;
11
11
  }
12
- .pk-label:has(+ :disabled), .pk-label:has(:disabled), :disabled + .pk-label { cursor: not-allowed; opacity: .5; }
12
+ /* The wrapper arms cover controls whose root is a <label> around a nested
13
+ input (e.g. Switch's <label class="pk-switch">) — scoped to label so an
14
+ unrelated container with a disabled control somewhere inside can't dim a
15
+ neighboring Label. */
16
+ .pk-label:has(+ :disabled), .pk-label:has(:disabled), :disabled + .pk-label,
17
+ .pk-label:has(+ label:has(:disabled)), label:has(:disabled) + .pk-label { cursor: not-allowed; opacity: .5; }
@@ -11,9 +11,8 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&)
14
- div(**mix({
14
+ base = {
15
15
  class: "pk-menubar",
16
- role: "menubar",
17
16
  data: {
18
17
  controller: "phlex-kit--menubar",
19
18
  # focusout: tabbing out of the bar closes the open [popover=manual]
@@ -22,7 +21,10 @@ module PhlexKit
22
21
  # NOT bound by navigation_menu.rb — nav menus are non-modal).
23
22
  action: "mousedown@window->phlex-kit--menubar#onMousedownOutside click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
24
23
  }
25
- }, @attrs), &)
24
+ }
25
+ # Default only when the caller didn't supply their own — `mix` fuses.
26
+ base[:role] = "menubar" unless attr_set?(:role)
27
+ div(**mix(base, @attrs), &)
26
28
  end
27
29
  end
28
30
  end
@@ -11,12 +11,12 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&block)
14
- label(**mix({
15
- class: "pk-menubar-item pk-menubar-checkbox-item",
16
- role: "menuitemcheckbox",
17
- tabindex: "-1",
18
- aria: { checked: @checked ? "true" : "false" }
19
- }, @attrs)) do
14
+ base = { class: "pk-menubar-item pk-menubar-checkbox-item" }
15
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
16
+ base[:role] = "menuitemcheckbox" unless attr_set?(:role)
17
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
18
+ base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
19
+ label(**mix(base, @attrs)) do
20
20
  input(type: :checkbox, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked,
21
21
  tabindex: "-1", data: { action: "change->phlex-kit--menubar#syncChecked" })
22
22
  span(class: "pk-menubar-item-indicator", aria: { hidden: "true" }) do
@@ -9,7 +9,11 @@ module PhlexKit
9
9
  end
10
10
 
11
11
  def view_template(&)
12
- div(**mix({ id: @id, class: "pk-menubar-content", popover: "manual", role: "menu" }, @attrs), &)
12
+ base = { id: @id, class: "pk-menubar-content" }
13
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
14
+ base[:popover] = "manual" unless attr_set?(:popover)
15
+ base[:role] = "menu" unless attr_set?(:role)
16
+ div(**mix(base, @attrs), &)
13
17
  end
14
18
  end
15
19
  end
@@ -19,7 +19,17 @@ let uid = 0
19
19
  // dedupes re-arming with the same fn. If the gesture's click never fires
20
20
  // (e.g. suppressed after a selection drag) the armed swallow eats the next
21
21
  // one — rare enough to accept.
22
- const swallowClick = (ev) => ev.preventDefault()
22
+ const swallowClick = (ev) => {
23
+ // preventDefault alone suppresses only native default actions; the
24
+ // dismissing outside click would still fire addEventListener handlers on
25
+ // whatever sits under the pointer. stopPropagation (this listener is
26
+ // capture-phase on window, the outermost node, so it runs before any deeper
27
+ // handler) starves those too — the modal-menu contract is that an outside
28
+ // click ONLY dismisses. Only menubar arms this; navigation_menu (non-modal)
29
+ // never does, so nav-menu outside clicks still pass through.
30
+ ev.preventDefault()
31
+ ev.stopPropagation()
32
+ }
23
33
  function armSwallowClick() {
24
34
  window.addEventListener("click", swallowClick, { once: true, capture: true })
25
35
  }
@@ -4,7 +4,10 @@ module PhlexKit
4
4
  class MenubarGroup < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- div(**mix({ class: "pk-menubar-group", role: "group" }, @attrs), &)
7
+ base = { class: "pk-menubar-group" }
8
+ # Default only when the caller didn't supply their own — `mix` fuses.
9
+ base[:role] = "group" unless attr_set?(:role)
10
+ div(**mix(base, @attrs), &)
8
11
  end
9
12
  end
10
13
  end
@@ -18,12 +18,13 @@ module PhlexKit
18
18
 
19
19
  def view_template(&block)
20
20
  base = {
21
- role: "menuitem",
22
- tabindex: "-1",
23
21
  class: [ "pk-menubar-item", fetch_option(VARIANTS, @variant, :variant), ("inset" if @inset) ].compact.join(" "),
24
22
  data: @disabled ? { disabled: "true" } : { action: "click->phlex-kit--menubar#close" }
25
23
  }
26
- base[:aria] = { disabled: "true" } if @disabled
24
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
25
+ base[:role] = "menuitem" unless attr_set?(:role)
26
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
27
+ base[:"aria-disabled"] = "true" if @disabled && !aria_key_set?(:disabled)
27
28
  # No default href: "#" would make Enter/click navigate (hash change +
28
29
  # scroll-to-top). tabindex="-1" keeps the row programmatically focusable.
29
30
  base[:href] = @href unless @href.nil? || @as == :div || @disabled
@@ -3,10 +3,13 @@ module PhlexKit
3
3
  class MenubarMenu < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
5
  def view_template(&)
6
+ base = { class: "pk-menubar-menu", data: { phlex_kit__menubar_target: "menu" } }
6
7
  # role="none": role="menubar" only allows menuitem children in the
7
8
  # accessibility tree — this layout wrapper must be presentational so
8
9
  # 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), &)
10
+ # Default only when the caller didn't supply their own `mix` fuses.
11
+ base[:role] = "none" unless attr_set?(:role)
12
+ div(**mix(base, @attrs), &)
10
13
  end
11
14
  end
12
15
  end
@@ -6,7 +6,10 @@ module PhlexKit
6
6
  class MenubarRadioGroup < BaseComponent
7
7
  def initialize(**attrs) = (@attrs = attrs)
8
8
  def view_template(&)
9
- div(**mix({ class: "pk-menubar-group", role: "group" }, @attrs), &)
9
+ base = { class: "pk-menubar-group" }
10
+ # Default only when the caller didn't supply their own — `mix` fuses.
11
+ base[:role] = "group" unless attr_set?(:role)
12
+ div(**mix(base, @attrs), &)
10
13
  end
11
14
  end
12
15
  end
@@ -11,12 +11,12 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&block)
14
- label(**mix({
15
- class: "pk-menubar-item pk-menubar-radio-item",
16
- role: "menuitemradio",
17
- tabindex: "-1",
18
- aria: { checked: @checked ? "true" : "false" }
19
- }, @attrs)) do
14
+ base = { class: "pk-menubar-item pk-menubar-radio-item" }
15
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
16
+ base[:role] = "menuitemradio" unless attr_set?(:role)
17
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
18
+ base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
19
+ label(**mix(base, @attrs)) do
20
20
  input(type: :radio, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked,
21
21
  tabindex: "-1", data: { action: "change->phlex-kit--menubar#syncChecked" })
22
22
  span(class: "pk-menubar-item-indicator", aria: { hidden: "true" }) do
@@ -2,6 +2,11 @@ module PhlexKit
2
2
  # See menubar.rb.
3
3
  class MenubarSeparator < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
- def view_template = div(**mix({ class: "pk-menubar-separator", role: "separator" }, @attrs))
5
+ def view_template
6
+ base = { class: "pk-menubar-separator" }
7
+ # Default only when the caller didn't supply their own — `mix` fuses.
8
+ base[:role] = "separator" unless attr_set?(:role)
9
+ div(**mix(base, @attrs))
10
+ end
6
11
  end
7
12
  end
@@ -3,7 +3,10 @@ module PhlexKit
3
3
  class MenubarSubContent < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
5
  def view_template(&)
6
- div(**mix({ class: "pk-menubar-sub-content", role: "menu" }, @attrs)) do
6
+ base = { class: "pk-menubar-sub-content" }
7
+ # Default only when the caller didn't supply their own — `mix` fuses.
8
+ base[:role] = "menu" unless attr_set?(:role)
9
+ div(**mix(base, @attrs)) do
7
10
  div(class: "pk-menubar-sub-viewport", &)
8
11
  end
9
12
  end
@@ -3,17 +3,18 @@ module PhlexKit
3
3
  class MenubarSubTrigger < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
5
  def view_template(&block)
6
- div(**mix({
7
- class: "pk-menubar-item pk-menubar-sub-trigger",
8
- role: "menuitem",
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" }
16
- }, @attrs)) do
6
+ base = { class: "pk-menubar-item pk-menubar-sub-trigger" }
7
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
8
+ base[:role] = "menuitem" unless attr_set?(:role)
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
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
12
+ base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
13
+ # expanded starts false; the sub is revealed by CSS (:hover /
14
+ # :focus-within), so MenubarSub's syncSub actions mirror that state
15
+ # onto this attribute.
16
+ base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
17
+ div(**mix(base, @attrs)) do
17
18
  block&.call
18
19
  render Icon.new(:chevron_right, size: nil, class: "pk-menubar-sub-chevron")
19
20
  end
@@ -3,13 +3,16 @@ module PhlexKit
3
3
  class MenubarTrigger < BaseComponent
4
4
  def initialize(**attrs) = (@attrs = attrs)
5
5
  def view_template(&)
6
- button(**mix({
6
+ base = {
7
7
  type: :button,
8
8
  class: "pk-menubar-trigger",
9
- role: "menuitem",
10
- aria: { haspopup: "menu", expanded: "false" },
11
9
  data: { action: "click->phlex-kit--menubar#toggle mouseenter->phlex-kit--menubar#switch" }
12
- }, @attrs), &)
10
+ }
11
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
12
+ base[:role] = "menuitem" unless attr_set?(:role)
13
+ base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
14
+ base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
15
+ button(**mix(base, @attrs), &)
13
16
  end
14
17
  end
15
18
  end
@@ -30,6 +30,11 @@ export default class extends Controller {
30
30
  };
31
31
 
32
32
  connect() {
33
+ // connect() re-derives reflected state: a Turbo snapshot serializes a
34
+ // mid-flight data-autoscrolling marker; no programmatic scroll is in
35
+ // flight on a fresh connect.
36
+ this.element.removeAttribute("data-autoscrolling");
37
+ if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
33
38
  // Reader is considered "following" the live edge until they move away.
34
39
  this.following = true;
35
40
  // True only while a programmatic scroll is in flight, so reader-intent
@@ -10,3 +10,7 @@
10
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; } }
13
+
14
+ /* RTL: Previous/Next chevrons follow the reading direction. */
15
+ [dir="rtl"] .pk-pagination-previous svg,
16
+ [dir="rtl"] .pk-pagination-next svg { transform: scaleX(-1); }
@@ -7,7 +7,7 @@ module PhlexKit
7
7
 
8
8
  def view_template
9
9
  input(**mix({
10
- type: "radio",
10
+ type: :radio,
11
11
  class: "pk-radio",
12
12
  data: {
13
13
  phlex_kit__form_field_target: "input",