phlex_kit 0.11.0 → 0.13.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -1
  3. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +35 -7
  5. data/app/components/phlex_kit/attachment/attachment.css +15 -0
  6. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -0
  7. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
  8. data/app/components/phlex_kit/calendar/calendar_controller.js +37 -9
  9. data/app/components/phlex_kit/carousel/carousel_controller.js +36 -2
  10. data/app/components/phlex_kit/chart/chart.rb +6 -4
  11. data/app/components/phlex_kit/chart/chart_controller.js +37 -8
  12. data/app/components/phlex_kit/checkbox/checkbox.rb +15 -1
  13. data/app/components/phlex_kit/codeblock/codeblock.rb +5 -1
  14. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  15. data/app/components/phlex_kit/combobox/combobox_controller.js +61 -2
  16. data/app/components/phlex_kit/command/command_controller.js +77 -2
  17. data/app/components/phlex_kit/command/command_dialog_content.rb +5 -0
  18. data/app/components/phlex_kit/command/command_input.rb +3 -2
  19. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  20. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  21. data/app/components/phlex_kit/context_menu/context_menu_controller.js +75 -12
  22. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  23. data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
  24. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  25. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  26. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  27. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
  28. data/app/components/phlex_kit/date_picker/date_picker.rb +13 -2
  29. data/app/components/phlex_kit/date_picker/date_picker_controller.js +26 -0
  30. data/app/components/phlex_kit/dialog/dialog_controller.js +3 -0
  31. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  32. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  33. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +4 -0
  34. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  35. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +78 -18
  36. data/app/components/phlex_kit/form_field/form_field_controller.js +32 -24
  37. data/app/components/phlex_kit/hover_card/hover_card.css +6 -0
  38. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  39. data/app/components/phlex_kit/icon/icon.rb +1 -1
  40. data/app/components/phlex_kit/input/input.css +0 -1
  41. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  42. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  43. data/app/components/phlex_kit/item/item.css +2 -1
  44. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  45. data/app/components/phlex_kit/label/label.css +1 -1
  46. data/app/components/phlex_kit/link/link.rb +11 -1
  47. data/app/components/phlex_kit/marker/marker.css +2 -1
  48. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  49. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  50. data/app/components/phlex_kit/menubar/menubar_controller.js +66 -16
  51. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  52. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  53. data/app/components/phlex_kit/popover/popover_controller.js +19 -0
  54. data/app/components/phlex_kit/progress/progress.css +19 -0
  55. data/app/components/phlex_kit/progress/progress.rb +29 -13
  56. data/app/components/phlex_kit/scroll_area/scroll_area.rb +2 -15
  57. data/app/components/phlex_kit/select/select_controller.js +22 -0
  58. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  59. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  60. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -0
  61. data/app/components/phlex_kit/sheet/sheet_controller.js +17 -2
  62. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  63. data/app/components/phlex_kit/switch/switch.rb +6 -0
  64. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  65. data/app/components/phlex_kit/toast/toast_action.rb +1 -1
  66. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  67. data/app/components/phlex_kit/toast/toast_controller.js +37 -16
  68. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  69. data/app/components/phlex_kit/toast/toast_region.rb +12 -3
  70. data/app/components/phlex_kit/toast/toaster_controller.js +35 -5
  71. data/app/components/phlex_kit/toggle_group/toggle_group.rb +16 -1
  72. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +11 -8
  73. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +15 -5
  74. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  75. data/lib/phlex_kit/base_component.rb +22 -3
  76. data/lib/phlex_kit/version.rb +1 -1
  77. data/lib/phlex_kit.rb +1 -0
  78. metadata +2 -1
@@ -48,6 +48,25 @@ export default class extends Controller {
48
48
  this.contentTarget.togglePopover()
49
49
  }
50
50
 
51
+ // Public close, for consumers that need to dismiss the panel from outside
52
+ // a trigger interaction (e.g. the date picker closing on a complete
53
+ // calendar selection). Returns focus to the trigger's focusable child —
54
+ // dismissing without it would strand focus on the day button that's
55
+ // about to disappear from the DOM.
56
+ close() {
57
+ if (this.contentTarget.matches(":popover-open")) {
58
+ this.contentTarget.hidePopover()
59
+ }
60
+ this.focusTrigger()
61
+ }
62
+
63
+ focusTrigger() {
64
+ const focusable = this.fallbackInvoker
65
+ || this.triggerTarget.querySelector("button, input, a[href], select, textarea, [tabindex]")
66
+ || this.triggerTarget
67
+ focusable.focus()
68
+ }
69
+
51
70
  // One stable handler added/removed symmetrically — an anonymous listener
