phlex_kit 0.12.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 (129) 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_content.rb +2 -2
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
  10. data/app/components/phlex_kit/attachment/attachment.css +7 -1
  11. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
  12. data/app/components/phlex_kit/avatar/avatar.css +4 -0
  13. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
  16. data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  18. data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
  19. data/app/components/phlex_kit/calendar/calendar.css +5 -0
  20. data/app/components/phlex_kit/calendar/calendar_controller.js +38 -9
  21. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
  22. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
  23. data/app/components/phlex_kit/carousel/carousel.css +7 -0
  24. data/app/components/phlex_kit/carousel/carousel.rb +8 -4
  25. data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
  26. data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
  27. data/app/components/phlex_kit/chart/chart.rb +1 -1
  28. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  29. data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
  30. data/app/components/phlex_kit/checkbox/checkbox.rb +19 -1
  31. data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
  32. data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
  33. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
  34. data/app/components/phlex_kit/codeblock/codeblock.rb +2 -2
  35. data/app/components/phlex_kit/combobox/combobox.rb +1 -1
  36. data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
  37. data/app/components/phlex_kit/combobox/combobox_controller.js +60 -5
  38. data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
  39. data/app/components/phlex_kit/command/command_controller.js +69 -2
  40. data/app/components/phlex_kit/command/command_dialog_content.rb +15 -6
  41. data/app/components/phlex_kit/command/command_empty.rb +5 -3
  42. data/app/components/phlex_kit/command/command_separator.rb +1 -1
  43. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
  44. data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
  45. data/app/components/phlex_kit/context_menu/context_menu_controller.js +42 -1
  46. data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
  47. data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
  48. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
  49. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
  50. data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
  51. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
  52. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
  53. data/app/components/phlex_kit/data_table/data_table.css +4 -0
  54. data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
  55. data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
  56. data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
  57. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
  58. data/app/components/phlex_kit/date_picker/date_picker_controller.js +3 -5
  59. data/app/components/phlex_kit/drawer/drawer.css +4 -0
  60. data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  62. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
  63. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
  64. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -5
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
  69. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
  72. data/app/components/phlex_kit/empty/empty.rb +1 -0
  73. data/app/components/phlex_kit/field/field.rb +4 -1
  74. data/app/components/phlex_kit/field/field_error.rb +4 -1
  75. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
  76. data/app/components/phlex_kit/icon/icon.rb +6 -2
  77. data/app/components/phlex_kit/input_group/input_group.css +6 -3
  78. data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
  79. data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
  80. data/app/components/phlex_kit/item/item.css +9 -1
  81. data/app/components/phlex_kit/item/item_separator.rb +1 -1
  82. data/app/components/phlex_kit/label/label.css +6 -1
  83. data/app/components/phlex_kit/link/link.rb +10 -1
  84. data/app/components/phlex_kit/menubar/menubar.rb +8 -4
  85. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
  86. data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
  87. data/app/components/phlex_kit/menubar/menubar_controller.js +36 -0
  88. data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
  89. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
  90. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  91. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
  92. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
  93. data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
  94. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
  95. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
  96. data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
  97. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
  98. data/app/components/phlex_kit/pagination/pagination.css +4 -0
  99. data/app/components/phlex_kit/progress/progress.css +19 -0
  100. data/app/components/phlex_kit/progress/progress.rb +29 -13
  101. data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
  102. data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
  103. data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
  104. data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -1
  105. data/app/components/phlex_kit/select/select.rb +1 -1
  106. data/app/components/phlex_kit/select/select_content.rb +7 -5
  107. data/app/components/phlex_kit/select/select_controller.js +10 -0
  108. data/app/components/phlex_kit/select/select_separator.rb +1 -1
  109. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  110. data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
  111. data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
  115. data/app/components/phlex_kit/switch/switch.rb +5 -2
  116. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
  117. data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
  118. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
  119. data/app/components/phlex_kit/toast/toast_controller.js +52 -2
  120. data/app/components/phlex_kit/toast/toast_item.rb +2 -2
  121. data/app/components/phlex_kit/toast/toaster_controller.js +63 -7
  122. data/app/components/phlex_kit/toggle/toggle.rb +1 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
  124. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
  126. data/app/components/phlex_kit/typography/typography.css +5 -2
  127. data/lib/phlex_kit/base_component.rb +9 -0
  128. data/lib/phlex_kit/version.rb +1 -1
  129. metadata +1 -1
