phlex_kit 0.9.1 → 0.11.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +8 -0
  3. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  4. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  5. data/app/components/phlex_kit/alert/alert.rb +1 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +15 -0
  7. data/app/components/phlex_kit/attachment/attachment.css +6 -3
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
  10. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
  11. data/app/components/phlex_kit/avatar/avatar.css +2 -2
  12. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  13. data/app/components/phlex_kit/badge/badge.css +1 -1
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
  15. data/app/components/phlex_kit/bubble/bubble.css +1 -1
  16. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
  18. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  19. data/app/components/phlex_kit/button_group/button_group.css +16 -16
  20. data/app/components/phlex_kit/calendar/calendar.css +10 -2
  21. data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
  22. data/app/components/phlex_kit/carousel/carousel.css +4 -4
  23. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  24. data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
  25. data/app/components/phlex_kit/chart/chart.rb +8 -2
  26. data/app/components/phlex_kit/clipboard/clipboard_controller.js +26 -3
  27. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  28. data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
  29. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  30. data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
  31. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
  32. data/app/components/phlex_kit/combobox/combobox.css +3 -3
  33. data/app/components/phlex_kit/combobox/combobox.rb +3 -3
  34. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  35. data/app/components/phlex_kit/combobox/combobox_controller.js +32 -10
  36. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  37. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  38. data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
  39. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +7 -1
  40. data/app/components/phlex_kit/command/command.css +2 -2
  41. data/app/components/phlex_kit/command/command_controller.js +63 -35
  42. data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
  43. data/app/components/phlex_kit/command/command_group.rb +5 -2
  44. data/app/components/phlex_kit/command/command_item.rb +4 -0
  45. data/app/components/phlex_kit/command/command_list.rb +6 -2
  46. data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
  47. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  49. data/app/components/phlex_kit/data_table/data_table.css +0 -10
  50. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  51. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  52. data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
  53. data/app/components/phlex_kit/dialog/dialog.css +5 -3
  54. data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
  55. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  56. data/app/components/phlex_kit/dialog/dialog_controller.js +22 -2
  57. data/app/components/phlex_kit/drawer/drawer.css +4 -4
  58. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
  59. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
  60. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  62. data/app/components/phlex_kit/empty/empty.css +1 -1
  63. data/app/components/phlex_kit/field/field.css +2 -1
  64. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  65. data/app/components/phlex_kit/form_field/form_field_controller.js +29 -1
  66. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  67. data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
  68. data/app/components/phlex_kit/icon/icon.css +9 -0
  69. data/app/components/phlex_kit/icon/icon.rb +7 -1
  70. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  71. data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
  72. data/app/components/phlex_kit/marker/marker.rb +15 -2
  73. data/app/components/phlex_kit/masked_input/masked_input_controller.js +35 -15
  74. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  75. data/app/components/phlex_kit/menubar/menubar_controller.js +28 -1
  76. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
  77. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
  78. data/app/components/phlex_kit/message/message.rb +5 -0
  79. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +12 -5
  80. data/app/components/phlex_kit/native_select/native_select.css +4 -2
  81. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +7 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +11 -2
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +11 -2
  86. data/app/components/phlex_kit/popover/popover_controller.js +31 -1
  87. data/app/components/phlex_kit/progress/progress.css +8 -0
  88. data/app/components/phlex_kit/progress/progress.rb +14 -5
  89. data/app/components/phlex_kit/resizable/resizable_controller.js +52 -5
  90. data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
  91. data/app/components/phlex_kit/scroll_area/scroll_area.rb +23 -4
  92. data/app/components/phlex_kit/select/select.css +5 -3
  93. data/app/components/phlex_kit/select/select_content.rb +9 -3
  94. data/app/components/phlex_kit/select/select_controller.js +59 -6
  95. data/app/components/phlex_kit/select/select_item.rb +5 -1
  96. data/app/components/phlex_kit/separator/separator.rb +12 -2
  97. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  98. data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
  99. data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
  100. data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
  101. data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
  102. data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
  103. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  104. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  105. data/app/components/phlex_kit/slider/slider.css +15 -0
  106. data/app/components/phlex_kit/switch/switch.css +5 -0
  107. data/app/components/phlex_kit/table/table.css +2 -1
  108. data/app/components/phlex_kit/table/table.rb +5 -2
  109. data/app/components/phlex_kit/tabs/tabs.css +2 -2
  110. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  111. data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
  112. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  113. data/app/components/phlex_kit/tabs/tabs_trigger.rb +15 -3
  114. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  115. data/app/components/phlex_kit/toast/toast.css +3 -2
  116. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  117. data/app/components/phlex_kit/toast/toast_controller.js +13 -5
  118. data/app/components/phlex_kit/toast/toast_region.rb +19 -9
  119. data/app/components/phlex_kit/toast/toaster_controller.js +34 -12
  120. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
  121. data/app/components/phlex_kit/toggle_group/toggle_group.rb +7 -2
  122. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +13 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
  124. data/app/components/phlex_kit/tooltip/tooltip.css +7 -3
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  126. data/app/components/phlex_kit/typography/heading.rb +13 -5
  127. data/app/components/phlex_kit/typography/list.rb +22 -0
  128. data/app/components/phlex_kit/typography/text.rb +7 -1
  129. data/lib/phlex_kit/base_component.rb +13 -0
  130. data/lib/phlex_kit/version.rb +1 -1
  131. metadata +3 -1
