phlex_kit 0.5.1 → 0.6.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -8
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -0
  4. data/app/components/phlex_kit/accordion/accordion.rb +1 -1
  5. data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
  6. data/app/components/phlex_kit/accordion/accordion_controller.js +26 -2
  7. data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +20 -4
  8. data/app/components/phlex_kit/accordion/accordion_trigger.rb +18 -2
  9. data/app/components/phlex_kit/alert/alert.rb +1 -1
  10. data/app/components/phlex_kit/alert_dialog/alert_dialog_cancel.rb +1 -1
  11. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +1 -1
  12. data/app/components/phlex_kit/attachment/attachment.rb +2 -2
  13. data/app/components/phlex_kit/attachment/attachment_media.rb +1 -1
  14. data/app/components/phlex_kit/avatar/avatar.rb +1 -1
  15. data/app/components/phlex_kit/badge/badge.rb +1 -1
  16. data/app/components/phlex_kit/button/button.rb +1 -1
  17. data/app/components/phlex_kit/button_group/button_group.rb +1 -1
  18. data/app/components/phlex_kit/calendar/calendar_controller.js +12 -8
  19. data/app/components/phlex_kit/card/card.rb +1 -1
  20. data/app/components/phlex_kit/carousel/carousel.rb +1 -1
  21. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +1 -1
  22. data/app/components/phlex_kit/combobox/combobox.css +21 -12
  23. data/app/components/phlex_kit/combobox/combobox.rb +19 -3
  24. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +7 -2
  25. data/app/components/phlex_kit/combobox/combobox_checkbox.rb +3 -0
  26. data/app/components/phlex_kit/combobox/combobox_controller.js +85 -20
  27. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +11 -2
  28. data/app/components/phlex_kit/combobox/combobox_item.rb +4 -1
  29. data/app/components/phlex_kit/combobox/combobox_list.rb +9 -2
  30. data/app/components/phlex_kit/combobox/combobox_popover.rb +8 -7
  31. data/app/components/phlex_kit/combobox/combobox_radio.rb +3 -0
  32. data/app/components/phlex_kit/combobox/combobox_search_input.rb +8 -3
  33. data/app/components/phlex_kit/command/command.rb +17 -2
  34. data/app/components/phlex_kit/command/command_controller.js +93 -3
  35. data/app/components/phlex_kit/command/command_dialog_content.rb +16 -4
  36. data/app/components/phlex_kit/command/command_input.rb +7 -2
  37. data/app/components/phlex_kit/command/command_item.rb +12 -3
  38. data/app/components/phlex_kit/command/command_list.rb +9 -2
  39. data/app/components/phlex_kit/context_menu/context_menu.css +25 -13
  40. data/app/components/phlex_kit/context_menu/context_menu_content.rb +1 -1
  41. data/app/components/phlex_kit/context_menu/context_menu_controller.js +71 -12
  42. data/app/components/phlex_kit/context_menu/context_menu_item.rb +7 -3
  43. data/app/components/phlex_kit/context_menu/context_menu_label.rb +1 -1
  44. data/app/components/phlex_kit/context_menu/context_menu_trigger.rb +3 -1
  45. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +1 -1
  46. data/app/components/phlex_kit/data_table/data_table_controller.js +12 -1
  47. data/app/components/phlex_kit/data_table/data_table_pagination.rb +12 -10
  48. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +1 -0
  49. data/app/components/phlex_kit/data_table/data_table_sort_head.rb +5 -1
  50. data/app/components/phlex_kit/dialog/dialog_content.rb +12 -4
  51. data/app/components/phlex_kit/dialog/dialog_controller.js +13 -0
  52. data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
  53. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +36 -15
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -3
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +8 -11
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +1 -1
  57. data/app/components/phlex_kit/empty/empty_media.rb +1 -1
  58. data/app/components/phlex_kit/field/field.rb +1 -1
  59. data/app/components/phlex_kit/field/field_legend.rb +1 -1
  60. data/app/components/phlex_kit/hover_card/hover_card.css +27 -8
  61. data/app/components/phlex_kit/hover_card/hover_card.rb +2 -1
  62. data/app/components/phlex_kit/hover_card/hover_card_content.rb +2 -2
  63. data/app/components/phlex_kit/hover_card/hover_card_controller.js +19 -5
  64. data/app/components/phlex_kit/input_group/input_group_addon.rb +1 -1
  65. data/app/components/phlex_kit/input_otp/input_otp.rb +4 -1
  66. data/app/components/phlex_kit/input_otp/input_otp_controller.js +12 -1
  67. data/app/components/phlex_kit/item/item.rb +1 -1
  68. data/app/components/phlex_kit/item/item_media.rb +1 -1
  69. data/app/components/phlex_kit/link/link.rb +1 -1
  70. data/app/components/phlex_kit/marker/marker.rb +1 -1
  71. data/app/components/phlex_kit/menubar/menubar.css +36 -14
  72. data/app/components/phlex_kit/menubar/menubar.rb +1 -1
  73. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +5 -2
  74. data/app/components/phlex_kit/menubar/menubar_content.rb +1 -1
  75. data/app/components/phlex_kit/menubar/menubar_controller.js +100 -12
  76. data/app/components/phlex_kit/menubar/menubar_item.rb +8 -5
  77. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +5 -2
  78. data/app/components/phlex_kit/menubar/menubar_trigger.rb +1 -0
  79. data/app/components/phlex_kit/native_select/native_select.rb +1 -1
  80. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +24 -7
  81. data/app/components/phlex_kit/navigation_menu/navigation_menu_content.rb +3 -1
  82. data/app/components/phlex_kit/popover/popover.css +24 -9
  83. data/app/components/phlex_kit/popover/popover.rb +2 -1
  84. data/app/components/phlex_kit/popover/popover_content.rb +6 -4
  85. data/app/components/phlex_kit/popover/popover_controller.js +12 -8
  86. data/app/components/phlex_kit/popover/popover_trigger.rb +1 -1
  87. data/app/components/phlex_kit/resizable/resizable_panel.rb +4 -2
  88. data/app/components/phlex_kit/select/select.css +24 -12
  89. data/app/components/phlex_kit/select/select_content.rb +6 -4
  90. data/app/components/phlex_kit/select/select_controller.js +21 -17
  91. data/app/components/phlex_kit/select/select_trigger.rb +1 -1
  92. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  93. data/app/components/phlex_kit/sheet/sheet_content_controller.js +68 -2
  94. data/app/components/phlex_kit/sheet/sheet_controller.js +28 -3
  95. data/app/components/phlex_kit/sidebar/sidebar.rb +1 -1
  96. data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +1 -1
  97. data/app/components/phlex_kit/spinner/spinner.rb +1 -1
  98. data/app/components/phlex_kit/switch/switch.rb +7 -4
  99. data/app/components/phlex_kit/tabs/tabs.rb +1 -1
  100. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -1
  101. data/app/components/phlex_kit/tabs/tabs_controller.js +60 -7
  102. data/app/components/phlex_kit/tabs/tabs_list.rb +2 -2
  103. data/app/components/phlex_kit/tabs/tabs_trigger.rb +14 -2
  104. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -3
  105. data/app/components/phlex_kit/toast/toast_controller.js +17 -0
  106. data/app/components/phlex_kit/toast/toast_icon.rb +1 -1
  107. data/app/components/phlex_kit/toast/toast_item.rb +1 -1
  108. data/app/components/phlex_kit/toggle/toggle.rb +2 -2
  109. data/app/components/phlex_kit/toggle_group/toggle_group.rb +2 -2
  110. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +1 -1
  111. data/app/components/phlex_kit/tooltip/tooltip.css +40 -22
  112. data/app/components/phlex_kit/tooltip/tooltip.rb +3 -1
  113. data/app/components/phlex_kit/tooltip/tooltip_content.rb +1 -1
  114. data/app/components/phlex_kit/tooltip/tooltip_controller.js +32 -0
  115. data/app/components/phlex_kit/tooltip/tooltip_trigger.rb +8 -3
  116. data/app/components/phlex_kit/typography/text.rb +1 -1
  117. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  118. data/lib/generators/phlex_kit/component/component_generator.rb +28 -0
  119. data/lib/generators/phlex_kit/install/templates/phlex_kit.rb +3 -0
  120. data/lib/phlex_kit/base_component.rb +12 -0
  121. data/lib/phlex_kit/engine.rb +11 -1
  122. data/lib/phlex_kit/icons/lucide.rb +1 -1
  123. data/lib/phlex_kit/icons/phosphor.rb +1 -1
  124. data/lib/phlex_kit/icons/remix.rb +1 -1
  125. data/lib/phlex_kit/icons/tabler.rb +1 -1
  126. data/lib/phlex_kit/version.rb +1 -1
  127. data/lib/phlex_kit.rb +5 -0
  128. metadata +2 -1
