phlex_kit 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +11 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -2
  4. data/app/components/phlex_kit/accordion/accordion_content.rb +11 -2
  5. data/app/components/phlex_kit/accordion/accordion_controller.js +23 -4
  6. data/app/components/phlex_kit/alert/alert.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +11 -4
  9. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +123 -6
  10. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +10 -4
  11. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  12. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  13. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  14. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  15. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  16. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  17. data/app/components/phlex_kit/badge/badge.css +4 -4
  18. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  19. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  20. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  21. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  22. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  23. data/app/components/phlex_kit/button/button.css +6 -6
  24. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  25. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  26. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  27. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  28. data/app/components/phlex_kit/calendar/calendar_body.rb +12 -1
  29. data/app/components/phlex_kit/calendar/calendar_controller.js +151 -12
  30. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  31. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  32. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  34. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  35. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  36. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  37. data/app/components/phlex_kit/chart/chart_controller.js +37 -10
  38. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  39. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  40. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  41. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
  42. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  43. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  44. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  45. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  46. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  47. data/app/components/phlex_kit/combobox/combobox_controller.js +67 -11
  48. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  49. data/app/components/phlex_kit/command/command.css +4 -4
  50. data/app/components/phlex_kit/command/command_controller.js +51 -10
  51. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  52. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  53. data/app/components/phlex_kit/command/command_input.rb +6 -2
  54. data/app/components/phlex_kit/command/command_item.rb +4 -1
  55. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  56. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
  57. data/app/components/phlex_kit/context_menu/context_menu_controller.js +69 -2
  58. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
  59. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
  60. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  61. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  62. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  63. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  64. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  65. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  66. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  67. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  68. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  69. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +6 -5
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  72. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
  73. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
  74. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +99 -45
  75. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  76. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
  77. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
  78. data/app/components/phlex_kit/field/field.css +1 -1
  79. data/app/components/phlex_kit/field/field_error.rb +5 -6
  80. data/app/components/phlex_kit/form_field/form_field_controller.js +44 -12
  81. data/app/components/phlex_kit/form_field/form_field_error.rb +8 -1
  82. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  83. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  84. data/app/components/phlex_kit/input/input.css +1 -1
  85. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  86. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  87. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  88. data/app/components/phlex_kit/input_otp/input_otp_controller.js +43 -4
  89. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  90. data/app/components/phlex_kit/item/item.css +1 -1
  91. data/app/components/phlex_kit/item/item_group.rb +4 -2
  92. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  93. data/app/components/phlex_kit/marker/marker.css +1 -1
  94. data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
  95. data/app/components/phlex_kit/masked_input/masked_input_controller.js +34 -9
  96. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  97. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  98. data/app/components/phlex_kit/menubar/menubar_controller.js +119 -12
  99. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  100. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  101. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +7 -2
  102. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  103. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  104. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  105. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +11 -3
  106. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  107. data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
  108. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  109. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  110. data/app/components/phlex_kit/popover/popover.css +1 -1
  111. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  112. data/app/components/phlex_kit/resizable/resizable_controller.js +78 -3
  113. data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
  114. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  115. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  116. data/app/components/phlex_kit/select/select.css +3 -3
  117. data/app/components/phlex_kit/select/select.rb +11 -7
  118. data/app/components/phlex_kit/select/select_controller.js +35 -4
  119. data/app/components/phlex_kit/select/select_item.rb +5 -2
  120. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  121. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  122. data/app/components/phlex_kit/sidebar/sidebar.css +27 -15
  123. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  124. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  125. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  126. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  127. data/app/components/phlex_kit/slider/slider.css +3 -3
  128. data/app/components/phlex_kit/slider/slider.rb +3 -1
  129. data/app/components/phlex_kit/switch/switch.rb +6 -1
  130. data/app/components/phlex_kit/table/table.css +5 -3
  131. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  132. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  133. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  134. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
  135. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +49 -7
  136. data/app/components/phlex_kit/toast/toast.css +32 -14
  137. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  138. data/app/components/phlex_kit/toast/toast_region.rb +19 -5
  139. data/app/components/phlex_kit/toast/toaster_controller.js +20 -6
  140. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  141. data/app/components/phlex_kit/toggle/toggle.rb +9 -3
  142. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  143. data/app/components/phlex_kit/typography/heading.rb +9 -3
  144. data/app/components/phlex_kit/typography/typography.css +4 -3
  145. data/app/javascript/phlex_kit/controllers/index.js +0 -2
  146. data/lib/phlex_kit/version.rb +1 -1
  147. metadata +1 -2
  148. data/app/components/phlex_kit/select/select_item_controller.js +0 -14