@@ -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; }
@@ -36,11 +36,20 @@ module PhlexKit
36
36
  end
37
37
 
38
38
  def view_template(&block)
39
- a(**mix({ href: @href, class: classes }, @attrs), &block)
39
+ base = { href: @href, class: classes }
40
+ # target="_blank" hands the new page a window.opener reference in older
41
+ # browsers (modern ones imply noopener) — default the safe rel unless
42
+ # the caller supplied their own (`mix` would fuse "noopener external").
43
+ base[:rel] = "noopener" if blank_target? && !attr_set?(:rel)
44
+ a(**mix(base, @attrs), &block)
40
45
  end
41
46
 
42
47
  private
43
48
 
49
+ def blank_target?
50
+ (@attrs[:target] || @attrs["target"]).to_s == "_blank"
51
+ end
52
+
44
53
  def classes
45
54
  [ "pk-button", fetch_option(VARIANTS, @variant, :variant), fetch_option(SIZES, @size, :size), ("icon" if @icon) ].compact.join(" ")
46
55
  end
@@ -11,16 +11,20 @@ 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]
20
19
  # panel (which no focus trap holds open).
21
- action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
20
+ # mousedown@window arms the modal-menu click swallow (deliberately
21
+ # NOT bound by navigation_menu.rb — nav menus are non-modal).
22
+ action: "mousedown@window->phlex-kit--menubar#onMousedownOutside click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
22
23
  }
23
- }, @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), &)
24
28
  end
25
29
  end
26
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
@@ -13,6 +13,27 @@ import { Controller } from "@hotwired/stimulus"
13
13
  // Connects to data-controller="phlex-kit--menubar"
14
14
  let uid = 0
15
15
 
16
+ // One-shot capture listener swallowing the click an outside mousedown is
17
+ // about to produce (see onMousedownOutside; same helper in dropdown/context
18
+ // menu — duplicated per controller by design). {once} self-removes; the DOM
19
+ // dedupes re-arming with the same fn. If the gesture's click never fires
20
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
21
+ // one — rare enough to accept.
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
+ }
33
+ function armSwallowClick() {
34
+ window.addEventListener("click", swallowClick, { once: true, capture: true })
35
+ }
36
+
16
37
  export default class extends Controller {
17
38
  static targets = ["menu"]
18
39
 
@@ -131,6 +152,21 @@ export default class extends Controller {
131
152
  if (opts.refocus === true) trigger?.focus()
132
153
  }
133
154
 
155
+ // Kit-wide dismiss contract (Radix parity): MENU overlays (dropdown,
156
+ // context, menubar, select) are modal — the dismissing outside click is
157
+ // swallowed rather than also acting on what was under the pointer. The
158
+ // decision happens at MOUSEDOWN: the same gesture's focusout closes the
159
+ // panel before the click event fires, so a click-time openMenu check
160
+ // would already be null (that race made the old swallow silently skip
161
+ // focusable click targets). Only menubar.rb binds this action — the
162
+ // navigation menu (hover-open mode) is non-modal like Radix's and its
163
+ // dismissing click clicks through.
164
+ onMousedownOutside(e) {
165
+ if (this.element.contains(e.target)) return
166
+ if (!this.openMenu) return
167
+ armSwallowClick()
168
+ }
169
+
134
170
  onClickOutside(e) {
135
171
  if (this.element.contains(e.target)) return
136
172
  this.close()
@@ -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); }
@@ -23,3 +23,22 @@
23
23
  .pk-progress:dir(rtl) .pk-progress-indicator {
24
24
  transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
25
25
  }
