phlex_kit 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) 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 +23 -6
  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/codeblock/codeblock.rb +5 -1
  13. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  14. data/app/components/phlex_kit/combobox/combobox_controller.js +57 -2
  15. data/app/components/phlex_kit/command/command_controller.js +17 -1
  16. data/app/components/phlex_kit/command/command_input.rb +3 -2
  17. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  18. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  19. data/app/components/phlex_kit/context_menu/context_menu_controller.js +49 -12
  20. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  21. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  22. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  23. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  24. data/app/components/phlex_kit/date_picker/date_picker.rb +13 -2
  25. data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -0
  26. data/app/components/phlex_kit/dialog/dialog_controller.js +3 -0
  27. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  28. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  29. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +4 -0
  30. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  31. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +54 -13
  32. data/app/components/phlex_kit/form_field/form_field_controller.js +32 -24
  33. data/app/components/phlex_kit/hover_card/hover_card.css +6 -0
  34. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  35. data/app/components/phlex_kit/input/input.css +0 -1
  36. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  37. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  38. data/app/components/phlex_kit/item/item.css +2 -1
  39. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  40. data/app/components/phlex_kit/label/label.css +1 -1
  41. data/app/components/phlex_kit/link/link.rb +1 -0
  42. data/app/components/phlex_kit/marker/marker.css +2 -1
  43. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  44. data/app/components/phlex_kit/menubar/menubar_controller.js +40 -16
  45. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  46. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  47. data/app/components/phlex_kit/popover/popover_controller.js +19 -0
  48. data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -14
  49. data/app/components/phlex_kit/select/select_controller.js +22 -0
  50. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  51. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  52. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -0
  53. data/app/components/phlex_kit/sheet/sheet_controller.js +17 -2
  54. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  55. data/app/components/phlex_kit/switch/switch.rb +6 -0
  56. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  57. data/app/components/phlex_kit/toast/toast_action.rb +1 -1
  58. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  59. data/app/components/phlex_kit/toast/toast_controller.js +32 -16
  60. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  61. data/app/components/phlex_kit/toast/toast_region.rb +12 -3
  62. data/app/components/phlex_kit/toast/toaster_controller.js +2 -0
  63. data/app/components/phlex_kit/toggle_group/toggle_group.rb +16 -1
  64. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +11 -8
  65. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +15 -5
  66. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  67. data/lib/phlex_kit/base_component.rb +13 -3
  68. data/lib/phlex_kit/version.rb +1 -1
  69. data/lib/phlex_kit.rb +1 -0
  70. metadata +2 -1
@@ -34,7 +34,8 @@ a.pk-item:hover { background: var(--pk-surface-2); }
34
34
  transform: translateY(.125rem);
35
35
  align-self: flex-start;
36
36
  }
37
- .pk-item-media.icon svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
37
+ /* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
38
+ .pk-item-media.icon svg:not([width]) { width: 1rem; height: 1rem; }
38
39
  .pk-item-media.image {
39
40
  width: 2.5rem;
40
41
  height: 2.5rem;
@@ -15,7 +15,8 @@
15
15
  font-weight: 500;
16
16
  }
17
17
  .pk-kbd-group { display: inline-flex; align-items: center; gap: .25rem; font-family: inherit; }
18
- .pk-kbd svg:not([class*="size-"]) { width: .75rem; height: .75rem; }
18
+ /* Glyphs default to size-3; explicit width attrs (Icon size:) win. */
19
+ .pk-kbd svg:not([width]) { width: .75rem; height: .75rem; }
19
20
  /* Inside an (inverted) tooltip the chip tints from the page background. */
20
21
  .pk-tooltip-content .pk-kbd {
21
22
  background: color-mix(in oklab, var(--pk-bg) 10%, transparent);
@@ -9,4 +9,4 @@
9
9
  line-height: 1;
10
10
  user-select: none;
11
11
  }
12
- .pk-label:has(+ :disabled), .pk-label:has(:disabled) { cursor: not-allowed; opacity: .5; }
12
+ .pk-label:has(+ :disabled), .pk-label:has(:disabled), :disabled + .pk-label { cursor: not-allowed; opacity: .5; }
@@ -20,6 +20,7 @@ module PhlexKit
20
20
  }.freeze
21
21
 