@@ -3,7 +3,9 @@ module PhlexKit
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template
5
5
  li do
6
- span(**mix({ class: "pk-pagination-ellipsis", aria: { hidden: true } }, @attrs)) do
6
+ # No aria-hidden on the wrapper it would swallow the sr-only text.
7
+ # The Icon renders its own aria-hidden="true"; only the glyph is hidden.
8
+ span(**mix({ class: "pk-pagination-ellipsis" }, @attrs)) do
7
9
  render Icon.new(:ellipsis, size: nil)
8
10
  span(class: "pk-sr-only") { "More pages" }
9
11
  end
@@ -21,7 +21,7 @@
21
21
  border-radius: var(--pk-radius);
22
22
  background: var(--pk-surface); color: var(--pk-text); padding: .625rem;
23
23
  font-size: .875rem;
24
- box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
24
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
25
25
  }
26
26
  /* display must be gated on :popover-open — an unconditional author
27
27
  `display: flex` would beat the UA's [popover] { display: none }. */
@@ -7,6 +7,8 @@
7
7
  position: relative;
8
8
  flex: none;
9
9
  background: var(--pk-border);
10
+ /* without this, touch drags scroll the page instead of resizing */
11
+ touch-action: none;
10
12
  }
11
13
  .pk-resizable-group.horizontal > .pk-resizable-handle { width: 1px; cursor: col-resize; }
12
14
  .pk-resizable-group.vertical > .pk-resizable-handle { height: 1px; cursor: row-resize; }
@@ -19,7 +21,7 @@
19
21
  }
20
22
  .pk-resizable-handle:focus-visible {
21
23
  outline: none;
22
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
24
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
23
25
  }
24
26
 
25
27
  /* Visible grip pill (their withHandle / .cn-resizable-handle-icon). */
@@ -1,13 +1,36 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // PhlexKit's stand-in for react-resizable-panels behind shadcn's Resizable:
4
- // dragging a handle rebalances the flex-grow of the two panels around it.
4
+ // dragging (or arrow-keying) a handle rebalances the flex-grow of the two
5
+ // panels around it. The handle is a focusable role="separator", so it also
6
+ // carries the ARIA value contract: aria-valuenow tracks the leading panel's
7
+ // share of the pair, and aria-orientation follows the group direction (a
8
+ // handle in a horizontal group is a vertical separator).
5
9
  // Connects to data-controller="phlex-kit--resizable"
