phlex_kit 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
  3. data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +1 -1
  4. data/app/components/phlex_kit/accordion/accordion_trigger.rb +1 -1
  5. data/app/components/phlex_kit/alert/alert.rb +5 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +2 -2
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
  10. data/app/components/phlex_kit/attachment/attachment.css +7 -1
  11. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
  12. data/app/components/phlex_kit/avatar/avatar.css +4 -0
  13. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
  16. data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  18. data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
  19. data/app/components/phlex_kit/calendar/calendar.css +5 -0
  20. data/app/components/phlex_kit/calendar/calendar_controller.js +38 -9
  21. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
  22. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
  23. data/app/components/phlex_kit/carousel/carousel.css +7 -0
  24. data/app/components/phlex_kit/carousel/carousel.rb +8 -4
  25. data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
  26. data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
  27. data/app/components/phlex_kit/chart/chart.rb +1 -1
  28. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  29. data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
  30. data/app/components/phlex_kit/checkbox/checkbox.rb +19 -1
  31. data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
  32. data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
  33. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
  34. data/app/components/phlex_kit/codeblock/codeblock.rb +2 -2
  35. data/app/components/phlex_kit/combobox/combobox.rb +1 -1
  36. data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
  37. data/app/components/phlex_kit/combobox/combobox_controller.js +60 -5
  38. data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
  39. data/app/components/phlex_kit/command/command_controller.js +69 -2
  40. data/app/components/phlex_kit/command/command_dialog_content.rb +15 -6
  41. data/app/components/phlex_kit/command/command_empty.rb +5 -3
  42. data/app/components/phlex_kit/command/command_separator.rb +1 -1
  43. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
  44. data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
  45. data/app/components/phlex_kit/context_menu/context_menu_controller.js +42 -1
  46. data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
  47. data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
  48. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
  49. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
  50. data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
  51. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
  52. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
  53. data/app/components/phlex_kit/data_table/data_table.css +4 -0
  54. data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
  55. data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
  56. data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
  57. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
  58. data/app/components/phlex_kit/date_picker/date_picker_controller.js +3 -5
  59. data/app/components/phlex_kit/drawer/drawer.css +4 -0
  60. data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  62. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
  63. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
  64. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -5
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
  69. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
  72. data/app/components/phlex_kit/empty/empty.rb +1 -0
  73. data/app/components/phlex_kit/field/field.rb +4 -1
  74. data/app/components/phlex_kit/field/field_error.rb +4 -1
  75. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
  76. data/app/components/phlex_kit/icon/icon.rb +6 -2
  77. data/app/components/phlex_kit/input_group/input_group.css +6 -3
  78. data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
  79. data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
  80. data/app/components/phlex_kit/item/item.css +9 -1
  81. data/app/components/phlex_kit/item/item_separator.rb +1 -1
  82. data/app/components/phlex_kit/label/label.css +6 -1
  83. data/app/components/phlex_kit/link/link.rb +10 -1
  84. data/app/components/phlex_kit/menubar/menubar.rb +8 -4
  85. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
  86. data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
  87. data/app/components/phlex_kit/menubar/menubar_controller.js +36 -0
  88. data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
  89. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
  90. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  91. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
  92. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
  93. data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
  94. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
  95. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
  96. data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
  97. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
  98. data/app/components/phlex_kit/pagination/pagination.css +4 -0
  99. data/app/components/phlex_kit/progress/progress.css +19 -0
  100. data/app/components/phlex_kit/progress/progress.rb +29 -13
  101. data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
  102. data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
  103. data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
  104. data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -1
  105. data/app/components/phlex_kit/select/select.rb +1 -1
  106. data/app/components/phlex_kit/select/select_content.rb +7 -5
  107. data/app/components/phlex_kit/select/select_controller.js +10 -0
  108. data/app/components/phlex_kit/select/select_separator.rb +1 -1
  109. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  110. data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
  111. data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
  115. data/app/components/phlex_kit/switch/switch.rb +5 -2
  116. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
  117. data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
  118. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
  119. data/app/components/phlex_kit/toast/toast_controller.js +52 -2
  120. data/app/components/phlex_kit/toast/toast_item.rb +2 -2
  121. data/app/components/phlex_kit/toast/toaster_controller.js +63 -7
  122. data/app/components/phlex_kit/toggle/toggle.rb +1 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
  124. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
  126. data/app/components/phlex_kit/typography/typography.css +5 -2
  127. data/lib/phlex_kit/base_component.rb +9 -0
  128. data/lib/phlex_kit/version.rb +1 -1
  129. metadata +1 -1