@@ -28,6 +28,9 @@ export default class extends Controller {
28
28
  // set here. Roving tabindex applies only in menubar mode: a nav's
29
29
  // triggers and links stay natural tab stops.
30
30
  this.roving = this.element.dataset.hoverOpen === undefined
31
+ // Outstanding syncSub rAF handles — cancelled on disconnect so a queued
32
+ // frame never touches a torn-down menubar.
33
+ this.subFrames = new Set()
31
34
  }
32
35
 
33
36
  // Pair each trigger with its panel for AT (aria-controls), and fold the
@@ -39,6 +42,12 @@ export default class extends Controller {
39
42
  if (!panel.id) panel.id = `pk-menu-panel-${++uid}`
40
43
  trigger.setAttribute("aria-controls", panel.id)
41
44
  }
45
+ // A Turbo snapshot serializes aria-expanded="true" even though the
46
+ // panel's :popover-open does not survive the restore — no panel can be
47
+ // open at connect time, so stamp every expanded marker back to false
48
+ // (only on elements that already carry it: plain nav links must not
49
+ // grow an aria-expanded). Covers the trigger and CSS-revealed subs.
50
+ menu.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
42
51
  if (this.roving) this.applyRoving()
43
52
  }
44
53
 
@@ -48,6 +57,8 @@ export default class extends Controller {
48
57
 
49
58
  disconnect() {
50
59
  clearTimeout(this.graceTimer)
60
+ this.subFrames.forEach((id) => cancelAnimationFrame(id))
61
+ this.subFrames.clear()
51
62
  }
52
63
 
53
64
  toggle(e) {
@@ -112,6 +123,20 @@ export default class extends Controller {
112
123
  this.close()
113
124
  }
114
125
 
126
+ // Tabbing out of the bar: close the open [popover=manual] panel once focus
127
+ // has left both the bar AND the panel (panels are top-layer popovers, but
128
+ // remain DOM children — check both anyway). Focus moving INTO the panel
129
+ // during normal navigation must not close it. relatedTarget is null when
130
+ // the window itself blurs — leave the menu alone then.
131
+ onFocusout(e) {
132
+ const to = e.relatedTarget
133
+ if (!to || this.element.contains(to)) return
134
+ const menu = this.openMenu
135
+ if (!menu) return
136
+ if (this.panel(menu)?.contains(to)) return
137
+ this.close()
138
+ }
139
+
115
140
  // Single keydown listener on the bar (wired via data-action, so no manual
116
141
  // cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
117
142
  onKeydown(e) {
@@ -215,10 +240,12 @@ export default class extends Controller {
215
240
  // state isn't settled until the event finishes dispatching.
216
241
  syncSub(e) {
217
242
  const sub = e.currentTarget
218
- requestAnimationFrame(() => {
243
+ const id = requestAnimationFrame(() => {
244
+ this.subFrames.delete(id)
219
245
  sub.querySelector(":scope > [aria-haspopup]")
220
246
  ?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
221
247
  })
248
+ this.subFrames.add(id)
222
249
  }
223
250
 
224
251
  shift(dir) {
@@ -6,7 +6,7 @@ module PhlexKit
6
6
  class MenubarItem < BaseComponent
7
7
  VARIANTS = { default: nil, destructive: "destructive" }.freeze
8
8
 
9
- def initialize(as: :a, href: "#", shortcut: nil, variant: :default, inset: false, disabled: false, **attrs)
9
+ def initialize(as: :a, href: nil, shortcut: nil, variant: :default, inset: false, disabled: false, **attrs)
10
10
  @as = as.to_sym
11
11
  @href = href
12
12
  @shortcut = shortcut
@@ -24,7 +24,9 @@ module PhlexKit
24
24
  data: @disabled ? { disabled: "true" } : { action: "click->phlex-kit--menubar#close" }
25
25
  }
26
26
  base[:aria] = { disabled: "true" } if @disabled
27
- base[:href] = @href unless @as == :div || @disabled
27
+ # No default href: "#" would make Enter/click navigate (hash change +
28
+ # scroll-to-top). tabindex="-1" keeps the row programmatically focusable.
29
+ base[:href] = @href unless @href.nil? || @as == :div || @disabled
28
30
  send(@as, **mix(base, @attrs)) do
29
31
  block&.call
30
32
  span(class: "pk-menubar-shortcut") { @shortcut } if @shortcut
@@ -1,10 +1,12 @@
1
1
  module PhlexKit
2
2
  # Exclusive-choice group of MenubarRadioItems (share the same `name:`).
3
- # Mirrors shadcn/ui's MenubarRadioGroup. See menubar.rb.
3
+ # Mirrors shadcn/ui's MenubarRadioGroup. role="group" (NOT "radiogroup"):
4
+ # radiogroup may not own menuitemradio children (axe aria-required-children).
5
+ # See menubar.rb.
4
6
  class MenubarRadioGroup < BaseComponent
5
7
  def initialize(**attrs) = (@attrs = attrs)
6
8
  def view_template(&)
7
- div(**mix({ class: "pk-menubar-group", role: "radiogroup" }, @attrs), &)
9
+ div(**mix({ class: "pk-menubar-group", role: "group" }, @attrs), &)
8
10
  end
9
11
  end
10
12
  end
@@ -3,8 +3,13 @@ module PhlexKit
3
3
  # align: :end reverses the row. Compose Message > MessageContent (+ Avatar/
4
4
  # Header/Footer); group several in a MessageGroup.
5
5
  class Message < BaseComponent
6
+ ALIGNS = %i[start end].freeze
7
+
6
8
  def initialize(align: :start, **attrs)
7
9
  @align = align.to_sym
10
+ # Fail loud like sibling Bubble: message.css only styles data-align="end",
11
+ # so an unknown value silently rendered a start-aligned row.
12
+ raise KeyError, "unknown Message align #{@align}" unless ALIGNS.include?(@align)
8
13
  @attrs = attrs
9
14
  end
10
15
  def view_template(&)
@@ -141,7 +141,8 @@ export default class extends Controller {
141
141
  if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
142
142
  this.programmatic = false;
143
143
  this.element.removeAttribute("data-autoscrolling");
144
- this.viewportTarget?.removeAttribute("data-autoscrolling");
144
+ // Stimulus target getters throw when missing — guard, don't optional-chain.
145
+ if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
145
146
  this.following = false;
146
147
  this.updateButton();
147
148
  }
@@ -184,14 +185,16 @@ export default class extends Controller {
184
185
  // flex row gap each prepended row introduces, or the preserved row
185
186
  // drifts down by one gap per insertion.
186
187
  prependedHeight += (node.offsetHeight || 0) + gap;
187
- } else {
188
- // Inserted at (or after) the end → new turn.
188
+ } else if (record.nextSibling === null) {
189
+ // Inserted at the end → new turn.
189
190
  appended = node;
190
191
  }
192
+ // Both siblings present → inserted between existing rows. Neither
193
+ // history nor a new turn — don't yank a following reader to the end.
191
194
  }
192
195
  }
193
196
 
194
- if (prependedHeight > 0 && this.preserveOnPrependValue) {
197
+ if (prependedHeight > 0 && this.preserveOnPrependValue && this.hasViewportTarget) {
195
198
  // Keep the reader's current row fixed while history loads in above.
196
199
  this.viewportTarget.scrollTop += prependedHeight;
197
200
  }
@@ -307,7 +310,8 @@ export default class extends Controller {
307
310
  finishScroll() {
308
311
  this.programmatic = false;
309
312
  this.element.removeAttribute("data-autoscrolling");
310
- this.viewportTarget?.removeAttribute("data-autoscrolling");
313
+ // Stimulus target getters throw when missing — guard, don't optional-chain.
314
+ if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
311
315
  this.following = this.isAtEnd();
312
316
  this.updateButton();
313
317
  }
@@ -374,6 +378,9 @@ export default class extends Controller {
374
378
  button.setAttribute("data-active", active ? "true" : "false");
375
379
  // Remove the inert button from the tab order so there are no ghost stops.
376
380
  button.setAttribute("tabindex", active ? "0" : "-1");
381
+ // opacity-only hiding leaves it in the accessibility tree — a SR
382
+ // virtual cursor would still find a phantom jump button.
383
+ button.setAttribute("aria-hidden", active ? "false" : "true");
377
384
  });
378
385
  }
379
386
  }
@@ -20,8 +20,10 @@
20
20
  color: var(--pk-text);
21
21
  font: inherit;
22
22
  font-size: .875rem;
23
- padding: .25rem 2rem .25rem .625rem;
24
- cursor: pointer;
23
+ padding-block: .25rem;
24
+ padding-inline-start: .625rem;
25
+ padding-inline-end: 2rem; /* chevron space — matches .pk-native-select-icon's inset-inline-end */
26
+ cursor: pointer;
25
27
  transition: color .15s ease, border-color .15s ease, box-shadow .15s ease;
26
28
  }
27
29
  .pk-native-select-field:hover { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
@@ -21,20 +21,29 @@ module PhlexKit
21
21
  sm: "sm"
22
22
  }.freeze
23
23
 
24
+ # An Integer size: is the NATIVE <select size> (multi-row list box) and
25
+ # passes straight through — the kit's visual sizes stay symbols.
24
26
  def initialize(size: :default, **attrs)
25
- @size = size.to_sym
27
+ if size.is_a?(Integer)
28
+ @native_size = size
29
+ @size = :default
30
+ else
31
+ @size = size.to_sym
32
+ end
26
33
  @attrs = attrs
27
34
  end
28
35
 
29
36
  def view_template(&block)
30
37
  div(class: "pk-native-select") do
31
- select(**mix({
38
+ base = {
32
39
  class: classes,
33
40
  data: {
34
41
  phlex_kit__form_field_target: "input",
35
42
  action: "change->phlex-kit--form-field#onChange invalid->phlex-kit--form-field#onInvalid"
36
43
  }
37
- }, @attrs), &block)
44
+ }
45
+ base[:size] = @native_size if @native_size
46
+ select(**mix(base, @attrs), &block)
38
47
  render PhlexKit::NativeSelectIcon.new
39
48
  end
40
49
  end
@@ -20,7 +20,13 @@ module PhlexKit
20
20
  # focus returned to the trigger (bare #close as an action received
21
21
  # the event as `opts`, so refocus never happened — focus fell to
22
22
  # <body> when the panel hid).
23
- action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown mouseleave->phlex-kit--menubar#closeSoon"
23
+ # mouseenter->cancelClose: re-entering the nav over list padding /
24
+ # whitespace must cancel the pending mouseleave grace-close (only
25
+ # trigger/panel mouseenter did before).
26
+ # focusout->onFocusout: a keyboard user tabbing past the panel's
27
+ # last link must close it (menubar wires the same handler; closeSoon
28
+ # is pointer-only and never fires for this path).
29
+ action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout mouseleave->phlex-kit--menubar#closeSoon mouseenter->phlex-kit--menubar#cancelClose"
24
30
  }
25
31
  }, @attrs), &)