6
10
  export default class extends Controller {
7
11
  static targets = ["panel", "handle"]
8
12
  static values = { direction: { type: String, default: "horizontal" } }
9
13
 
14
+ disconnect() {
15
+ // a disconnect mid-drag must drop the handle's move/up listeners
16
+ if (this._activeDrag) {
17
+ const { handle, onMove, onUp } = this._activeDrag
18
+ handle.removeEventListener("pointermove", onMove)
19
+ handle.removeEventListener("pointerup", onUp)
20
+ handle.removeEventListener("pointercancel", onUp)
21
+ this._activeDrag = null
22
+ }
23
+ }
24
+
25
+ handleTargetConnected(handle) {
26
+ handle.setAttribute("aria-orientation", this.directionValue === "horizontal" ? "vertical" : "horizontal")
27
+ handle.setAttribute("aria-valuemin", "0")
28
+ handle.setAttribute("aria-valuemax", "100")
29
+ this.syncValuenow(handle)
30
+ }
31
+
10
32
  start(e) {
33
+ if (e.button !== 0 || !e.isPrimary) return
11
34
  const handle = e.currentTarget
12
35
  const prev = handle.previousElementSibling
13
36
  const next = handle.nextElementSibling
@@ -20,6 +43,10 @@ export default class extends Controller {
20
43
  startPos: horizontal ? e.clientX : e.clientY,
21
44
  prevSize: sizeOf(prev),
22
45
  nextSize: sizeOf(next),
46
+ // The drag redistributes the PAIR's combined flex-grow, not a fixed
47
+ // constant: normalizing to 2 rescaled the pair against untouched
48
+ // siblings, so in a 25/25/50 group the 50 panel ballooned on first drag.
49
+ pairGrow: this.growOf(prev) + this.growOf(next) || 2,
23
50
  }
24
51
  try { handle.setPointerCapture(e.pointerId) } catch {}
25
52
 
@@ -27,16 +54,64 @@ export default class extends Controller {
27
54
  const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
28
55
  const total = drag.prevSize + drag.nextSize
29
56
  const prevSize = Math.min(Math.max(drag.prevSize + delta, 0), total)
30
- prev.style.flexGrow = (prevSize / total) * 2
31
- next.style.flexGrow = ((total - prevSize) / total) * 2
57
+ prev.style.flexGrow = (prevSize / total) * drag.pairGrow
58
+ next.style.flexGrow = ((total - prevSize) / total) * drag.pairGrow
59
+ this.syncValuenow(handle)
32
60
  }
33
61
  const onUp = () => {
34
62
  handle.removeEventListener("pointermove", onMove)
35
63
  handle.removeEventListener("pointerup", onUp)
36
64
  handle.removeEventListener("pointercancel", onUp)
65
+ this._activeDrag = null
37
66
  }
38
67
  handle.addEventListener("pointermove", onMove)
39
68
  handle.addEventListener("pointerup", onUp)
40
69
  handle.addEventListener("pointercancel", onUp)
70
+ this._activeDrag = { handle, onMove, onUp } // for disconnect() cleanup
71
+ }
72
+
73
+ // 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.
75
+ keydown(e) {
76
+ const handle = e.currentTarget
77
+ const prev = handle.previousElementSibling
78
+ const next = handle.nextElementSibling
79
+ if (!prev || !next) return
80
+
81
+ const horizontal = this.directionValue === "horizontal"
82
+ const steps = horizontal
83
+ ? { ArrowLeft: -0.05, ArrowRight: 0.05 }
84
+ : { ArrowUp: -0.05, ArrowDown: 0.05 }
85
+
86
+ const pairGrow = this.growOf(prev) + this.growOf(next) || 2
87
+ let share = this.growOf(prev) / (pairGrow || 1)
88
+ if (e.key in steps) {
89
+ share = Math.min(1, Math.max(0, share + steps[e.key]))
90
+ } else if (e.key === "Home") {
91
+ share = 0
92
+ } else if (e.key === "End") {
93
+ share = 1
94
+ } else {
95
+ return
96
+ }
97
+ e.preventDefault()
98
+
99
+ prev.style.flexGrow = share * pairGrow
100
+ next.style.flexGrow = (1 - share) * pairGrow
101
+ this.syncValuenow(handle)
102
+ }
103
+
104
+ growOf(el) {
105
+ const g = parseFloat(getComputedStyle(el).flexGrow)
106
+ return Number.isFinite(g) ? g : 1
107
+ }
108
+
109
+ syncValuenow(handle) {
110
+ const prev = handle.previousElementSibling
111
+ const next = handle.nextElementSibling
112
+ if (!prev || !next) return
113
+ const pair = this.growOf(prev) + this.growOf(next)
114
+ const share = pair > 0 ? this.growOf(prev) / pair : 0.5
115
+ handle.setAttribute("aria-valuenow", String(Math.round(share * 100)))
41
116
  }
42
117
  }
@@ -12,10 +12,12 @@ module PhlexKit
12
12
  class: "pk-resizable-handle",
13
13
  role: "separator",
14
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.
15
17
  aria: { orientation: "vertical" },
16
18
  data: {
17
19
  phlex_kit__resizable_target: "handle",
18
- action: "pointerdown->phlex-kit--resizable#start"
20
+ action: "pointerdown->phlex-kit--resizable#start keydown->phlex-kit--resizable#keydown"
19
21
  }
20
22
  }, @attrs)) do
21
23
  span(class: "pk-resizable-handle-grip", aria: { hidden: "true" }) if @with_handle
@@ -10,7 +10,9 @@ module PhlexKit
10
10
  end
11
11
 
12
12
  def view_template(&)