@@ -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
@@ -18,9 +18,9 @@ module PhlexKit
18
18
  # Defaults only when the caller didn't supply their own — `mix`
19
19
  # would fuse role="dialog region" / aria-modal="true false" /
20
20
  # tabindex="-1 0" instead of overriding.
21
- panel_attrs[:role] = "dialog" unless @attrs.key?(:role) || @attrs.key?("role")
21
+ panel_attrs[:role] = "dialog" unless attr_set?(:role)
22
22
  panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
- panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
23
+ panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
24
24
  div(**mix(panel_attrs, @attrs)) do
25
25
  block&.call
26
26
  if @show_close_button
@@ -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))
@@ -9,7 +9,7 @@ module PhlexKit
9
9
  # value: is silently clobbered below (the checkbox's real value comes
10
10
  # from checked_value:) — fail loud rather than let a caller's value:
11
11
  # vanish (mirrors ToggleGroupItem's unsupported-kwargs guard).
12
- if attrs.key?(:value)
12
+ if attrs.key?(:value) || attrs.key?("value")
13
13
  raise ArgumentError, "Switch does not support value: — the checkbox's value comes from checked_value:, pass that instead"
14
14
  end
15
15
  @include_hidden = include_hidden
@@ -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) {
@@ -208,7 +253,12 @@ export default class extends Controller {
208
253
  this.element.dataset.swipe = "cancel"
209
254
  this.element.style.removeProperty("--pk-toast-swipe-x")
210
255
  this.element.style.removeProperty("--pk-toast-swipe-y")
211
- this._resume("hover")
256
+ // pointerleave is suppressed while a swipe is active, so release the
257
+ // hover hold here — but only if the pointer actually left the toast; a
258
+ // cancelled swipe ending under the cursor must stay hover-paused (the
259
+ // eventual pointerleave resumes it, including the touch case where
260
+ // pointerleave fires right after pointerup).
261
+ if (!this.element.matches(":hover")) this._resume("hover")
212
262
  }
213
263
  }
214
264
  }
@@ -60,10 +60,10 @@ module PhlexKit
60
60
  # Defaults only when the caller didn't supply their own — `mix` would
61
61
  # fuse role="status log" / tabindex="0 -1" / aria-atomic="true false"
62
62
  # instead of overriding.
63
- unless @attrs.key?(:role) || @attrs.key?("role")
63
+ unless attr_set?(:role)
64
64
  attrs[:role] = ALERT_VARIANTS.include?(@variant) ? "alert" : "status"
65
65
  end
