phlex_kit 0.10.0 → 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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +3 -0
  5. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  6. data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
  7. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  8. data/app/components/phlex_kit/badge/badge.css +1 -1
  9. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  10. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  11. data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
  12. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  13. data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
  14. data/app/components/phlex_kit/chart/chart.rb +8 -2
  15. data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
  16. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  17. data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
  18. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  19. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  20. data/app/components/phlex_kit/combobox/combobox_controller.js +15 -1
  21. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  22. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  23. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
  24. data/app/components/phlex_kit/command/command_controller.js +6 -1
  25. data/app/components/phlex_kit/command/command_item.rb +4 -0
  26. data/app/components/phlex_kit/command/command_list.rb +6 -2
  27. data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
  28. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  29. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  30. data/app/components/phlex_kit/data_table/data_table.css +0 -10
  31. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  32. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  33. data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
  34. data/app/components/phlex_kit/dialog/dialog.css +3 -1
  35. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  36. data/app/components/phlex_kit/dialog/dialog_controller.js +13 -2
  37. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
  38. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
  39. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  40. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  41. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  42. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -1
  43. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  44. data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
  45. data/app/components/phlex_kit/icon/icon.css +9 -0
  46. data/app/components/phlex_kit/icon/icon.rb +7 -1
  47. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  48. data/app/components/phlex_kit/marker/marker.rb +9 -2
  49. data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
  50. data/app/components/phlex_kit/menubar/menubar_controller.js +6 -0
  51. data/app/components/phlex_kit/message/message.rb +5 -0
  52. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
  53. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  54. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  55. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  56. data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
  57. data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
  58. data/app/components/phlex_kit/popover/popover_controller.js +23 -1
  59. data/app/components/phlex_kit/progress/progress.css +8 -0
  60. data/app/components/phlex_kit/progress/progress.rb +14 -5
  61. data/app/components/phlex_kit/resizable/resizable_controller.js +3 -0
  62. data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -2
  63. data/app/components/phlex_kit/select/select.css +5 -2
  64. data/app/components/phlex_kit/select/select_controller.js +27 -4
  65. data/app/components/phlex_kit/separator/separator.rb +5 -1
  66. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  67. data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
  68. data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
  69. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  70. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  71. data/app/components/phlex_kit/slider/slider.css +15 -0
  72. data/app/components/phlex_kit/switch/switch.css +5 -0
  73. data/app/components/phlex_kit/table/table.css +2 -1
  74. data/app/components/phlex_kit/table/table.rb +5 -2
  75. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  76. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  77. data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
  78. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  79. data/app/components/phlex_kit/toast/toast.css +2 -1
  80. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  81. data/app/components/phlex_kit/toast/toast_controller.js +7 -4
  82. data/app/components/phlex_kit/toast/toast_region.rb +19 -9
  83. data/app/components/phlex_kit/toast/toaster_controller.js +15 -7
  84. data/app/components/phlex_kit/toggle_group/toggle_group.rb +1 -1
  85. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +5 -1
  86. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
  87. data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
  88. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  89. data/app/components/phlex_kit/typography/heading.rb +7 -1
  90. data/app/components/phlex_kit/typography/list.rb +22 -0
  91. data/app/components/phlex_kit/typography/text.rb +7 -1
  92. data/lib/phlex_kit/base_component.rb +13 -0
  93. data/lib/phlex_kit/version.rb +1 -1
  94. metadata +3 -1