@@ -2,7 +2,7 @@ module PhlexKit
2
2
  class PopoverTrigger < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template(&)
5
- div(**mix({ class: "pk-popover-trigger", data: { phlex_kit__popover_target: "trigger", action: "click->phlex-kit--popover#toggle" } }, @attrs), &)
5
+ div(**mix({ class: "pk-popover-trigger", data: { phlex_kit__popover_target: "trigger", action: "pointerdown->phlex-kit--popover#armToggle click->phlex-kit--popover#toggle" } }, @attrs), &)
6
6
  end
7
7
  end
8
8
  end
@@ -3,12 +3,14 @@ module PhlexKit
3
3
  # See resizable_panel_group.rb.
4
4
  class ResizablePanel < BaseComponent
5
5
  def initialize(default_size: nil, **attrs)
6
- @default_size = default_size
6
+ # Numeric coercion: the value is interpolated into a style attribute, and
7
+ # hosts plausibly round-trip saved panel sizes from client state.
8
+ @default_size = default_size.nil? ? nil : Float(default_size)
7
9
  @attrs = attrs
8
10
  end
9
11
 
10
12
  def view_template(&)
11
- style = @default_size ? "flex-grow: #{@default_size}" : nil
13
+ style = @default_size ? "flex-grow: #{Kernel.format("%g", @default_size)}" : nil
12
14
  div(**mix({
13
15
  class: "pk-resizable-panel",
14
16
  style: style,
@@ -2,15 +2,18 @@
2
2
  is the native one). ruby_ui's structure + Stimulus kept; Tailwind replaced with
3
3
  our vanilla CSS on the palette tokens. Geometry from ruby_ui (h-9 trigger,
4
4
  rounded-md, p-1 viewport, px-2 py-1.5 items, accent on hover/selected). The
5
- panel is positioned with plain CSS (top:100%) since we dropped @floating-ui.
6
- Theme tokens come from the global stylesheet. */
7
- .pk-select { position: relative; width: 100%; }
5
+ panel is a native [popover=manual] in the top layer, anchor-positioned to
6
+ the trigger (Baseline 2026, no @floating-ui); position-try-fallbacks flips
7
+ it above at the viewport bottom. Theme tokens come from the global
8
+ stylesheet. */
9
+ .pk-select { width: 100%; anchor-scope: --pk-select; }
8
10
 
9
11
  /* Hidden input that carries the form value. */
10
12
  .pk-select-input { display: none; }
11
13
 
12
14
  /* Closed-state trigger button (mirrors .pk-input geometry). */
13
15
  .pk-select-trigger {
16
+ anchor-name: --pk-select;
14
17
  display: flex;
15
18
  align-items: center;
16
19
  justify-content: space-between;
@@ -44,20 +47,26 @@
44
47
  .pk-select-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-left: .5rem; opacity: .5; }
45
48
  .pk-select-value { overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
46
49
 
47
- /* Dropdown panel — opens directly below the trigger (no floating-ui). */
50
+ /* Dropdown panel — anchored directly below the trigger, flips above at the
51
+ viewport bottom. */
48
52
  .pk-select-content {
49
- position: absolute;
50
- top: 100%;
51
- left: 0;
52
- z-index: 50;
53
+ position: fixed;
54
+ position-anchor: --pk-select;
55
+ inset: auto;
56
+ /* the bordered box is the inner viewport — strip the [popover] UA chrome
57
+ (border: solid, padding, Canvas background) from this wrapper */
58
+ border: 0; padding: 0; background: transparent; color: inherit; overflow: visible;
59
+ position-area: block-end span-inline-end;
60
+ margin: .25rem 0 0;
61
+ position-try-fallbacks: flip-block;
53
62
  /* Content-hugging with the trigger width as the floor — items never wrap,
54
63
  so a narrow trigger (e.g. the currency select in a button group) still
55
- gets a readable panel; for form selects min-width keeps panel = trigger. */
64
+ gets a readable panel; for form selects the anchor-size floor keeps
65
+ panel = trigger (percentages resolve against the position-area cell,
66
+ not the trigger, once anchored). */
56
67
  width: max-content;
57
- min-width: 100%;
58
- margin-top: .25rem;
68
+ min-width: anchor-size(width);
59
69
  }
60
- .pk-select-content.hidden { display: none; }
61
70
  .pk-select-viewport {
62
71
  max-height: 24rem;
63
72
  overflow: auto;
@@ -118,3 +127,6 @@
118
127
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
119
128
  }
120
129
  }
130
+ /* Hiding must survive author display values (a host class like
131
+ display:grid would beat the UA [popover] { display: none }). */
132
+ .pk-select-content:not(:popover-open) { display: none; }
@@ -1,7 +1,8 @@
1
1
  module PhlexKit
2
- # The dropdown panel for PhlexKit::Select — hidden until the trigger opens it (the
3
- # controller toggles the `hidden` class). Outer div is the positioned/targeted
4
- # layer; the inner `.pk-select-viewport` is the bordered, scrollable box.
2
+ # The dropdown panel for PhlexKit::Select — a native [popover=manual] the
3
+ # controller toggles, anchor-positioned to the trigger with viewport-edge
4
+ # flipping (select.css). Outer div is the positioned/targeted layer; the
5
+ # inner `.pk-select-viewport` is the bordered, scrollable box.
5
6
  # Holds SelectGroup / SelectLabel / SelectItem children. See select.rb.
6
7
  class SelectContent < BaseComponent
7
8
  def initialize(**attrs)
@@ -14,7 +15,8 @@ module PhlexKit
14
15
  id: @id,
15
16
  role: "listbox",
16
17
  tabindex: "-1",
17
- class: "pk-select-content hidden",
18
+ class: "pk-select-content",
19
+ popover: "manual",
18
20
  data: { phlex_kit__select_target: "content" }
19
21
  }, @attrs)) do
