phlex_kit 0.10.0 → 0.12.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/phlex_kit.css +1 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -1
  5. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +38 -7
  7. data/app/components/phlex_kit/attachment/attachment.css +15 -0
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +8 -0
  10. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  11. data/app/components/phlex_kit/badge/badge.css +1 -1
  12. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
  13. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  14. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  15. data/app/components/phlex_kit/calendar/calendar_controller.js +56 -15
  16. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  17. data/app/components/phlex_kit/carousel/carousel_controller.js +59 -4
  18. data/app/components/phlex_kit/chart/chart.rb +10 -2
  19. data/app/components/phlex_kit/chart/chart_controller.js +37 -8
  20. data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
  21. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  22. data/app/components/phlex_kit/codeblock/codeblock.rb +11 -1
  23. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  24. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  25. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  26. data/app/components/phlex_kit/combobox/combobox_controller.js +72 -3
  27. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  28. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  29. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
  30. data/app/components/phlex_kit/command/command_controller.js +23 -2
  31. data/app/components/phlex_kit/command/command_input.rb +3 -2
  32. data/app/components/phlex_kit/command/command_item.rb +4 -0
  33. data/app/components/phlex_kit/command/command_list.rb +6 -2
  34. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  35. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  36. data/app/components/phlex_kit/context_menu/context_menu_controller.js +74 -12
  37. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  38. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  39. data/app/components/phlex_kit/data_table/data_table.css +1 -11
  40. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  41. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  42. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  43. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  44. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  45. data/app/components/phlex_kit/date_picker/date_picker.rb +19 -2
  46. data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -0
  47. data/app/components/phlex_kit/dialog/dialog.css +3 -1
  48. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  49. data/app/components/phlex_kit/dialog/dialog_controller.js +16 -2
  50. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  51. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  52. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +9 -2
  53. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +81 -13
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  57. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  58. data/app/components/phlex_kit/form_field/form_field_controller.js +54 -25
  59. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  60. data/app/components/phlex_kit/hover_card/hover_card.css +10 -2
  61. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  62. data/app/components/phlex_kit/icon/icon.css +9 -0
  63. data/app/components/phlex_kit/icon/icon.rb +7 -1
  64. data/app/components/phlex_kit/input/input.css +0 -1
  65. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  66. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  67. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  68. data/app/components/phlex_kit/item/item.css +2 -1
  69. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  70. data/app/components/phlex_kit/label/label.css +1 -1
  71. data/app/components/phlex_kit/link/link.rb +1 -0
  72. data/app/components/phlex_kit/marker/marker.css +2 -1
  73. data/app/components/phlex_kit/marker/marker.rb +9 -2
  74. data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
  75. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  76. data/app/components/phlex_kit/menubar/menubar_controller.js +46 -16
  77. data/app/components/phlex_kit/message/message.rb +5 -0
  78. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
  79. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  80. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  81. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
  86. data/app/components/phlex_kit/popover/popover_controller.js +42 -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 +3 -0
  90. data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -15
  91. data/app/components/phlex_kit/select/select.css +5 -2
  92. data/app/components/phlex_kit/select/select_controller.js +49 -4
  93. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  94. data/app/components/phlex_kit/separator/separator.rb +5 -1
  95. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  96. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  97. data/app/components/phlex_kit/sheet/sheet_content_controller.js +40 -0
  98. data/app/components/phlex_kit/sheet/sheet_controller.js +31 -1
  99. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  100. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  101. data/app/components/phlex_kit/slider/slider.css +15 -0
  102. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  103. data/app/components/phlex_kit/switch/switch.css +5 -0
  104. data/app/components/phlex_kit/switch/switch.rb +6 -0
  105. data/app/components/phlex_kit/table/table.css +2 -1
  106. data/app/components/phlex_kit/table/table.rb +5 -2
  107. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  108. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  109. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  110. data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
  111. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  112. data/app/components/phlex_kit/toast/toast.css +2 -1
  113. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  114. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  115. data/app/components/phlex_kit/toast/toast_controller.js +40 -21
  116. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  117. data/app/components/phlex_kit/toast/toast_region.rb +29 -10
  118. data/app/components/phlex_kit/toast/toaster_controller.js +17 -7
  119. data/app/components/phlex_kit/toggle_group/toggle_group.rb +17 -2
  120. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +16 -9
  121. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +20 -2
  122. data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
  123. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  124. data/app/components/phlex_kit/typography/heading.rb +7 -1
  125. data/app/components/phlex_kit/typography/list.rb +22 -0
  126. data/app/components/phlex_kit/typography/text.rb +7 -1
  127. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  128. data/lib/phlex_kit/base_component.rb +23 -0
  129. data/lib/phlex_kit/version.rb +1 -1
  130. data/lib/phlex_kit.rb +1 -0
  131. metadata +4 -1