52
71
  // here would pile up a duplicate on every target reconnect.
53
72
  syncState = (e) => {
@@ -23,3 +23,22 @@
23
23
  .pk-progress:dir(rtl) .pk-progress-indicator {
24
24
  transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
25
25
  }
26
+
27
+ /* Indeterminate (value: nil): a 40%-wide segment sweeps the track.
28
+ translateX % is relative to the segment's own width: -100% parks it just
29
+ off the track's start edge, +250% (track = 250% of segment) just past the
30
+ end. Direction-agnostic — the sweep reads fine mirrored, no RTL arm. */
31
+ .pk-progress.indeterminate .pk-progress-indicator {
32
+ width: 40%;
33
+ transition: none;
34
+ animation: pk-progress-indeterminate 1.5s ease-in-out infinite;
35
+ }
36
+ @keyframes pk-progress-indeterminate {
37
+ from { transform: translateX(-100%); }
38
+ to { transform: translateX(250%); }
39
+ }
40
+ @media (prefers-reduced-motion: reduce) {
41
+ .pk-progress.indeterminate .pk-progress-indicator {
42
+ animation-duration: 3s;
43
+ }
44
+ }
@@ -6,29 +6,45 @@ module PhlexKit
6
6
  class Progress < BaseComponent
7
7
  # value_text: is a named kwarg (not read from **attrs) because `mix`
8
8
  # would *fuse* a caller "aria-valuetext" with the generated default.
9
+ # `value: nil` renders the INDETERMINATE state (Radix parity): a sliding
10
+ # animation, data-state="indeterminate", and no aria-valuenow — which is
11
+ # exactly how ARIA marks a progressbar of unknown value.
9
12
  def initialize(value: 0, value_text: nil, **attrs)
10
- @value = value.to_f.clamp(0.0, 100.0)
13
+ @value = value.nil? ? nil : value.to_f.clamp(0.0, 100.0)
11
14
  @value_text = value_text
12
15
  @attrs = attrs
13
16
  end
14
17
 
15
18
  def view_template
16
- # %g drops float artifacts ("40.0" would be announced as "forty point
17
- # zero percent"). The value travels as a custom property so the fill
18
- # transform lives in progress.css, where a :dir(rtl) arm can flip it —
19
- # an inline translateX always filled from the physical left.
20
- v = Kernel.format("%g", @value)
21
- div(**mix({
19
+ div(**mix(root_attrs, @attrs)) do
20
+ div(class: "pk-progress-indicator")
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def root_attrs
27
+ base = {
22
28
  role: "progressbar",
23
- "aria-valuenow": v,
24
29
  "aria-valuemin": 0,
25
30
  "aria-valuemax": 100,
26
- "aria-valuetext": @value_text || "#{v}%",
27
- class: "pk-progress",
28
- style: "--pk-progress-value: #{v};"
29
- }, @attrs)) do
30
- div(class: "pk-progress-indicator")
31
+ class: "pk-progress"
32
+ }
33
+ if @value.nil?
34
+ base[:class] = "pk-progress indeterminate"
35
+ base[:data] = { state: "indeterminate" }
36
+ base[:"aria-valuetext"] = @value_text if @value_text
37
+ else
38
+ # %g drops float artifacts ("40.0" would be announced as "forty point
39
+ # zero percent"). The value travels as a custom property so the fill
40
+ # transform lives in progress.css, where a :dir(rtl) arm can flip it —
41
+ # an inline translateX always filled from the physical left.
42
+ v = Kernel.format("%g", @value)
43
+ base[:"aria-valuenow"] = v
44
+ base[:"aria-valuetext"] = @value_text || "#{v}%"
45
+ base[:style] = "--pk-progress-value: #{v};"
31
46
  end
47
+ base
32
48
  end
33
49
  end
34
50
  end
@@ -15,22 +15,9 @@ module PhlexKit
15
15
  # Defaults only when the caller didn't supply their own — `mix` would
16
16
  # fuse role="region log" / tabindex="0 -1" instead of overriding.
17
17
  base = { class: "pk-scroll-area" }
18
- base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
19
- base[:role] = "region" if labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
18
+ base[:tabindex] = "0" unless attr_set?(:tabindex)
19
+ base[:role] = "region" if aria_labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
20
20
  div(**mix(base, @attrs), &)
21
21
  end
22
-
23
- private
24
-
25
- # True when the caller supplies an accessible name, via the aria: hash
26
- # (aria: { label:/labelledby: }) or flat aria_label/aria-labelledby attrs.
27
- def labelled?
28
- aria = @attrs[:aria] || @attrs["aria"]
29
- if aria.is_a?(Hash)
30
- return true if %w[label labelledby].any? { |k| aria[k.to_sym] || aria[k] }
31
- end
32
- [ :aria_label, "aria_label", "aria-label",
33
- :aria_labelledby, "aria_labelledby", "aria-labelledby" ].any? { |k| @attrs[k] }
34
- end
35
22
  end
36
23
  end
@@ -59,6 +59,28 @@ export default class extends Controller {
59
59
  this.closeContent();
60
60
  }
61
61
 
62
+ // APG select pattern: ArrowDown/ArrowUp on the CLOSED trigger opens the
63
+ // listbox and highlights the first/last option, instead of only working
64
+ // once already open (combobox_controller.js's keyDownPressed/keyUpPressed
65
+ // does the equivalent for its own open/closed model).
66
+ handleTriggerArrowDown(event) {
67
+ if (this.openValue) return
68
+ event.preventDefault()
69
+ this.#show()
70
+ if (this.itemTargets.length === 0) return
71
+ this.resetCurrent()
72
+ this.setAriaCurrentAndActiveDescendant(0)
73
+ }
74
+
75
+ handleTriggerArrowUp(event) {
76
+ if (this.openValue) return
77
+ event.preventDefault()
78
+ this.#show()
79
+ if (this.itemTargets.length === 0) return
80
+ this.resetCurrent()
81
+ this.setAriaCurrentAndActiveDescendant(this.itemTargets.length - 1)
82
+ }
83
+
62
84
  onClick() {
63
85
  this.toggleContent();
64
86
 
@@ -16,7 +16,10 @@ module PhlexKit
16
16
  role: "combobox",
17
17
  class: [ "pk-select-trigger", fetch_option(SIZES, @size, :size) ].compact.join(" "),
18
18
  aria: { expanded: "false", haspopup: "listbox", autocomplete: "none" },
19
- data: { action: "phlex-kit--select#onClick", phlex_kit__select_target: "trigger" }
19
+ data: { action: "phlex-kit--select#onClick " \
20
+ "keydown.down->phlex-kit--select#handleTriggerArrowDown " \
21
+ "keydown.up->phlex-kit--select#handleTriggerArrowUp",
22
+ phlex_kit__select_target: "trigger" }
20
23
  }, @attrs)) do