66
- attrs[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
66
+ attrs[:tabindex] = "0" unless attr_set?(:tabindex)
67
67
  unless aria_key_set?(:atomic)
68
68
  attrs[:aria] = { atomic: "true" }
69
69
  end
@@ -63,12 +63,34 @@ export default class extends Controller {
63
63
  // _setExpanded's state change left auto-dismiss running under the cursor.
64
64
  this._onPointerEnter = () => { this._setPaused(true); this._setExpanded(true) }
65
65
  this._onPointerLeave = () => { this._setPaused(false); if (!this.expandValue) this._setExpanded(false) }
66
- this._onWindowToast = (e) => this._spawn(e.detail || {})
66
+ this._onWindowToast = (e) => {
67
+ const detail = e.detail || {}
68
+ // Routing: toast(msg, { region: "<id>" }) targets the region whose
69
+ // wrapper or list carries that id. Without region only the page's
70
+ // FIRST region spawns — the event is a window broadcast, so every
71
+ // region hears it and an unguarded spawn would duplicate the toast
72
+ // once per region.
73
+ if (!this._handlesRegion(detail.region)) return
74
+ this._spawn(detail)
75
+ }
67
76
  this._onWindowDismissAll = () => this._dismissById(null)
77
+ // Dismiss-by-id is a broadcast too: _dismissById only acts on a toast in
78
+ // its OWN list, so whichever region owns the id handles it — the api
79
+ // registration (last region wins) no longer has to be the owner.
80
+ this._onWindowDismissId = (e) => this._dismissById(e.detail?.id)
68
81
  this._onKey = this._onKey.bind(this)
69
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
+
70
90
  window.addEventListener("phlex-kit:toast", this._onWindowToast)
71
91
  window.addEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
92
+ window.addEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
93
+ document.addEventListener("turbo:before-render", this._onBeforeRender)
72
94
  this._listEl.addEventListener("pointerenter", this._onPointerEnter)
73
95
  this._listEl.addEventListener("pointerleave", this._onPointerLeave)
74
96
  document.addEventListener("keydown", this._onKey)
@@ -81,6 +103,8 @@ export default class extends Controller {
81
103
  disconnect() {
82
104
  window.removeEventListener("phlex-kit:toast", this._onWindowToast)
83
105
  window.removeEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
106
+ window.removeEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
107
+ document.removeEventListener("turbo:before-render", this._onBeforeRender)
84
108
  this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
85
109
  this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
86
110
  document.removeEventListener("keydown", this._onKey)
@@ -155,7 +179,10 @@ export default class extends Controller {
155
179
  node.appendChild(btn)
156
180
  }
157
181
 
158
- 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"]')) {
159
186
  const x = this._cloneSlot(this.closeTplTarget)
160
187
  node.classList.add("pk-toast-with-close")
161
188
  node.appendChild(x)
@@ -165,6 +192,31 @@ export default class extends Controller {
165
192
  return node.id
166
193
  }
167
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
+
211
+ // True when this region should act on a toast event carrying `region`
212
+ // (the list's base id or the wrapper's "<id>-region" both match). Without
213
+ // an explicit region, only the page's first region in DOM order handles
214
+ // the broadcast — single-region pages are unaffected.
215
+ _handlesRegion(region) {
216
+ if (region) return region === this.element.id || region === this._listEl?.id
217
+ return this.element === document.querySelector("[data-controller~='phlex-kit--toaster']")
218
+ }
219
+
168
220
  _dismissById(id) {
169
221
  if (!id) {
170
222
  this.toastTargets.forEach((el) =>
@@ -311,12 +363,14 @@ export default class extends Controller {
311
363
  api.info = (m, o) => fire("info", m, o)
312
364
  api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 })
313
365
  api.dismiss = (id) => {
314
- if (id) this._dismissById(id)
366
+ // Both broadcasts: whichever region owns the id dismisses it — the
367
+ // api may be registered by a different region than the owner.
368
+ if (id) window.dispatchEvent(new CustomEvent("phlex-kit:toast:dismiss", { detail: { id } }))
315
369
  else window.dispatchEvent(new CustomEvent("phlex-kit:toast:dismiss-all"))
316
370
  }
317
371
  api.promise = (p, msgs = {}) => {
318
372
  const id = `toast-${this._uuid()}`
319
- fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 })
373
+ fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0, region: msgs.region })
320
374
  Promise.resolve(p).then(
321
375
  (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success),
322
376
  (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error)
@@ -330,9 +384,11 @@ export default class extends Controller {
330
384
  }
331
385
 
332
386
  _mutate(id, variant, text) {
333
- if (!this._listEl) return
334
- const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
335
- if (!el) return
387
+ // Document-scoped: promise()'s loading toast spawns in whichever region
388
+ // the broadcast routed it to, which need not be the region that
389
+ // registered the api this closure belongs to.
390
+ const el = document.getElementById(id)
391
+ if (!el || !el.closest(".pk-toast-list")) return
336
392
  el.dataset.variant = variant
337
393
  el.setAttribute("role", variant === "error" ? "alert" : "status")
338
394
  this._swapIcon(el, variant)
@@ -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; }
@@ -31,6 +31,15 @@ module PhlexKit
31
31
  end
32
32
  end
33
33
 
34
+ # True when the caller supplied their own value for a plain attribute in
35
+ # @attrs — symbol or string spelling. A component with a generated default
36
+ # for that attribute must skip it then: `mix` merges duplicate attrs
37
+ # (role="dialog region") instead of overriding. Aria attributes have their
38
+ # own check (aria_key_set?) covering the aria: hash spelling too.
39
+ def attr_set?(key)
40
+ @attrs.key?(key) || @attrs.key?(key.to_s)
41
+ end
42
+
34
43
  # True when the caller supplied an accessible name in @attrs — via the
35
44
  # aria: hash or flat aria_label/aria-label(ledby) keys. Components with a
36
45
  # generated default label must skip it then: `mix` merges duplicate string
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexKit
4
- VERSION = "0.12.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.12.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kennedy