26
32
  end
@@ -2,9 +2,17 @@ module PhlexKit
2
2
  # Page navigation. Ported from ruby_ui's RubyUI::Pagination. Compose Pagination >
3
3
  # PaginationContent > PaginationItem(href:, active:) / PaginationEllipsis.
4
4
  class Pagination < BaseComponent
5
- def initialize(**attrs) = (@attrs = attrs)
5
+ # label: is a named kwarg (not a mix default) because `mix` would *fuse* a
6
+ # caller aria-label with the hardcoded "pagination" ("pagination Résultats").
7
+ def initialize(label: "pagination", **attrs)
8
+ @label = label
9
+ @attrs = attrs
10
+ end
11
+
6
12
  def view_template(&)
7
- nav(**mix({ class: "pk-pagination", role: "navigation", aria: { label: "pagination" } }, @attrs), &)
13
+ base = { class: "pk-pagination", role: "navigation" }
14
+ base[:aria] = { label: @label } unless aria_labelled?
15
+ nav(**mix(base, @attrs), &)
8
16
  end
9
17
  end
10
18
  end
@@ -2,15 +2,24 @@ module PhlexKit
2
2
  # "Next" control (label + chevron, label hidden on small screens), ported
3
3
  # from shadcn/ui's PaginationNext. Renders its own <li>. See pagination.rb.