22
22
  SIZES = {
23
+ xs: "xs",
23
24
  sm: "sm",
24
25
  md: nil,
25
26
  lg: "lg",
@@ -25,7 +25,8 @@ a.pk-marker, button.pk-marker {
25
25
  font-size: .875rem;
26
26
  }
27
27
  a.pk-marker:hover, button.pk-marker:hover { color: var(--pk-text); }
28
- .pk-marker svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
28
+ /* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
29
+ .pk-marker svg:not([width]) { width: 1rem; height: 1rem; }
29
30
  .pk-marker-icon { display: inline-flex; flex: none; width: 1rem; height: 1rem; }
30
31
  .pk-marker-content { min-width: 0; overflow-wrap: break-word; }
31
32
  .pk-marker-content a { text-decoration: underline; text-underline-offset: 3px; color: inherit; }
@@ -129,6 +129,9 @@
129
129
  the viewport right edge. */
130
130
  .pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
131
131
  .pk-menubar-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
132
+ /* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
133
+ [dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
134
+ [dir="rtl"] .pk-menubar-sub-chevron { transform: scaleX(-1); }
132
135
  .pk-menubar-sub-content {
133
136
  position: fixed;
134
137
  position-anchor: --pk-menubar-sub;
@@ -48,6 +48,12 @@ export default class extends Controller {
48
48
  // (only on elements that already carry it: plain nav links must not
49
49
  // grow an aria-expanded). Covers the trigger and CSS-revealed subs.
50
50
  menu.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
51
+ // Same shape for checkbox/radio rows: their `checked` DOM property
52
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
53
+ // from it — resync every row from its live input.
54
+ menu.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
55
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
56
+ })
51
57
  if (this.roving) this.applyRoving()
52
58
  }
53
59
 
@@ -107,8 +113,15 @@ export default class extends Controller {
107
113
  if (focus) (this.items(menu)[0] ?? this.trigger(menu))?.focus()
108
114
  }
109
115
 
116
+ // Also wired directly as the click handler on plain nav links (see
117
+ // navigation_menu_link.rb) — `opts` is then the click Event rather than an
118
+ // options hash. Guard the default href="#" (dropdown_menu_controller.js's
119
+ // close() does the same) so a link without a real destination doesn't
120
+ // scroll-to-top/append a hash; a link WITH a real href is left to navigate
121
+ // normally, closing whatever panel is open on the way out.
110
122
  close(opts = {}) {
111
123
  clearTimeout(this.graceTimer)
124
+ if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault?.()
112
125
  const menu = this.openMenu
113
126
  if (!menu) return
114
127
  const panel = this.panel(menu)
@@ -147,10 +160,14 @@ export default class extends Controller {
147
160
  e.preventDefault()
148
161
  this.show(menu, true)
149
162
  } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
150
- // Closed bar: left/right move focus between the triggers (APG).
163
+ // Closed bar: left/right move focus between the triggers (APG). In RTL
164
+ // the bar mirrors, so the physical LEFT arrow moves to the next
165
+ // trigger. Runtime dir check is reliable after a dynamic flip.
151
166
  e.preventDefault()
167
+ const rtl = getComputedStyle(this.element).direction === "rtl"
168
+ const step = (e.key === "ArrowRight" ? 1 : -1) * (rtl ? -1 : 1)
152
169
  const menus = this.menuTargets
153
- const next = menus[(menus.indexOf(menu) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length]
170
+ const next = menus[(menus.indexOf(menu) + step + menus.length) % menus.length]
154
171
  const target = next ? this.trigger(next) : null
155
172
  target?.focus()
156
173
  if (this.roving && target) this.applyRoving(target)
@@ -190,23 +207,30 @@ export default class extends Controller {
190
207
  }
191
208
  break
192
209
  case "ArrowRight":
193
- e.preventDefault()
194
- // On a sub trigger, enter the submenu (focus reveals it via
195
- // :focus-within) instead of jumping to the next top-level menu.
196
- if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
197
- this.enterSub(document.activeElement)
198
- } else {
199
- this.shift(1)
200
- }
201
- break
202
210
  case "ArrowLeft": {
203
211
  e.preventDefault()
204
- // Inside a submenu, step back to its trigger instead of switching menus.
205
- const sub = document.activeElement?.closest(".pk-menubar-sub-content")
206
- if (sub) {
207
- sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
212
+ // Both the submenu (opens inline-end = visually LEFT in RTL) and the
213
+ // top-level menu traversal follow visual direction: the "enter/next"
214
+ // key is ArrowLeft in RTL, ArrowRight in LTR. Runtime dir check is
215
+ // reliable after a dynamic flip.
216
+ const rtl = getComputedStyle(this.element).direction === "rtl"
217
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
218
+ if (e.key === enterKey) {
219
+ // On a sub trigger, enter the submenu (focus reveals it via
220
+ // :focus-within) instead of jumping to the next top-level menu.
221
+ if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
222
+ this.enterSub(document.activeElement)
223
+ } else {
224
+ this.shift(1)
225
+ }
208
226
  } else {
209
- this.shift(-1)
227
+ // Inside a submenu, step back to its trigger instead of switching menus.
228
+ const sub = document.activeElement?.closest(".pk-menubar-sub-content")
229
+ if (sub) {
230
+ sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
231
+ } else {
232
+ this.shift(-1)
233
+ }
210
234
  }
211
235
  break
212
236
  }
@@ -33,6 +33,20 @@
33
33
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
34
34
  }
35
35
  .pk-native-select-field:disabled { cursor: not-allowed; opacity: .5; }
36
+ /* [size] (a multi-row list box, NativeSelect.new(size: N)) or [multiple]
37
+ renders as a scrollable option list, not a single-line control — the fixed
38
+ 2rem row height squashes it and there's no single line for the absolute
39
+ chevron to sit in, so auto-height the field and hide the chevron.
40
+ Note: [size="1"] is a single-line dropdown, not a list box — exclude it. */
41
+ .pk-native-select-field[size]:not([size="1"]),
42
+ .pk-native-select-field[multiple] {
43
+ height: auto;
44
+ padding-inline-end: .625rem;
45
+ }
46
+ .pk-native-select:has(.pk-native-select-field[size]:not([size="1"])) .pk-native-select-icon,
47
+ .pk-native-select:has(.pk-native-select-field[multiple]) .pk-native-select-icon {
48
+ display: none;
49
+ }
36
50
  .pk-native-select-field.sm {
37
51
  height: 1.75rem;
38
52
  padding-top: .125rem;
@@ -11,7 +11,14 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&)
14
- a(**mix({ class: "pk-navigation-menu-link", href: @href, data: { action: "mouseenter->phlex-kit--menubar#switch" } }, @attrs), &)
14
+ a(**mix({
15
+ class: "pk-navigation-menu-link",
16
+ href: @href,
17
+ # click: closes the open panel on real activation (and swallows the
18
+ # default href="#" the way dropdown_menu_controller.js's close() does
19
+ # — see menubar_controller.js#close).
20
+ data: { action: "mouseenter->phlex-kit--menubar#switch click->phlex-kit--menubar#close" }
21
+ }, @attrs), &)
15
22
  end
16
23
  end
17
24
  end
@@ -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) => {
@@ -16,21 +16,8 @@ module PhlexKit
16
16
  # fuse role="region log" / tabindex="0 -1" instead of overriding.
17
17
  base = { class: "pk-scroll-area" }
18
18
  base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
19
- base[:role] = "region" if labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
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 @attrs.key?(:role) || @attrs.key?("role")
22
+ panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
+ panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("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)
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,7 @@ 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
+ this._resume("hover")
196
212
  }
197
213
  }
198
214
  }
@@ -56,13 +56,17 @@ module PhlexKit
56
56
  data[:phlex_kit__toast_on_dismiss_value] = @on_dismiss if @on_dismiss
57
57
  data[:phlex_kit__toast_on_auto_close_value] = @on_auto_close if @on_auto_close
58
58
 
59
- attrs = {
60
- role: ALERT_VARIANTS.include?(@variant) ? "alert" : "status",
61
- tabindex: "0",
62
- class: "pk-toast",
63
- aria: { atomic: "true" },
64
- data: data
65
- }
59
+ attrs = { class: "pk-toast", data: data }
60
+ # Defaults only when the caller didn't supply their own — `mix` would
61
+ # fuse role="status log" / tabindex="0 -1" / aria-atomic="true false"
62
+ # instead of overriding.
63
+ unless @attrs.key?(:role) || @attrs.key?("role")
64
+ attrs[:role] = ALERT_VARIANTS.include?(@variant) ? "alert" : "status"
65
+ end
66
+ attrs[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
67
+ unless aria_key_set?(:atomic)
68
+ attrs[:aria] = { atomic: "true" }
69
+ end
66
70
  attrs[:id] = @id if @id
67
71
  attrs
68
72
  end