13
- style = @default_size ? "flex-grow: #{Kernel.format("%g", @default_size)}" : nil
13
+ # Trailing ";" matters: Phlex's mix joins string attrs with a space, so
14
+ # a caller style: would otherwise fuse into one invalid declaration.
15
+ style = @default_size ? "flex-grow: #{Kernel.format("%g", @default_size)};" : nil
14
16
  div(**mix({
15
17
  class: "pk-resizable-panel",
16
18
  style: style,
@@ -1,7 +1,9 @@
1
1
  module PhlexKit
2
2
  # Styled scroll container, ported from shadcn/ui's ScrollArea. Radix's custom
3
3
  # scrollbars are replaced with native thin scrollbars themed via CSS — no JS.
4
- # Constrain it with a height/width from the caller. `.pk-scroll-area`
4
+ # Constrain it with a height/width from the caller. tabindex=0 makes the
5
+ # scrollable region keyboard-focusable (WCAG); role=region gives that focus
6
+ # stop a role — pass `aria: { label: ... }` to name it. `.pk-scroll-area`
5
7
  # (scroll_area.css).
6
8
  class ScrollArea < BaseComponent
7
9
  def initialize(**attrs)
@@ -9,7 +11,7 @@ module PhlexKit
9
11
  end
10
12
 
11
13
  def view_template(&)
12
- div(**mix({ class: "pk-scroll-area", tabindex: "0" }, @attrs), &)
14
+ div(**mix({ class: "pk-scroll-area", tabindex: "0", role: "region" }, @attrs), &)
13
15
  end
14
16
  end
15
17
  end
@@ -44,7 +44,7 @@
44
44
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
45
45
  }
46
46
  .pk-select-trigger:disabled { cursor: not-allowed; opacity: .5; }
47
- .pk-select-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-left: .5rem; opacity: .5; }
47
+ .pk-select-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-inline-start: .5rem; opacity: .5; }
48
48
  .pk-select-value { overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
49
49
 
50
50
  /* Dropdown panel — anchored directly below the trigger, flips above at the
@@ -75,7 +75,7 @@
75
75
  background: var(--pk-surface);
76
76
  color: var(--pk-text);
77
77
  padding: .25rem;
78
- box-shadow: 0 4px 16px rgba(0, 0, 0, .4);
78
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
79
79
  }
80
80
 
81
81
  .pk-select-label { margin: 0; padding: .25rem .375rem; font-size: .75rem; font-weight: 400; color: var(--pk-muted); }
@@ -102,7 +102,7 @@
102
102
  .pk-select-item-check {
103
103
  visibility: hidden;
104
104
  position: absolute;
105
- right: .5rem;
105
+ inset-inline-end: .5rem;
106
106
  display: flex;
107
107
  align-items: center;
108
108
  justify-content: center;
@@ -2,11 +2,13 @@ module PhlexKit
2
2
  # Custom dropdown select, ported from ruby_ui's RubyUI::Select — the styled
3
3
  # popover (Image #2), NOT the native <select> (that's PhlexKit::NativeSelect). Unlike
4
4
  # the rest of the kit this component IS JS-driven: it keeps ruby_ui's Stimulus
5
- # wiring (`phlex-kit--select` / `phlex-kit--select-item`, in
6
- # app/javascript/controllers/ruby_ui/), since the open/close, selection, and
7
- # keyboard nav are the point. The one change from upstream: the controller drops
8
- # the `@floating-ui/dom` dependency and positions the panel with plain CSS
9
- # (opens directly below the trigger). Tailwind → vanilla `.pk-select-*` (select.css).
5
+ # wiring (`phlex-kit--select`), since the open/close, selection, and keyboard
6
+ # nav are the point. Two changes from upstream: the controller drops the
7
+ # `@floating-ui/dom` dependency and positions the panel with plain CSS (opens
8
+ # directly below the trigger), and selection flips aria-selected via the
9
+ # instance-scoped itemTargets rather than upstream's document-scoped
10
+ # `.pk-select-item` outlet (which clobbered every other Select on the page).
11
+ # Tailwind → vanilla `.pk-select-*` (select.css).
10
12
  #
11
13
  # Multi-part. The hidden SelectInput carries the form value/name; SelectTrigger +
12
14
  # SelectValue are the closed-state button; SelectContent > SelectGroup >
@@ -34,8 +36,10 @@ module PhlexKit
34
36
  data: {
35
37
  controller: "phlex-kit--select",
36
38
  phlex_kit__select_open_value: "false",
37
- action: "click@window->phlex-kit--select#clickOutside",
38
- phlex_kit__select_phlex_kit__select_item_outlet: ".pk-select-item"
39
+ # keydown.esc rides on the root (not only the items) so Escape closes
40
+ # the [popover=manual] panel with focus on the trigger too; handleEsc
41
+ # no-ops while closed.
42
+ action: "click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc"
39
43
  }
40
44
  }, @attrs), &block)
41
45
  end
@@ -8,7 +8,6 @@ import { Controller } from "@hotwired/stimulus";
8
8
  export default class extends Controller {
9
9
  static targets = ["trigger", "content", "input", "value", "item"];
10
10
  static values = { open: Boolean };
11
- static outlets = ["phlex-kit--select-item"];
12
11
 
13
12
  connect() {
14
13
  this.generateItemsIds();
@@ -17,13 +16,20 @@ export default class extends Controller {
17
16
  selectItem(event) {
18
17
  event.preventDefault();
19
18
 
20
- this.phlexKitSelectItemOutlets.forEach((item) => item.handleSelectItem(event));
19
+ // currentTarget, not target: the click may land on a child of the item
20
+ // (target.dataset.value would be undefined). itemTargets, not a
21
+ // document-scoped outlet: outlets match `.pk-select-item` page-wide and
22
+ // clobbered every other Select's aria-selected.
23
+ const item = event.currentTarget;
24
+ this.itemTargets.forEach((el) => {
25
+ el.setAttribute("aria-selected", el === item ? "true" : "false");
26
+ });
21
27
 
22
28
  const oldValue = this.inputTarget.value;
23
- const newValue = event.target.dataset.value;
29
+ const newValue = item.dataset.value;
24
30
 
25
31
  this.inputTarget.value = newValue;
26
- this.valueTarget.innerText = event.target.innerText;
32
+ this.valueTarget.innerText = item.innerText;
27
33
 
28
34
  this.dispatchOnChange(oldValue, newValue);
29
35
  this.closeContent();
@@ -66,7 +72,25 @@ export default class extends Controller {
66
72
  }
67
73
  }
68
74
 
75
+ handleHome(event) {
76
+ event.preventDefault();
77
+ if (this.itemTargets.length === 0) return;
78
+ this.resetCurrent();
79
+ this.setAriaCurrentAndActiveDescendant(0);
80
+ }
81
+
82
+ handleEnd(event) {
83
+ event.preventDefault();
84
+ if (this.itemTargets.length === 0) return;
85
+ this.resetCurrent();
86
+ this.setAriaCurrentAndActiveDescendant(this.itemTargets.length - 1);
87
+ }
88
+
89
+ // Bound on the root as well as the items, so Escape also closes with focus
90
+ // on the trigger; no-op while closed (a closed-state Escape must not steal
91
+ // focus back to the trigger).
69
92
  handleEsc(event) {
93
+ if (!this.contentTarget.matches(":popover-open")) return;
70
94
  event.preventDefault();
71
95
  this.closeContent();
72
96
  }
@@ -79,7 +103,11 @@ export default class extends Controller {
79
103
  const focusItem = selectedItem || (this.hasItemTarget ? this.itemTarget : null);
80
104
  if (!focusItem) return; // empty select — nothing to focus
81
105
 
106
+ // preventScroll stops the page jumping when the top-layer panel opens;
107
+ // scrollIntoView(nearest) then scrolls only the panel's own viewport so
108
+ // the focused option is actually visible (max-height panel).
82
109
  focusItem.focus({ preventScroll: true });
110
+ focusItem.scrollIntoView({ block: "nearest" });
83
111
  focusItem.setAttribute("aria-current", "true");
84
112
  this.triggerTarget.setAttribute("aria-activedescendant", focusItem.getAttribute("id"));
85
113
  }
@@ -127,6 +155,9 @@ export default class extends Controller {
127
155
  setAriaCurrentAndActiveDescendant(currentIndex) {
128
156
  const currentItem = this.itemTargets[currentIndex];
129
157
  currentItem.focus({ preventScroll: true });
158
+ // Keep the highlighted option visible inside the scrollable viewport —
159
+ // preventScroll (page-jump guard) otherwise lets it scroll out of view.
160
+ currentItem.scrollIntoView({ block: "nearest" });
130
161
  currentItem.setAttribute("aria-current", "true");
131
162
  this.triggerTarget.setAttribute("aria-activedescendant", currentItem.getAttribute("id"));
132
163
  }
@@ -16,14 +16,17 @@ module PhlexKit
16
16
  def view_template(&block)
17
17
  div(**mix({
18
18
  role: "option",
19
- tabindex: "0",
19
+ # Roving listbox pattern: options are focusable programmatically only
20
+ # (focus() works on tabindex=-1), so Tab exits the widget instead of
21
+ # walking every option.
22
+ tabindex: "-1",
20
23
  class: "pk-select-item",
21
24
  "aria-selected": (@selected ? "true" : "false"),
22
25
  data: {
23
26
  value: @value,
24
- controller: "phlex-kit--select-item",
25
27
  action: "click->phlex-kit--select#selectItem keydown.enter->phlex-kit--select#selectItem " \
26
28
  "keydown.down->phlex-kit--select#handleKeyDown keydown.up->phlex-kit--select#handleKeyUp " \
29
+ "keydown.home->phlex-kit--select#handleHome keydown.end->phlex-kit--select#handleEnd " \
27
30
  "keydown.esc->phlex-kit--select#handleEsc",
28
31
  phlex_kit__select_target: "item"
29
32
  }
@@ -4,15 +4,15 @@
4
4
  75%-wide capped at 24rem. Overlay at the kit-unified black/10 + blur-xs
5
5
  (see dialog.css). Theme tokens from the global stylesheet. */
6
6
  .pk-sheet-trigger { display: inline-block; }
7
- .pk-sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: rgb(0 0 0 / .1); backdrop-filter: blur(4px); }
7
+ .pk-sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: color-mix(in srgb, var(--pk-overlay) 10%, transparent); backdrop-filter: blur(4px); }
8
8
  @supports not (backdrop-filter: blur(1px)) {
9
- .pk-sheet-backdrop { background: rgb(0 0 0 / .5); }
9
+ .pk-sheet-backdrop { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
10
10
  }
11
11
  .pk-sheet-content {
12
12
  position: fixed; z-index: 50; display: flex; flex-direction: column; gap: 1rem;
13
13
  background: var(--pk-surface); color: var(--pk-text); padding: 0;
14
14
  font-size: .875rem;
15
- box-shadow: 0 10px 30px rgb(0 0 0 / .35); overflow: auto;
15
+ box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent); overflow: auto;
16
16
  }
