phlex_kit 0.9.1 → 0.11.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +8 -0
  3. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  4. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  5. data/app/components/phlex_kit/alert/alert.rb +1 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +15 -0
  7. data/app/components/phlex_kit/attachment/attachment.css +6 -3
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
  10. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
  11. data/app/components/phlex_kit/avatar/avatar.css +2 -2
  12. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  13. data/app/components/phlex_kit/badge/badge.css +1 -1
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
  15. data/app/components/phlex_kit/bubble/bubble.css +1 -1
  16. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
  18. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  19. data/app/components/phlex_kit/button_group/button_group.css +16 -16
  20. data/app/components/phlex_kit/calendar/calendar.css +10 -2
  21. data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
  22. data/app/components/phlex_kit/carousel/carousel.css +4 -4
  23. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  24. data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
  25. data/app/components/phlex_kit/chart/chart.rb +8 -2
  26. data/app/components/phlex_kit/clipboard/clipboard_controller.js +26 -3
  27. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  28. data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
  29. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  30. data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
  31. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
  32. data/app/components/phlex_kit/combobox/combobox.css +3 -3
  33. data/app/components/phlex_kit/combobox/combobox.rb +3 -3
  34. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  35. data/app/components/phlex_kit/combobox/combobox_controller.js +32 -10
  36. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  37. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  38. data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
  39. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +7 -1
  40. data/app/components/phlex_kit/command/command.css +2 -2
  41. data/app/components/phlex_kit/command/command_controller.js +63 -35
  42. data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
  43. data/app/components/phlex_kit/command/command_group.rb +5 -2
  44. data/app/components/phlex_kit/command/command_item.rb +4 -0
  45. data/app/components/phlex_kit/command/command_list.rb +6 -2
  46. data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
  47. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  49. data/app/components/phlex_kit/data_table/data_table.css +0 -10
  50. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  51. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  52. data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
  53. data/app/components/phlex_kit/dialog/dialog.css +5 -3
  54. data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
  55. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  56. data/app/components/phlex_kit/dialog/dialog_controller.js +22 -2
  57. data/app/components/phlex_kit/drawer/drawer.css +4 -4
  58. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
  59. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
  60. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  62. data/app/components/phlex_kit/empty/empty.css +1 -1
  63. data/app/components/phlex_kit/field/field.css +2 -1
  64. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  65. data/app/components/phlex_kit/form_field/form_field_controller.js +29 -1
  66. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  67. data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
  68. data/app/components/phlex_kit/icon/icon.css +9 -0
  69. data/app/components/phlex_kit/icon/icon.rb +7 -1
  70. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  71. data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
  72. data/app/components/phlex_kit/marker/marker.rb +15 -2
  73. data/app/components/phlex_kit/masked_input/masked_input_controller.js +35 -15
  74. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  75. data/app/components/phlex_kit/menubar/menubar_controller.js +28 -1
  76. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
  77. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
  78. data/app/components/phlex_kit/message/message.rb +5 -0
  79. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +12 -5
  80. data/app/components/phlex_kit/native_select/native_select.css +4 -2
  81. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +7 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +11 -2
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +11 -2
  86. data/app/components/phlex_kit/popover/popover_controller.js +31 -1
  87. data/app/components/phlex_kit/progress/progress.css +8 -0
  88. data/app/components/phlex_kit/progress/progress.rb +14 -5
  89. data/app/components/phlex_kit/resizable/resizable_controller.js +52 -5
  90. data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
  91. data/app/components/phlex_kit/scroll_area/scroll_area.rb +23 -4
  92. data/app/components/phlex_kit/select/select.css +5 -3
  93. data/app/components/phlex_kit/select/select_content.rb +9 -3
  94. data/app/components/phlex_kit/select/select_controller.js +59 -6
  95. data/app/components/phlex_kit/select/select_item.rb +5 -1
  96. data/app/components/phlex_kit/separator/separator.rb +12 -2
  97. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  98. data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
  99. data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
  100. data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
  101. data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
  102. data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
  103. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  104. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  105. data/app/components/phlex_kit/slider/slider.css +15 -0
  106. data/app/components/phlex_kit/switch/switch.css +5 -0
  107. data/app/components/phlex_kit/table/table.css +2 -1
  108. data/app/components/phlex_kit/table/table.rb +5 -2
  109. data/app/components/phlex_kit/tabs/tabs.css +2 -2
  110. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  111. data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
  112. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  113. data/app/components/phlex_kit/tabs/tabs_trigger.rb +15 -3
  114. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  115. data/app/components/phlex_kit/toast/toast.css +3 -2
  116. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  117. data/app/components/phlex_kit/toast/toast_controller.js +13 -5
  118. data/app/components/phlex_kit/toast/toast_region.rb +19 -9
  119. data/app/components/phlex_kit/toast/toaster_controller.js +34 -12
  120. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
  121. data/app/components/phlex_kit/toggle_group/toggle_group.rb +7 -2
  122. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +13 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
  124. data/app/components/phlex_kit/tooltip/tooltip.css +7 -3
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  126. data/app/components/phlex_kit/typography/heading.rb +13 -5
  127. data/app/components/phlex_kit/typography/list.rb +22 -0
  128. data/app/components/phlex_kit/typography/text.rb +7 -1
  129. data/lib/phlex_kit/base_component.rb +13 -0
  130. data/lib/phlex_kit/version.rb +1 -1
  131. metadata +3 -1
