phlex_kit 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
  3. data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +1 -1
  4. data/app/components/phlex_kit/accordion/accordion_trigger.rb +1 -1
  5. data/app/components/phlex_kit/alert/alert.rb +5 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
  8. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
  9. data/app/components/phlex_kit/attachment/attachment.css +7 -1
  10. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
  11. data/app/components/phlex_kit/avatar/avatar.css +4 -0
  12. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
  13. data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
  16. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  17. data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
  18. data/app/components/phlex_kit/calendar/calendar.css +5 -0
  19. data/app/components/phlex_kit/calendar/calendar_controller.js +26 -8
  20. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
  21. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
  22. data/app/components/phlex_kit/carousel/carousel.css +7 -0
  23. data/app/components/phlex_kit/carousel/carousel.rb +8 -4
  24. data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
  25. data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
  26. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  27. data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
  28. data/app/components/phlex_kit/checkbox/checkbox.rb +5 -1
  29. data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
  30. data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
  31. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
  32. data/app/components/phlex_kit/combobox/combobox.rb +1 -1
  33. data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
  34. data/app/components/phlex_kit/combobox/combobox_controller.js +56 -5
  35. data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
  36. data/app/components/phlex_kit/command/command_controller.js +9 -1
  37. data/app/components/phlex_kit/command/command_dialog_content.rb +10 -6
  38. data/app/components/phlex_kit/command/command_empty.rb +5 -3
  39. data/app/components/phlex_kit/command/command_separator.rb +1 -1
  40. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
  41. data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
  42. data/app/components/phlex_kit/context_menu/context_menu_controller.js +17 -2
  43. data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
  44. data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
  45. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
  46. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
  47. data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
  49. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
  50. data/app/components/phlex_kit/data_table/data_table.css +4 -0
  51. data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
  52. data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
  53. data/app/components/phlex_kit/drawer/drawer.css +4 -0
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +10 -1
  57. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
  58. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
  59. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
  60. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
  62. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
  63. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
  64. data/app/components/phlex_kit/empty/empty.rb +1 -0
  65. data/app/components/phlex_kit/field/field.rb +4 -1
  66. data/app/components/phlex_kit/field/field_error.rb +4 -1
  67. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
  68. data/app/components/phlex_kit/icon/icon.rb +5 -1
  69. data/app/components/phlex_kit/input_group/input_group.css +6 -3
  70. data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
  71. data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
  72. data/app/components/phlex_kit/item/item.css +9 -1
  73. data/app/components/phlex_kit/item/item_separator.rb +1 -1
  74. data/app/components/phlex_kit/label/label.css +6 -1
  75. data/app/components/phlex_kit/menubar/menubar.rb +5 -3
  76. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
  77. data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
  78. data/app/components/phlex_kit/menubar/menubar_controller.js +11 -1
  79. data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
  80. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
  81. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  82. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
  83. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
  84. data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
  85. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
  86. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
  87. data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
  88. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
  89. data/app/components/phlex_kit/pagination/pagination.css +4 -0
  90. data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
  91. data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
  92. data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
  93. data/app/components/phlex_kit/select/select.rb +1 -1
  94. data/app/components/phlex_kit/select/select_content.rb +7 -5
  95. data/app/components/phlex_kit/select/select_controller.js +10 -0
  96. data/app/components/phlex_kit/select/select_separator.rb +1 -1
  97. data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
  98. data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
  99. data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
  100. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
  101. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
  102. data/app/components/phlex_kit/switch/switch.rb +4 -1
  103. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
  104. data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
  105. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
  106. data/app/components/phlex_kit/toast/toast_controller.js +46 -1
  107. data/app/components/phlex_kit/toast/toaster_controller.js +29 -1
  108. data/app/components/phlex_kit/toggle/toggle.rb +1 -1
  109. data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
  110. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
  111. data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
  112. data/app/components/phlex_kit/typography/typography.css +5 -2
  113. data/lib/phlex_kit/version.rb +1 -1
  114. metadata +1 -1