@@ -2,14 +2,19 @@ module PhlexKit
2
2
  class TabsList < BaseComponent
3
3
  VARIANTS = { default: nil, line: "line" }.freeze
4
4
 
5
- def initialize(variant: :default, **attrs)
5
+ def initialize(variant: :default, orientation: :horizontal, **attrs)
6
6
  @variant = variant.to_sym
7
+ @orientation = orientation.to_sym
7
8
  @attrs = attrs
8
9
  end
9
10
 
10
11
  def view_template(&)
11
12
  classes = [ "pk-tabs-list", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
12
- div(**mix({ class: classes, role: "tablist", data: { phlex_kit__tabs_target: "list", action: "keydown->phlex-kit--tabs#keydown" } }, @attrs), &)
13
+ base = { class: classes, role: "tablist", data: { phlex_kit__tabs_target: "list", action: "keydown->phlex-kit--tabs#keydown" } }
14
+ # Server-rendered so AT hears the right axis pre-JS / with JS off; the
15
+ # controller re-stamps it on connect from the root's vertical class.
16
+ base[:aria] = { orientation: "vertical" } if @orientation == :vertical
17
+ div(**mix(base, @attrs), &)
13
18
  end
14
19
  end
15
20
  end
@@ -1,9 +1,18 @@
1
1
  module PhlexKit
2
2
  class TabsTrigger < BaseComponent
3
- def initialize(value:, as: :button, active: false, **attrs)
3
+ # id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
4
+ # caller id with the deterministic one into an invalid two-token id,
5
+ # breaking the aria pairing pre-JS (and scopeIds' caller-id detection).
6
+ AS_TAGS = %i[button a].freeze
7
+
8
+ def initialize(value:, as: :button, active: false, id: nil, **attrs)
4
9
  @value = value
5
10
  @as = as.to_sym
11
+ unless AS_TAGS.include?(@as)
12
+ raise ArgumentError, "unknown TabsTrigger as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
13
+ end
6
14
  @active = active
15
+ @id = id || "pk-tabs-trigger-#{value}"
7
16
  @attrs = attrs
8
17
  end
9
18
  def view_template(&)
@@ -13,7 +22,7 @@ module PhlexKit
13
22
  # aria-selected and the roving tabindex are kept in sync by the
14
23
  # controller's activeValueChanged.
15
24
  base = {
16
- class: "pk-tabs-trigger", role: "tab", id: "pk-tabs-trigger-#{@value}",
25
+ class: "pk-tabs-trigger", role: "tab", id: @id,
17
26
  aria_selected: @active ? "true" : "false",
18
27
  aria_controls: "pk-tabs-panel-#{@value}",
19
28
  tabindex: @active ? "0" : "-1",
@@ -18,11 +18,25 @@ export default class extends Controller {
18
18
  if (!this.storedTheme()) this.applyTheme("system")
19
19
  }
20
20
  this._media.addEventListener("change", this._onMediaChange)
21
+ // Multiple toggles on one page (header + mobile menu): each instance
22
+ // observes :root[data-theme] so a change applied by ANY of them (or by
23
+ // host code) re-syncs every toggle's pressed state — without this the
24
+ // stale one showed the wrong state and its first click was eaten by the
25
+ // echo guard in apply().
26
+ this._rootObserver = new MutationObserver(() => this.syncPressed())
27
+ this._rootObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] })
21
28
  this.applyTheme(this.storedTheme() || "system")
22
29
  }
23
30
 
24
31
  disconnect() {
25
32
  this._media?.removeEventListener("change", this._onMediaChange)
33
+ this._rootObserver?.disconnect()
34
+ }
35
+
36
+ syncPressed() {
37
+ const theme = document.documentElement.getAttribute("data-theme") || "system"
38
+ const dark = theme === "system" ? this._media.matches : theme === "dark"
39
+ this.element.setAttribute("data-phlex-kit--toggle-pressed-value", dark ? "true" : "false")
26
40
  }
27
41
 