17
17
  .pk-sheet-content.right { inset-block: 0; right: 0; height: 100%; width: 75%; max-width: 24rem; border-left: 1px solid var(--pk-border); }
18
18
  .pk-sheet-content.left { inset-block: 0; left: 0; height: 100%; width: 75%; max-width: 24rem; border-right: 1px solid var(--pk-border); }
@@ -2,12 +2,14 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--sheet-content" (on the cloned node —
4
4
  // drawers ride the same machinery). Owns the modal contract for the open panel:
5
- // scroll lock, initial focus, Tab focus trap, Escape-to-close, focus restore to
6
- // the opener, and aria-labelledby wiring to the SheetTitle/DrawerTitle. connect
7
- // and disconnect exactly bracket the clone's lifetime, so removal by any path
8
- // (close button, backdrop, Escape, turbo:before-cache) restores state.
5
+ // scroll lock, background inert, initial focus, Tab focus trap, Escape-to-close,
6
+ // focus restore to the opener, and aria-labelledby/-describedby wiring to the
7
+ // Sheet/Drawer Title + Description. connect and disconnect exactly bracket the
8
+ // clone's lifetime, so removal by any path (close button, backdrop, Escape,
9
+ // turbo:before-cache — the phlex-kit--sheet source controller removes the clone
10
+ // there) restores state.
9
11
  const FOCUSABLE =