20
22
  div(class: "pk-select-viewport", &block)
@@ -1,10 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- // Ported from ruby_ui's phlex-kit--select controller. The ONE change from upstream:
4
- // the @floating-ui/dom dependency is removed — the panel is positioned with plain
5
- // CSS (.ui-select-content { position:absolute; top:100% }) instead of
6
- // computePosition/autoUpdate, so this app needs no npm/build step. Everything else
7
- // (open/close, item selection, keyboard nav, click-outside) is unchanged.
3
+ // Ported from ruby_ui's phlex-kit--select controller, minus the
4
+ // @floating-ui/dom dependency: the panel is a native [popover=manual] in the
5
+ // top layer, anchor-positioned with viewport-edge flipping by select.css
6
+ // (manual so this controller keeps owning open/close, item selection,
7
+ // keyboard nav, and click-outside all unchanged from upstream).
8
8
  export default class extends Controller {
9
9
  static targets = ["trigger", "content", "input", "value", "item"];
10
10
  static values = { open: Boolean };
@@ -47,7 +47,8 @@ export default class extends Controller {
47
47
  );
48
48
 
49
49
  if (currentIndex + 1 < this.itemTargets.length) {
50
- this.itemTargets[currentIndex].removeAttribute("aria-current");
50
+ // No aria-current yet (fresh open via keyboard) → start at the first item.
51
+ if (currentIndex >= 0) this.itemTargets[currentIndex].removeAttribute("aria-current");
51
52
  this.setAriaCurrentAndActiveDescendant(currentIndex + 1);
52
53
  }
53
54
  }