21
24
  block&.call
22
25
  icon
@@ -9,9 +9,19 @@ module PhlexKit
9
9
  end
10
10
  def view_template(&block)
11
11
  template(data: { phlex_kit__sheet_target: "content" }) do
12
- div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown" }) do
13
- div(class: "pk-sheet-backdrop", data: { action: "click->phlex-kit--sheet-content#close" })
14
- div(**mix({ class: [ "pk-sheet-content", fetch_option(SIDES, @side, :side) ].join(" "), role: "dialog", aria: { modal: "true" }, tabindex: "-1", data: { phlex_kit__sheet_content_target: "panel" } }, @attrs)) do
12
+ # data-pk-overlay-clone is a common marker read by other clone-based
13
+ # overlays (see alert_dialog_controller.js#topmost) to determine
14
+ # z-stacking across overlay families when they nest.
15
+ div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown", pk_overlay_clone: "" }) do
16
+ div(class: "pk-sheet-backdrop", data: { action: "click->phlex-kit--sheet-content#close mousedown->phlex-kit--sheet-content#overlayMousedown" })
17
+ panel_attrs = { class: [ "pk-sheet-content", fetch_option(SIDES, @side, :side) ].join(" "), data: { phlex_kit__sheet_content_target: "panel" } }
18
+ # Defaults only when the caller didn't supply their own — `mix`
19
+ # would fuse role="dialog region" / aria-modal="true false" /
20
+ # tabindex="-1 0" instead of overriding.
21
+ panel_attrs[:role] = "dialog" unless attr_set?(:role)
22
+ panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
+ panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
24
+ div(**mix(panel_attrs, @attrs)) do
15
25
  block&.call