@@ -57,6 +57,9 @@ export default class extends Controller {
57
57
  try { handle.setPointerCapture(e.pointerId) } catch {}
58
58
 
59
59
  const onMove = (ev) => {
60
+ // Only the initiating pointer drives the drag — a second touch point
61
+ // over the handle must not hijack it.
62
+ if (ev.pointerId !== e.pointerId) return
60
63
  // LTR assumption: clientX grows toward the trailing panel; RTL drag
61
64
  // inversion is a documented limitation.
62
65
  const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
@@ -65,9 +68,12 @@ export default class extends Controller {
65
68
  const prevGrow = this.clampPrevGrow(prev, next, (prevSize / total) * drag.pairGrow, drag.pairGrow, drag.groupGrow)
66
69
  prev.style.flexGrow = prevGrow
67
70
  next.style.flexGrow = drag.pairGrow - prevGrow
68
- this.syncValuenow(handle)
71
+ // Resizing this pair moves every share: 3+ panel groups have sibling
72
+ // handles whose aria-valuenow would otherwise go stale.
73
+ this.handleTargets.forEach((h) => this.syncValuenow(h))
69
74
  }
70
- const onUp = () => {
75
+ const onUp = (ev) => {
76
+ if (ev.pointerId !== e.pointerId) return
71
77
  handle.removeEventListener("pointermove", onMove)
72
78
  handle.removeEventListener("pointerup", onUp)
73
79
  handle.removeEventListener("pointercancel", onUp)
@@ -89,8 +95,14 @@ export default class extends Controller {
89
95
  if (!prev || !next) return
90
96
 
91
97
  const horizontal = this.directionValue === "horizontal"
98
+ // Horizontal arrows follow visual direction (round-7 kit convention, same
99
+ // as tabs/toggle_group): in RTL the DOM-first panel is on the RIGHT, so
100
+ // ArrowLeft must GROW it (move the divider visually left). Only the
101
+ // pointer-drag clientX math keeps its documented LTR exemption; Up/Down
102
+ // never flip. Runtime dir check — reliable after a dynamic flip.
103
+ const rtl = horizontal && getComputedStyle(this.element).direction === "rtl"
92
104
  const steps = horizontal
93
- ? { ArrowLeft: -0.05, ArrowRight: 0.05 }
105
+ ? (rtl ? { ArrowLeft: 0.05, ArrowRight: -0.05 } : { ArrowLeft: -0.05, ArrowRight: 0.05 })
94
106
  : { ArrowUp: -0.05, ArrowDown: 0.05 }
95
107
 
96
108
  const pairGrow = this.growOf(prev) + this.growOf(next) || 2
@@ -109,7 +121,8 @@ export default class extends Controller {
109
121
  const prevGrow = this.clampPrevGrow(prev, next, share * pairGrow, pairGrow, this.groupGrow())
110
122
  prev.style.flexGrow = prevGrow
111
123
  next.style.flexGrow = pairGrow - prevGrow
112
- this.syncValuenow(handle)
124
+ // All handles: sibling dividers share panels in 3+ panel groups.
125
+ this.handleTargets.forEach((h) => this.syncValuenow(h))
113
126
  }
114
127
 
115
128
  growOf(el) {
@@ -8,18 +8,20 @@ module PhlexKit
8
8
  end
9
9
 
10
10
  def view_template
11
- div(**mix({
11
+ base = {
12
12
  class: "pk-resizable-handle",
13
- role: "separator",
14
- tabindex: "0",
15
- # Correct for the default horizontal group; the controller re-stamps
16
- # orientation (and aria-valuenow/min/max) per group direction on connect.
17
- aria: { orientation: "vertical" },
18
13
  data: {
19
14
  phlex_kit__resizable_target: "handle",
20
15
  action: "pointerdown->phlex-kit--resizable#start keydown->phlex-kit--resizable#keydown"
21
16
  }
22
- }, @attrs)) do
17
+ }
18
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
19
+ base[:role] = "separator" unless attr_set?(:role)
20
+ base[:tabindex] = "0" unless attr_set?(:tabindex)
21
+ # Correct for the default horizontal group; the controller re-stamps
22
+ # orientation (and aria-valuenow/min/max) per group direction on connect.
23
+ base[:aria] = { orientation: "vertical" } unless aria_key_set?(:orientation)
24
+ div(**mix(base, @attrs)) do
23
25
  span(class: "pk-resizable-handle-grip", aria: { hidden: "true" }) if @with_handle
24
26
  end
25
27
  end
@@ -39,7 +39,7 @@ module PhlexKit
39
39
  # keydown.esc rides on the root (not only the items) so Escape closes
40
40
  # the [popover=manual] panel with focus on the trigger too; handleEsc
41
41
  # no-ops while closed.
42
- action: "click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc"
42
+ action: "click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc focusout->phlex-kit--select#onFocusout"
43
43
  }
44
44
  }, @attrs), &block)
45
45
  end
@@ -14,17 +14,19 @@ module PhlexKit
14
14
  end
15
15
 
16
16
  def view_template(&block)
17
- div(**mix({
17
+ base = {
18
18
  id: @id,
19
- role: "listbox",
20
- tabindex: "-1",
21
19
  class: "pk-select-content",
22
- popover: "manual",
23
20
  data: {
24
21
  phlex_kit__select_target: "content",
25
22
  action: "keydown->phlex-kit--select#typeahead"
26
23
  }
27
- }, @attrs)) do
24
+ }
25
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
26
+ base[:role] = "listbox" unless attr_set?(:role)
27
+ base[:tabindex] = "-1" unless attr_set?(:tabindex)
28
+ base[:popover] = "manual" unless attr_set?(:popover)
29
+ div(**mix(base, @attrs)) do
28
30
  div(class: "pk-select-viewport", &block)
29
31
  end
30
32
  end
@@ -170,6 +170,16 @@ export default class extends Controller {
170
170
  this.#hide();
171
171
  }
172
172
 
173
+ // APG (select-only combobox): Tab out of an open listbox closes it —
174
+ // dropdown_menu's onFocusout pattern. relatedTarget is null when the
175
+ // window itself blurs; leave the panel alone then.
176
+ onFocusout(event) {
177
+ if (!this.contentTarget.matches(":popover-open")) return;
178
+ const to = event.relatedTarget;
179
+ if (!to || this.element.contains(to)) return;
180
+ this.#hide();
181
+ }
182
+
173
183
  // Open/close derive from the live :popover-open state, never a stored
174
184
  // flag — a stale flag is how a close on an already-closed panel becomes
175
185
  // an open (bit the popover's keyboard toggle).
@@ -4,7 +4,7 @@ module PhlexKit
4
4
  class SelectSeparator < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template
7
- div(**mix({ class: "pk-select-separator", role: "separator", aria: { hidden: true } }, @attrs))
7
+ div(**mix({ class: "pk-select-separator", role: "separator", aria: { hidden: "true" } }, @attrs))
8
8
  end
9
9
  end
10
10
  end
@@ -63,12 +63,19 @@ export default class extends Controller {
63
63
  }
64
64
 
65
65
  keydown(event) {
66
- // A native <dialog> nested inside this panel (e.g. a Dialog opened from
67
- // within SheetContent) owns its own Escape handling; the keydown still
68
- // bubbles through this element-scoped listener since dialog is a DOM
69
- // descendant ignore it so one Escape doesn't also close the sheet
70
- // underneath.
66
+ // Yield to any inner overlay that owns this Escape so one keypress doesn't
67
+ // also close the sheet/drawer underneath: a native <dialog> (its own
68
+ // Escape handling), or an open [popover] menu Select / DropdownMenu /
69
+ // Combobox, all popover=manual rendered inside the panel. Two signals
70
+ // make the outcome independent of ordering: event.defaultPrevented catches
71
+ // an inner handler that already consumed the key (Select's Escape is
72
+ // element-scoped and fires deeper in the bubble, so it runs first); the
73
+ // live :popover-open check catches a menu whose Escape handler is
74
+ // document-level (DropdownMenu) and thus still open when this
75
+ // element-scoped listener runs.
76
+ if (event.key === "Escape" && event.defaultPrevented) return
71
77
  if (event.key === "Escape" && event.target.closest("dialog[open]")) return
78
+ if (event.key === "Escape" && this.panel.querySelector("[popover]:popover-open")) return
72
79
  if (event.key === "Escape") {
73
80
  event.preventDefault()
74
81
  // Stop the keydown from reaching a lower/outer overlay's own
@@ -8,7 +8,7 @@ module PhlexKit
8
8
  end
9
9
 
10
10
  def view_template(&block)
11
- button(**mix({ class: "pk-sidebar-group-action", type: "button" }, @attrs), &block)
11
+ button(**mix({ class: "pk-sidebar-group-action", type: :button }, @attrs), &block)
12
12
  end
13
13
  end
14
14
  end
@@ -7,7 +7,7 @@ module PhlexKit
7
7
  end
8
8
 
9
9
  def view_template(&block)
10
- button(**mix({ class: "pk-sidebar-menu-action", type: "button" }, @attrs), &block)
10
+ button(**mix({ class: "pk-sidebar-menu-action", type: :button }, @attrs), &block)
11
11
  end
12
12
  end
13
13
  end
@@ -17,12 +17,13 @@ module PhlexKit
17
17
  base = {
18
18
  type: :button,
19
19
  class: "pk-sidebar-rail",
20
- aria_label: "Toggle sidebar",
21
20
  "aria-expanded": @expanded ? "true" : "false",
22
21
  tabindex: "-1",
23
- title: "Toggle sidebar",
24
22
  data: { action: "click->phlex-kit--sidebar#toggle" }
25
23
  }
24
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
25
+ base[:aria_label] = "Toggle sidebar" unless aria_labelled?
26
+ base[:title] = "Toggle sidebar" unless attr_set?(:title)
26
27
  base["aria-controls"] = @controls if @controls
27
28
  button(**mix(base, @attrs))
28
29
  end
@@ -20,10 +20,11 @@ module PhlexKit
20
20
  base = {
21
21
  type: :button,
22
22
  class: "pk-sidebar-trigger",
23
- aria_label: "Toggle sidebar",
24
23
  "aria-expanded": @expanded ? "true" : "false",
25
24
  data: { action: "click->phlex-kit--sidebar#toggle" }
26
25
  }
26
+ # Default only when the caller didn't supply their own — `mix` fuses.
27
+ base[:aria_label] = "Toggle sidebar" unless aria_labelled?
27
28
  base["aria-controls"] = @controls if @controls
28
29
  button(**mix(base, @attrs)) do
29
30
  block ? yield : render(Icon.new(:menu, size: nil))
@@ -30,7 +30,10 @@ module PhlexKit
30
30
  end
31
31
  # role="switch" belongs on the focusable control: native checkedness then
32
32
  # maps to aria-checked for AT, and the label stays a plain label.
33
- input(**mix({ class: "pk-switch-input", role: "switch" }, @attrs).merge(type: "checkbox", value: @checked_value))
33
+ # Default role only when the caller didn't supply their own — `mix` fuses.
34
+ input_base = { class: "pk-switch-input" }
35
+ input_base[:role] = "switch" unless attr_set?(:role)
36
+ input(**mix(input_base, @attrs).merge(type: "checkbox", value: @checked_value))
34
37
  span(class: "pk-switch-thumb")
35
38
  end
36
39
  end
@@ -13,7 +13,12 @@ module PhlexKit
13
13
  # focusable, per the APG tabs pattern. `active:` (mirroring TabsTrigger's)
14
14
  # renders the panel visible pre-JS/no-JS; the controller's sync()
15
15
  # reconciles visibility from the active value after connect.
16
- div(**mix({ class: @active ? "pk-tabs-content" : "pk-tabs-content pk-hidden", role: "tabpanel", id: @id, aria_labelledby: "pk-tabs-trigger-#{@value}", tabindex: "0", data: { phlex_kit__tabs_target: "content", value: @value } }, @attrs), &)
16
+ base = { class: @active ? "pk-tabs-content" : "pk-tabs-content pk-hidden", role: "tabpanel", id: @id, data: { phlex_kit__tabs_target: "content", value: @value } }
17
+ # Generated defaults only when the caller didn't supply their own —
18
+ # `mix` fuses (custom-id trigger pairing / a non-focusable panel).
19
+ base[:aria_labelledby] = "pk-tabs-trigger-#{@value}" unless aria_key_set?(:labelledby)
20
+ base[:tabindex] = "0" unless attr_set?(:tabindex)
21
+ div(**mix(base, @attrs), &)
17
22
  end
18
23
  end
19
24
  end
@@ -24,13 +24,15 @@ module PhlexKit
24
24
  base = {
25
25
  class: "pk-tabs-trigger", role: "tab", id: @id,
26
26
  aria_selected: @active ? "true" : "false",
27
- aria_controls: "pk-tabs-panel-#{@value}",
28
27
  tabindex: @active ? "0" : "-1",
29
28
  # data-state is the only hook tabs.css styles the active trigger by —
30
29
  # it must be server-rendered or the active tab looks inactive pre-JS.
31
30
  data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value,
32
31
  state: @active ? "active" : "inactive" }
33
32
  }
33
+ # Generated idref default only when the caller didn't supply their own
34
+ # (pairing with a custom-id TabsContent) — `mix` fuses.
35
+ base[:aria_controls] = "pk-tabs-panel-#{@value}" unless aria_key_set?(:controls)
34
36
  if @as == :a
35
37
  a(**mix(base, @attrs), &)
36
38
  else
@@ -25,12 +25,23 @@ export default class extends Controller {
25
25
  // echo guard in apply().
26
26
  this._rootObserver = new MutationObserver(() => this.syncPressed())
27
27
  this._rootObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] })
28
+ // Cross-tab sync: `storage` fires only in OTHER tabs when localStorage.theme
29
+ // changes, so another tab's toggle re-applies here. Without this, this tab's
30
+ // DOM went stale while storedTheme() (read fresh from localStorage) already
31
+ // returned the new value — the echo guard in apply() then swallowed a
32
+ // genuine click because resolvedTheme() already matched.
33
+ this._onStorage = (e) => {
34
+ if (e.key !== "theme") return
35
+ this.applyTheme(this.storedTheme() || "system")
36
+ }
37
+ window.addEventListener("storage", this._onStorage)
28
38
  this.applyTheme(this.storedTheme() || "system")
29
39
  }
