phlex_kit 0.8.1 → 0.9.1

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +11 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -2
  4. data/app/components/phlex_kit/accordion/accordion_controller.js +8 -2
  5. data/app/components/phlex_kit/alert/alert.css +3 -3
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +12 -8
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
  10. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  11. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  12. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  13. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  14. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  15. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  16. data/app/components/phlex_kit/badge/badge.css +4 -4
  17. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  18. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  19. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  20. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  21. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  22. data/app/components/phlex_kit/button/button.css +6 -6
  23. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  24. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  25. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  26. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  27. data/app/components/phlex_kit/calendar/calendar_body.rb +3 -0
  28. data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
  29. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  30. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  31. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  32. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  34. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  35. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  36. data/app/components/phlex_kit/chart/chart_controller.js +32 -9
  37. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  38. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  39. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  40. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  41. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  42. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  43. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  44. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  45. data/app/components/phlex_kit/combobox/combobox_controller.js +52 -9
  46. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  47. data/app/components/phlex_kit/command/command.css +4 -4
  48. data/app/components/phlex_kit/command/command_controller.js +51 -10
  49. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  50. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  51. data/app/components/phlex_kit/command/command_input.rb +6 -2
  52. data/app/components/phlex_kit/command/command_item.rb +4 -1
  53. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  54. data/app/components/phlex_kit/context_menu/context_menu_controller.js +18 -1
  55. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  56. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  57. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  58. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  59. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  60. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  61. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  62. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  63. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  64. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -3
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  69. data/app/components/phlex_kit/field/field.css +1 -1
  70. data/app/components/phlex_kit/field/field_error.rb +5 -6
  71. data/app/components/phlex_kit/form_field/form_field_controller.js +35 -16
  72. data/app/components/phlex_kit/form_field/form_field_error.rb +5 -1
  73. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  74. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  75. data/app/components/phlex_kit/input/input.css +1 -1
  76. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  77. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  78. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  79. data/app/components/phlex_kit/input_otp/input_otp_controller.js +13 -0
  80. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  81. data/app/components/phlex_kit/item/item.css +1 -1
  82. data/app/components/phlex_kit/item/item_group.rb +4 -2
  83. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  84. data/app/components/phlex_kit/marker/marker.css +1 -1
  85. data/app/components/phlex_kit/masked_input/masked_input_controller.js +10 -4
  86. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  87. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  88. data/app/components/phlex_kit/menubar/menubar_controller.js +70 -7
  89. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  90. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  91. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +4 -1
  92. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  93. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  94. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  95. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +7 -2
  96. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  97. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  98. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  99. data/app/components/phlex_kit/popover/popover.css +1 -1
  100. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  101. data/app/components/phlex_kit/resizable/resizable_controller.js +13 -0
  102. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  103. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  104. data/app/components/phlex_kit/select/select.css +3 -3
  105. data/app/components/phlex_kit/select/select.rb +4 -1
  106. data/app/components/phlex_kit/select/select_controller.js +25 -0
  107. data/app/components/phlex_kit/select/select_item.rb +5 -1
  108. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  109. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  110. data/app/components/phlex_kit/sidebar/sidebar.css +8 -8
  111. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  115. data/app/components/phlex_kit/slider/slider.css +1 -1
  116. data/app/components/phlex_kit/slider/slider.rb +3 -1
  117. data/app/components/phlex_kit/switch/switch.rb +6 -1
  118. data/app/components/phlex_kit/table/table.css +5 -3
  119. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  120. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  121. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  122. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +2 -2
  123. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
  124. data/app/components/phlex_kit/toast/toast.css +32 -14
  125. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  126. data/app/components/phlex_kit/toast/toast_region.rb +13 -3
  127. data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
  128. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  129. data/app/components/phlex_kit/toggle/toggle.rb +6 -2
  130. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  131. data/app/components/phlex_kit/typography/heading.rb +9 -3
  132. data/app/components/phlex_kit/typography/typography.css +4 -3
  133. data/lib/phlex_kit/version.rb +1 -1
  134. metadata +1 -1