@@ -75,15 +76,12 @@ export default class extends Controller {
75
76
  (item) => item.getAttribute("aria-selected") === "true",
76
77
  );
77
78
 
78
- if (selectedItem) {
79
- selectedItem.focus({ preventScroll: true });
80
- selectedItem.setAttribute("aria-current", "true");
81
- this.triggerTarget.setAttribute("aria-activedescendant", selectedItem.getAttribute("id"));
82
- } else {
83
- this.itemTarget.focus({ preventScroll: true });
84
- this.itemTarget.setAttribute("aria-current", "true");
85
- this.triggerTarget.setAttribute("aria-activedescendant", this.itemTarget.getAttribute("id"));
86
- }
79
+ const focusItem = selectedItem || (this.hasItemTarget ? this.itemTarget : null);
80
+ if (!focusItem) return; // empty select — nothing to focus
81
+
82
+ focusItem.focus({ preventScroll: true });
83
+ focusItem.setAttribute("aria-current", "true");
84
+ this.triggerTarget.setAttribute("aria-activedescendant", focusItem.getAttribute("id"));
87
85
  }
88
86
 
89
87
  resetCurrent() {
@@ -100,7 +98,11 @@ export default class extends Controller {
100
98
 
101
99
  toogleContent() {
102
100
  this.openValue = !this.openValue;
103
- this.contentTarget.classList.toggle("hidden");
101
+ if (this.openValue) {
102
+ this.contentTarget.showPopover();
103
+ } else if (this.contentTarget.matches(":popover-open")) {
104
+ this.contentTarget.hidePopover();
105
+ }
104
106
  this.triggerTarget.setAttribute("aria-expanded", this.openValue);
105
107
  }
106
108
 
@@ -124,7 +126,9 @@ export default class extends Controller {
124
126
  this.toogleContent();
125
127
  this.resetCurrent();
126
128
 
127
- this.triggerTarget.setAttribute("aria-activedescendant", true);
129
+ // aria-activedescendant holds an element id; on close it must be removed,
130
+ // not set to the literal string "true".
131
+ this.triggerTarget.removeAttribute("aria-activedescendant");
128
132
  this.triggerTarget.focus({ preventScroll: true });
129
133
  }
130
134
 
@@ -14,7 +14,7 @@ module PhlexKit
14
14
  button(**mix({
15
15
  type: :button,
16
16
  role: "combobox",
17
- class: [ "pk-select-trigger", SIZES.fetch(@size) ].compact.join(" "),
17
+ class: [ "pk-select-trigger", fetch_option(SIZES, @size, :size) ].compact.join(" "),
18
18
  aria: { expanded: "false", haspopup: "listbox", autocomplete: "none" },
19
19
  data: { action: "phlex-kit--select#onClick", phlex_kit__select_target: "trigger" }
20
20
  }, @attrs)) do
@@ -9,9 +9,9 @@ module PhlexKit
9
9
  end
10
10
  def view_template(&block)
11
11
  template(data: { phlex_kit__sheet_target: "content" }) do
12
- div(data: { controller: "phlex-kit--sheet-content" }) do
12
+ div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown" }) do
13
13
  div(class: "pk-sheet-backdrop", data: { action: "click->phlex-kit--sheet-content#close" })
14
- div(**mix({ class: ["pk-sheet-content", SIDES.fetch(@side)].join(" ") }, @attrs)) do
14
+ div(**mix({ class: [ "pk-sheet-content", fetch_option(SIDES, @side, :side) ].join(" "), role: "dialog", aria: { modal: "true" }, tabindex: "-1", data: { phlex_kit__sheet_content_target: "panel" } }, @attrs)) do
15
15
  block&.call
16
16
  if @show_close_button
17
17
  button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--sheet-content#close" }) do
@@ -1,6 +1,72 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Connects to data-controller="phlex-kit--sheet-content" (on the cloned node).
3
+ // Connects to data-controller="phlex-kit--sheet-content" (on the cloned node
4
+ // drawers ride the same machinery). Owns the modal contract for the open panel:
5
+ // scroll lock, initial focus, Tab focus trap, Escape-to-close, focus restore to
6
+ // the opener, and aria-labelledby wiring to the SheetTitle/DrawerTitle. connect
7
+ // and disconnect exactly bracket the clone's lifetime, so removal by any path
8
+ // (close button, backdrop, Escape, turbo:before-cache) restores state.
9
+ const FOCUSABLE =
10
+ 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
11
+
4
12
  export default class extends Controller {
5
- close() { this.element.remove() }
13
+ static targets = ["panel"]
14
+
15
+ connect() {
16
+ this.opener = document.activeElement
17
+ this.previousOverflow = document.body.style.overflow
18
+ document.body.style.overflow = "hidden"
19
+ this.#wireAriaLabelledby()
20
+ const focusables = this.#focusables()
21
+ ;(focusables[0] || this.panel).focus()
22
+ }
23
+
24
+ disconnect() {
25
+ document.body.style.overflow = this.previousOverflow
26
+ if (this.opener?.isConnected) this.opener.focus()
27
+ }
28
+
29
+ close() {
30
+ this.element.remove()
31
+ }
32
+
33
+ keydown(event) {
34
+ if (event.key === "Escape") {
35
+ event.preventDefault()
36
+ this.close()
37
+ return
38
+ }
39
+ if (event.key !== "Tab") return
40
+ const focusables = this.#focusables()
41
+ if (focusables.length === 0) {
42
+ event.preventDefault()
43
+ this.panel.focus()
44
+ return
45
+ }
46
+ const first = focusables[0]
47
+ const last = focusables[focusables.length - 1]
48
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === this.panel)) {
49
+ event.preventDefault()
50
+ last.focus()
51
+ } else if (!event.shiftKey && document.activeElement === last) {
52
+ event.preventDefault()
53
+ first.focus()
54
+ }
55
+ }
56
+
57
+ get panel() {
58
+ return this.hasPanelTarget ? this.panelTarget : this.element
59
+ }
60
+
61
+ #focusables() {
62
+ return [...this.panel.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0)
63
+ }
64
+
65
+ #wireAriaLabelledby() {
66
+ if (this.panel.getAttribute("aria-labelledby")) return
67
+ const title = this.element.querySelector(".pk-sheet-title, .pk-drawer-title")
68
+ if (!title) return
69
+ if (!title.id) title.id = `pk-modal-title-${Math.random().toString(36).slice(2, 10)}`
70
+ this.panel.setAttribute("aria-labelledby", title.id)
71
+ }
6
72
  }