4
4
  class PaginationNext < BaseComponent
5
- def initialize(href: "#", label: "Next", **attrs)
5
+ DEFAULT_LABEL = "Next"
6
+
7
+ def initialize(href: "#", label: DEFAULT_LABEL, **attrs)
6
8
  @href = href
7
9
  @label = label
8
10
  @attrs = attrs
9
11
  end
10
12
 
11
13
  def view_template
14
+ # The English aria-label only accompanies the default English label; a
15
+ # custom (possibly localized) label: speaks for itself — hardcoding
16
+ # "Go to next page" over it would make AT announce the wrong language.
17
+ base = { href: @href, class: "pk-button ghost pk-pagination-next" }
18
+ # ...and never over a caller-supplied aria label — `mix` would fuse
19
+ # the two strings instead of overriding.
20
+ base[:aria] = { label: "Go to next page" } if @label == DEFAULT_LABEL && !aria_labelled?
12
21
  li do
13
- a(**mix({ href: @href, class: "pk-button ghost pk-pagination-next", aria: { label: "Go to next page" } }, @attrs)) do
22
+ a(**mix(base, @attrs)) do
14
23
  span(class: "pk-pagination-label") { @label }
15
24
  render Icon.new(:chevron_right, size: nil)
16
25
  end
@@ -3,15 +3,24 @@ module PhlexKit
3
3
  # ported from shadcn/ui's PaginationPrevious. Renders its own <li>.