@@ -3,26 +3,62 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="phlex-kit--theme-toggle". Sits on the same wrapper
4
4
  // as phlex-kit--toggle; pressed = dark. Flips :root[data-theme] to match the
5
5
  // PhlexKit token system (not .dark/.light classes). Adapted from ruby_ui.
6
+ //
7
+ // With no stored choice the root is stamped data-theme="system" — _tokens.css
8
+ // resolves that via prefers-color-scheme, and the matchMedia listener below
9
+ // keeps the toggle's pressed state live when the OS preference flips. Only an
10
+ // explicit user toggle persists "dark"/"light". localStorage access is
11
+ // try/catch-wrapped (storage-blocked contexts): the toggle then still works,
12
+ // just non-persistently, via the in-memory fallback.
6
13
  export default class extends Controller {
7
- connect() { this.applyTheme(this.currentTheme()) }
14
+ connect() {
15
+ this._media = window.matchMedia("(prefers-color-scheme: dark)")
16
+ this._onMediaChange = () => {
17
+ // Only re-apply while the user has no explicit choice.
18
+ if (!this.storedTheme()) this.applyTheme("system")
19
+ }
20
+ this._media.addEventListener("change", this._onMediaChange)
21
+ this.applyTheme(this.storedTheme() || "system")
22
+ }
23
+
24
+ disconnect() {
25
+ this._media?.removeEventListener("change", this._onMediaChange)
26
+ }
27
+
8
28
  apply(e) {
9
29
  const theme = e.detail?.pressed ? "dark" : "light"
10
30
  // connect()'s own pressed-state sync re-enters here via the toggle's
11
31
  // change event (Stimulus value observation is async, so a flag can't
12
- // gate it). If the incoming theme already matches the applied one this
32
+ // gate it). If the incoming theme already matches the resolved one this
13
33
  // is that echo, not a user toggle — persisting it would silently pin
14
34
  // the visitor's OS preference in localStorage on first visit.
15
- if (theme === document.documentElement.getAttribute("data-theme")) return
16
- localStorage.theme = theme
35
+ if (theme === this.resolvedTheme()) return
36
+ this.storeTheme(theme)
17
37
  this.applyTheme(theme)
18
38
  }
19
- currentTheme() {
20
- if (localStorage.theme === "dark") return "dark"
21
- if (localStorage.theme === "light") return "light"
22
- return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
39
+
40
+ // The user's explicit choice, if any ("dark"/"light"), else null.
41
+ storedTheme() {
42
+ try {
43
+ const t = localStorage.theme
44
+ if (t === "dark" || t === "light") return t
45
+ } catch {}
46
+ return this._fallbackTheme || null
47
+ }
48
+
49
+ storeTheme(theme) {
50
+ this._fallbackTheme = theme
51
+ try { localStorage.theme = theme } catch {}
23
52
  }
53
+
54
+ // The theme actually in effect right now: explicit choice, else OS.
55
+ resolvedTheme() {
56
+ return this.storedTheme() || (this._media.matches ? "dark" : "light")
57
+ }
58
+
24
59
  applyTheme(theme) {
25
60
  document.documentElement.setAttribute("data-theme", theme)
26
- this.element.setAttribute("data-phlex-kit--toggle-pressed-value", theme === "dark" ? "true" : "false")
61
+ const dark = theme === "system" ? this._media.matches : theme === "dark"
62
+ this.element.setAttribute("data-phlex-kit--toggle-pressed-value", dark ? "true" : "false")
27
63
  }
28
64
  }
@@ -45,7 +45,7 @@
45
45
  color: var(--pk-text);
46
46
  padding: 1rem;
47
47
  font-size: 13px;
48
- box-shadow: 0 4px 12px rgba(0, 0, 0, .1);
48
+ box-shadow: 0 4px 12px color-mix(in srgb, var(--pk-shadow-color) 10%, transparent);
49
49
  transition: transform .3s ease-out, opacity .3s ease-out;
50
50
  will-change: transform;
51
51
  opacity: var(--opacity, 1);
@@ -53,9 +53,27 @@
53
53
  .pk-toast[data-state="pending"], .pk-toast[data-state="closing"] { opacity: 0; }
54
54
  .pk-toast[data-swipe="move"] { transition: none; }
55
55
 
56
+ /* Swipe-out: fly along the direction the controller stamps into
57
+ --swipe-end-x/y (implicit `from` = the inline transform, which still
58
+ carries the drag offset via --pk-toast-swipe-x/y). forwards keeps the
59
+ end pose until the 200ms unmount removes the node. */
60
+ .pk-toast[data-swipe="end"] {
61
+ animation: pk-toast-swipe-out .2s ease-out forwards;
62
+ }
63
+ @keyframes pk-toast-swipe-out {
64
+ to {
65
+ transform: translate3d(var(--swipe-end-x, 0px), calc(var(--y-offset, 0px) + var(--swipe-end-y, 0px)), 0);
66
+ opacity: 0;
67
+ }
68
+ }
69
+ @media (prefers-reduced-motion: reduce) {
70
+ /* No fly-out; [data-state="closing"] still fades it. */
71
+ .pk-toast[data-swipe="end"] { animation: none; }
72
+ }
73
+
56
74
  /* Room for the corner close button (region-wide, or per-spawned-toast). */
57
75
  .pk-toast-region[data-close-button="true"] .pk-toast,
58
- .pk-toast.pk-toast-with-close { padding-right: 2.5rem; }
76
+ .pk-toast.pk-toast-with-close { padding-inline-end: 2.5rem; }
59
77
 
60
78
  .pk-toast-icon {
61
79
  position: relative;
@@ -65,11 +83,11 @@
65
83
  justify-content: flex-start;
66
84
  width: 1rem;
67
85
  height: 1rem;
68
- margin-left: -3px;
69
- margin-right: .25rem;
86
+ margin-inline-start: -3px;
87
+ margin-inline-end: .25rem;
70
88
  color: var(--pk-text);
71
89
  }
72
- .pk-toast-icon-svg { width: 1rem; height: 1rem; margin-left: -1px; }
90
+ .pk-toast-icon-svg { width: 1rem; height: 1rem; margin-inline-start: -1px; }
73
91
  .pk-toast-icon-svg.spin { animation: pk-toast-spin 1s linear infinite; }
74
92
  @keyframes pk-toast-spin { to { transform: rotate(360deg); } }
75
93
 
@@ -93,7 +111,7 @@
93
111
  justify-content: center;
94
112
  flex: none;
95
113
  height: 1.5rem;
96
- margin-left: auto;
114
+ margin-inline-start: auto;
97
115
  border: 0;
98
116
  border-radius: calc(var(--pk-radius) - 4px);
99
117
  padding: 0 .5rem;
@@ -106,9 +124,9 @@
106
124
  transition: opacity .15s ease;
107
125
  }