10
- 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
12
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
11
13
 
12
14
  export default class extends Controller {
13
15
  static targets = ["panel"]
@@ -16,12 +18,15 @@ export default class extends Controller {
16
18
  this.opener = document.activeElement
17
19
  this.previousOverflow = document.body.style.overflow
18
20
  document.body.style.overflow = "hidden"
19
- this.#wireAriaLabelledby()
21
+ this.#inertOthers()
22
+ this.#wireAria("aria-labelledby", ".pk-sheet-title, .pk-drawer-title")
23
+ this.#wireAria("aria-describedby", ".pk-sheet-description, .pk-drawer-description")
20
24
  const focusables = this.#focusables()
21
25
  ;(focusables[0] || this.panel).focus()
22
26
  }
23
27
 
24
28
  disconnect() {
29
+ this.#restoreInert()
25
30
  document.body.style.overflow = this.previousOverflow
26
31
  if (this.opener?.isConnected) this.opener.focus()
27
32
  }
@@ -62,11 +67,30 @@ export default class extends Controller {
62
67
  return [...this.panel.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0)
63
68
  }
64
69
 
65
- #wireAriaLabelledby() {
66
- if (this.panel.getAttribute("aria-labelledby")) return
67
- const title = this.element.querySelector(".pk-sheet-title, .pk-drawer-title")
68
- if (!title) return
69
- if (!title.id) title.id = `pk-modal-title-${Math.random().toString(36).slice(2, 10)}`
70
- this.panel.setAttribute("aria-labelledby", title.id)
70
+ // Make everything behind the panel inert (the clone is <body>'s last child,
71
+ // so its siblings are the whole page). Prior inert state is saved per
72
+ // element and restored on disconnect — which fires on every removal path,
73
+ // including the source controller's turbo:before-cache cleanup.
74
+ #inertOthers() {
75
+ this.inerted = []
76
+ for (const el of document.body.children) {
77
+ if (el === this.element) continue
78
+ if (["SCRIPT", "STYLE", "LINK", "TEMPLATE"].includes(el.tagName)) continue
79
+ this.inerted.push([el, el.inert])
80
+ el.inert = true
81
+ }
82
+ }
83
+
84
+ #restoreInert() {
85
+ for (const [el, wasInert] of this.inerted || []) el.inert = wasInert
86
+ this.inerted = null
87
+ }
88
+
89
+ #wireAria(attr, selector) {
90
+ if (this.panel.getAttribute(attr)) return
91
+ const el = this.element.querySelector(selector)
92
+ if (!el) return
93
+ if (!el.id) el.id = `pk-modal-${attr.slice(5)}-${Math.random().toString(36).slice(2, 10)}`
94
+ this.panel.setAttribute(attr, el.id)
71
95
  }