4
4
  # See pagination.rb.
5
5
  class PaginationPrevious < BaseComponent
6
- def initialize(href: "#", label: "Previous", **attrs)
6
+ DEFAULT_LABEL = "Previous"
7
+
8
+ def initialize(href: "#", label: DEFAULT_LABEL, **attrs)
7
9
  @href = href
8
10
  @label = label
9
11
  @attrs = attrs
10
12
  end
11
13
 
12
14
  def view_template
15
+ # The English aria-label only accompanies the default English label; a
16
+ # custom (possibly localized) label: speaks for itself — hardcoding
17
+ # "Go to previous page" over it would make AT announce the wrong language.
18
+ base = { href: @href, class: "pk-button ghost pk-pagination-previous" }
19
+ # ...and never over a caller-supplied aria label — `mix` would fuse
20
+ # the two strings instead of overriding.
21
+ base[:aria] = { label: "Go to previous page" } if @label == DEFAULT_LABEL && !aria_labelled?
13
22
  li do
14
- a(**mix({ href: @href, class: "pk-button ghost pk-pagination-previous", aria: { label: "Go to previous page" } }, @attrs)) do
23
+ a(**mix(base, @attrs)) do
15
24
  render Icon.new(:chevron_left, size: nil)
16
25
  span(class: "pk-pagination-label") { @label }
17
26
  end
@@ -17,14 +17,23 @@ export default class extends Controller {
17
17
  if (invoker) {
18
18
  invoker.popoverTargetElement = this.contentTarget
19
19
  } else {
20
+ // Button-less fallback: keyboard toggling and popup semantics don't
21
+ // come for free like they do with popoverTargetElement — wire Enter
22
+ // (and Space, off text-entry controls) plus aria-haspopup/-expanded
23
+ // onto the focusable child so AT users can discover and open it.
24
+ this.fallbackInvoker = this.triggerTarget.querySelector("input, a[href], select, textarea, [tabindex]") || this.triggerTarget
25
+ this.fallbackInvoker.setAttribute("aria-haspopup", "dialog")
26
+ this.fallbackInvoker.setAttribute("aria-expanded", this.contentTarget.matches(":popover-open") ? "true" : "false")
20
27
  this.triggerTarget.addEventListener("pointerdown", this.armToggle)
21
28
  this.triggerTarget.addEventListener("click", this.toggle)
29
+ this.triggerTarget.addEventListener("keydown", this.keyToggle)
22
30
  }
23
31
  }
24
32
 
25
33
  disconnect() {
26
34
  this.triggerTarget.removeEventListener("pointerdown", this.armToggle)
27
35
  this.triggerTarget.removeEventListener("click", this.toggle)
36
+ this.triggerTarget.removeEventListener("keydown", this.keyToggle)
28
37
  }
29
38
 
30
39
  armToggle = () => { this.wasOpen = this.contentTarget.matches(":popover-open") }
@@ -32,8 +41,29 @@ export default class extends Controller {
32
41
  if (this.wasOpen) { this.wasOpen = false; return }
33
42
  this.contentTarget.togglePopover()
34
43
  }
44
+ keyToggle = (e) => {
45
+ const textEntry = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)
46
+ if (e.key !== "Enter" && !(e.key === " " && !textEntry)) return
47
+ e.preventDefault()
48
+ this.contentTarget.togglePopover()
49
+ }
50
+
51
+ // One stable handler added/removed symmetrically — an anonymous listener
52
+ // here would pile up a duplicate on every target reconnect.
53
+ syncState = (e) => {
54
+ e.target.dataset.state = e.newState === "open" ? "open" : "closed"
55
+ this.fallbackInvoker?.setAttribute("aria-expanded", e.newState === "open" ? "true" : "false")
56
+ }
35
57
 
36
58
  contentTargetConnected(el) {
37
- el.addEventListener("toggle", (e) => { el.dataset.state = e.newState === "open" ? "open" : "closed" })
59
+ // A Turbo snapshot serializes data-state="open" but :popover-open does
60
+ // not survive the restore — resync before listening so host CSS keyed
61
+ // on the state hook doesn't misfire.
62
+ el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
63
+ el.addEventListener("toggle", this.syncState)
64
+ }
65
+
66
+ contentTargetDisconnected(el) {
67
+ el.removeEventListener("toggle", this.syncState)
38
68
  }