108
126
  .pk-toast-action:hover { opacity: .9; }
109
- .pk-toast-action:focus {
127
+ .pk-toast-action:focus-visible {
110
128
  outline: none;
111
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
129
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
112
130
  }
113
131
  .pk-toast-action:disabled { pointer-events: none; opacity: .5; }
114
132
 
@@ -119,7 +137,7 @@
119
137
  justify-content: center;
120
138
  flex: none;
121
139
  height: 1.5rem;
122
- margin-left: auto;
140
+ margin-inline-start: auto;
123
141
  border: 0;
124
142
  border-radius: calc(var(--pk-radius) - 4px);
125
143
  padding: 0 .5rem;
@@ -132,15 +150,15 @@
132
150
  transition: background-color .15s ease;
133
151
  }
134
152
  .pk-toast-cancel:hover { background: color-mix(in oklab, var(--pk-text) 15%, transparent); }
135
- .pk-toast-cancel:focus {
153
+ .pk-toast-cancel:focus-visible {
136
154
  outline: none;
137
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
155
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
138
156
  }
139
157
 
140
158
  /* Corner × close. */
141
159
  .pk-toast-close {
142
160
  position: absolute;
143
- right: .5rem;
161
+ inset-inline-end: .5rem;
144
162
  top: .5rem;
145
163
  display: flex;
146
164
  align-items: center;
@@ -156,8 +174,8 @@
156
174
  transition: background-color .15s ease, color .15s ease;
157
175
  }
158
176
  .pk-toast-close:hover { background: var(--pk-accent); color: var(--pk-text); }
159
- .pk-toast-close:focus {
177
+ .pk-toast-close:focus-visible {
160
178
  outline: none;
161
- box-shadow: 0 0 0 1px color-mix(in oklab, var(--pk-ring) 50%, transparent);
179
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
162
180
  }
163
181
  .pk-toast-close svg { width: .875rem; height: .875rem; }
@@ -50,7 +50,17 @@ export default class extends Controller {
50
50
 
51
51
  requestAnimationFrame(() => {
52
52
  this.element.dataset.state = "open"
53
- this._start()
53
+ // Spawned under an already-hovered stack (the toaster stamps
54
+ // data-pk-toasts-paused while dispatching pause): arm as paused so the
55
+ // region's resume event starts the timer, instead of ticking away
56
+ // under the user's cursor.
57
+ if (Number.isFinite(this.durationValue) && this.durationValue > 0 &&
58
+ this.element.closest("[data-pk-toasts-paused]")) {
59
+ this._paused = true
60
+ this._remaining = this.durationValue
61
+ } else {
62
+ this._start()
63
+ }
54
64
  })
55
65
  }
56
66
 
@@ -142,7 +152,11 @@ export default class extends Controller {
142
152
  const dx = e.clientX - this._swipe.x
143
153
  const dy = e.clientY - this._swipe.y
144
154
  this.element.dataset.swipe = "move"
145
- this.element.style.transform = `translate(${dx}px, ${dy}px)`
155
+ // Never write style.transform here: the toaster owns it (stacking
156
+ // translate3d + scale, written to compose these vars). Writing it
157
+ // directly snapped non-front toasts out of position.
158
+ this.element.style.setProperty("--pk-toast-swipe-x", `${dx}px`)
159
+ this.element.style.setProperty("--pk-toast-swipe-y", `${dy}px`)
146
160
  }
147
161
 
148
162
  _onPointerUp(e) {
@@ -156,14 +170,20 @@ export default class extends Controller {
156
170
  this.element.removeEventListener("pointercancel", this._onPointerUp)
157
171
  this._swipe.active = false
158
172
  if (dist > SWIPE_THRESHOLD || velocity > 0.5) {
173
+ // Keep the swipe vars in place: they are the animation's start point.
174
+ // toast.css's [data-swipe="end"] keyframes fly the toast out along
175
+ // --swipe-end-x/y while _close fades + removes it.
159
176
  this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`)
160
177
  this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`)
161
178
  this.element.dataset.swipe = "end"
162
- this.element.style.transform = ""
163
179
  this._close("dismiss")
164
180
  } else {
181
+ // Clearing the vars composes the toast back into the toaster's stacking
182
+ // transform; the transition (re-enabled once data-swipe leaves "move")
183
+ // animates it home.
165
184
  this.element.dataset.swipe = "cancel"
166
- this.element.style.transform = ""
185
+ this.element.style.removeProperty("--pk-toast-swipe-x")
186
+ this.element.style.removeProperty("--pk-toast-swipe-y")
167
187
  this._resume()
168
188
  }
169
189
  }
@@ -32,6 +32,7 @@ module PhlexKit
32
32
  hotkey: %w[alt t],
33
33
  dir: :ltr,
34
34
  flash: nil,
35
+ id: "pk-toaster",
35
36
  **attrs
36
37
  )
37
38
  @position = POSITIONS.fetch(position.to_sym)
@@ -46,12 +47,16 @@ module PhlexKit
46
47
  @hotkey = hotkey
47
48
  @dir = dir
48
49
  @flash = flash
50
+ # Base id for the <ol> stack; the wrapper takes "#{id}-region". Defaults
51
+ # keep the historical "pk-toaster"/"pk-toaster-region" pair — pass a
52
+ # distinct id when rendering more than one region on a page.
53
+ @id = id
49
54
  @attrs = attrs
50
55
  end
51
56
 
52
57
  def view_template(&block)
53
58
  div(**mix(region_attrs, @attrs)) do
54
- ol(id: "pk-toaster", class: "pk-toast-list") do
59
+ ol(id: @id, class: "pk-toast-list") do
55
60
  render_flash if @flash
56
61
  yield(self) if block
57
62
  end
@@ -73,6 +78,9 @@ module PhlexKit
73
78
  render ToastItem.new(variant: variant, id: "flash-#{key}", duration: @duration) do
74
79
  render ToastIcon.new(variant: variant)
75
80
  render ToastTitle.new { message.to_s }
81
+ # The region-wide close button must reach flash toasts too — the CSS
82
+ # already reserves their right padding when close_button is on.
83
+ render ToastClose.new if @close_button
76
84
  end
77
85
  end
78
86
  end
@@ -98,10 +106,12 @@ module PhlexKit
98
106
 
99
107
  def region_attrs
100
108
  {
101
- id: "pk-toaster-region",
109
+ id: "#{@id}-region",
102
110
  role: "region",
103
111
  class: "pk-toast-region",
104
- aria: { label: "Notifications", live: "polite" },
112
+ # No aria-live here: each toast item is its own live region
113
+ # (role="status"/"alert"); a live wrapper would double-announce.
114
+ aria: { label: "Notifications" },
105
115
  data: {
106
116
  controller: "phlex-kit--toaster",
107
117
  turbo_permanent: "",
@@ -181,6 +181,9 @@ export default class extends Controller {
181
181
  _setExpanded(value) {
182
182
  if (this._expanded === value) return
183
183
  this._expanded = value
184
+ // Mirror the pause state onto the DOM so toasts spawned while the stack
185
+ // is already hovered can arm paused (the pause event predates them).
186
+ this._listEl?.toggleAttribute("data-pk-toasts-paused", value)
184
187
  document.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
185
188
  this._reflow()
186
189
  }
@@ -227,7 +230,10 @@ export default class extends Controller {
227
230
  el.style.transformOrigin = isBottom ? "center bottom" : "center top"
228
231
  el.style.top = isBottom ? "auto" : "0"
229
232
  el.style.bottom = isBottom ? "0" : "auto"
230
- el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`
233
+ // Compose the toast controller's swipe offset (custom props it sets
234
+ // during pointer-drag) into the stacking transform — the swipe must
235
+ // never overwrite this transform or non-front toasts snap out of place.
236
+ el.style.transform = `translate3d(var(--pk-toast-swipe-x, 0px), calc(${ty}px + var(--pk-toast-swipe-y, 0px)), 0) scale(${scale})`
231
237
  el.style.zIndex = String(1000 - i)
232
238
  el.style.pointerEvents = visible ? "auto" : "none"
233
239
  el.tabIndex = visible ? 0 : -1
@@ -257,13 +263,17 @@ export default class extends Controller {
257
263
  const wantAlt = parts.includes("alt")
258
264
  const wantCtrl = parts.includes("ctrl")
259
265
  const wantMeta = parts.includes("meta")
266
+ const wantShift = parts.includes("shift")
260
267
  if (e.key.toLowerCase() !== key.toLowerCase()) return
261
268
  if (wantAlt !== e.altKey) return
262
269
  if (wantCtrl !== e.ctrlKey) return
263
270
  if (wantMeta !== e.metaKey) return
271
+ if (wantShift !== e.shiftKey) return
264
272
  e.preventDefault()
265
- const first = this._listEl.firstElementChild
266
- first?.focus()
273
+ // Newest toast is the LAST child (append order) and fronts the stack —
274
+ // the first child is the oldest, faded, possibly pointer-events:none one.
275
+ const newest = this._listEl.lastElementChild
276
+ newest?.focus()
267
277
  }
268
278
 
269
279
  _registerGlobalApi() {
@@ -25,7 +25,7 @@
25
25
  .pk-toggle svg { pointer-events: none; flex-shrink: 0; }
26
26
  .pk-toggle svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
27
27
  .pk-toggle.sm svg:not([class*="size-"]) { width: .875rem; height: .875rem; }
28
- .pk-toggle:has(> [data-icon="inline-start"]) { padding-left: .5rem; }
29
- .pk-toggle:has(> [data-icon="inline-end"]) { padding-right: .5rem; }
30
- .pk-toggle.sm:has(> [data-icon="inline-start"]) { padding-left: .375rem; }
31
- .pk-toggle.sm:has(> [data-icon="inline-end"]) { padding-right: .375rem; }
28
+ .pk-toggle:has(> [data-icon="inline-start"]) { padding-inline-start: .5rem; }
29
+ .pk-toggle:has(> [data-icon="inline-end"]) { padding-inline-end: .5rem; }
30
+ .pk-toggle.sm:has(> [data-icon="inline-start"]) { padding-inline-start: .375rem; }
31
+ .pk-toggle.sm:has(> [data-icon="inline-end"]) { padding-inline-end: .375rem; }
@@ -54,8 +54,12 @@ module PhlexKit
54
54
  end
55
55
 
56
56
  def render_hidden_input
57
- input(type: "hidden", name: @name, value: @pressed ? @value : @unpressed_value.to_s,
58
- data: { phlex_kit__toggle_target: "input" })
57
+ # Disabled in lockstep with the button a disabled toggle must not
58
+ # submit its value (matches native disabled-control form semantics).
59
+ a = { type: "hidden", name: @name, value: @pressed ? @value : @unpressed_value.to_s,
60
+ data: { phlex_kit__toggle_target: "input" } }
61
+ a[:disabled] = true if @disabled
62
+ input(**a)
59
63
  end
60
64
  end
61
65
  end
@@ -34,9 +34,14 @@
34
34
  opacity: 0;
35
35
  visibility: hidden;
36
36
  pointer-events: none;
37
- transition: opacity .12s ease;
37
+ /* Zero delay here = hover-out hides immediately; the hover-in rule below
38
+ re-declares the delays for the open direction only. */
39
+ transition: opacity .12s ease 0s, visibility 0s linear 0s;
38
40
  }
39
- .pk-tooltip-content:has(kbd) { padding-right: .375rem; }
41
+ @media (prefers-reduced-motion: reduce) {
42
+ .pk-tooltip-content { transition-duration: 0s, 0s; }
43
+ }
44
+ .pk-tooltip-content:has(kbd) { padding-inline-end: .375rem; }
40
45
  /* The notch. The arrow is position:fixed so both anchors are acceptable
41
46
  (an absolute pseudo's containing block is the bubble, which doesn't
42
47
  contain the trigger — anchor() wouldn't resolve). Its center chases the
@@ -66,8 +71,25 @@
66
71
  position-area: inline-end; margin: 0 0 0 6px;
67
72
  position-try-fallbacks: flip-inline;
68
73
  }
69
- .pk-tooltip:hover .pk-tooltip-content,
70
- .pk-tooltip:focus-within .pk-tooltip-content { opacity: 1; visibility: visible; }
71
- /* Escape-dismissed (WCAG 1.4.13): suppressed until pointer/focus leaves. */
74
+ /* Hover reveal only on devices that actually hover (a touch tap must not
75
+ stick the bubble open), with an open delay so tooltips don't flash while
76
+ the cursor sweeps across dense UI. Visibility gets the same delay so the
77
+ bubble doesn't occupy hit-testing space during the wait. */
78
+ @media (hover: hover) {
79
+ .pk-tooltip:hover .pk-tooltip-content {
80
+ opacity: 1;
81
+ visibility: visible;
82
+ transition-delay: .4s, .4s;
83
+ }
84
+ }
85
+ /* Keyboard focus reveals instantly (:focus-visible, so pointer/touch clicks
86
+ that focus the trigger don't stick the bubble open). */
87
+ .pk-tooltip:has(:focus-visible) .pk-tooltip-content {
88
+ opacity: 1;
89
+ visibility: visible;
90
+ transition-delay: 0s, 0s;
91
+ }
92
+ /* Escape-dismissed (WCAG 1.4.13): suppressed until pointer/focus leaves.
93
+ transition: none so the hide is immediate regardless of hover-in delays. */
72
94
  .pk-tooltip[data-pk-dismissed]:hover .pk-tooltip-content,
73
- .pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; }
95
+ .pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; transition: none; }
@@ -1,14 +1,20 @@
1
1
  module PhlexKit
2
- # Heading, ported from ruby_ui's RubyUI::Heading. `level:` (1–4) picks the
2
+ # Heading, ported from ruby_ui's RubyUI::Heading. `level:` (1–6) picks the
3
3
  # element AND a default size; `as:` overrides the element; `size:` (1–9)
4
4
  # overrides the size on ruby_ui's scale (text-xs … text-5xl). Presentational;
5
5
  # attrs pass through via mix. Tailwind → vanilla `.pk-heading*` (typography.css).
6
6
  class Heading < BaseComponent
7
7
  SIZES = %w[1 2 3 4 5 6 7 8 9].freeze
8
- # ruby_ui's level → default size mapping (h1 is big, h4 smaller).
9
- LEVEL_SIZE = { "1" => "7", "2" => "6", "3" => "5", "4" => "4" }.freeze
8
+ # ruby_ui's level → default size mapping (h1 is big, h4 smaller); 5/6
9
+ # extend the scale downward HTML has h1–h6, so all six must map.
10
+ LEVEL_SIZE = { "1" => "7", "2" => "6", "3" => "5", "4" => "4", "5" => "3", "6" => "2" }.freeze
10
11
 
11
12
  def initialize(level: nil, as: nil, size: nil, **attrs)
13
+ # Fail loud at construction: level 7 previously exploded at render time
14
+ # with NoMethodError (no h7 element), and 5/6 fell through the size map.
15
+ unless level.nil? || LEVEL_SIZE.key?(level.to_s)
16
+ raise ArgumentError, "Invalid heading level: #{level.inspect} — valid: 1–6"
17
+ end
12
18
  @level = level
13
19
  @as = as
14
20
  @size = size
@@ -38,7 +38,8 @@
38
38
  font-size: .875rem;
39
39
  background: var(--pk-bg);
40
40
  border: 1px solid var(--pk-border);
41
- border-radius: 6px;
41
+ /* Tracks the radius token — 6px at the default --pk-radius (0.625rem). */
42
+ border-radius: calc(var(--pk-radius) - 4px);
42
43
  padding: .15rem .4rem;
43
44
  }
44
45
 
@@ -47,8 +48,8 @@
47
48
  .pk-inline-link:hover { text-decoration: underline; }
48
49
 
49
50
  /* Blockquote */
50
- .pk-blockquote { margin: 1.5rem 0; border-left: 2px solid var(--pk-border); padding-left: 1.5rem; font-style: italic; }
51
+ .pk-blockquote { margin: 1.5rem 0; border-inline-start: 2px solid var(--pk-border); padding-inline-start: 1.5rem; font-style: italic; }
51
52
 
52
53
  /* Prose list (shadcn typography's list style). */
53
- .pk-list { margin: 1.5rem 0; padding-left: 1.5rem; list-style: disc; }
54
+ .pk-list { margin: 1.5rem 0; padding-inline-start: 1.5rem; list-style: disc; }
54
55
  .pk-list > li { margin-top: .5rem; }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexKit
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.1"
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.8.1
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kennedy