16
26
  if @show_close_button
17
27
  button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--sheet-content#close" }) do
@@ -15,6 +15,7 @@ export default class extends Controller {
15
15
  static targets = ["panel"]
16
16
 
17
17
  connect() {
18
+ this.restored = false
18
19
  this.opener = document.activeElement
19
20
  this.previousOverflow = document.body.style.overflow
20
21
  document.body.style.overflow = "hidden"
@@ -26,6 +27,20 @@ export default class extends Controller {
26
27
  }
27
28
 
28
29
  disconnect() {
30
+ this.restoreImmediate()
31
+ }
32
+
33
+ // Synchronously undoes everything connect() did: background inert,
34
+ // scroll lock, and the closed-notification to the source sheet
35
+ // controller. Called from disconnect() (the normal removal path) AND
36
+ // from the source phlex-kit--sheet controller's turbo:before-cache
37
+ // handler, which must restore state before Turbo's synchronous snapshot
38
+ // — disconnect() fires via MutationObserver, too late for that snapshot.
39
+ // Idempotent (guarded by `this.restored`) so whichever path runs second
40
+ // is a harmless no-op.
41
+ restoreImmediate() {
42
+ if (this.restored) return
43
+ this.restored = true
29
44
  this.#restoreInert()
30
45
  document.body.style.overflow = this.previousOverflow
31
46
  if (this.opener?.isConnected) this.opener.focus()
@@ -39,9 +54,30 @@ export default class extends Controller {
39
54
  this.element.remove()
40
55
  }
41
56
 
57
+ // Backdrop mousedown would move focus to <body>, killing this
58
+ // element-scoped keydown listener (Escape after a drag-off-backdrop would
59
+ // stop working) — prevented here so focus stays inside the panel. The
60
+ // backdrop's click->close action still fires normally afterward.
61
+ overlayMousedown(event) {
62
+ event.preventDefault()
63
+ }
64
+
42
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.
71
+ if (event.key === "Escape" && event.target.closest("dialog[open]")) return
43
72
  if (event.key === "Escape") {
44
73
  event.preventDefault()
74
+ // Stop the keydown from reaching a lower/outer overlay's own
75
+ // document-level Escape listener (e.g. an alert_dialog this sheet is
76
+ // stacked on top of) — this element-scoped listener runs first during
77
+ // bubbling, and by the time a document-level listener would see it,
78
+ // this.close() has already removed the [data-pk-overlay-clone] marker
79
+ // the other overlay's #topmost() check relies on.
80
+ event.stopPropagation()
45
81
  this.close()
46
82
  return
47
83
  }
@@ -11,8 +11,19 @@ export default class extends Controller {
11
11
 
12
12
  initialize() {
13
13
  this.overlay = null
14
+ // Turbo snapshots synchronously right after dispatching this event —
15
+ // BEFORE Stimulus disconnect() runs (it fires via MutationObserver, too
16
+ // late to keep the snapshot from capturing an inerted, scroll-locked
17
+ // page). Reach into the clone's own controller and restore its state
18
+ // synchronously before removing it; that method is idempotent, so the
19
+ // content controller's own disconnect() (fired later, once the removal
20
+ // is observed) is a harmless no-op.
14
21
  this.clearOverlay = () => {
15
- if (this.overlay?.isConnected) this.overlay.remove()
22
+ if (this.overlay?.isConnected) {
23
+ const content = this.application.getControllerForElementAndIdentifier(this.overlay, "phlex-kit--sheet-content")
24
+ content?.restoreImmediate()
25
+ this.overlay.remove()
26
+ }
16
27
  this.overlay = null
17
28
  }
18
29
  }
@@ -22,7 +33,11 @@ export default class extends Controller {
22
33
  // Popup semantics for AT: the trigger's real control announces that it
23
34
  // opens a dialog, and expanded state tracks the clone's lifetime (the
24
35
  // content controller signals removal via phlex-kit:sheet-content:closed).
25
- this.invoker = this.element.querySelector(".pk-sheet-trigger button, .pk-sheet-trigger a, .pk-sheet-trigger [tabindex]")
36
+ // Drawer rides this same controller (see drawer.rb) its trigger is
37
+ // marked .pk-drawer-trigger rather than .pk-sheet-trigger.
38
+ this.invoker = this.element.querySelector(
39
+ ".pk-sheet-trigger button, .pk-sheet-trigger a, .pk-sheet-trigger [tabindex], .pk-drawer-trigger button, .pk-drawer-trigger a, .pk-drawer-trigger [tabindex]"
40
+ )
26
41
  this.invoker?.setAttribute("aria-haspopup", "dialog")
27
42
  this.invoker?.setAttribute("aria-expanded", "false")
28
43
  this.onOverlayClosed = () => {
@@ -11,12 +11,15 @@ module PhlexKit
11
11
 
12
12
  def view_template
13
13
  # A visible loader, not decoration: role=status + label, aria-hidden off.
14
- render Icon.new(:loader, size: nil, **mix({
14
+ base = {
15
15
  class: [ "pk-spinner", fetch_option(SIZES, @size, :size) ].compact.join(" "),
16
16
  role: "status",
17
- aria: { label: "Loading" },
18
17
  "aria-hidden": false
19
- }, @attrs))
18
+ }
19
+ # Default label only when the caller didn't supply their own — `mix`
20
+ # would fuse aria-label="Loading Saving" instead of overriding.
21
+ base[:aria] = { label: "Loading" } unless aria_labelled?
22
+ render Icon.new(:loader, size: nil, **mix(base, @attrs))
20
23
  end
21
24
  end
22
25
  end
@@ -6,6 +6,12 @@ module PhlexKit
6
6
  SIZES = { md: nil, sm: "sm" }.freeze
7
7
 
8
8
  def initialize(include_hidden: true, checked_value: "1", unchecked_value: "0", size: :md, wrapper: {}, **attrs)
9
+ # value: is silently clobbered below (the checkbox's real value comes
10
+ # from checked_value:) — fail loud rather than let a caller's value:
11
+ # vanish (mirrors ToggleGroupItem's unsupported-kwargs guard).
12
+ if attrs.key?(:value) || attrs.key?("value")
13
+ raise ArgumentError, "Switch does not support value: — the checkbox's value comes from checked_value:, pass that instead"
14
+ end
9
15
  @include_hidden = include_hidden
10
16
  @checked_value = checked_value
11
17
  @unchecked_value = unchecked_value
@@ -29,8 +29,14 @@ export default class extends Controller {
29
29
  // tab sets also answer to ArrowUp/ArrowDown.
30
30
  keydown(event) {
31
31
  const vertical = this.element.classList.contains("vertical")
32
- const nextKeys = vertical ? ["ArrowRight", "ArrowDown"] : ["ArrowRight"]
33
- const prevKeys = vertical ? ["ArrowLeft", "ArrowUp"] : ["ArrowLeft"]
32
+ // In RTL the tablist mirrors: the physical LEFT arrow moves to the NEXT
33
+ // trigger. The vertical Up/Down pair is unaffected (runtime dir check is
34
+ // reliable even after a dynamic flip — carousel's idiom).
35
+ const rtl = getComputedStyle(this.element).direction === "rtl"
36
+ const fwd = rtl ? "ArrowLeft" : "ArrowRight"
37
+ const back = rtl ? "ArrowRight" : "ArrowLeft"
38
+ const nextKeys = vertical ? [fwd, "ArrowDown"] : [fwd]
39
+ const prevKeys = vertical ? [back, "ArrowUp"] : [back]
34
40
  const triggers = this.triggerTargets.filter((el) => !el.disabled)
35
41
  if (triggers.length === 0) return
36
42
  const index = triggers.indexOf(document.activeElement)
@@ -15,7 +15,7 @@ module PhlexKit
15
15
  # Caller action first, then dismiss — client-spawned actions dismiss
16
16
  # their toast after onClick (Sonner parity), so server-rendered ones
17
17
  # must too.
18
- data[:action] = [ @on, "click->phlex-kit--toast#dismiss" ].compact.join(" ") if @on
18
+ data[:action] = [ @on, "click->phlex-kit--toast#dismiss" ].compact.join(" ")
19
19
  button(**mix({ type: :button, class: "pk-toast-action", data: data }, @attrs)) { @label }
20
20
  end
21
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)
@@ -52,7 +57,8 @@ export default class extends Controller {
52
57
  this._regionList.addEventListener("phlex-kit:toast:pause", this._onRegionPause)
53
58
  this._regionList.addEventListener("phlex-kit:toast:resume", this._onRegionResume)
54
59
 
55
- requestAnimationFrame(() => {
60
+ this._connectFrame = requestAnimationFrame(() => {
61
+ this._connectFrame = null
56
62
  this.element.dataset.state = "open"
57
63
  // Spawned under an already-hovered stack (the toaster stamps
58
64
  // data-pk-toasts-paused while dispatching pause): arm as paused so the
@@ -60,7 +66,7 @@ export default class extends Controller {
60
66
  // under the user's cursor.
61
67
  if (Number.isFinite(this.durationValue) && this.durationValue > 0 &&
62
68
  this.element.closest("[data-pk-toasts-paused]")) {
63
- this._paused = true
69
+ this._pauseSources.add("region")
64
70
  this._remaining = this.durationValue
65
71
  } else {
66
72
  this._start()
@@ -70,6 +76,8 @@ export default class extends Controller {
70
76
 
71
77
  disconnect() {
72
78
  this._clearTimer()
79
+ if (this._connectFrame) cancelAnimationFrame(this._connectFrame)
80
+ this._connectFrame = null
73
81
  // Don't let a pending 200ms unmount fire after teardown (e.g. into a
74
82
  // Turbo-cached copy of the page).
75
83
  if (this._unmountTimer) clearTimeout(this._unmountTimer)
@@ -115,22 +123,30 @@ export default class extends Controller {
115
123
  this._timer = setTimeout(() => this._close("auto"), this._remaining)
116
124
  }
117
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.
118
130
  _restart() {
119
131
  this._clearTimer()
132
+ this._remaining = this.durationValue
133
+ if (this._pauseSources.size > 0) return
120
134
  this._start()
121
135
  }
122
136
 
123
- _pause() {
124
- if (this._paused || !this._timer) return
125
- this._paused = true
137
+ _pause(source) {
138
+ this._pauseSources.add(source)
139
+ if (!this._timer) return
126
140
  clearTimeout(this._timer)
127
141
  this._timer = null
128
142
  this._remaining -= performance.now() - this._startedAt
129
143
  }
130
144
 
131
- _resume() {
132
- if (!this._paused) return
133
- 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
134
150
  if (this._remaining <= 0) return this._close("auto")
135
151
  this._startedAt = performance.now()
136
152
  this._timer = setTimeout(() => this._close("auto"), this._remaining)
@@ -192,7 +208,12 @@ export default class extends Controller {
192
208
  this.element.dataset.swipe = "cancel"
193
209
  this.element.style.removeProperty("--pk-toast-swipe-x")
194
210
  this.element.style.removeProperty("--pk-toast-swipe-y")
195
- this._resume()
211
+ // pointerleave is suppressed while a swipe is active, so release the
212
+ // hover hold here — but only if the pointer actually left the toast; a
213
+ // cancelled swipe ending under the cursor must stay hover-paused (the
214
+ // eventual pointerleave resumes it, including the touch case where
215
+ // pointerleave fires right after pointerup).
216
+ if (!this.element.matches(":hover")) this._resume("hover")
196
217
  }
197
218
  }
198
219
  }
@@ -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 attr_set?(:role)
64
+ attrs[:role] = ALERT_VARIANTS.include?(@variant) ? "alert" : "status"
65
+ end
66
+ attrs[:tabindex] = "0" unless attr_set?(: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
@@ -53,7 +59,8 @@ module PhlexKit
53
59
  end
54
60
  @close_button = close_button
55
61
  @hotkey = hotkey
56
- @dir = dir.to_sym
62
+ @dir_explicit = !dir.equal?(DIR_UNSET)
63
+ @dir = (@dir_explicit ? dir : :ltr).to_sym
57
64
  unless %i[ltr rtl auto].include?(@dir)
58
65
  raise ArgumentError, "ToastRegion dir: must be :ltr, :rtl or :auto"
59
66
  end
@@ -138,7 +145,9 @@ module PhlexKit
138
145
  phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+")
139
146
  }
140
147
  }
141
- base[:dir] = @dir.to_s unless @dir == :ltr
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
142
151
  base
143
152
  end
144
153
  end