39
69
  }
@@ -14,4 +14,12 @@
14
14
  width: 100%;
15
15
  background: var(--pk-brand);
16
16
  transition: transform .3s ease;
17
+ /* value% − 100%: slides the full-width indicator into view from the
18
+ inline-start edge. --pk-progress-value is a unitless 0–100 set inline
19
+ by progress.rb. */
20
+ transform: translateX(calc((var(--pk-progress-value, 0) - 100) * 1%));
21
+ }
22
+ /* RTL: the bar fills from the right, so travel flips. */
23
+ .pk-progress:dir(rtl) .pk-progress-indicator {
24
+ transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
17
25
  }
@@ -4,21 +4,30 @@ module PhlexKit
4
4
  # JS; width comes from a caller `class:`/`style:`. Tailwind → vanilla
5
5
  # `.pk-progress*` (progress.css).
6
6
  class Progress < BaseComponent
7
- def initialize(value: 0, **attrs)
7
+ # value_text: is a named kwarg (not read from **attrs) because `mix`
8
+ # would *fuse* a caller "aria-valuetext" with the generated default.
9
+ def initialize(value: 0, value_text: nil, **attrs)
8
10
  @value = value.to_f.clamp(0.0, 100.0)
11
+ @value_text = value_text
9
12
  @attrs = attrs
10
13
  end
11
14
 
12
15
  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)
13
21
  div(**mix({
14
22
  role: "progressbar",
15
- "aria-valuenow": @value,
23
+ "aria-valuenow": v,
16
24
  "aria-valuemin": 0,
17
25
  "aria-valuemax": 100,
18
- "aria-valuetext": "#{@value}%",
19
- class: "pk-progress"
26
+ "aria-valuetext": @value_text || "#{v}%",
27
+ class: "pk-progress",
28
+ style: "--pk-progress-value: #{v};"
20
29
  }, @attrs)) do
21
- div(class: "pk-progress-indicator", style: "transform: translateX(-#{100 - @value}%)")
30
+ div(class: "pk-progress-indicator")
22
31
  end
23
32
  end
24
33
  end