28
42
  apply(e) {
@@ -7,7 +7,8 @@
7
7
  .pk-toast-region {
8
8
  position: fixed;
9
9
  z-index: 100;
10
- padding: 1.5rem;
10
+ /* --pk-toast-offset is stamped inline by ToastRegion(offset:). */
11
+ padding: var(--pk-toast-offset, 1.5rem);
11
12
  pointer-events: none;
12
13
  }
13
14
  .pk-toast-region[data-position="top-left"] { top: 0; left: 0; }
@@ -12,7 +12,10 @@ module PhlexKit
12
12
 
13
13
  def view_template
14
14
  data = { slot: "action" }
15
- data[:action] = @on if @on
15
+ # Caller action first, then dismiss — client-spawned actions dismiss
16
+ # their toast after onClick (Sonner parity), so server-rendered ones
17
+ # must too.
18
+ data[:action] = [ @on, "click->phlex-kit--toast#dismiss" ].compact.join(" ")
16
19
  button(**mix({ type: :button, class: "pk-toast-action", data: data }, @attrs)) { @label }
17
20
  end
18
21
  end
@@ -7,12 +7,15 @@ module PhlexKit
7
7
  end
8
8
 
9
9
  def view_template
10
- button(**mix({
10
+ base = {
11
11
  type: :button,
12
12
  class: "pk-toast-close",
13
- aria: { label: "Close toast" },
14
13
  data: { slot: "close", action: "click->phlex-kit--toast#dismiss" }
15
- }, @attrs)) do
14
+ }
15
+ # Default label only when the caller didn't supply their own — `mix`
16
+ # would fuse aria-label into a two-token value instead of overriding.
17
+ base[:aria] = { label: "Close toast" } unless aria_labelled?
18
+ button(**mix(base, @attrs)) do
16
19
  render Icon.new(:x, size: 14)
17
20
  span(class: "pk-sr-only") { "Close" }
18
21
  end
@@ -20,23 +20,28 @@ export default class extends Controller {
20
20
  this._unmountTimer = null
21
21
  this._startedAt = 0
22
22
  this._remaining = this.durationValue
23
- this._paused = false
23
+ // Independent pause sources (hover / focus / region) — resume only once
24
+ // ALL are clear. A single shared boolean let any one source's resume
25
+ // restart the timer while another source still held it paused (e.g.
26
+ // pointer leaving a toast into the gap while the region is still
27
+ // hover-paused).
28
+ this._pauseSources = new Set()
24
29
  this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }
25
30
 
26
31
  this._onPointerDown = this._onPointerDown.bind(this)
27
32
  this._onPointerMove = this._onPointerMove.bind(this)
28
33
  this._onPointerUp = this._onPointerUp.bind(this)
29
- this._onPointerEnter = () => this._pause()
30
- this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }
34
+ this._onPointerEnter = () => this._pause("hover")
35
+ this._onPointerLeave = () => { if (!this._swipe.active) this._resume("hover") }
31
36
  // Keyboard parity with hover: tabbing into the toast (e.g. to reach its
32
37
  // action button) must pause auto-dismiss just like pointerenter does.
33
- this._onFocusIn = () => this._pause()
34
- this._onFocusOut = (e) => { if (!this.element.contains(e.relatedTarget)) this._resume() }
38
+ this._onFocusIn = () => this._pause("focus")
39
+ this._onFocusOut = (e) => { if (!this.element.contains(e.relatedTarget)) this._resume("focus") }
35
40
  this._onKeyDown = this._onKeyDown.bind(this)
36
41
  this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
37
42
  this._onRestart = () => this._restart()
38
- this._onRegionPause = () => this._pause()
39
- this._onRegionResume = () => this._resume()
43
+ this._onRegionPause = () => this._pause("region")
44
+ this._onRegionResume = () => this._resume("region")
40
45
 
41
46
  this.element.addEventListener("pointerdown", this._onPointerDown)
42
47
  this.element.addEventListener("pointerenter", this._onPointerEnter)