26
+
27
+ /* Indeterminate (value: nil): a 40%-wide segment sweeps the track.
28
+ translateX % is relative to the segment's own width: -100% parks it just
29
+ off the track's start edge, +250% (track = 250% of segment) just past the
30
+ end. Direction-agnostic — the sweep reads fine mirrored, no RTL arm. */
31
+ .pk-progress.indeterminate .pk-progress-indicator {
32
+ width: 40%;
33
+ transition: none;
34
+ animation: pk-progress-indeterminate 1.5s ease-in-out infinite;
35
+ }
36
+ @keyframes pk-progress-indeterminate {
37
+ from { transform: translateX(-100%); }
38
+ to { transform: translateX(250%); }
39
+ }
40
+ @media (prefers-reduced-motion: reduce) {
41
+ .pk-progress.indeterminate .pk-progress-indicator {
42
+ animation-duration: 3s;
43
+ }
44
+ }
@@ -6,29 +6,45 @@ module PhlexKit
6
6
  class Progress < BaseComponent
7
7
  # value_text: is a named kwarg (not read from **attrs) because `mix`
8
8
  # would *fuse* a caller "aria-valuetext" with the generated default.
9
+ # `value: nil` renders the INDETERMINATE state (Radix parity): a sliding
10
+ # animation, data-state="indeterminate", and no aria-valuenow — which is
11
+ # exactly how ARIA marks a progressbar of unknown value.
9
12
  def initialize(value: 0, value_text: nil, **attrs)
10
- @value = value.to_f.clamp(0.0, 100.0)
13
+ @value = value.nil? ? nil : value.to_f.clamp(0.0, 100.0)
11
14
  @value_text = value_text
12
15
  @attrs = attrs
13
16
  end
14
17
 
15
18
  def view_template
16
- # %g drops float artifacts ("40.0" would be announced as "forty point
17
- # zero percent"). The value travels as a custom property so the fill
18
- # transform lives in progress.css, where a :dir(rtl) arm can flip it —
19
- # an inline translateX always filled from the physical left.
20
- v = Kernel.format("%g", @value)
21
- div(**mix({
19
+ div(**mix(root_attrs, @attrs)) do
20
+ div(class: "pk-progress-indicator")
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def root_attrs
27
+ base = {
22
28
  role: "progressbar",
23
- "aria-valuenow": v,
24
29
  "aria-valuemin": 0,
25
30
  "aria-valuemax": 100,
26
- "aria-valuetext": @value_text || "#{v}%",
27
- class: "pk-progress",
28
- style: "--pk-progress-value: #{v};"
29
- }, @attrs)) do
30
- div(class: "pk-progress-indicator")
31
+ class: "pk-progress"
32
+ }
33
+ if @value.nil?
34
+ base[:class] = "pk-progress indeterminate"
35
+ base[:data] = { state: "indeterminate" }
36
+ base[:"aria-valuetext"] = @value_text if @value_text
37
+ else
38
+ # %g drops float artifacts ("40.0" would be announced as "forty point
39
+ # zero percent"). The value travels as a custom property so the fill
40
+ # transform lives in progress.css, where a :dir(rtl) arm can flip it —
41
+ # an inline translateX always filled from the physical left.
42
+ v = Kernel.format("%g", @value)
43
+ base[:"aria-valuenow"] = v
44
+ base[:"aria-valuetext"] = @value_text || "#{v}%"
45
+ base[:style] = "--pk-progress-value: #{v};"
31
46
  end
47
+ base
32
48
  end
33
49
  end
34
50
  end