72
96
  }
@@ -42,7 +42,7 @@
42
42
  .pk-sidebar-group-action {
43
43
  position: absolute;
44
44
  top: .375rem;
45
- right: .25rem;
45
+ inset-inline-end: .25rem;
46
46
  display: flex;
47
47
  align-items: center;
48
48
  justify-content: center;
@@ -71,7 +71,7 @@
71
71
  color: var(--pk-sidebar-text, var(--pk-text));
72
72
  font: inherit;
73
73
  font-size: .9rem;
74
- text-align: left;
74
+ text-align: start;
75
75
  text-decoration: none;
76
76
  cursor: pointer;
77
77
  }
@@ -86,7 +86,7 @@
86
86
  .pk-sidebar-menu-action {
87
87
  position: absolute;
88
88
  top: .5rem;
89
- right: .25rem;
89
+ inset-inline-end: .25rem;
90
90
  display: flex;
91
91
  align-items: center;
92
92
  justify-content: center;
@@ -105,7 +105,7 @@
105
105
  user-select: none;
106
106
  position: absolute;
107
107
  top: .5rem;
108
- right: .25rem;
108
+ inset-inline-end: .25rem;
109
109
  display: flex;
110
110
  align-items: center;
111
111
  justify-content: center;
@@ -130,7 +130,7 @@
130
130
  min-width: 0;
131
131
  margin: 0 .875rem;
132
132
  padding: .125rem .625rem;
133
- border-left: 1px solid var(--pk-sidebar-border, var(--pk-border));
133
+ border-inline-start: 1px solid var(--pk-sidebar-border, var(--pk-border));
134
134
  }
135
135
  .pk-sidebar-menu-sub-item { position: relative; }
136
136
  .pk-sidebar-menu-sub-button {
@@ -148,7 +148,7 @@
148
148
  color: var(--pk-sidebar-text, var(--pk-text));
149
149
  font: inherit;
150
150
  font-size: .875rem;
151
- text-align: left;
151
+ text-align: start;
152
152
  text-decoration: none;
153
153
  cursor: pointer;
154
154
  }
@@ -169,14 +169,23 @@
169
169
  .pk-sidebar-wrapper.collapsible-offcanvas .pk-sidebar,
170
170
  .pk-sidebar-wrapper.collapsible-icon .pk-sidebar {
171
171
  position: relative;
172
- transition: margin-left .2s ease, transform .2s ease, width .2s ease;
172
+ /* visibility rides the transition: it stays visible while sliding out and
173
+ flips hidden at the end — hidden also drops the off-screen links from
174
+ the tab order (they stayed focusable while invisible before). */
175
+ transition: margin-left .2s ease, transform .2s ease, width .2s ease, visibility .2s;
173
176
  }