30
40
 
31
41
  disconnect() {
32
42
  this._media?.removeEventListener("change", this._onMediaChange)
33
43
  this._rootObserver?.disconnect()
44
+ window.removeEventListener("storage", this._onStorage)
34
45
  }
35
46
 
36
47
  syncPressed() {
@@ -41,12 +52,19 @@ export default class extends Controller {
41
52
 
42
53
  apply(e) {
43
54
  const theme = e.detail?.pressed ? "dark" : "light"
44
- // connect()'s own pressed-state sync re-enters here via the toggle's
45
- // change event (Stimulus value observation is async, so a flag can't
46
- // gate it). If the incoming theme already matches the resolved one this
47
- // is that echo, not a user toggle persisting it would silently pin
48
- // the visitor's OS preference in localStorage on first visit.
49
- if (theme === this.resolvedTheme()) return
55
+ // Skip when this isn't a genuine user toggle two echo shapes:
56
+ // - the incoming theme already matches the RESOLVED one: connect()'s own
57
+ // pressed-state sync re-enters here via the toggle's change event
58
+ // (Stimulus value observation is async, so a flag can't gate it), and a
59
+ // "system" resolution lands here too persisting either would silently
60
+ // pin the visitor's OS preference in localStorage on first visit.
61
+ // - the root's data-theme ALREADY equals it: host code (or a cross-tab
62
+ // storage sync) wrote the theme directly. The contract is that only a
63
+ // user toggle persists, and a real user toggle reaches here BEFORE
64
+ // applyTheme updates the root, so the root still holds the OLD value —
65
+ // a match therefore means "not a user action, don't persist".
66
+ const rootTheme = document.documentElement.getAttribute("data-theme")
67
+ if (theme === this.resolvedTheme() || theme === rootTheme) return
50
68
  this.storeTheme(theme)
51
69
  this.applyTheme(theme)
52
70
  }
@@ -16,6 +16,11 @@ export default class extends Controller {
16
16
  }
17
17
 
18
18
  connect() {
19
+ // Inherit the region's duration when this item didn't set its own: a
20
+ // block-rendered ToastItem carries no duration-value, so without this the
21
+ // Stimulus 4000ms default would win and ToastRegion(duration:) would be
22
+ // dead config for block items (skeleton/flash items already stamp it).
23
+ this._inheritRegionDuration()
19
24
  this._timer = null
20
25
  this._unmountTimer = null
21
26
  this._startedAt = 0
@@ -35,7 +40,16 @@ export default class extends Controller {
35
40
  this._onPointerLeave = () => { if (!this._swipe.active) this._resume("hover") }
36
41
  // Keyboard parity with hover: tabbing into the toast (e.g. to reach its
37
42
  // action button) must pause auto-dismiss just like pointerenter does.
38
- this._onFocusIn = () => this._pause("focus")
43
+ this._onFocusIn = (e) => {
44
+ this._pause("focus")
45
+ // Remember where focus came from the FIRST time it enters from outside
46
+ // the toaster, so dismissing the last focused toast can hand focus back
47
+ // instead of dropping it on <body>.
48
+ const list = this.element.closest(".pk-toast-list") || this.element
49
+ if (this._focusReturn == null && e.relatedTarget && !list.contains(e.relatedTarget)) {
50
+ this._focusReturn = e.relatedTarget
51
+ }
52
+ }
39
53
  this._onFocusOut = (e) => { if (!this.element.contains(e.relatedTarget)) this._resume("focus") }
40
54
  this._onKeyDown = this._onKeyDown.bind(this)
41
55
  this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
@@ -103,11 +117,42 @@ export default class extends Controller {
103
117
  _close(reason) {
104
118
  if (this.element.dataset.state === "closing") return
105
119
  this.element.dataset.state = "closing"
120
+ // If focus is inside this toast, move it BEFORE removal so it doesn't drop
121
+ // to <body> when the element is gone (Escape, close button, or action).
122
+ const hadFocus = this.element.contains(document.activeElement)
106
123
  this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "phlex-kit:toast:auto-close" : "phlex-kit:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
107
124
  this._invokeCallback(reason === "auto" ? this.onAutoCloseValue : this.onDismissValue)
125
+ if (hadFocus) this._restoreFocusOnClose()
108
126
  this._unmountTimer = setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
109
127
  }
110
128
 
129
+ // Keep keyboard focus inside the toaster when a focused toast is dismissed:
130
+ // hand it to an adjacent remaining toast, else back to wherever focus came
131
+ // from before it entered the toaster (tracked on first focusin).
132
+ _restoreFocusOnClose() {
133
+ const list = this.element.closest(".pk-toast-list")
134
+ const others = list
135
+ ? [...list.querySelectorAll("[data-controller~='phlex-kit--toast']")]
136
+ .filter((t) => t !== this.element && t.dataset.state !== "closing")
137
+ : []
138
+ if (others.length) {
139
+ const children = [...list.children]
140
+ const myIdx = children.indexOf(this.element)
141
+ const next = others.find((t) => children.indexOf(t) > myIdx) || others[others.length - 1]
142
+ next.focus()
143
+ return
144
+ }
145
+ if (this._focusReturn?.isConnected) this._focusReturn.focus()
146
+ }
147
+
148
+ _inheritRegionDuration() {
149
+ if (this.element.hasAttribute("data-phlex-kit--toast-duration-value")) return
150
+ const region = this.element.closest("[data-phlex-kit--toaster-duration-value]")
151
+ if (!region) return
152
+ const inherited = Number(region.getAttribute("data-phlex-kit--toaster-duration-value"))
153
+ if (Number.isFinite(inherited)) this.durationValue = inherited
154
+ }
155
+
111
156
  // on_dismiss:/on_auto_close: name a global function (Sonner-style callback
112
157
  // for server-rendered toasts).
113
158
  _invokeCallback(name) {
@@ -80,9 +80,17 @@ export default class extends Controller {
80
80
  this._onWindowDismissId = (e) => this._dismissById(e.detail?.id)
81
81
  this._onKey = this._onKey.bind(this)
82
82
 
83
+ // The region is data-turbo-permanent (in-flight toasts survive a Turbo
84
+ // Drive visit), but that means Turbo KEEPS this old region and DISCARDS
85
+ // the incoming page's — throwing away any server-rendered flash toasts it
86
+ // holds. Adopt them into this permanent list before the transplant so
87
+ // flash: works on Turbo Drive visits, not only full page loads.
88
+ this._onBeforeRender = (e) => this._adoptIncomingToasts(e)
89
+
83
90
  window.addEventListener("phlex-kit:toast", this._onWindowToast)
84
91
  window.addEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
85
92
  window.addEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
93
+ document.addEventListener("turbo:before-render", this._onBeforeRender)
86
94
  this._listEl.addEventListener("pointerenter", this._onPointerEnter)
87
95
  this._listEl.addEventListener("pointerleave", this._onPointerLeave)
88
96
  document.addEventListener("keydown", this._onKey)
@@ -96,6 +104,7 @@ export default class extends Controller {
96
104
  window.removeEventListener("phlex-kit:toast", this._onWindowToast)
97
105
  window.removeEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
98
106
  window.removeEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
107
+ document.removeEventListener("turbo:before-render", this._onBeforeRender)
99
108
  this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
100
109
  this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
101
110
  document.removeEventListener("keydown", this._onKey)
@@ -170,7 +179,10 @@ export default class extends Controller {
170
179
  node.appendChild(btn)
171
180
  }
172
181
 
173
- if (detail.closeButton && this.hasCloseTplTarget) {
182
+ // Don't append a second close button when the skeleton already baked one
183
+ // in (region rendered with close_button: true) — spawning with
184
+ // closeButton: true would otherwise stack two ×'s.
185
+ if (detail.closeButton && this.hasCloseTplTarget && !node.querySelector('[data-slot="close"]')) {
174
186
  const x = this._cloneSlot(this.closeTplTarget)
175
187
  node.classList.add("pk-toast-with-close")
176
188
  node.appendChild(x)
@@ -180,6 +192,22 @@ export default class extends Controller {
180
192
  return node.id
181
193
  }
182
194
 
195
+ // Move server-rendered flash toasts from the incoming (about-to-be-discarded)
196
+ // page's matching region into this permanent list, so a Turbo Drive visit
197
+ // doesn't silently drop them. Runs on turbo:before-render, before Turbo
198
+ // transplants this permanent element into the new body (children ride along).
199
+ _adoptIncomingToasts(e) {
200
+ const newBody = e.detail?.newBody
201
+ if (!newBody || !this._listEl?.id) return
202
+ const incomingList = newBody.querySelector(`#${CSS.escape(this._listEl.id)}`)
203
+ if (!incomingList) return
204
+ incomingList.querySelectorAll(":scope > [data-phlex-kit--toaster-target='toast']").forEach((el) => {
205
+ // Skip a flash id already showing here, so a re-render doesn't dupe it.
206
+ if (el.id && this._listEl.querySelector(`#${CSS.escape(el.id)}`)) return
207
+ this._listEl.appendChild(el.cloneNode(true))
208
+ })
209
+ }
210
+
183
211
  // True when this region should act on a toast event carrying `region`
184
212
  // (the list's base id or the wrapper's "<id>-region" both match). Without
185
213
  // an explicit region, only the page's first region in DOM order handles
@@ -39,7 +39,7 @@ module PhlexKit
39
39
  end
40
40
 
41
41
  def button_default_attrs
42
- a = { type: "button", class: classes, aria: { pressed: @pressed.to_s },
42
+ a = { type: :button, class: classes, aria: { pressed: @pressed.to_s },
43
43
  data: { state: @pressed ? "on" : "off", phlex_kit__toggle_target: "button" } }
44
44
  a[:disabled] = true if @disabled
45
45
  a
@@ -42,9 +42,16 @@ module PhlexKit
42
42
  # (`pressed || enabledItems()[0]`). Items call this once each, in render
43
43
  # order, as they're constructed — a single-threaded sequential claim, not
44
44
  # a lookahead over siblings not yet rendered.
45
- def claim_tab_stop(item_disabled)
46
- return false unless selected_values.empty?
45
+ def claim_tab_stop(item_disabled, pressed: false)
46
+ # A pressed-but-disabled item can't take focus (reconcile() mirrors
47
+ # this with `pressed && !el.disabled`): unlock the fallback claim so a
48
+ # LATER enabled item takes the stop instead of the whole group
49
+ # rendering Tab-unreachable. (If every enabled item precedes the
50
+ # pressed-disabled one, the pre-JS render still has no stop — the
51
+ # sequential claim can't look ahead; reconcile() fixes it at connect.)
52
+ @selection_unfocusable = true if pressed && item_disabled
47
53
  return false if item_disabled
54
+ return false unless selected_values.empty? || @selection_unfocusable
48
55
  return false if @tab_stop_claimed
49
56
  @tab_stop_claimed = true
50
57
  true
@@ -61,10 +68,17 @@ module PhlexKit
61
68
 
62
69
  def render_hidden_inputs
63
70
  return unless @name
71
+ # Disabled in lockstep with the group — a disabled toggle group must
72
+ # not submit its value (matches toggle.rb and native form semantics).
73
+ disabled = @disabled ? true : nil
64
74
  if @type == :single
65
- input(type: "hidden", name: @name, value: selected_values.first.to_s, data: { phlex_kit__toggle_group_target: "input" })
75
+ input(type: "hidden", name: @name, value: selected_values.first.to_s, disabled: disabled,
76
+ data: { phlex_kit__toggle_group_target: "input" })
66
77
  else
67
- selected_values.each { |v| input(type: "hidden", name: "#{@name}[]", value: v, data: { phlex_kit__toggle_group_target: "input" }) }
78
+ selected_values.each do |v|
79
+ input(type: "hidden", name: "#{@name}[]", value: v, disabled: disabled,
80
+ data: { phlex_kit__toggle_group_target: "input" })
81
+ end
68
82
  end
69
83
  end
70
84
 
@@ -17,7 +17,7 @@ module PhlexKit
17
17
  # group_context[:group] is nil for a hand-built context (bypassing
18
18
  # ToggleGroup#item_context) — treat that as "no tab-stop claim" rather
19
19
  # than raising, since standalone tests construct contexts by hand.
20
- @is_first_tab_stop = group_context[:group]&.claim_tab_stop(item_disabled) || false
20
+ @is_first_tab_stop = group_context[:group]&.claim_tab_stop(item_disabled, pressed: pressed) || false
21
21
  super(pressed: pressed, name: nil, value: @item_value,
22
22
  variant: variant || group_context[:variant],
23
23
  size: size || group_context[:size],
@@ -31,7 +31,7 @@ module PhlexKit
31
31
  private
32
32
 
33
33
  def item_default_attrs
34
- a = { type: "button",
34
+ a = { type: :button,
35
35
  class: ([ "pk-toggle" ] + self.class.modifier_classes(variant: @variant, size: @size) + [ "pk-toggle-group-item" ]).join(" "),
36
36
  data: { state: @pressed ? "on" : "off", value: @item_value,
37
37
  phlex_kit__toggle_group_target: "item",
@@ -20,6 +20,11 @@ export default class extends Controller {
20
20
  })
21
21
  }
22
22
 
23
+ // connect() re-derives reflected state from live truth: a Turbo snapshot
24
+ // serializes an Escape-dismissed suppression, and a cache restore must
25
+ // not resurrect it (the pointer/focus that justified it is gone).
26
+ delete this.element.dataset.pkDismissed
27
+
23
28
  this._onKeydown = (e) => {
24
29
  if (e.key !== "Escape") return
25
30
  if (this.element.matches(":hover, :focus-within")) this.element.dataset.pkDismissed = ""
@@ -50,6 +50,9 @@
50
50
  /* Blockquote */
51
51
  .pk-blockquote { margin: 1.5rem 0; border-inline-start: 2px solid var(--pk-border); padding-inline-start: 1.5rem; font-style: italic; }
52
52
 
53
- /* Prose list (shadcn typography's list style). */
54
- .pk-list { margin: 1.5rem 0; padding-inline-start: 1.5rem; list-style: disc; }
53
+ /* Prose list (shadcn typography's list style). Ordered variant (as: :ol)
54
+ keeps its numbers the disc is scoped to unordered lists. */
55
+ .pk-list { margin: 1.5rem 0; padding-inline-start: 1.5rem; }
56
+ ul.pk-list { list-style: disc; }
57
+ ol.pk-list { list-style: decimal; }
55
58
  .pk-list > li { margin-top: .5rem; }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexKit
4
- VERSION = "0.13.0"
4
+ VERSION = "0.14.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kennedy