@@ -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",
@@ -57,6 +57,9 @@ export default class extends Controller {
57
57
  try { handle.setPointerCapture(e.pointerId) } catch {}
58
58
 
59
59
  const onMove = (ev) => {
60
+ // Only the initiating pointer drives the drag — a second touch point
61
+ // over the handle must not hijack it.
62
+ if (ev.pointerId !== e.pointerId) return
60
63
  // LTR assumption: clientX grows toward the trailing panel; RTL drag
61
64
  // inversion is a documented limitation.
62
65
  const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
@@ -65,9 +68,12 @@ export default class extends Controller {
65
68
  const prevGrow = this.clampPrevGrow(prev, next, (prevSize / total) * drag.pairGrow, drag.pairGrow, drag.groupGrow)
66
69
  prev.style.flexGrow = prevGrow
67
70
  next.style.flexGrow = drag.pairGrow - prevGrow
68
- this.syncValuenow(handle)
71
+ // Resizing this pair moves every share: 3+ panel groups have sibling
72
+ // handles whose aria-valuenow would otherwise go stale.
73
+ this.handleTargets.forEach((h) => this.syncValuenow(h))
69
74
  }
70
- const onUp = () => {
75
+ const onUp = (ev) => {
76
+ if (ev.pointerId !== e.pointerId) return
71
77
  handle.removeEventListener("pointermove", onMove)
72
78
  handle.removeEventListener("pointerup", onUp)
73
79
  handle.removeEventListener("pointercancel", onUp)
@@ -89,8 +95,14 @@ export default class extends Controller {
89
95
  if (!prev || !next) return
90
96
 
91
97
  const horizontal = this.directionValue === "horizontal"
98
+ // Horizontal arrows follow visual direction (round-7 kit convention, same
99
+ // as tabs/toggle_group): in RTL the DOM-first panel is on the RIGHT, so
100
+ // ArrowLeft must GROW it (move the divider visually left). Only the
101
+ // pointer-drag clientX math keeps its documented LTR exemption; Up/Down
102
+ // never flip. Runtime dir check — reliable after a dynamic flip.
103
+ const rtl = horizontal && getComputedStyle(this.element).direction === "rtl"
92
104
  const steps = horizontal
93
- ? { ArrowLeft: -0.05, ArrowRight: 0.05 }
105
+ ? (rtl ? { ArrowLeft: 0.05, ArrowRight: -0.05 } : { ArrowLeft: -0.05, ArrowRight: 0.05 })
94
106
  : { ArrowUp: -0.05, ArrowDown: 0.05 }
95
107
 
96
108
  const pairGrow = this.growOf(prev) + this.growOf(next) || 2
@@ -109,7 +121,8 @@ export default class extends Controller {
109
121
  const prevGrow = this.clampPrevGrow(prev, next, share * pairGrow, pairGrow, this.groupGrow())
110
122
  prev.style.flexGrow = prevGrow
111
123
  next.style.flexGrow = pairGrow - prevGrow
112
- this.syncValuenow(handle)
124
+ // All handles: sibling dividers share panels in 3+ panel groups.
125
+ this.handleTargets.forEach((h) => this.syncValuenow(h))
113
126
  }
114
127
 
115
128
  growOf(el) {
@@ -8,18 +8,20 @@ module PhlexKit
8
8
  end
9
9
 
10
10
  def view_template
11
- div(**mix({
11
+ base = {
12
12
  class: "pk-resizable-handle",
13
- role: "separator",
14
- tabindex: "0",
15
- # Correct for the default horizontal group; the controller re-stamps
16
- # orientation (and aria-valuenow/min/max) per group direction on connect.
17
- aria: { orientation: "vertical" },
18
13
  data: {
19
14
  phlex_kit__resizable_target: "handle",
20
15
  action: "pointerdown->phlex-kit--resizable#start keydown->phlex-kit--resizable#keydown"
21
16
  }
22
- }, @attrs)) do
17
+ }
18
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
19
+ base[:role] = "separator" unless attr_set?(:role)
20
+ base[:tabindex] = "0" unless attr_set?(:tabindex)
21
+ # Correct for the default horizontal group; the controller re-stamps
22
+ # orientation (and aria-valuenow/min/max) per group direction on connect.
23
+ base[:aria] = { orientation: "vertical" } unless aria_key_set?(:orientation)
24
+ div(**mix(base, @attrs)) do
23
25
  span(class: "pk-resizable-handle-grip", aria: { hidden: "true" }) if @with_handle
24
26
  end
25
27
  end
@@ -15,7 +15,7 @@ module PhlexKit
15
15
  # Defaults only when the caller didn't supply their own — `mix` would
16
16
  # fuse role="region log" / tabindex="0 -1" instead of overriding.
17
17
  base = { class: "pk-scroll-area" }
18
- base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
18
+ base[:tabindex] = "0" unless attr_set?(:tabindex)
19
19
  base[:role] = "region" if aria_labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
20
20
  div(**mix(base, @attrs), &)
21
21
  end
@@ -39,7 +39,7 @@ module PhlexKit
39
39
  # keydown.esc rides on the root (not only the items) so Escape closes
40
40
  # the [popover=manual] panel with focus on the trigger too; handleEsc
41
41
  # no-ops while closed.
42
- action: "click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc"
42
+ action: "click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc focusout->phlex-kit--select#onFocusout"
43
43
  }
44
44
  }, @attrs), &block)
45
45
  end