@@ -1,10 +1,35 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--sheet". Clones the content <template>
4
- // into <body> on open. Ported from ruby_ui.
4
+ // into <body> on open. Ported from ruby_ui. Guards against double-opening (one
5
+ // live clone at a time) and clears the clone on turbo:before-cache so a cached
6
+ // page never restores a stale overlay. The clone's own phlex-kit--sheet-content
7
+ // controller owns the modal behavior (focus trap, Escape, scroll lock).
5
8
  export default class extends Controller {
6
9
  static targets = ["content"]
7
10
  static values = { open: { type: Boolean, default: false } }
8
- connect() { if (this.openValue) this.open() }
9
- open() { document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML) }
11
+
12
+ initialize() {
13
+ this.overlay = null
14
+ this.clearOverlay = () => {
15
+ if (this.overlay?.isConnected) this.overlay.remove()
16
+ this.overlay = null
17
+ }
18
+ }
19
+
20
+ connect() {
21
+ document.addEventListener("turbo:before-cache", this.clearOverlay)
22
+ if (this.openValue) this.open()
23
+ }
24
+
25
+ disconnect() {
26
+ document.removeEventListener("turbo:before-cache", this.clearOverlay)
27
+ this.clearOverlay()
28
+ }
29
+
30
+ open() {
31
+ if (this.overlay?.isConnected) return
32
+ document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
33
+ this.overlay = document.body.lastElementChild
34
+ }
10
35
  }
@@ -26,7 +26,7 @@ module PhlexKit
26
26
  private
27
27
 
28
28
  def classes
29
- [ "pk-sidebar", MENUS.fetch(@menu) ].compact.join(" ")
29
+ [ "pk-sidebar", fetch_option(MENUS, @menu, :menu) ].compact.join(" ")
30
30
  end
31
31
  end
32
32
  end
@@ -59,7 +59,7 @@ module PhlexKit
59
59
  def collapsible? = @collapsible != :none
60
60
 
61
61
  def classes
62
- [ "pk-sidebar-wrapper", COLLAPSIBLE.fetch(@collapsible) ].compact.join(" ")
62
+ [ "pk-sidebar-wrapper", fetch_option(COLLAPSIBLE, @collapsible, :collapsible) ].compact.join(" ")
63
63
  end
64
64
  end
65
65
  end
@@ -12,7 +12,7 @@ module PhlexKit
12
12
  def view_template
13
13
  # A visible loader, not decoration: role=status + label, aria-hidden off.