@@ -36,6 +36,9 @@ export default class extends Controller {
36
36
  const next = handle.nextElementSibling
37
37
  if (!prev || !next) return
38
38
  e.preventDefault()
39
+ // preventDefault suppresses mouse-driven focus — hand it to the handle
40
+ // so a drag can be fine-tuned with the arrow keys immediately.
41
+ handle.focus({ preventScroll: true })
39
42
 
40
43
  const horizontal = this.directionValue === "horizontal"
41
44
  const sizeOf = (el) => horizontal ? el.getBoundingClientRect().width : el.getBoundingClientRect().height
@@ -12,8 +12,11 @@ module PhlexKit
12
12
  end
13
13
 
14
14
  def view_template(&)
15
- base = { class: "pk-scroll-area", tabindex: "0" }
16
- base[:role] = "region" if labelled?
15
+ # Defaults only when the caller didn't supply their own — `mix` would
16
+ # fuse role="region log" / tabindex="0 -1" instead of overriding.
17
+ base = { class: "pk-scroll-area" }
18
+ base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
19
+ base[:role] = "region" if labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
17
20
  div(**mix(base, @attrs), &)
18
21
  end
19
22
 
@@ -23,7 +23,8 @@
23
23
  border-radius: var(--pk-radius);
24
24
  border: 1px solid var(--pk-input);
25
25
  color: var(--pk-text);
26
- padding: .25rem .5rem .25rem .625rem;
26
+ padding-block: .25rem;
27
+ padding-inline: .625rem .5rem;
27
28
  gap: .375rem;
28
29
  font: inherit;
29
30
  font-size: .875rem;
@@ -87,7 +88,9 @@
87
88
  user-select: none;
88
89
  gap: .375rem;
89
90
  border-radius: calc(var(--pk-radius) - 2px);
90
- padding: .25rem 2rem .25rem .375rem;
91
+ /* 2rem gutter on the inline-end — where .pk-select-item-check sits. */
92
+ padding-block: .25rem;
93
+ padding-inline: .375rem 2rem;
91
94
  font-size: .875rem;
92
95
  color: var(--pk-text);
93
96
  outline: none;
@@ -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
 
@@ -176,10 +197,12 @@ export default class extends Controller {
176
197
  generateItemsIds() {
177
198
  const contentId = this.contentTarget.getAttribute("id");
178
199
  this.triggerTarget.setAttribute("aria-controls", contentId);
179
-
180
- this.itemTargets.forEach((item, index) => {
181
- item.id = `${contentId}-${index}`;
182
- });
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
+ }
183
206
  }
184
207
 
185
208
  setAriaCurrentAndActiveDescendant(currentIndex) {
@@ -24,7 +24,11 @@ module PhlexKit
24
24
  end
25
25
 
26
26
  def view_template(&block)
27
- 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)
28
32
  end
29
33
 
30
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); }
@@ -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
  }
@@ -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
@@ -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
@@ -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,7 +22,7 @@ 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",
@@ -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; }
@@ -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
@@ -46,8 +46,11 @@ export default class extends Controller {
46
46
  this.element.addEventListener("keydown", this._onKeyDown)
47
47
  this.element.addEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
48
48
  this.element.addEventListener("phlex-kit:toast:restart", this._onRestart)
49
- document.addEventListener("phlex-kit:toast:pause", this._onRegionPause)
50
- 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)
51
54
 