174
- .pk-sidebar-wrapper.collapsible-offcanvas[data-collapsed] .pk-sidebar { margin-left: -16rem; }
177
+ .pk-sidebar-wrapper.collapsible-offcanvas[data-collapsed] .pk-sidebar { margin-left: -16rem; visibility: hidden; }
175
178
 
176
179
  /* Icon mode: the rail shrinks to a 3rem strip — labels and text hide, menu
177
180
  buttons become centered icon squares, hover shows a data-tooltip bubble. */
178
181
  .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar { width: 3rem; }
179
- .pk-sidebar-wrapper.collapsible-icon[data-collapsed] :is(.pk-sidebar-group-label, .pk-sidebar-group-action, .pk-sidebar-menu-badge, .pk-sidebar-menu-action, .pk-sidebar-menu-sub, .pk-sidebar-input, .pk-sidebar-separator) { display: none; }
182
+ /* Desktop-only: at mobile widths icon mode becomes the full drawer, and the
183
+ labels/subs must keep their AUTHOR display values (flex). The old approach
184
+ un-hid them with `display: revert`, but revert rolls back to the USER-AGENT
185
+ origin — group labels lost their flex centering, sub lists their gap. */
186
+ @media (min-width: 768px) {
187
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] :is(.pk-sidebar-group-label, .pk-sidebar-group-action, .pk-sidebar-menu-badge, .pk-sidebar-menu-action, .pk-sidebar-menu-sub, .pk-sidebar-input, .pk-sidebar-separator) { display: none; }
188
+ }
180
189
  .pk-sidebar-wrapper.collapsible-icon[data-collapsed] :is(.pk-sidebar-header, .pk-sidebar-content, .pk-sidebar-footer) {
181
190
  padding-inline: .5rem;
182
191
  align-items: center;
@@ -187,7 +196,9 @@
187
196
  padding: .5rem;
188
197
  justify-content: center;
189
198
  }
190
- .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button > :not(svg) { display: none; }
199
+ @media (min-width: 768px) {
200
+ .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button > :not(svg) { display: none; }
201
+ }
191
202
  .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button[data-tooltip] { position: relative; }
192
203
  .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button[data-tooltip]:hover::after {
193
204
  content: attr(data-tooltip);
@@ -252,9 +263,8 @@
252
263
  @media (max-width: 767px) {
253
264
  /* Below the cutoff both collapsible modes become the overlay drawer. */
254
265
  .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar { width: 16rem; }
255
- .pk-sidebar-wrapper.collapsible-icon[data-collapsed] :is(.pk-sidebar-group-label, .pk-sidebar-group-action, .pk-sidebar-menu-badge, .pk-sidebar-menu-action, .pk-sidebar-menu-sub, .pk-sidebar-input, .pk-sidebar-separator) { display: revert; }
266
+ /* No un-hiding needed: the icon-mode hide rules are scoped to >=768px. */
256
267
  .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button { width: 100%; height: auto; padding: .5rem; justify-content: flex-start; }
257
- .pk-sidebar-wrapper.collapsible-icon[data-collapsed] .pk-sidebar-menu-button > :not(svg) { display: revert; }
258
268
  .pk-sidebar-wrapper.collapsible-icon .pk-sidebar-rail { display: none; }
259
269
  .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon) .pk-sidebar {
260
270
  position: fixed;
@@ -264,15 +274,17 @@
264
274
  height: 100dvh;
265
275
  margin-left: 0;
266
276
  transform: translateX(-100%);
267
- box-shadow: 0 8px 30px rgb(0 0 0 / .35);
277
+ /* closed drawer: invisible AND out of the tab order */
278
+ visibility: hidden;
279
+ box-shadow: 0 8px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
268
280
  }
269
- .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon)[data-open] .pk-sidebar { transform: none; }
281
+ .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon)[data-open] .pk-sidebar { transform: none; visibility: visible; }
270
282
  .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon)[data-open] .pk-sidebar-scrim {
271
283
  display: block;
272
284
  position: fixed;
273
285
  inset: 0;
274
286
  z-index: 49;
275
- background: rgb(0 0 0 / .5);
287
+ background: color-mix(in srgb, var(--pk-overlay) 50%, transparent);
276
288
  border: 0;
277
289
  padding: 0;
278
290
  cursor: default;