14
14
  render Icon.new(:loader, size: nil, **mix({
15
- class: [ "pk-spinner", SIZES.fetch(@size) ].compact.join(" "),
15
+ class: [ "pk-spinner", fetch_option(SIZES, @size, :size) ].compact.join(" "),
16
16
  role: "status",
17
17
  aria: { label: "Loading" },
18
18
  "aria-hidden": false
@@ -5,18 +5,21 @@ module PhlexKit
5
5
  class Switch < BaseComponent
6
6
  SIZES = { md: nil, sm: "sm" }.freeze
7
7
 
8
- def initialize(include_hidden: true, checked_value: "1", unchecked_value: "0", size: :md, **attrs)
8
+ def initialize(include_hidden: true, checked_value: "1", unchecked_value: "0", size: :md, wrapper: {}, **attrs)
9
9
  @include_hidden = include_hidden
10
10
  @checked_value = checked_value
11
11
  @unchecked_value = unchecked_value
12
12
  @size = size.to_sym
13
+ @wrapper = wrapper
13
14
  @attrs = attrs
14
15
  end
15
16
 
16
17
  def view_template
17
- label(**mix({ role: "switch", class: [ "pk-switch", SIZES.fetch(@size) ].compact.join(" ") }, {})) do
18
- input(type: "hidden", name: @attrs[:name], value: @unchecked_value) if @include_hidden
19
- input(**mix({ class: "pk-switch-input" }, @attrs).merge(type: "checkbox", value: @checked_value))
18
+ label(**mix({ class: [ "pk-switch", fetch_option(SIZES, @size, :size) ].compact.join(" ") }, @wrapper)) do
19
+ input(type: "hidden", name: @attrs[:name], value: @unchecked_value) if @include_hidden && @attrs[:name]
20
+ # role="switch" belongs on the focusable control: native checkedness then
21
+ # maps to aria-checked for AT, and the label stays a plain label.
22
+ input(**mix({ class: "pk-switch-input", role: "switch" }, @attrs).merge(type: "checkbox", value: @checked_value))
20
23
  span(class: "pk-switch-thumb")
21
24
  end
22
25
  end
@@ -11,7 +11,7 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&)
14
- classes = [ "pk-tabs", ORIENTATIONS.fetch(@orientation) ].compact.join(" ")
14
+ classes = [ "pk-tabs", fetch_option(ORIENTATIONS, @orientation, :orientation) ].compact.join(" ")
15
15
  div(**mix({ class: classes, data: { controller: "phlex-kit--tabs", phlex_kit__tabs_active_value: @default } }, @attrs), &)
16
16
  end
17
17
  end
@@ -5,7 +5,10 @@ module PhlexKit
5
5
  @attrs = attrs
6
6
  end
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-tabs-content pk-hidden", role: "tabpanel", data: { phlex_kit__tabs_target: "content", value: @value } }, @attrs), &)
8
+ # id / aria-labelledby mirror TabsTrigger's deterministic ids (re-scoped
9
+ # per instance by the controller); tabindex="0" makes the panel itself
10
+ # focusable, per the APG tabs pattern.
11
+ div(**mix({ class: "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), &)
9
12
  end
10
13
  end
11
14
  end
@@ -1,26 +1,79 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ let uid = 0
4
+
3
5
  // Connects to data-controller="phlex-kit--tabs"
4
6
  export default class extends Controller {
5
- static targets = ["trigger", "content"]
7
+ static targets = ["list", "trigger", "content"]
6
8
  static values = { active: String }
7
9
 
8
10
  connect() {
11
+ this.scopeIds()
12
+ if (this.hasListTarget && this.element.classList.contains("vertical")) {
13
+ this.listTarget.setAttribute("aria-orientation", "vertical")
14
+ }
9
15
  if (!this.hasActiveValue && this.triggerTargets.length > 0) {
10
16
  this.activeValue = this.triggerTargets[0].dataset.value
17
+ } else {
18
+ this.sync()
11
19
  }
12
20
  }
13
21
 
14
22
  show(e) { this.activeValue = e.currentTarget.dataset.value }
15
23
 
24
+ // APG tabs keyboard model on the tablist: arrows move focus AND activate
25
+ // (shadcn/Radix activation-on-focus), Home/End jump to the ends. Vertical
26
+ // tab sets also answer to ArrowUp/ArrowDown.
27
+ keydown(event) {
28
+ const vertical = this.element.classList.contains("vertical")
29
+ const nextKeys = vertical ? ["ArrowRight", "ArrowDown"] : ["ArrowRight"]
30
+ const prevKeys = vertical ? ["ArrowLeft", "ArrowUp"] : ["ArrowLeft"]
31
+ const triggers = this.triggerTargets.filter((el) => !el.disabled)
32
+ if (triggers.length === 0) return
33
+ const index = triggers.indexOf(document.activeElement)
34
+ let target
35
+ if (nextKeys.includes(event.key)) target = triggers[(index + 1) % triggers.length]
36
+ else if (prevKeys.includes(event.key)) target = triggers[(index - 1 + triggers.length) % triggers.length]
37
+ else if (event.key === "Home") target = triggers[0]
38
+ else if (event.key === "End") target = triggers[triggers.length - 1]
39
+ if (!target) return
40
+ event.preventDefault()
41
+ target.focus()
42
+ this.activeValue = target.dataset.value
43
+ }
44
+
16
45
  activeValueChanged(current, previous) {
17
46
  if (current === "" || current === previous) return
18
- this.contentTargets.forEach((el) => el.classList.add("pk-hidden"))
19
- this.triggerTargets.forEach((el) => { el.dataset.state = "inactive" })
20
- const c = this.activeContentTarget()
21
- if (c) c.classList.remove("pk-hidden")
22
- const t = this.activeTriggerTarget()
23
- if (t) t.dataset.state = "active"
47
+ this.sync()
48
+ }
49
+
50
+ // Reflect the active value onto every trigger/panel: visibility,
51
+ // data-state, aria-selected, and the roving tabindex.
52
+ sync() {
53
+ this.contentTargets.forEach((el) => el.classList.toggle("pk-hidden", el.dataset.value !== this.activeValue))
54
+ this.triggerTargets.forEach((el) => {
55
+ const active = el.dataset.value === this.activeValue
56
+ el.dataset.state = active ? "active" : "inactive"
57
+ el.setAttribute("aria-selected", active ? "true" : "false")
58
+ el.setAttribute("tabindex", active ? "0" : "-1")
59
+ })
60
+ }
61
+
62
+ // The parts render deterministic ids derived from their value, which would
63
+ // collide across multiple tab sets on one page — re-scope them with a
64
+ // per-instance prefix and rewire aria-controls/aria-labelledby. Caller-
65
+ // supplied (non pk-tabs-*) ids are left alone.
66
+ scopeIds() {
67
+ const prefix = `pk-tabs-${++uid}`
68
+ this.triggerTargets.forEach((trigger) => {
69
+ const value = trigger.dataset.value
70
+ const panel = this.contentTargets.find((el) => el.dataset.value === value)
71
+ if (!trigger.id || trigger.id.startsWith("pk-tabs-trigger-")) trigger.id = `${prefix}-trigger-${value}`
72
+ if (!panel) return
73
+ if (!panel.id || panel.id.startsWith("pk-tabs-panel-")) panel.id = `${prefix}-panel-${value}`
74
+ trigger.setAttribute("aria-controls", panel.id)
75
+ panel.setAttribute("aria-labelledby", trigger.id)
76
+ })
24
77
  }
25
78
 
26
79
  activeTriggerTarget() { return this.triggerTargets.find((el) => el.dataset.value === this.activeValue) }
@@ -8,8 +8,8 @@ module PhlexKit
8
8
  end
9
9
 
10
10
  def view_template(&)
11
- classes = [ "pk-tabs-list", VARIANTS.fetch(@variant) ].compact.join(" ")
12
- div(**mix({ class: classes, role: "tablist" }, @attrs), &)
11
+ 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
13
  end
14
14
  end
15
15
  end
@@ -1,12 +1,24 @@
1
1
  module PhlexKit
2
2
  class TabsTrigger < BaseComponent
3
- def initialize(value:, as: :button, **attrs)
3
+ def initialize(value:, as: :button, active: false, **attrs)
4
4
  @value = value
5
5
  @as = as.to_sym
6
+ @active = active
6
7
  @attrs = attrs
7
8
  end
8
9
  def view_template(&)
9
- base = { class: "pk-tabs-trigger", role: "tab", data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value } }
10
+ # Deterministic ids pair each trigger with its panel (aria-controls /
11
+ # aria-labelledby); the controller re-scopes them per instance in
12
+ # connect() so multiple tab sets sharing a value can't collide.
13
+ # aria-selected and the roving tabindex are kept in sync by the
14
+ # controller's activeValueChanged.
15
+ base = {
16
+ class: "pk-tabs-trigger", role: "tab", id: "pk-tabs-trigger-#{@value}",
17
+ aria_selected: @active ? "true" : "false",
18
+ aria_controls: "pk-tabs-panel-#{@value}",
19
+ tabindex: @active ? "0" : "-1",
20
+ data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value }
21
+ }
10
22
  if @as == :a
11
23
  a(**mix(base, @attrs), &)
12
24
  else
@@ -3,11 +3,19 @@ module PhlexKit
3
3
  # ThemeToggle. The sibling phlex-kit--theme-toggle controller listens for the
4
4
  # toggle's change event and flips :root[data-theme] (matching the token system).
5
5
  class ThemeToggle < BaseComponent
6
- def initialize(**attrs) = (@attrs = attrs)
6
+ def initialize(wrapper: {}, aria: {}, **attrs)
7
+ @wrapper = wrapper
8
+ @aria = aria
9
+ @attrs = attrs
10
+ end
11
+
7
12
  def view_template(&block)
13
+ # Merge caller wrapper/aria instead of splatting @attrs over the defaults —
14
+ # a caller `wrapper:` would otherwise replace the hash wholesale and
15
+ # silently delete the theme-toggle controller wiring.
8
16
  render PhlexKit::Toggle.new(
9
- aria: { label: "Toggle theme" },
10
- wrapper: { data: { controller: "phlex-kit--theme-toggle", action: "phlex-kit--toggle:change->phlex-kit--theme-toggle#apply" } },
17
+ aria: { label: "Toggle theme" }.merge(@aria),
18
+ wrapper: mix({ data: { controller: "phlex-kit--theme-toggle", action: "phlex-kit--toggle:change->phlex-kit--theme-toggle#apply" } }, @wrapper),
11
19
  **@attrs, &block
12
20
  )
13
21
  end
@@ -27,6 +27,10 @@ export default class extends Controller {
27
27
  this._onPointerUp = this._onPointerUp.bind(this)
28
28
  this._onPointerEnter = () => this._pause()
29
29
  this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }
30
+ // Keyboard parity with hover: tabbing into the toast (e.g. to reach its
31
+ // action button) must pause auto-dismiss just like pointerenter does.
32
+ this._onFocusIn = () => this._pause()
33
+ this._onFocusOut = (e) => { if (!this.element.contains(e.relatedTarget)) this._resume() }
30
34
  this._onKeyDown = this._onKeyDown.bind(this)
31
35
  this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
32
36
  this._onRestart = () => this._restart()
@@ -36,6 +40,8 @@ export default class extends Controller {
36
40
  this.element.addEventListener("pointerdown", this._onPointerDown)
37
41
  this.element.addEventListener("pointerenter", this._onPointerEnter)
38
42
  this.element.addEventListener("pointerleave", this._onPointerLeave)
43
+ this.element.addEventListener("focusin", this._onFocusIn)
44
+ this.element.addEventListener("focusout", this._onFocusOut)
39
45
  this.element.addEventListener("keydown", this._onKeyDown)
40
46
  this.element.addEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
41
47
  this.element.addEventListener("phlex-kit:toast:restart", this._onRestart)
@@ -53,6 +59,8 @@ export default class extends Controller {
53
59
  this.element.removeEventListener("pointerdown", this._onPointerDown)
54
60
  this.element.removeEventListener("pointerenter", this._onPointerEnter)
55
61
  this.element.removeEventListener("pointerleave", this._onPointerLeave)
62
+ this.element.removeEventListener("focusin", this._onFocusIn)
63
+ this.element.removeEventListener("focusout", this._onFocusOut)
56
64
  this.element.removeEventListener("keydown", this._onKeyDown)
57
65
  this.element.removeEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
58
66
  this.element.removeEventListener("phlex-kit:toast:restart", this._onRestart)
@@ -70,9 +78,18 @@ export default class extends Controller {
70
78
  if (this.element.dataset.state === "closing") return
71
79
  this.element.dataset.state = "closing"
72
80
  this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "phlex-kit:toast:auto-close" : "phlex-kit:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
81
+ this._invokeCallback(reason === "auto" ? this.onAutoCloseValue : this.onDismissValue)
73
82
  setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
74
83
  }
75
84
 
85
+ // on_dismiss:/on_auto_close: name a global function (Sonner-style callback
86
+ // for server-rendered toasts).
87
+ _invokeCallback(name) {
88
+ if (!name) return
89
+ const fn = window[name]
90
+ if (typeof fn === "function") fn({ id: this.element.id })
91
+ }
92
+
76
93
  _start() {
77
94
  if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return
78
95
  this._startedAt = performance.now()
@@ -22,7 +22,7 @@ module PhlexKit
22
22
  def view_template
23
23
  return unless renderable?
24
24
  span(**mix({ class: "pk-toast-icon", data: { slot: "icon" } }, @attrs)) do
25
- render Icon.new(GLYPHS.fetch(@variant), size: 16, class: svg_classes)
25
+ render Icon.new(fetch_option(GLYPHS, @variant, :glyph), size: 16, class: svg_classes)
26
26
  end
27
27
  end
28
28
 
@@ -44,7 +44,7 @@ module PhlexKit
44
44
 
45
45
  def item_attrs
46
46
  data = {
47
- variant: VARIANTS.fetch(@variant),
47
+ variant: fetch_option(VARIANTS, @variant, :variant),
48
48
  state: "pending",
49
49
  swipe: "none",
50
50
  controller: "phlex-kit--toast",
@@ -7,7 +7,7 @@ module PhlexKit
7
7
  SIZES = { sm: "sm", default: nil, lg: "lg" }.freeze
8
8
 
9
9
  def self.modifier_classes(variant:, size:)
10
- [VARIANTS.fetch(variant, nil), SIZES.fetch(size, nil)].compact
10
+ [ VARIANTS.fetch(variant, nil), SIZES.fetch(size, nil) ].compact
11
11
  end
12
12
 
13
13
  def initialize(pressed: false, name: nil, value: "1", unpressed_value: nil,
@@ -33,7 +33,7 @@ module PhlexKit
33
33
  private
34
34
 
35
35
  def classes
36
- (["pk-toggle"] + self.class.modifier_classes(variant: @variant, size: @size)).join(" ")
36
+ ([ "pk-toggle" ] + self.class.modifier_classes(variant: @variant, size: @size)).join(" ")
37
37
  end
38
38
 
39
39
  def button_default_attrs