@@ -9,7 +9,28 @@ export default class extends Controller {
9
9
  static targets = ["trigger", "content", "input", "value", "item"];
10
10
  static values = { open: Boolean };
11
11
 
12
+ initialize() {
13
+ // Stimulus fires [target]Connected before connect(): state used by
14
+ // itemTargetConnected must be initialized here.
15
+ this.itemIdCounter = 0;
16
+ }
17
+
18
+ // Per-item, not a connect()-time sweep: options injected later (Turbo
19
+ // stream/frame) must still get ids or aria-activedescendant would point
20
+ // at nothing — and a caller-provided id is left alone, not clobbered.
21
+ itemTargetConnected(item) {
22
+ if (!item.id) item.id = `${this.contentTarget.id}-${this.itemIdCounter++}`;
23
+ }
24
+
12
25
  connect() {
26
+ // A Turbo snapshot serializes aria-expanded / aria-activedescendant /
27
+ // aria-current even though :popover-open does not survive the restore —
28
+ // normalize here so a restored page doesn't announce an open listbox
29
+ // over a closed select (same mitigation as dropdown_menu's connect).
30
+ this.openValue = false;
31
+ this.triggerTarget.setAttribute("aria-expanded", "false");
32
+ this.triggerTarget.removeAttribute("aria-activedescendant");
33
+ this.itemTargets.forEach((item) => item.removeAttribute("aria-current"));
13
34
  this.generateItemsIds();
14
35
  }
15
36
 
@@ -21,6 +42,9 @@ export default class extends Controller {
21
42
  // document-scoped outlet: outlets match `.pk-select-item` page-wide and
22
43
  // clobbered every other Select's aria-selected.
23
44
  const item = event.currentTarget;
45
+ // A malformed (hand-written) item without data-value must never submit
46
+ // the literal string "undefined". SelectItem itself fails loud on nil.
47
+ if (item.dataset.value === undefined) return;
24
48
  this.itemTargets.forEach((el) => {
25
49
  el.setAttribute("aria-selected", el === item ? "true" : "false");
26
50
  });
@@ -36,7 +60,7 @@ export default class extends Controller {
36
60
  }
37
61
 
38
62
  onClick() {
39
- this.toogleContent();
63
+ this.toggleContent();
40
64
 
41
65
  if (this.openValue) {
42
66
  this.setFocusAndCurrent();
@@ -127,10 +151,37 @@ export default class extends Controller {
127
151
  // Open/close derive from the live :popover-open state, never a stored
128
152
  // flag — a stale flag is how a close on an already-closed panel becomes
129
153
  // an open (bit the popover's keyboard toggle).
130
- toogleContent() {
154
+ toggleContent() {
131
155
  this.contentTarget.matches(":popover-open") ? this.#hide() : this.#show();
132
156
  }
133
157
 
158
+ // Listbox typeahead: printable keys accumulate into a short-lived buffer
159
+ // and move the highlight to the first option whose text starts with it.
160
+ typeahead(event) {
161
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
162
+ if (event.key.length !== 1 || event.key === " ") return; // Space selects
163
+ clearTimeout(this.typeaheadTimer);
164
+ this.typeaheadBuffer = (this.typeaheadBuffer || "") + event.key.toLowerCase();
165
+ this.typeaheadTimer = setTimeout(() => { this.typeaheadBuffer = ""; }, 500);
166
+
167
+ let index = this.itemTargets.findIndex((item) =>
168
+ item.innerText.trim().toLowerCase().startsWith(this.typeaheadBuffer));
169
+ if (index < 0) {
170
+ // Buffer stopped matching — restart it from this keystroke (APG
171
+ // typeahead: a failed prefix shouldn't dead-end until the timer clears).
172
+ this.typeaheadBuffer = event.key.toLowerCase();
173
+ index = this.itemTargets.findIndex((item) =>
174
+ item.innerText.trim().toLowerCase().startsWith(this.typeaheadBuffer));
175
+ }
176
+ if (index < 0) return;
177
+ this.resetCurrent();
178
+ this.setAriaCurrentAndActiveDescendant(index);
179
+ }
180
+
181
+ disconnect() {
182
+ clearTimeout(this.typeaheadTimer);
183
+ }
184
+
134
185
  #show() {
135
186
  this.openValue = true;
136
187
  this.contentTarget.showPopover();
@@ -146,10 +197,12 @@ export default class extends Controller {
146
197
  generateItemsIds() {
147
198
  const contentId = this.contentTarget.getAttribute("id");
148
199
  this.triggerTarget.setAttribute("aria-controls", contentId);
149
-
150
- this.itemTargets.forEach((item, index) => {
151
- item.id = `${contentId}-${index}`;
152
- });
200
+ // Name the listbox from the trigger (Radix wires it the same way) —
201
+ // an unnamed role=listbox is announced as an anonymous list.
202
+ if (!this.triggerTarget.id) this.triggerTarget.id = `${contentId}-trigger`;
203
+ if (!this.contentTarget.hasAttribute("aria-labelledby")) {
204
+ this.contentTarget.setAttribute("aria-labelledby", this.triggerTarget.id);
205
+ }
153
206
  }
154
207
 
155
208
  setAriaCurrentAndActiveDescendant(currentIndex) {
@@ -7,7 +7,10 @@ module PhlexKit
7
7
  # repeated attribute rather than override it.
8
8
  # See select.rb.
9
9
  class SelectItem < BaseComponent
10
- def initialize(value: nil, selected: false, **attrs)
10
+ def initialize(value:, selected: false, **attrs)
11
+ # nil would render no data-value at all and the controller would submit
12
+ # the literal string "undefined" — fail loud instead.
13
+ raise ArgumentError, "SelectItem requires a value:" if value.nil?
11
14
  @value = value
12
15
  @selected = selected
13
16
  @attrs = attrs
@@ -25,6 +28,7 @@ module PhlexKit
25
28
  data: {
26
29
  value: @value,
27
30
  action: "click->phlex-kit--select#selectItem keydown.enter->phlex-kit--select#selectItem " \
31
+ "keydown.space->phlex-kit--select#selectItem " \
28
32
  "keydown.down->phlex-kit--select#handleKeyDown keydown.up->phlex-kit--select#handleKeyUp " \
29
33
  "keydown.home->phlex-kit--select#handleHome keydown.end->phlex-kit--select#handleEnd " \
30
34
  "keydown.esc->phlex-kit--select#handleEsc",
@@ -6,9 +6,15 @@ module PhlexKit
6
6
  # the kit's fail-loud selectors.
7
7
  class Separator < BaseComponent
8
8
  ORIENTATIONS = %i[horizontal vertical].freeze
9
+ # `as:` is dispatched dynamically (send) — whitelist the documented
10
+ # elements so it can't reach arbitrary (including private) methods.
11
+ AS_TAGS = %i[div hr].freeze
9
12
 
10
13
  def initialize(as: :div, orientation: :horizontal, decorative: true, **attrs)
11
- @as = as
14
+ @as = as.to_sym
15
+ unless AS_TAGS.include?(@as)
16
+ raise ArgumentError, "unknown Separator as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
17
+ end
12
18
  @orientation = orientation.to_sym
13
19
  unless ORIENTATIONS.include?(@orientation)
14
20
  raise ArgumentError, "Invalid orientation: #{orientation.inspect}"
@@ -18,7 +24,11 @@ module PhlexKit
18
24
  end
19
25
 
20
26
  def view_template(&block)
21
- send(@as, **mix({ role: (@decorative ? "none" : "separator"), class: classes }, @attrs), &block)
27
+ base = { role: (@decorative ? "none" : "separator"), class: classes }
28
+ # role="separator" is implicitly horizontal — a semantic vertical one
29
+ # must say so or AT announces the wrong axis.
30
+ base[:"aria-orientation"] = "vertical" if !@decorative && @orientation == :vertical
31
+ send(@as, **mix(base, @attrs), &block)
22
32
  end
23
33
 
24
34
  private
@@ -16,8 +16,10 @@
16
16
  }
17
17
  .pk-sheet-content.right { inset-block: 0; right: 0; height: 100%; width: 75%; max-width: 24rem; border-left: 1px solid var(--pk-border); }
18
18
  .pk-sheet-content.left { inset-block: 0; left: 0; height: 100%; width: 75%; max-width: 24rem; border-right: 1px solid var(--pk-border); }
19
- .pk-sheet-content.top { inset-inline: 0; top: 0; border-bottom: 1px solid var(--pk-border); }
20
- .pk-sheet-content.bottom { inset-inline: 0; bottom: 0; border-top: 1px solid var(--pk-border); }
19
+ /* max-height: without it a tall top/bottom panel grows past the viewport and
20
+ overflow:auto never engages (footer/close unreachable). */
21
+ .pk-sheet-content.top { inset-inline: 0; top: 0; max-height: 100dvh; border-bottom: 1px solid var(--pk-border); }
22
+ .pk-sheet-content.bottom { inset-inline: 0; bottom: 0; max-height: 100dvh; border-top: 1px solid var(--pk-border); }
21
23
  .pk-sheet-header { display: flex; flex-direction: column; gap: .125rem; padding: 1rem; }
22
24
  .pk-sheet-footer { display: flex; flex-direction: column-reverse; gap: .5rem; padding: 1rem; margin-top: auto; }
23
25
  .pk-sheet-title { font-size: 1rem; font-weight: 500; line-height: 1; color: var(--pk-text); }
@@ -4,7 +4,7 @@ module PhlexKit
4
4
  class SheetClose < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- span(**mix({ style: "display: contents", data: { action: "click->phlex-kit--sheet-content#close" } }, @attrs), &)
7
+ span(**mix({ style: "display: contents;", data: { action: "click->phlex-kit--sheet-content#close" } }, @attrs), &)
8
8
  end
9
9
  end
10
10
  end
@@ -29,6 +29,10 @@ export default class extends Controller {
29
29
  this.#restoreInert()
30
30
  document.body.style.overflow = this.previousOverflow
31
31
  if (this.opener?.isConnected) this.opener.focus()
32
+ // Tell the source phlex-kit--sheet controller the clone is gone so it
33
+ // can flip the trigger's aria-expanded back (removal happens by many
34
+ // paths: close button, Escape, backdrop, before-cache).
35
+ document.dispatchEvent(new CustomEvent("phlex-kit:sheet-content:closed"))
32
36
  }
33
37
 
34
38
  close() {
@@ -19,17 +19,32 @@ export default class extends Controller {
19
19
 
20
20
  connect() {
21
21
  document.addEventListener("turbo:before-cache", this.clearOverlay)
22
+ // Popup semantics for AT: the trigger's real control announces that it
23
+ // opens a dialog, and expanded state tracks the clone's lifetime (the
24
+ // 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]")
26
+ this.invoker?.setAttribute("aria-haspopup", "dialog")
27
+ this.invoker?.setAttribute("aria-expanded", "false")
28
+ this.onOverlayClosed = () => {
29
+ if (!this.overlay?.isConnected) this.invoker?.setAttribute("aria-expanded", "false")
30
+ }
31
+ document.addEventListener("phlex-kit:sheet-content:closed", this.onOverlayClosed)
22
32
  if (this.openValue) this.open()
23
33
  }
24
34
 
25
35
  disconnect() {
26
36
  document.removeEventListener("turbo:before-cache", this.clearOverlay)
37
+ document.removeEventListener("phlex-kit:sheet-content:closed", this.onOverlayClosed)
27
38
  this.clearOverlay()
28
39
  }
29
40
 
30
41
  open() {
31
42
  if (this.overlay?.isConnected) return
43
+ // open: is one-shot — the reflected value sits in the Turbo snapshot, so
44
+ // a cache-restored reconnect would re-open a dismissed sheet.
45
+ this.openValue = false
32
46
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
33
47
  this.overlay = document.body.lastElementChild
48
+ this.invoker?.setAttribute("aria-expanded", "true")
34
49
  }
35
50
  }
@@ -66,7 +66,7 @@
66
66
  width: 100%;
67
67
  padding: .5rem .65rem;
68
68
  border: 0;
69
- border-radius: 8px;
69
+ border-radius: calc(var(--pk-radius) - 2px);
70
70
  background: transparent;
71
71
  color: var(--pk-sidebar-text, var(--pk-text));
72
72
  font: inherit;
@@ -25,11 +25,21 @@ export default class extends Controller {
25
25
  static COOKIE = "pk_sidebar_state"
26
26
 
27
27
  connect() {
28
+ // Crossing to desktop while the mobile drawer is open would strand the
29
+ // page inert: the scrim/drawer CSS stops applying at >=768px but the
30
+ // inert marks (and data-open) would survive. Close on breakpoint change.
31
+ this.mql = window.matchMedia(this.constructor.MOBILE)
32
+ this.onBreakpoint = () => {
33
+ if (!this.mql.matches) this.closeMobile()
34
+ this.#syncExpanded()
35
+ }
36
+ this.mql.addEventListener("change", this.onBreakpoint)
28
37
  this.#wireAriaControls()
29
38
  this.#syncExpanded()
30
39
  }
31
40
 
32
41
  disconnect() {
42
+ this.mql.removeEventListener("change", this.onBreakpoint)
33
43
  this.#restoreInert()
34
44
  }
35
45
 
@@ -11,8 +11,16 @@ module PhlexKit
11
11
  # untouched — keep the label in a real element there. Attrs pass through via
12
12
  # mix. See sidebar.rb.
13
13
  class SidebarMenuButton < BaseComponent
14
+ # `as:` is dispatched dynamically (send) — whitelist so it can't reach
15
+ # arbitrary (including private) methods; to_sym so a String "a" still
16
+ # earns aria-current when active.
17
+ AS_TAGS = %i[button a].freeze
18
+
14
19
  def initialize(as: :button, active: false, tooltip: nil, **attrs)
15
- @as = as
20
+ @as = as.to_sym
21
+ unless AS_TAGS.include?(@as)
22
+ raise ArgumentError, "unknown SidebarMenuButton as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
23
+ end
16
24
  @active = active
17
25
  @tooltip = tooltip
18
26
  @attrs = attrs
@@ -5,8 +5,15 @@ module PhlexKit
5
5
  # primary). Kwarg rather than attr for the same mix-merge reason as
6
6
  # SidebarMenuButton. See sidebar.rb.
7
7
  class SidebarMenuSubButton < BaseComponent
8
+ # `as:` is dispatched dynamically (send) — whitelist, matching
9
+ # SidebarMenuButton.
10
+ AS_TAGS = %i[a button].freeze
11
+
8
12
  def initialize(as: :a, active: false, **attrs)
9
- @as = as
13
+ @as = as.to_sym
14
+ unless AS_TAGS.include?(@as)
15
+ raise ArgumentError, "unknown SidebarMenuSubButton as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
16
+ end
10
17
  @active = active
11
18
  @attrs = attrs
12
19
  end
@@ -18,6 +18,15 @@
18
18
  var(--pk-brand) var(--pk-slider-progress, 50%),
19
19
  var(--pk-surface-2) var(--pk-slider-progress, 50%));
20
20
  }
21
+ /* Chrome mirrors the native range input in RTL but the gradient stays
22
+ physical — repaint the fill from the right. (:dir() reads document
23
+ directionality, so .vertical's internal `direction: rtl` flip below
24
+ doesn't match; its own `to top` gradient also comes later and wins.) */
25
+ .pk-slider:dir(rtl)::-webkit-slider-runnable-track {
26
+ background: linear-gradient(to left,
27
+ var(--pk-brand) var(--pk-slider-progress, 50%),
28
+ var(--pk-surface-2) var(--pk-slider-progress, 50%));
29
+ }
21
30
  .pk-slider::-webkit-slider-thumb {
22
31
  -webkit-appearance: none;
23
32
  width: .75rem;
@@ -57,6 +66,12 @@
57
66
  .pk-slider:active::-moz-range-thumb {
58
67
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
59
68
  }
69
+ /* line 13 strips the native outline in every browser — without this arm the
70
+ replacement ring only existed on the -webkit thumb and Firefox keyboard
71
+ focus was invisible. */
72
+ .pk-slider:focus-visible::-moz-range-thumb {
73
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
74
+ }
60
75
  .pk-slider:disabled { opacity: .5; cursor: not-allowed; }
61
76
  /* Vertical: modern writing-mode flip (min-h-40 like theirs). */
62
77
  .pk-slider.vertical {
@@ -49,6 +49,11 @@
49
49
  transform: translateX(calc(100% - 2px));
50
50
  background: var(--pk-brand-ink);
51
51
  }
52
+ /* RTL: the resting thumb sits at the physical right — checked travel must
53
+ slide it left or it leaves the track. */
54
+ .pk-switch:dir(rtl):has(.pk-switch-input:checked) .pk-switch-thumb {
55
+ transform: translateX(calc(-100% + 2px));
56
+ }
52
57
  :root[data-theme="light"] .pk-switch { background: var(--pk-input); }
53
58
  :root[data-theme="light"] .pk-switch:has(.pk-switch-input:checked) { background: var(--pk-brand); }
54
59
  :root[data-theme="light"] .pk-switch-thumb { background: var(--pk-bg) !important; }
@@ -5,9 +5,10 @@
5
5
  .pk-table-wrapper { position: relative; width: 100%; overflow: auto; }
6
6
  .pk-table { width: 100%; border-collapse: collapse; caption-side: bottom; font-size: .875rem; }
7
7
 
8
- .pk-table-header .pk-table-row { border-bottom: 1px solid var(--pk-border); }
9
8
  .pk-table-body .pk-table-row:last-child { border-bottom: 0; }
10
9
  .pk-table-footer { border-top: 1px solid var(--pk-border); font-weight: 500; background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent); }
10
+ /* shadcn parity: the totals row doesn't draw a stray line under the table. */
11
+ .pk-table-footer .pk-table-row:last-child { border-bottom: 0; }
11
12
 
12
13
  .pk-table-row { border-bottom: 1px solid var(--pk-border); transition: background-color .12s ease; }
13
14
  .pk-table-row:hover { background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent); }
@@ -22,12 +22,15 @@ module PhlexKit
22
22
  #
23
23
  # Tailwind → vanilla `.pk-table-*` (table.css), palette from the global tokens.
24
24
  class Table < BaseComponent
25
- def initialize(**attrs)
25
+ def initialize(wrapper: {}, **attrs)
26
+ @wrapper = wrapper
26
27
  @attrs = attrs
27
28
  end
28
29
 
29
30
  def view_template(&block)
30
- div(class: "pk-table-wrapper") do
31
+ # Caller attrs land on the <table>; wrapper: reaches the scroll
32
+ # container (e.g. to cap its height) — same escape hatch as Switch.
33
+ div(**mix({ class: "pk-table-wrapper" }, @wrapper)) do
31
34
  table(**mix({ class: "pk-table" }, @attrs), &block)
32
35
  end
33
36
  end
@@ -44,7 +44,7 @@
44
44
  border-color: transparent;
45
45
  box-shadow: none;
46
46
  }
47
- .pk-tabs-trigger::after {
47
+ .pk-tabs-list.line .pk-tabs-trigger::after {
48
48
  content: "";
49
49
  position: absolute;
50
50
  inset-inline: 0;
@@ -54,7 +54,7 @@
54
54
  opacity: 0;
55
55
  transition: opacity .15s ease;
56
56
  }
57
- .pk-tabs.vertical .pk-tabs-trigger::after {
57
+ .pk-tabs.vertical .pk-tabs-list.line .pk-tabs-trigger::after {
58
58
  inset-inline: auto;
59
59
  inset-block: 0;
60
60
  inset-inline-end: -.25rem;
@@ -1,8 +1,10 @@
1
1
  module PhlexKit
2
2
  class TabsContent < BaseComponent
3
- def initialize(value:, active: false, **attrs)
3
+ # id: is a named kwarg (not left in **attrs) — see TabsTrigger.
4
+ def initialize(value:, active: false, id: nil, **attrs)
4
5
  @value = value
5
6
  @active = active
7
+ @id = id || "pk-tabs-panel-#{value}"
6
8
  @attrs = attrs
7
9
  end
8
10
  def view_template(&)
@@ -11,7 +13,7 @@ module PhlexKit
11
13
  # focusable, per the APG tabs pattern. `active:` (mirroring TabsTrigger's)
12
14
  # renders the panel visible pre-JS/no-JS; the controller's sync()
13
15
  # reconciles visibility from the active value after connect.
14
- div(**mix({ class: @active ? "pk-tabs-content" : "pk-tabs-content pk-hidden", role: "tabpanel", id: "pk-tabs-panel-#{@value}", aria_labelledby: "pk-tabs-trigger-#{@value}", tabindex: "0", data: { phlex_kit__tabs_target: "content", value: @value } }, @attrs), &)
16
+ div(**mix({ class: @active ? "pk-tabs-content" : "pk-tabs-content pk-hidden", role: "tabpanel", id: @id, aria_labelledby: "pk-tabs-trigger-#{@value}", tabindex: "0", data: { phlex_kit__tabs_target: "content", value: @value } }, @attrs), &)
15
17
  end
16
18
  end
17
19
  end
@@ -13,7 +13,10 @@ export default class extends Controller {
13
13
  this.listTarget.setAttribute("aria-orientation", "vertical")
14
14
  }
15
15
  if (!this.hasActiveValue && this.triggerTargets.length > 0) {
16
- this.activeValue = this.triggerTargets[0].dataset.value
16
+ // Honor a server-marked TabsTrigger(active: true) before falling back
17
+ // to the first trigger — hydration must not contradict the markup.
18
+ const marked = this.triggerTargets.find((el) => el.dataset.state === "active")
19
+ this.activeValue = (marked || this.triggerTargets[0]).dataset.value
17
20
  } else {
18
21
  this.sync()
19
22
  }
@@ -2,14 +2,19 @@ module PhlexKit
2
2
  class TabsList < BaseComponent
3
3
  VARIANTS = { default: nil, line: "line" }.freeze
4
4
 
5
- def initialize(variant: :default, **attrs)
5
+ def initialize(variant: :default, orientation: :horizontal, **attrs)
6
6
  @variant = variant.to_sym
7
+ @orientation = orientation.to_sym
7
8
  @attrs = attrs
8
9
  end
9
10
 
10
11
  def view_template(&)
11
12
  classes = [ "pk-tabs-list", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
12
- div(**mix({ class: classes, role: "tablist", data: { phlex_kit__tabs_target: "list", action: "keydown->phlex-kit--tabs#keydown" } }, @attrs), &)
13
+ base = { class: classes, role: "tablist", data: { phlex_kit__tabs_target: "list", action: "keydown->phlex-kit--tabs#keydown" } }
14
+ # Server-rendered so AT hears the right axis pre-JS / with JS off; the
15
+ # controller re-stamps it on connect from the root's vertical class.
16
+ base[:aria] = { orientation: "vertical" } if @orientation == :vertical
17
+ div(**mix(base, @attrs), &)
13
18
  end
14
19
  end
15
20
  end
@@ -1,9 +1,18 @@
1
1
  module PhlexKit
2
2
  class TabsTrigger < BaseComponent
3
- def initialize(value:, as: :button, active: false, **attrs)
3
+ # id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
4
+ # caller id with the deterministic one into an invalid two-token id,
5
+ # breaking the aria pairing pre-JS (and scopeIds' caller-id detection).
6
+ AS_TAGS = %i[button a].freeze
7
+
8
+ def initialize(value:, as: :button, active: false, id: nil, **attrs)
4
9
  @value = value
5
10
  @as = as.to_sym
11
+ unless AS_TAGS.include?(@as)
12
+ raise ArgumentError, "unknown TabsTrigger as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
13
+ end
6
14
  @active = active
15
+ @id = id || "pk-tabs-trigger-#{value}"
7
16
  @attrs = attrs
8
17
  end
9
18
  def view_template(&)
@@ -13,11 +22,14 @@ module PhlexKit
13
22
  # aria-selected and the roving tabindex are kept in sync by the
14
23
  # controller's activeValueChanged.
15
24
  base = {
16
- class: "pk-tabs-trigger", role: "tab", id: "pk-tabs-trigger-#{@value}",
25
+ class: "pk-tabs-trigger", role: "tab", id: @id,
17
26
  aria_selected: @active ? "true" : "false",
18
27
  aria_controls: "pk-tabs-panel-#{@value}",
19
28
  tabindex: @active ? "0" : "-1",
20
- data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value }
29
+ # data-state is the only hook tabs.css styles the active trigger by —
30
+ # it must be server-rendered or the active tab looks inactive pre-JS.
31
+ data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value,
32
+ state: @active ? "active" : "inactive" }
21
33
  }
22
34
  if @as == :a
23
35
  a(**mix(base, @attrs), &)
@@ -18,11 +18,25 @@ export default class extends Controller {
18
18
  if (!this.storedTheme()) this.applyTheme("system")
19
19
  }
20
20
  this._media.addEventListener("change", this._onMediaChange)
21
+ // Multiple toggles on one page (header + mobile menu): each instance
22
+ // observes :root[data-theme] so a change applied by ANY of them (or by
23
+ // host code) re-syncs every toggle's pressed state — without this the
24
+ // stale one showed the wrong state and its first click was eaten by the
25
+ // echo guard in apply().
26
+ this._rootObserver = new MutationObserver(() => this.syncPressed())
27
+ this._rootObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] })
21
28
  this.applyTheme(this.storedTheme() || "system")
22
29
  }
23
30
 
24
31
  disconnect() {
25
32
  this._media?.removeEventListener("change", this._onMediaChange)
33
+ this._rootObserver?.disconnect()
34
+ }
35
+
36
+ syncPressed() {
37
+ const theme = document.documentElement.getAttribute("data-theme") || "system"
38
+ const dark = theme === "system" ? this._media.matches : theme === "dark"
39
+ this.element.setAttribute("data-phlex-kit--toggle-pressed-value", dark ? "true" : "false")
26
40
  }
27
41
 
28
42
  apply(e) {
@@ -7,7 +7,8 @@
7
7
  .pk-toast-region {
8
8
  position: fixed;
9
9
  z-index: 100;
10
- padding: 1.5rem;
10
+ /* --pk-toast-offset is stamped inline by ToastRegion(offset:). */
11
+ padding: var(--pk-toast-offset, 1.5rem);
11
12
  pointer-events: none;
12
13
  }
13
14
  .pk-toast-region[data-position="top-left"] { top: 0; left: 0; }
@@ -40,7 +41,7 @@
40
41
  width: 356px;
41
42
  max-width: 100%;
42
43
  border: 1px solid var(--pk-border);
43
- border-radius: 1rem;
44
+ border-radius: calc(var(--pk-radius) + 6px); /* 1rem at the default 0.625rem token */
44
45
  background: var(--pk-surface);
45
46
  color: var(--pk-text);
46
47
  padding: 1rem;
@@ -12,7 +12,10 @@ module PhlexKit
12
12
 
13
13
  def view_template
14
14
  data = { slot: "action" }
15
- data[:action] = @on if @on
15
+ # Caller action first, then dismiss — client-spawned actions dismiss
16
+ # their toast after onClick (Sonner parity), so server-rendered ones
17
+ # must too.
18
+ data[:action] = [ @on, "click->phlex-kit--toast#dismiss" ].compact.join(" ") if @on
16
19
  button(**mix({ type: :button, class: "pk-toast-action", data: data }, @attrs)) { @label }
17
20
  end
18
21
  end
@@ -17,6 +17,7 @@ export default class extends Controller {
17
17
 
18
18
  connect() {
19
19
  this._timer = null
20
+ this._unmountTimer = null
20
21
  this._startedAt = 0
21
22
  this._remaining = this.durationValue
22
23
  this._paused = false
@@ -45,8 +46,11 @@ export default class extends Controller {
45
46
  this.element.addEventListener("keydown", this._onKeyDown)
46
47
  this.element.addEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
47
48
  this.element.addEventListener("phlex-kit:toast:restart", this._onRestart)
48
- document.addEventListener("phlex-kit:toast:pause", this._onRegionPause)
49
- document.addEventListener("phlex-kit:toast:resume", this._onRegionResume)
49
+ // Region-scoped: the toaster dispatches pause/resume on its own list, so
50
+ // another region's hover can't freeze (or resume) this toast's timer.
51
+ this._regionList = this.element.closest(".pk-toast-list") || document
52
+ this._regionList.addEventListener("phlex-kit:toast:pause", this._onRegionPause)
53
+ this._regionList.addEventListener("phlex-kit:toast:resume", this._onRegionResume)
50
54
 
51
55
  requestAnimationFrame(() => {
52
56
  this.element.dataset.state = "open"
@@ -66,6 +70,10 @@ export default class extends Controller {
66
70
 
67
71
  disconnect() {
68
72
  this._clearTimer()
73
+ // Don't let a pending 200ms unmount fire after teardown (e.g. into a
74
+ // Turbo-cached copy of the page).
75
+ if (this._unmountTimer) clearTimeout(this._unmountTimer)
76
+ this._unmountTimer = null
69
77
  this.element.removeEventListener("pointerdown", this._onPointerDown)
70
78
  this.element.removeEventListener("pointerenter", this._onPointerEnter)
71
79
  this.element.removeEventListener("pointerleave", this._onPointerLeave)
@@ -74,8 +82,8 @@ export default class extends Controller {
74
82
  this.element.removeEventListener("keydown", this._onKeyDown)
75
83
  this.element.removeEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
76
84
  this.element.removeEventListener("phlex-kit:toast:restart", this._onRestart)
77
- document.removeEventListener("phlex-kit:toast:pause", this._onRegionPause)
78
- document.removeEventListener("phlex-kit:toast:resume", this._onRegionResume)
85
+ this._regionList?.removeEventListener("phlex-kit:toast:pause", this._onRegionPause)
86
+ this._regionList?.removeEventListener("phlex-kit:toast:resume", this._onRegionResume)
79
87
  }
80
88
 
81
89
  dismiss(e) {
@@ -89,7 +97,7 @@ export default class extends Controller {
89
97
  this.element.dataset.state = "closing"
90
98
  this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "phlex-kit:toast:auto-close" : "phlex-kit:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
91
99
  this._invokeCallback(reason === "auto" ? this.onAutoCloseValue : this.onDismissValue)
92
- setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
100
+ this._unmountTimer = setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
93
101
  }
94
102
 
95
103
  // on_dismiss:/on_auto_close: name a global function (Sonner-style callback