@@ -46,10 +51,14 @@ export default class extends Controller {
46
51
  this.element.addEventListener("keydown", this._onKeyDown)
47
52
  this.element.addEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
48
53
  this.element.addEventListener("phlex-kit:toast:restart", this._onRestart)
49
- document.addEventListener("phlex-kit:toast:pause", this._onRegionPause)
50
- document.addEventListener("phlex-kit:toast:resume", this._onRegionResume)
51
-
52
- requestAnimationFrame(() => {
54
+ // Region-scoped: the toaster dispatches pause/resume on its own list, so
55
+ // another region's hover can't freeze (or resume) this toast's timer.
56
+ this._regionList = this.element.closest(".pk-toast-list") || document
57
+ this._regionList.addEventListener("phlex-kit:toast:pause", this._onRegionPause)
58
+ this._regionList.addEventListener("phlex-kit:toast:resume", this._onRegionResume)
59
+
60
+ this._connectFrame = requestAnimationFrame(() => {
61
+ this._connectFrame = null
53
62
  this.element.dataset.state = "open"
54
63
  // Spawned under an already-hovered stack (the toaster stamps
55
64
  // data-pk-toasts-paused while dispatching pause): arm as paused so the
@@ -57,7 +66,7 @@ export default class extends Controller {
57
66
  // under the user's cursor.
58
67
  if (Number.isFinite(this.durationValue) && this.durationValue > 0 &&
59
68
  this.element.closest("[data-pk-toasts-paused]")) {
60
- this._paused = true
69
+ this._pauseSources.add("region")
61
70
  this._remaining = this.durationValue
62
71
  } else {
63
72
  this._start()
@@ -67,6 +76,8 @@ export default class extends Controller {
67
76
 
68
77
  disconnect() {
69
78
  this._clearTimer()
79
+ if (this._connectFrame) cancelAnimationFrame(this._connectFrame)
80
+ this._connectFrame = null
70
81
  // Don't let a pending 200ms unmount fire after teardown (e.g. into a
71
82
  // Turbo-cached copy of the page).
72
83
  if (this._unmountTimer) clearTimeout(this._unmountTimer)
@@ -79,8 +90,8 @@ export default class extends Controller {
79
90
  this.element.removeEventListener("keydown", this._onKeyDown)
80
91
  this.element.removeEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
81
92
  this.element.removeEventListener("phlex-kit:toast:restart", this._onRestart)
82
- document.removeEventListener("phlex-kit:toast:pause", this._onRegionPause)
83
- document.removeEventListener("phlex-kit:toast:resume", this._onRegionResume)
93
+ this._regionList?.removeEventListener("phlex-kit:toast:pause", this._onRegionPause)
94
+ this._regionList?.removeEventListener("phlex-kit:toast:resume", this._onRegionResume)
84
95
  }
85
96
 
86
97
  dismiss(e) {
@@ -112,22 +123,30 @@ export default class extends Controller {
112
123
  this._timer = setTimeout(() => this._close("auto"), this._remaining)
113
124
  }
114
125
 
126
+ // Resets the timer duration but must respect any pause source still
127
+ // active (e.g. a promise resolving into a fresh duration while the toast
128
+ // is hovered) — arm paused instead of ticking away under the cursor, and
129
+ // leave the source set intact so the matching resume can still fire.
115
130
  _restart() {
116
131
  this._clearTimer()
132
+ this._remaining = this.durationValue
133
+ if (this._pauseSources.size > 0) return
117
134
  this._start()
118
135
  }
119
136
 
120
- _pause() {
121
- if (this._paused || !this._timer) return
122
- this._paused = true
137
+ _pause(source) {
138
+ this._pauseSources.add(source)
139
+ if (!this._timer) return
123
140
  clearTimeout(this._timer)
124
141
  this._timer = null
125
142
  this._remaining -= performance.now() - this._startedAt
126
143
  }
127
144
 
128
- _resume() {
129
- if (!this._paused) return
130
- this._paused = false
145
+ _resume(source) {
146
+ this._pauseSources.delete(source)
147
+ if (this._pauseSources.size > 0) return
148
+ if (this._timer) return
149
+ if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return
131
150
  if (this._remaining <= 0) return this._close("auto")
132
151
  this._startedAt = performance.now()
133
152
  this._timer = setTimeout(() => this._close("auto"), this._remaining)
@@ -189,7 +208,7 @@ export default class extends Controller {
189
208
  this.element.dataset.swipe = "cancel"
190
209
  this.element.style.removeProperty("--pk-toast-swipe-x")
191
210
  this.element.style.removeProperty("--pk-toast-swipe-y")
192
- this._resume()
211
+ this._resume("hover")
193
212
  }
194
213
  }
195
214
  }
@@ -56,13 +56,17 @@ module PhlexKit
56
56
  data[:phlex_kit__toast_on_dismiss_value] = @on_dismiss if @on_dismiss
57
57
  data[:phlex_kit__toast_on_auto_close_value] = @on_auto_close if @on_auto_close
58
58
 
59
- attrs = {
60
- role: ALERT_VARIANTS.include?(@variant) ? "alert" : "status",
61
- tabindex: "0",
62
- class: "pk-toast",
63
- aria: { atomic: "true" },
64
- data: data
65
- }
59
+ attrs = { class: "pk-toast", data: data }
60
+ # Defaults only when the caller didn't supply their own — `mix` would
61
+ # fuse role="status log" / tabindex="0 -1" / aria-atomic="true false"
62
+ # instead of overriding.
63
+ unless @attrs.key?(:role) || @attrs.key?("role")
64
+ attrs[:role] = ALERT_VARIANTS.include?(@variant) ? "alert" : "status"
65
+ end
66
+ attrs[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
67
+ unless aria_key_set?(:atomic)
68
+ attrs[:aria] = { atomic: "true" }
69
+ end
66
70
  attrs[:id] = @id if @id
67
71
  attrs
68
72
  end
@@ -10,6 +10,12 @@ module PhlexKit
10
10
  class ToastRegion < BaseComponent
11
11
  SKELETON_VARIANTS = %i[default success error warning info loading].freeze
12
12
 
13
+ # Sentinel default for dir: — distinguishes "caller didn't pass dir:" (omit
14
+ # the attribute, inherit from the page) from an explicit dir: :ltr (must
15
+ # render dir="ltr"), which a plain `:ltr` default can't tell apart.
16
+ DIR_UNSET = Object.new.freeze
17
+ private_constant :DIR_UNSET
18
+
13
19
  POSITIONS = {
14
20
  top_left: "top-left",
15
21
  top_center: "top-center",
@@ -30,7 +36,7 @@ module PhlexKit
30
36
  rich_colors: false,
31
37
  close_button: false,
32
38
  hotkey: %w[alt t],
33
- dir: :ltr,
39
+ dir: DIR_UNSET,
34
40
  flash: nil,
35
41
  id: "pk-toaster",
36
42
  **attrs
@@ -41,11 +47,23 @@ module PhlexKit
41
47
  @duration = duration
42
48
  @gap = gap
43
49
  @offset = offset
50
+ # theme:/rich_colors: are Sonner API surface the kit deliberately does
51
+ # not implement (tokens are :root-scoped; there is no status palette) —
52
+ # fail loud rather than silently no-op (audit round 6).
44
53
  @theme = theme.to_sym
45
- @rich_colors = rich_colors
54
+ unless @theme == :system
55
+ raise ArgumentError, "ToastRegion theme: only :system is supported — toasts follow the page theme (:root[data-theme])"
56
+ end
57
+ if rich_colors
58
+ raise ArgumentError, "ToastRegion rich_colors: is not supported — the kit has no status color palette (toasts follow --pk-* tokens)"
59
+ end
46
60
  @close_button = close_button
47
61
  @hotkey = hotkey
48
- @dir = dir
62
+ @dir_explicit = !dir.equal?(DIR_UNSET)
63
+ @dir = (@dir_explicit ? dir : :ltr).to_sym
64
+ unless %i[ltr rtl auto].include?(@dir)
65
+ raise ArgumentError, "ToastRegion dir: must be :ltr, :rtl or :auto"
66
+ end
49
67
  @flash = flash
50
68
  # Base id for the <ol> stack; the wrapper takes "#{id}-region". Defaults
51
69
  # keep the historical "pk-toaster"/"pk-toaster-region" pair — pass a
@@ -105,10 +123,12 @@ module PhlexKit
105
123
  end
106
124
 
107
125
  def region_attrs
108
- {
126
+ base = {
109
127
  id: "#{@id}-region",
110
128
  role: "region",
111
129
  class: "pk-toast-region",
130
+ # offset: viewport-edge padding, consumed by toast.css.
131
+ style: "--pk-toast-offset: #{@offset.is_a?(Numeric) ? "#{@offset}px" : @offset};",
112
132
  # No aria-live here: each toast item is its own live region
113
133
  # (role="status"/"alert"); a live wrapper would double-announce.
114
134
  aria: { label: "Notifications" },
@@ -122,14 +142,13 @@ module PhlexKit
122
142
  phlex_kit__toaster_max_value: @max.to_s,
123
143
  phlex_kit__toaster_duration_value: @duration.to_s,
124
144
  phlex_kit__toaster_gap_value: @gap.to_s,
125
- phlex_kit__toaster_offset_value: @offset.to_s,
126
- phlex_kit__toaster_theme_value: @theme.to_s,
127
- phlex_kit__toaster_rich_colors_value: @rich_colors.to_s,
128
- phlex_kit__toaster_close_button_value: @close_button.to_s,
129
- phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+"),
130
- phlex_kit__toaster_dir_value: @dir.to_s
145
+ phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+")
131
146
  }
132
147
  }
148
+ # Only an explicit dir: renders the attribute — the true default (no
149
+ # dir: passed) omits it so the toast inherits the page's direction.
150
+ base[:dir] = @dir.to_s if @dir_explicit
151
+ base
133
152
  end
134
153
  end
135
154
  end
@@ -34,12 +34,10 @@ export default class extends Controller {
34
34
  max: { type: Number, default: 3 },
35
35
  duration: { type: Number, default: 4000 },
36
36
  gap: { type: Number, default: 14 },
37
- offset: { type: Number, default: 24 },
38
- theme: { type: String, default: "system" },
39
- richColors: { type: Boolean, default: false },
40
- closeButton: { type: Boolean, default: false },
37
+ // offset is a CSS custom property (--pk-toast-offset) and dir a real
38
+ // dir attribute, both stamped by ToastRegion; theme/richColors are
39
+ // unsupported (region raises) none of them are controller state.
41
40
  hotkey: { type: String, default: "alt+t" },
42
- dir: { type: String, default: "ltr" },
43
41
  }
44
42
 
45
43
  // State the target callbacks touch lives here, NOT in connect(): Stimulus
@@ -174,6 +172,7 @@ export default class extends Controller {
174
172
  )
175
173
  return
176
174
  }
175
+ if (!this._listEl) return
177
176
  const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
178
177
  if (el) el.dispatchEvent(new CustomEvent("phlex-kit:toast:force-dismiss", { bubbles: true }))
179
178
  }
@@ -192,7 +191,10 @@ export default class extends Controller {
192
191
  // Mirror the pause state onto the DOM so toasts spawned while the stack
193
192
  // is already hovered can arm paused (the pause event predates them).
194
193
  this._listEl?.toggleAttribute("data-pk-toasts-paused", value)
195
- document.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
194
+ // Region-scoped, not document-global: on multi-region pages one region's
195
+ // hover must not pause (or worse, RESUME under the pointer) another's
196
+ // toasts. Toasts listen on their own list element.
197
+ this._listEl.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
196
198
  }
197
199
 
198
200
  _setExpanded(value) {
@@ -277,7 +279,14 @@ export default class extends Controller {
277
279
  const wantCtrl = parts.includes("ctrl")
278
280
  const wantMeta = parts.includes("meta")
279
281
  const wantShift = parts.includes("shift")
280
- if (e.key.toLowerCase() !== key.toLowerCase()) return
282
+ // Match e.code as well as e.key: with Option held, macOS reports the
283
+ // COMPOSED character (Option+T → "†"), so alt+ hotkeys never match on
284
+ // e.key alone (Sonner matches event.code for the same reason).
285
+ const code = (e.code || "").toLowerCase()
286
+ const keyMatches = e.key.toLowerCase() === key.toLowerCase()
287
+ || code === `key${key.toLowerCase()}`
288
+ || code === `digit${key.toLowerCase()}`
289
+ if (!keyMatches) return
281
290
  if (wantAlt !== e.altKey) return
282
291
  if (wantCtrl !== e.ctrlKey) return
283
292
  if (wantMeta !== e.metaKey) return
@@ -321,6 +330,7 @@ export default class extends Controller {
321
330
  }
322
331
 
323
332
  _mutate(id, variant, text) {
333
+ if (!this._listEl) return
324
334
  const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
325
335
  if (!el) return
326
336
  el.dataset.variant = variant
@@ -21,20 +21,35 @@ module PhlexKit
21
21
 
22
22
  def view_template(&block)
23
23
  div(**mix(group_default_attrs, @attrs)) do
24
- yield(self)
24
+ yield(self) if block
25
25
  render_hidden_inputs
26
26
  end
27
27
  end
28
28
 
29
29
  def item_context
30
30
  { type: @type, variant: @variant, size: @size, disabled: @disabled,
31
- selected_values: selected_values, spacing: @spacing, orientation: @orientation }
31
+ selected_values: selected_values, spacing: @spacing, orientation: @orientation,
32
+ group: self }
32
33
  end
33
34
 
34
35
  def ToggleGroupItem(**kwargs, &block)
35
36
  render PhlexKit::ToggleGroupItem.new(group_context: item_context, **kwargs), &block
36
37
  end
37
38
 
39
+ # Roving-tabindex initial stop (single-type, nothing selected): the FIRST
40
+ # enabled item claims tabindex="0" server-side, mirroring what
41
+ # toggle_group_controller.js's reconcile() computes at runtime
42
+ # (`pressed || enabledItems()[0]`). Items call this once each, in render
43
+ # order, as they're constructed — a single-threaded sequential claim, not
44
+ # a lookahead over siblings not yet rendered.
45
+ def claim_tab_stop(item_disabled)
46
+ return false unless selected_values.empty?
47
+ return false if item_disabled
48
+ return false if @tab_stop_claimed
49
+ @tab_stop_claimed = true
50
+ true
51
+ end
52
+
38
53
  private
39
54
 
40
55
  def selected_values
@@ -33,23 +33,30 @@ export default class extends Controller {
33
33
  if (items.length === 0) return
34
34
  const currentIndex = items.indexOf(event.currentTarget)
35
35
  let nextIndex = currentIndex
36
- switch (event.key) {
37
- case "ArrowRight": case "ArrowDown": nextIndex = (currentIndex + 1) % items.length; break
38
- case "ArrowLeft": case "ArrowUp": nextIndex = (currentIndex - 1 + items.length) % items.length; break
39
- case "Home": nextIndex = 0; break
40
- case "End": nextIndex = items.length - 1; break
41
- case " ": case "Enter": event.preventDefault(); event.currentTarget.click(); return
42
- default: return
43
- }
36
+ // In RTL the horizontal arrows mirror (physical LEFT = next); Up/Down are
37
+ // unaffected. Runtime dir check is reliable after a dynamic flip.
38
+ const rtl = getComputedStyle(this.element).direction === "rtl"
39
+ const fwd = rtl ? "ArrowLeft" : "ArrowRight"
40
+ const back = rtl ? "ArrowRight" : "ArrowLeft"
41
+ if (event.key === fwd || event.key === "ArrowDown") nextIndex = (currentIndex + 1) % items.length
42
+ else if (event.key === back || event.key === "ArrowUp") nextIndex = (currentIndex - 1 + items.length) % items.length
43
+ else if (event.key === "Home") nextIndex = 0
44
+ else if (event.key === "End") nextIndex = items.length - 1
45
+ else if (event.key === " " || event.key === "Enter") { event.preventDefault(); event.currentTarget.click(); return }
46
+ else return
44
47
  event.preventDefault()
45
48
  const next = items[nextIndex]
46
49
  this.updateRovingTabindex(next)
47
50
  next.focus()
51
+ // APG radio-group keyboard model: with role="radio", arrows move focus
52
+ // AND check the landed-on item (Home/End included).
53
+ if (next !== event.currentTarget) next.click()
48
54
  }
49
55
 
50
56
  reconcile() {
51
57
  if (this.typeValue === "single") {
52
- const pressed = this.itemTargets.find((el) => this.isPressed(el))
58
+ // A disabled button can't take focus — never hand it the tab stop.
59
+ const pressed = this.itemTargets.find((el) => this.isPressed(el) && !el.disabled)
53
60
  const first = pressed || this.enabledItems()[0]
54
61
  this.itemTargets.forEach((el) => el.setAttribute("tabindex", el === first ? "0" : "-1"))
55
62
  } else {
@@ -1,13 +1,27 @@
1
1
  module PhlexKit
2
2
  class ToggleGroupItem < Toggle
3
3
  def initialize(value:, group_context:, variant: nil, size: nil, **attrs)
4
+ # Toggle kwargs this item renders no markup for, or that the group
5
+ # derives itself (pressed: comes from the group's selected_values) —
6
+ # inheriting them silently discarded the caller's input.
7
+ unsupported = attrs.keys & %i[wrapper name unpressed_value pressed]
8
+ if unsupported.any?
9
+ raise ArgumentError, "ToggleGroupItem does not support #{unsupported.join(", ")} (group items render a bare button)"
10
+ end
4
11
  @item_value = value.to_s
5
12
  @group_context = group_context
6
13
  pressed = group_context[:selected_values].include?(@item_value)
14
+ item_disabled = attrs.key?(:disabled) ? attrs.delete(:disabled) : group_context[:disabled]
15
+ # Roving tabindex initial stop: when nothing is selected yet, the first
16
+ # enabled item claims tabindex="0" (see ToggleGroup#claim_tab_stop).
17
+ # group_context[:group] is nil for a hand-built context (bypassing
18
+ # ToggleGroup#item_context) — treat that as "no tab-stop claim" rather
19
+ # than raising, since standalone tests construct contexts by hand.
20
+ @is_first_tab_stop = group_context[:group]&.claim_tab_stop(item_disabled) || false
7
21
  super(pressed: pressed, name: nil, value: @item_value,
8
22
  variant: variant || group_context[:variant],
9
23
  size: size || group_context[:size],
10
- disabled: group_context[:disabled], **attrs)
24
+ disabled: item_disabled, **attrs)
11
25
  end
12
26
 
13
27
  def view_template(&block)
@@ -26,7 +40,11 @@ module PhlexKit
26
40
  if @group_context[:type] == :single
27
41
  a[:role] = "radio"
28
42
  a[:aria] = { checked: @pressed.to_s }
29
- a[:tabindex] = @pressed ? "0" : "-1"
43
+ # A disabled button can't take focus — giving the tab stop to a
44
+ # pressed-but-disabled item made the whole group Tab-unreachable.
45
+ # When nothing is selected, the first enabled item claims the initial
46
+ # roving-tabindex stop instead of every item defaulting to -1.
47
+ a[:tabindex] = (@pressed && !@disabled) || @is_first_tab_stop ? "0" : "-1"
30
48
  else
31
49
  a[:aria] = { pressed: @pressed.to_s }
32
50
  a[:tabindex] = "0"
@@ -79,6 +79,9 @@
79
79
  .pk-tooltip:hover .pk-tooltip-content {
80
80
  opacity: 1;
81
81
  visibility: visible;
82
+ /* WCAG 1.4.13 Hoverable: the revealed bubble must accept the pointer —
83
+ it's a DOM child of .pk-tooltip, so hovering it keeps the reveal. */
84
+ pointer-events: auto;
82
85
  transition-delay: .4s, .4s;
83
86
  }
84
87
  }
@@ -87,9 +90,10 @@
87
90
  .pk-tooltip:has(:focus-visible) .pk-tooltip-content {
88
91
  opacity: 1;
89
92
  visibility: visible;
93
+ pointer-events: auto;
90
94
  transition-delay: 0s, 0s;
91
95
  }
92
96
  /* Escape-dismissed (WCAG 1.4.13): suppressed until pointer/focus leaves.
93
97
  transition: none so the hide is immediate regardless of hover-in delays. */
94
98
  .pk-tooltip[data-pk-dismissed]:hover .pk-tooltip-content,
95
- .pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; transition: none; }
99
+ .pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; pointer-events: none; transition: none; }
@@ -11,7 +11,13 @@ export default class extends Controller {
11
11
  const trigger = this.element.querySelector(".pk-tooltip-trigger")
12
12
  if (content && trigger) {
13
13
  if (!content.id) content.id = `pk-tooltip-${Math.random().toString(36).slice(2, 8)}`
14
- if (!trigger.hasAttribute("aria-describedby")) trigger.setAttribute("aria-describedby", content.id)
14
+ // Describe the element that actually takes focus: with focusable: false
15
+ // the wrapper span never receives focus, so a describedby left only on
16
+ // it is never announced — wire the focusable child too.
17
+ const focusable = trigger.querySelector("button, a[href], input, select, textarea, [tabindex]")
18
+ ;[trigger, focusable].forEach((el) => {
19
+ if (el && !el.hasAttribute("aria-describedby")) el.setAttribute("aria-describedby", content.id)
20
+ })
15
21
  }
16
22
 
17
23
  this._onKeydown = (e) => {
@@ -8,6 +8,9 @@ module PhlexKit
8
8
  # ruby_ui's level → default size mapping (h1 is big, h4 smaller); 5/6
9
9
  # extend the scale downward — HTML has h1–h6, so all six must map.
10
10
  LEVEL_SIZE = { "1" => "7", "2" => "6", "3" => "5", "4" => "4", "5" => "3", "6" => "2" }.freeze
11
+ # `as:` is dispatched dynamically (send) — whitelist so it can't reach
12
+ # arbitrary (including private) methods. Mirrors Separator/Marker.
13
+ AS_TAGS = %i[h1 h2 h3 h4 h5 h6 p span div].freeze
11
14
 
12
15
  # level: defaults to 1 so `Heading.new` is identical to `Heading.new(level: 1)`
13
16
  # (a nil default used to render h1 with the level-6 size — self-disagreeing).
@@ -18,7 +21,10 @@ module PhlexKit
18
21
  raise ArgumentError, "Invalid heading level: #{level.inspect} — valid: 1–6"
19
22
  end
20
23
  @level = level
21
- @as = as
24
+ @as = as&.to_sym
25
+ if @as && !AS_TAGS.include?(@as)
26
+ raise ArgumentError, "unknown Heading as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
27
+ end
22
28
  @size = size
23
29
  @attrs = attrs
24
30
  end
@@ -0,0 +1,22 @@
1
+ module PhlexKit
2
+ # Prose list, ported from shadcn/ui's typography "list" style (ruby_ui's
3
+ # TypographyList). Renders a ul (or ol via as:) with the .pk-list styling
4
+ # that previously shipped as an orphaned utility class. Children are plain
5
+ # <li>s.
6
+ class List < BaseComponent
7
+ # `as:` is dispatched dynamically (send) — whitelist like Separator.
8
+ AS_TAGS = %i[ul ol].freeze
9
+
10
+ def initialize(as: :ul, **attrs)
11
+ @as = as.to_sym
12
+ unless AS_TAGS.include?(@as)
13
+ raise ArgumentError, "unknown List as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
14
+ end
15
+ @attrs = attrs
16
+ end
17
+
18
+ def view_template(&)
19
+ send(@as, **mix({ class: "pk-list" }, @attrs), &)
20
+ end
21
+ end
22
+ end
@@ -27,9 +27,15 @@ module PhlexKit
27
27
  bold: "pk-text-bold",
28
28
  muted: "pk-text-muted"
29
29
  }.freeze
30
+ # `as:` is dispatched dynamically (send) — whitelist so it can't reach
31
+ # arbitrary (including private) methods. Mirrors Separator/Marker.
32
+ AS_TAGS = %i[p span div label small strong em].freeze
30
33
 
31
34
  def initialize(as: :p, size: "3", weight: :regular, **attrs)
32
- @as = as
35
+ @as = as.to_sym
36
+ unless AS_TAGS.include?(@as)
37
+ raise ArgumentError, "unknown Text as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
38
+ end
33
39
  @size = size.to_s
34
40
  @weight = weight.to_sym
35
41
  @attrs = attrs