52
55
  requestAnimationFrame(() => {
53
56
  this.element.dataset.state = "open"
@@ -79,8 +82,8 @@ export default class extends Controller {
79
82
  this.element.removeEventListener("keydown", this._onKeyDown)
80
83
  this.element.removeEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
81
84
  this.element.removeEventListener("phlex-kit:toast:restart", this._onRestart)
82
- document.removeEventListener("phlex-kit:toast:pause", this._onRegionPause)
83
- 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)
84
87
  }
85
88
 
86
89
  dismiss(e) {
@@ -41,11 +41,22 @@ module PhlexKit
41
41
  @duration = duration
42
42
  @gap = gap
43
43
  @offset = offset
44
+ # theme:/rich_colors: are Sonner API surface the kit deliberately does
45
+ # not implement (tokens are :root-scoped; there is no status palette) —
46
+ # fail loud rather than silently no-op (audit round 6).
44
47
  @theme = theme.to_sym
45
- @rich_colors = rich_colors
48
+ unless @theme == :system
49
+ raise ArgumentError, "ToastRegion theme: only :system is supported — toasts follow the page theme (:root[data-theme])"
50
+ end
51
+ if rich_colors
52
+ raise ArgumentError, "ToastRegion rich_colors: is not supported — the kit has no status color palette (toasts follow --pk-* tokens)"
53
+ end
46
54
  @close_button = close_button
47
55
  @hotkey = hotkey
48
- @dir = dir
56
+ @dir = dir.to_sym
57
+ unless %i[ltr rtl auto].include?(@dir)
58
+ raise ArgumentError, "ToastRegion dir: must be :ltr, :rtl or :auto"
59
+ end
49
60
  @flash = flash
50
61
  # Base id for the <ol> stack; the wrapper takes "#{id}-region". Defaults
51
62
  # keep the historical "pk-toaster"/"pk-toaster-region" pair — pass a
@@ -105,10 +116,12 @@ module PhlexKit
105
116
  end
106
117
 
107
118
  def region_attrs
108
- {
119
+ base = {
109
120
  id: "#{@id}-region",
110
121
  role: "region",
111
122
  class: "pk-toast-region",
123
+ # offset: viewport-edge padding, consumed by toast.css.
124
+ style: "--pk-toast-offset: #{@offset.is_a?(Numeric) ? "#{@offset}px" : @offset};",
112
125
  # No aria-live here: each toast item is its own live region
113
126
  # (role="status"/"alert"); a live wrapper would double-announce.
114
127
  aria: { label: "Notifications" },
@@ -122,14 +135,11 @@ module PhlexKit
122
135
  phlex_kit__toaster_max_value: @max.to_s,
123
136
  phlex_kit__toaster_duration_value: @duration.to_s,
124
137
  phlex_kit__toaster_gap_value: @gap.to_s,
125
- phlex_kit__toaster_offset_value: @offset.to_s,
126
- phlex_kit__toaster_theme_value: @theme.to_s,
127
- phlex_kit__toaster_rich_colors_value: @rich_colors.to_s,
128
- phlex_kit__toaster_close_button_value: @close_button.to_s,
129
- phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+"),
130
- phlex_kit__toaster_dir_value: @dir.to_s
138
+ phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+")
131
139
  }
132
140
  }
141
+ base[:dir] = @dir.to_s unless @dir == :ltr
142
+ base
133
143
  end
134
144
  end
135
145
  end
@@ -34,12 +34,10 @@ export default class extends Controller {
34
34
  max: { type: Number, default: 3 },
35
35
  duration: { type: Number, default: 4000 },
36
36
  gap: { type: Number, default: 14 },
37
- offset: { type: Number, default: 24 },
38
- theme: { type: String, default: "system" },
39
- richColors: { type: Boolean, default: false },
40
- closeButton: { type: Boolean, default: false },
37
+ // offset is a CSS custom property (--pk-toast-offset) and dir a real
38
+ // dir attribute, both stamped by ToastRegion; theme/richColors are
39
+ // unsupported (region raises) none of them are controller state.
41
40
  hotkey: { type: String, default: "alt+t" },
42
- dir: { type: String, default: "ltr" },
43
41
  }
44
42
 
45
43
  // State the target callbacks touch lives here, NOT in connect(): Stimulus
@@ -192,7 +190,10 @@ export default class extends Controller {
192
190
  // Mirror the pause state onto the DOM so toasts spawned while the stack
193
191
  // is already hovered can arm paused (the pause event predates them).
194
192
  this._listEl?.toggleAttribute("data-pk-toasts-paused", value)
195
- document.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
193
+ // Region-scoped, not document-global: on multi-region pages one region's
194
+ // hover must not pause (or worse, RESUME under the pointer) another's
195
+ // toasts. Toasts listen on their own list element.
196
+ this._listEl.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
196
197
  }
197
198
 
198
199
  _setExpanded(value) {
@@ -277,7 +278,14 @@ export default class extends Controller {
277
278
  const wantCtrl = parts.includes("ctrl")
278
279
  const wantMeta = parts.includes("meta")
279
280
  const wantShift = parts.includes("shift")
280
- if (e.key.toLowerCase() !== key.toLowerCase()) return
281
+ // Match e.code as well as e.key: with Option held, macOS reports the
282
+ // COMPOSED character (Option+T → "†"), so alt+ hotkeys never match on
283
+ // e.key alone (Sonner matches event.code for the same reason).
284
+ const code = (e.code || "").toLowerCase()
285
+ const keyMatches = e.key.toLowerCase() === key.toLowerCase()
286
+ || code === `key${key.toLowerCase()}`
287
+ || code === `digit${key.toLowerCase()}`
288
+ if (!keyMatches) return
281
289
  if (wantAlt !== e.altKey) return
282
290
  if (wantCtrl !== e.ctrlKey) return
283
291
  if (wantMeta !== e.metaKey) return
@@ -21,7 +21,7 @@ module PhlexKit
21
21
 
22
22
  def view_template(&block)
23
23
  div(**mix(group_default_attrs, @attrs)) do
24
- yield(self)
24
+ yield(self) if block
25
25
  render_hidden_inputs
26
26
  end
27
27
  end
@@ -45,11 +45,15 @@ export default class extends Controller {
45
45
  const next = items[nextIndex]
46
46
  this.updateRovingTabindex(next)
47
47
  next.focus()
48
+ // APG radio-group keyboard model: with role="radio", arrows move focus
49
+ // AND check the landed-on item (Home/End included).
50
+ if (next !== event.currentTarget) next.click()
48
51
  }
49
52
 
50
53
  reconcile() {
51
54
  if (this.typeValue === "single") {
52
- const pressed = this.itemTargets.find((el) => this.isPressed(el))
55
+ // A disabled button can't take focus — never hand it the tab stop.
56
+ const pressed = this.itemTargets.find((el) => this.isPressed(el) && !el.disabled)
53
57
  const first = pressed || this.enabledItems()[0]
54
58
  this.itemTargets.forEach((el) => el.setAttribute("tabindex", el === first ? "0" : "-1"))
55
59
  } else {
@@ -1,6 +1,12 @@
1
1
  module PhlexKit
2
2
  class ToggleGroupItem < Toggle
3
3
  def initialize(value:, group_context:, variant: nil, size: nil, **attrs)
4
+ # Toggle kwargs this item renders no markup for — inheriting them
5
+ # silently discarded the caller's input.
6
+ unsupported = attrs.keys & %i[wrapper name unpressed_value]
7
+ if unsupported.any?
8
+ raise ArgumentError, "ToggleGroupItem does not support #{unsupported.join(", ")} (group items render a bare button)"
9
+ end
4
10
  @item_value = value.to_s
5
11
  @group_context = group_context
6
12
  pressed = group_context[:selected_values].include?(@item_value)
@@ -26,7 +32,9 @@ module PhlexKit
26
32
  if @group_context[:type] == :single
27
33
  a[:role] = "radio"
28
34
  a[:aria] = { checked: @pressed.to_s }
29
- a[:tabindex] = @pressed ? "0" : "-1"
35
+ # A disabled button can't take focus — giving the tab stop to a
36
+ # pressed-but-disabled item made the whole group Tab-unreachable.
37
+ a[:tabindex] = (@pressed && !@disabled) ? "0" : "-1"
30
38
  else
31
39
  a[:aria] = { pressed: @pressed.to_s }
32
40
  a[:tabindex] = "0"
@@ -79,6 +79,9 @@
79
79
  .pk-tooltip:hover .pk-tooltip-content {
80
80
  opacity: 1;
81
81
  visibility: visible;
82
+ /* WCAG 1.4.13 Hoverable: the revealed bubble must accept the pointer —
83
+ it's a DOM child of .pk-tooltip, so hovering it keeps the reveal. */
84
+ pointer-events: auto;
82
85
  transition-delay: .4s, .4s;
83
86
  }
84
87
  }
@@ -87,9 +90,10 @@
87
90
  .pk-tooltip:has(:focus-visible) .pk-tooltip-content {
88
91
  opacity: 1;
89
92
  visibility: visible;
93
+ pointer-events: auto;
90
94
  transition-delay: 0s, 0s;
91
95
  }
92
96
  /* Escape-dismissed (WCAG 1.4.13): suppressed until pointer/focus leaves.
93
97
  transition: none so the hide is immediate regardless of hover-in delays. */
94
98
  .pk-tooltip[data-pk-dismissed]:hover .pk-tooltip-content,
95
- .pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; transition: none; }
99
+ .pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; pointer-events: none; transition: none; }
@@ -11,7 +11,13 @@ export default class extends Controller {
11
11
  const trigger = this.element.querySelector(".pk-tooltip-trigger")
12
12
  if (content && trigger) {
13
13
  if (!content.id) content.id = `pk-tooltip-${Math.random().toString(36).slice(2, 8)}`
14
- if (!trigger.hasAttribute("aria-describedby")) trigger.setAttribute("aria-describedby", content.id)
14
+ // Describe the element that actually takes focus: with focusable: false
15
+ // the wrapper span never receives focus, so a describedby left only on
16
+ // it is never announced — wire the focusable child too.
17
+ const focusable = trigger.querySelector("button, a[href], input, select, textarea, [tabindex]")
18
+ ;[trigger, focusable].forEach((el) => {
19
+ if (el && !el.hasAttribute("aria-describedby")) el.setAttribute("aria-describedby", content.id)
20
+ })
15
21
  }
16
22
 
17
23
  this._onKeydown = (e) => {