@@ -36,6 +36,9 @@ export default class extends Controller {
36
36
  const next = handle.nextElementSibling
37
37
  if (!prev || !next) return
38
38
  e.preventDefault()
39
+ // preventDefault suppresses mouse-driven focus — hand it to the handle
40
+ // so a drag can be fine-tuned with the arrow keys immediately.
41
+ handle.focus({ preventScroll: true })
39
42
 
40
43
  const horizontal = this.directionValue === "horizontal"
41
44
  const sizeOf = (el) => horizontal ? el.getBoundingClientRect().width : el.getBoundingClientRect().height
@@ -47,15 +50,21 @@ export default class extends Controller {
47
50
  // constant: normalizing to 2 rescaled the pair against untouched
48
51
  // siblings, so in a 25/25/50 group the 50 panel ballooned on first drag.
49
52
  pairGrow: this.growOf(prev) + this.growOf(next) || 2,
53
+ // Snapshot the group's total grow: min/max-size percentages are shares
54
+ // of the WHOLE group, and untouched siblings keep their grow mid-drag.
55
+ groupGrow: this.groupGrow(),
50
56
  }
51
57
  try { handle.setPointerCapture(e.pointerId) } catch {}
52
58
 
53
59
  const onMove = (ev) => {
60
+ // LTR assumption: clientX grows toward the trailing panel; RTL drag
61
+ // inversion is a documented limitation.
54
62
  const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
55
63
  const total = drag.prevSize + drag.nextSize
56
64
  const prevSize = Math.min(Math.max(drag.prevSize + delta, 0), total)
57
- prev.style.flexGrow = (prevSize / total) * drag.pairGrow
58
- next.style.flexGrow = ((total - prevSize) / total) * drag.pairGrow
65
+ const prevGrow = this.clampPrevGrow(prev, next, (prevSize / total) * drag.pairGrow, drag.pairGrow, drag.groupGrow)
66
+ prev.style.flexGrow = prevGrow
67
+ next.style.flexGrow = drag.pairGrow - prevGrow
59
68
  this.syncValuenow(handle)
60
69
  }
61
70
  const onUp = () => {
@@ -71,7 +80,8 @@ export default class extends Controller {
71
80
  }
72
81
 
73
82
  // Arrow keys resize by 5% of the pair per press (matching the drag axis);
74
- // Home/End collapse the leading panel to its min/max.
83
+ // Home/End collapse the leading panel to its min/max (clamped to any
84
+ // per-panel min-size/max-size bounds, same as drags).
75
85
  keydown(e) {
76
86
  const handle = e.currentTarget
77
87
  const prev = handle.previousElementSibling
@@ -96,8 +106,9 @@ export default class extends Controller {
96
106
  }
97
107
  e.preventDefault()
98
108
 
99
- prev.style.flexGrow = share * pairGrow
100
- next.style.flexGrow = (1 - share) * pairGrow
109
+ const prevGrow = this.clampPrevGrow(prev, next, share * pairGrow, pairGrow, this.groupGrow())
110
+ prev.style.flexGrow = prevGrow
111
+ next.style.flexGrow = pairGrow - prevGrow
101
112
  this.syncValuenow(handle)
102
113
  }
103
114
 
@@ -106,6 +117,42 @@ export default class extends Controller {
106
117
  return Number.isFinite(g) ? g : 1
107
118
  }
108
119
 
120
+ // Clamp the leading panel's proposed flex-grow to both panels' optional
121
+ // min-size/max-size percentages. Bounds are shares of the whole group
122
+ // (grow / groupGrow), and the pair's combined grow is fixed, so a bound on
123
+ // one panel implies the complementary bound on its neighbour. Without any
124
+ // bounds this collapses to the historical [0, pairGrow] clamp.
125
+ clampPrevGrow(prev, next, prevGrow, pairGrow, groupGrow) {
126
+ const pb = this.boundsOf(prev)
127
+ const nb = this.boundsOf(next)
128
+ let lo = 0
129
+ let hi = pairGrow
130
+ if (pb.min !== null) lo = Math.max(lo, pb.min * groupGrow)
131
+ if (nb.max !== null) lo = Math.max(lo, pairGrow - nb.max * groupGrow)
132
+ if (pb.max !== null) hi = Math.min(hi, pb.max * groupGrow)
133
+ if (nb.min !== null) hi = Math.min(hi, pairGrow - nb.min * groupGrow)
134
+ if (lo > hi) return Math.min(Math.max(prevGrow, 0), pairGrow) // unsatisfiable bounds: ignore them
135
+ return Math.min(Math.max(prevGrow, lo), hi)
136
+ }
137
+
138
+ // A panel's declared size bounds as fractions of the group, or nulls.
139
+ boundsOf(el) {
140
+ const min = parseFloat(el.getAttribute("data-phlex-kit--resizable-min-size"))
141
+ const max = parseFloat(el.getAttribute("data-phlex-kit--resizable-max-size"))
142
+ return {
143
+ min: Number.isFinite(min) ? min / 100 : null,
144
+ max: Number.isFinite(max) ? max / 100 : null,
145
+ }
146
+ }
147
+
148
+ // Combined flex-grow of this group's own panels (direct children only —
149
+ // panels of a nested group belong to that group's own controller).
150
+ groupGrow() {
151
+ return [...this.element.children]
152
+ .filter((el) => el.matches('[data-phlex-kit--resizable-target~="panel"]'))
153
+ .reduce((sum, el) => sum + this.growOf(el), 0) || 2
154
+ }
155
+
109
156
  syncValuenow(handle) {
110
157
  const prev = handle.previousElementSibling
111
158
  const next = handle.nextElementSibling
@@ -1,11 +1,16 @@
1
1
  module PhlexKit
2
- # One pane of a ResizablePanelGroup. `default_size:` is a percentage.
2
+ # One pane of a ResizablePanelGroup. `default_size:`, `min_size:` and
3
+ # `max_size:` are percentages of the group (shadcn's defaultSize/minSize/
4
+ # maxSize); the bounds ride along as data attributes the resizable
5
+ # controller clamps drags and keyboard resizes against.
3
6
  # See resizable_panel_group.rb.
4
7
  class ResizablePanel < BaseComponent
5
- def initialize(default_size: nil, **attrs)
6
- # Numeric coercion: the value is interpolated into a style attribute, and
8
+ def initialize(default_size: nil, min_size: nil, max_size: nil, **attrs)
9
+ # Numeric coercion: the values are interpolated into attributes, and
7
10
  # hosts plausibly round-trip saved panel sizes from client state.
8
11
  @default_size = default_size.nil? ? nil : Float(default_size)
12
+ @min_size = min_size.nil? ? nil : Float(min_size)
13
+ @max_size = max_size.nil? ? nil : Float(max_size)
9
14
  @attrs = attrs
10
15
  end
11
16
 
@@ -16,7 +21,11 @@ module PhlexKit
16
21
  div(**mix({
17
22
  class: "pk-resizable-panel",
18
23
  style: style,
19
- data: { phlex_kit__resizable_target: "panel" }
24
+ data: {
25
+ phlex_kit__resizable_target: "panel",
26
+ phlex_kit__resizable_min_size: @min_size && Kernel.format("%g", @min_size),
27
+ phlex_kit__resizable_max_size: @max_size && Kernel.format("%g", @max_size)
28
+ }.compact
20
29
  }.compact, @attrs), &)
21
30
  end
22
31
  end
@@ -2,16 +2,35 @@ module PhlexKit
2
2
  # Styled scroll container, ported from shadcn/ui's ScrollArea. Radix's custom
3
3
  # scrollbars are replaced with native thin scrollbars themed via CSS — no JS.
4
4
  # Constrain it with a height/width from the caller. tabindex=0 makes the
5
- # scrollable region keyboard-focusable (WCAG); role=region gives that focus
6
- # stop a role — pass `aria: { label: ... }` to name it. `.pk-scroll-area`
7
- # (scroll_area.css).
5
+ # scrollable region keyboard-focusable (WCAG scrollable-region-focusable).
6
+ # Pass `aria: { label: ... }` (or aria-labelledby) to name it — only then
7
+ # does it render role=region; a nameless region is noise for AT, so the
8
+ # role is omitted when unlabelled. `.pk-scroll-area` (scroll_area.css).
8
9
  class ScrollArea < BaseComponent
9
10
  def initialize(**attrs)
10
11
  @attrs = attrs
11
12
  end
12
13
 
13
14
  def view_template(&)
14
- div(**mix({ class: "pk-scroll-area", tabindex: "0", role: "region" }, @attrs), &)
15
+ # Defaults only when the caller didn't supply their own — `mix` would
16
+ # fuse role="region log" / tabindex="0 -1" instead of overriding.
17
+ base = { class: "pk-scroll-area" }
18
+ base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
19
+ base[:role] = "region" if labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
20
+ div(**mix(base, @attrs), &)
21
+ end
22
+
23
+ private
24
+
25
+ # True when the caller supplies an accessible name, via the aria: hash
26
+ # (aria: { label:/labelledby: }) or flat aria_label/aria-labelledby attrs.
27
+ def labelled?
28
+ aria = @attrs[:aria] || @attrs["aria"]
29
+ if aria.is_a?(Hash)
30
+ return true if %w[label labelledby].any? { |k| aria[k.to_sym] || aria[k] }
31
+ end
32
+ [ :aria_label, "aria_label", "aria-label",
33
+ :aria_labelledby, "aria_labelledby", "aria-labelledby" ].any? { |k| @attrs[k] }
15
34
  end
16
35
  end
17
36
  end
@@ -22,9 +22,9 @@
22
22
  white-space: nowrap;
23
23
  border-radius: var(--pk-radius);
24
24
  border: 1px solid var(--pk-input);
25
- background: transparent;
26
25
  color: var(--pk-text);
27
- padding: .25rem .5rem .25rem .625rem;
26
+ padding-block: .25rem;
27
+ padding-inline: .625rem .5rem;
28
28
  gap: .375rem;
29
29
  font: inherit;
30
30
  font-size: .875rem;
@@ -88,7 +88,9 @@
88
88
  user-select: none;
89
89
  gap: .375rem;
90
90
  border-radius: calc(var(--pk-radius) - 2px);
91
- padding: .25rem 2rem .25rem .375rem;
91
+ /* 2rem gutter on the inline-end — where .pk-select-item-check sits. */
92
+ padding-block: .25rem;
93
+ padding-inline: .375rem 2rem;
92
94
  font-size: .875rem;
93
95
  color: var(--pk-text);
94
96
  outline: none;
@@ -5,8 +5,11 @@ module PhlexKit
5
5
  # inner `.pk-select-viewport` is the bordered, scrollable box.
6
6
  # Holds SelectGroup / SelectLabel / SelectItem children. See select.rb.
7
7
  class SelectContent < BaseComponent
8
- def initialize(**attrs)
9
- @id = "pk-select-content-#{SecureRandom.hex(4)}"
8
+ # id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
9
+ # caller id with the generated one into an invalid two-token id, breaking
10
+ # aria-controls and the generated item ids.
11
+ def initialize(id: nil, **attrs)
12
+ @id = id || "pk-select-content-#{SecureRandom.hex(4)}"
10
13
  @attrs = attrs
11
14
  end
12
15
 
@@ -17,7 +20,10 @@ module PhlexKit
17
20
  tabindex: "-1",
18
21
  class: "pk-select-content",
19
22
  popover: "manual",
20
- data: { phlex_kit__select_target: "content" }
23
+ data: {
24
+ phlex_kit__select_target: "content",
25
+ action: "keydown->phlex-kit--select#typeahead"
26
+ }
21
27
  }, @attrs)) do
22
28
  div(class: "pk-select-viewport", &block)
23
29
  end