phlex_kit 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) 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.css +3 -1
  5. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +38 -7
  7. data/app/components/phlex_kit/attachment/attachment.css +15 -0
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +8 -0
  10. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  11. data/app/components/phlex_kit/badge/badge.css +1 -1
  12. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
  13. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  14. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  15. data/app/components/phlex_kit/calendar/calendar_controller.js +56 -15
  16. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  17. data/app/components/phlex_kit/carousel/carousel_controller.js +59 -4
  18. data/app/components/phlex_kit/chart/chart.rb +10 -2
  19. data/app/components/phlex_kit/chart/chart_controller.js +37 -8
  20. data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
  21. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  22. data/app/components/phlex_kit/codeblock/codeblock.rb +11 -1
  23. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  24. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  25. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  26. data/app/components/phlex_kit/combobox/combobox_controller.js +72 -3
  27. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  28. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  29. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
  30. data/app/components/phlex_kit/command/command_controller.js +23 -2
  31. data/app/components/phlex_kit/command/command_input.rb +3 -2
  32. data/app/components/phlex_kit/command/command_item.rb +4 -0
  33. data/app/components/phlex_kit/command/command_list.rb +6 -2
  34. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  35. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  36. data/app/components/phlex_kit/context_menu/context_menu_controller.js +74 -12
  37. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  38. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  39. data/app/components/phlex_kit/data_table/data_table.css +1 -11
  40. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  41. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  42. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  43. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  44. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  45. data/app/components/phlex_kit/date_picker/date_picker.rb +19 -2
  46. data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -0
  47. data/app/components/phlex_kit/dialog/dialog.css +3 -1
  48. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  49. data/app/components/phlex_kit/dialog/dialog_controller.js +16 -2
  50. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  51. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  52. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +9 -2
  53. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +81 -13
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  57. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  58. data/app/components/phlex_kit/form_field/form_field_controller.js +54 -25
  59. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  60. data/app/components/phlex_kit/hover_card/hover_card.css +10 -2
  61. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  62. data/app/components/phlex_kit/icon/icon.css +9 -0
  63. data/app/components/phlex_kit/icon/icon.rb +7 -1
  64. data/app/components/phlex_kit/input/input.css +0 -1
  65. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  66. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  67. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  68. data/app/components/phlex_kit/item/item.css +2 -1
  69. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  70. data/app/components/phlex_kit/label/label.css +1 -1
  71. data/app/components/phlex_kit/link/link.rb +1 -0
  72. data/app/components/phlex_kit/marker/marker.css +2 -1
  73. data/app/components/phlex_kit/marker/marker.rb +9 -2
  74. data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
  75. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  76. data/app/components/phlex_kit/menubar/menubar_controller.js +46 -16
  77. data/app/components/phlex_kit/message/message.rb +5 -0
  78. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
  79. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  80. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  81. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
  86. data/app/components/phlex_kit/popover/popover_controller.js +42 -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 +3 -0
  90. data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -15
  91. data/app/components/phlex_kit/select/select.css +5 -2
  92. data/app/components/phlex_kit/select/select_controller.js +49 -4
  93. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  94. data/app/components/phlex_kit/separator/separator.rb +5 -1
  95. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  96. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  97. data/app/components/phlex_kit/sheet/sheet_content_controller.js +40 -0
  98. data/app/components/phlex_kit/sheet/sheet_controller.js +31 -1
  99. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  100. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  101. data/app/components/phlex_kit/slider/slider.css +15 -0
  102. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  103. data/app/components/phlex_kit/switch/switch.css +5 -0
  104. data/app/components/phlex_kit/switch/switch.rb +6 -0
  105. data/app/components/phlex_kit/table/table.css +2 -1
  106. data/app/components/phlex_kit/table/table.rb +5 -2
  107. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  108. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  109. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  110. data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
  111. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  112. data/app/components/phlex_kit/toast/toast.css +2 -1
  113. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  114. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  115. data/app/components/phlex_kit/toast/toast_controller.js +40 -21
  116. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  117. data/app/components/phlex_kit/toast/toast_region.rb +29 -10
  118. data/app/components/phlex_kit/toast/toaster_controller.js +17 -7
  119. data/app/components/phlex_kit/toggle_group/toggle_group.rb +17 -2
  120. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +16 -9
  121. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +20 -2
  122. data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
  123. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  124. data/app/components/phlex_kit/typography/heading.rb +7 -1
  125. data/app/components/phlex_kit/typography/list.rb +22 -0
  126. data/app/components/phlex_kit/typography/text.rb +7 -1
  127. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  128. data/lib/phlex_kit/base_component.rb +23 -0
  129. data/lib/phlex_kit/version.rb +1 -1
  130. data/lib/phlex_kit.rb +1 -0
  131. metadata +4 -1
@@ -17,14 +17,23 @@ export default class extends Controller {
17
17
  if (invoker) {
18
18
  invoker.popoverTargetElement = this.contentTarget
19
19
  } else {
20
+ // Button-less fallback: keyboard toggling and popup semantics don't
21
+ // come for free like they do with popoverTargetElement — wire Enter
22
+ // (and Space, off text-entry controls) plus aria-haspopup/-expanded
23
+ // onto the focusable child so AT users can discover and open it.
24
+ this.fallbackInvoker = this.triggerTarget.querySelector("input, a[href], select, textarea, [tabindex]") || this.triggerTarget
25
+ this.fallbackInvoker.setAttribute("aria-haspopup", "dialog")
26
+ this.fallbackInvoker.setAttribute("aria-expanded", this.contentTarget.matches(":popover-open") ? "true" : "false")
20
27
  this.triggerTarget.addEventListener("pointerdown", this.armToggle)
21
28
  this.triggerTarget.addEventListener("click", this.toggle)
29
+ this.triggerTarget.addEventListener("keydown", this.keyToggle)
22
30
  }
23
31
  }
24
32
 
25
33
  disconnect() {
26
34
  this.triggerTarget.removeEventListener("pointerdown", this.armToggle)
27
35
  this.triggerTarget.removeEventListener("click", this.toggle)
36
+ this.triggerTarget.removeEventListener("keydown", this.keyToggle)
28
37
  }
29
38
 
30
39
  armToggle = () => { this.wasOpen = this.contentTarget.matches(":popover-open") }
@@ -32,12 +41,44 @@ export default class extends Controller {
32
41
  if (this.wasOpen) { this.wasOpen = false; return }
33
42
  this.contentTarget.togglePopover()
34
43
  }
44
+ keyToggle = (e) => {
45
+ const textEntry = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)
46
+ if (e.key !== "Enter" && !(e.key === " " && !textEntry)) return
47
+ e.preventDefault()
48
+ this.contentTarget.togglePopover()
49
+ }
50
+
51
+ // Public close, for consumers that need to dismiss the panel from outside
52
+ // a trigger interaction (e.g. the date picker closing on a complete
53
+ // calendar selection). Returns focus to the trigger's focusable child —
54
+ // dismissing without it would strand focus on the day button that's
55
+ // about to disappear from the DOM.
56
+ close() {
57
+ if (this.contentTarget.matches(":popover-open")) {
58
+ this.contentTarget.hidePopover()
59
+ }
60
+ this.focusTrigger()
61
+ }
62
+
63
+ focusTrigger() {
64
+ const focusable = this.fallbackInvoker
65
+ || this.triggerTarget.querySelector("button, input, a[href], select, textarea, [tabindex]")
66
+ || this.triggerTarget
67
+ focusable.focus()
68
+ }
35
69
 
36
70
  // One stable handler added/removed symmetrically — an anonymous listener
37
71
  // here would pile up a duplicate on every target reconnect.
38
- syncState = (e) => { e.target.dataset.state = e.newState === "open" ? "open" : "closed" }
72
+ syncState = (e) => {
73
+ e.target.dataset.state = e.newState === "open" ? "open" : "closed"
74
+ this.fallbackInvoker?.setAttribute("aria-expanded", e.newState === "open" ? "true" : "false")
75
+ }
39
76
 
40
77
  contentTargetConnected(el) {
78
+ // A Turbo snapshot serializes data-state="open" but :popover-open does
79
+ // not survive the restore — resync before listening so host CSS keyed
80
+ // on the state hook doesn't misfire.
81
+ el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
41
82
  el.addEventListener("toggle", this.syncState)
42
83
  }
43
84
 
@@ -14,4 +14,12 @@
14
14
  width: 100%;
15
15
  background: var(--pk-brand);
16
16
  transition: transform .3s ease;
17
+ /* value% − 100%: slides the full-width indicator into view from the
18
+ inline-start edge. --pk-progress-value is a unitless 0–100 set inline
19
+ by progress.rb. */
20
+ transform: translateX(calc((var(--pk-progress-value, 0) - 100) * 1%));
21
+ }
22
+ /* RTL: the bar fills from the right, so travel flips. */
23
+ .pk-progress:dir(rtl) .pk-progress-indicator {
24
+ transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
17
25
  }
@@ -4,21 +4,30 @@ module PhlexKit
4
4
  # JS; width comes from a caller `class:`/`style:`. Tailwind → vanilla
5
5
  # `.pk-progress*` (progress.css).
6
6
  class Progress < BaseComponent
7
- def initialize(value: 0, **attrs)
7
+ # value_text: is a named kwarg (not read from **attrs) because `mix`
8
+ # would *fuse* a caller "aria-valuetext" with the generated default.
9
+ def initialize(value: 0, value_text: nil, **attrs)
8
10
  @value = value.to_f.clamp(0.0, 100.0)
11
+ @value_text = value_text
9
12
  @attrs = attrs
10
13
  end
11
14
 
12
15
  def view_template
16
+ # %g drops float artifacts ("40.0" would be announced as "forty point
17
+ # zero percent"). The value travels as a custom property so the fill
18
+ # transform lives in progress.css, where a :dir(rtl) arm can flip it —
19
+ # an inline translateX always filled from the physical left.
20
+ v = Kernel.format("%g", @value)
13
21
  div(**mix({
14
22
  role: "progressbar",
15
- "aria-valuenow": @value,
23
+ "aria-valuenow": v,
16
24
  "aria-valuemin": 0,
17
25
  "aria-valuemax": 100,
18
- "aria-valuetext": "#{@value}%",
19
- class: "pk-progress"
26
+ "aria-valuetext": @value_text || "#{v}%",
27
+ class: "pk-progress",
28
+ style: "--pk-progress-value: #{v};"
20
29
  }, @attrs)) do
21
- div(class: "pk-progress-indicator", style: "transform: translateX(-#{100 - @value}%)")
30
+ div(class: "pk-progress-indicator")
22
31
  end
23
32
  end
24
33
  end
@@ -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,22 +12,12 @@ 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 aria_labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
17
20
  div(**mix(base, @attrs), &)
18
21
  end
19
-
20
- private
21
-
22
- # True when the caller supplies an accessible name, via the aria: hash
23
- # (aria: { label:/labelledby: }) or flat aria_label/aria-labelledby attrs.
24
- def labelled?
25
- aria = @attrs[:aria] || @attrs["aria"]
26
- if aria.is_a?(Hash)
27
- return true if %w[label labelledby].any? { |k| aria[k.to_sym] || aria[k] }
28
- end
29
- [ :aria_label, "aria_label", "aria-label",
30
- :aria_labelledby, "aria_labelledby", "aria-labelledby" ].any? { |k| @attrs[k] }
31
- end
32
22
  end
33
23
  end
@@ -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
 
@@ -38,6 +59,28 @@ export default class extends Controller {
38
59
  this.closeContent();
39
60
  }
40
61
 
62
+ // APG select pattern: ArrowDown/ArrowUp on the CLOSED trigger opens the
63
+ // listbox and highlights the first/last option, instead of only working
64
+ // once already open (combobox_controller.js's keyDownPressed/keyUpPressed
65
+ // does the equivalent for its own open/closed model).
66
+ handleTriggerArrowDown(event) {
67
+ if (this.openValue) return
68
+ event.preventDefault()
69
+ this.#show()
70
+ if (this.itemTargets.length === 0) return
71
+ this.resetCurrent()
72
+ this.setAriaCurrentAndActiveDescendant(0)
73
+ }
74
+
75
+ handleTriggerArrowUp(event) {
76
+ if (this.openValue) return
77
+ event.preventDefault()
78
+ this.#show()
79
+ if (this.itemTargets.length === 0) return
80
+ this.resetCurrent()
81
+ this.setAriaCurrentAndActiveDescendant(this.itemTargets.length - 1)
82
+ }
83
+
41
84
  onClick() {
42
85
  this.toggleContent();
43
86
 
@@ -176,10 +219,12 @@ export default class extends Controller {
176
219
  generateItemsIds() {
177
220
  const contentId = this.contentTarget.getAttribute("id");
178
221
  this.triggerTarget.setAttribute("aria-controls", contentId);
179
-
180
- this.itemTargets.forEach((item, index) => {
181
- item.id = `${contentId}-${index}`;
182
- });
222
+ // Name the listbox from the trigger (Radix wires it the same way) —
223
+ // an unnamed role=listbox is announced as an anonymous list.
224
+ if (!this.triggerTarget.id) this.triggerTarget.id = `${contentId}-trigger`;
225
+ if (!this.contentTarget.hasAttribute("aria-labelledby")) {
226
+ this.contentTarget.setAttribute("aria-labelledby", this.triggerTarget.id);
227
+ }
183
228
  }
184
229
 
185
230
  setAriaCurrentAndActiveDescendant(currentIndex) {
@@ -16,7 +16,10 @@ module PhlexKit
16
16
  role: "combobox",
17
17
  class: [ "pk-select-trigger", fetch_option(SIZES, @size, :size) ].compact.join(" "),
18
18
  aria: { expanded: "false", haspopup: "listbox", autocomplete: "none" },
19
- data: { action: "phlex-kit--select#onClick", phlex_kit__select_target: "trigger" }
19
+ data: { action: "phlex-kit--select#onClick " \
20
+ "keydown.down->phlex-kit--select#handleTriggerArrowDown " \
21
+ "keydown.up->phlex-kit--select#handleTriggerArrowUp",
22
+ phlex_kit__select_target: "trigger" }
20
23
  }, @attrs)) do
21
24
  block&.call
22
25
  icon
@@ -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); }
@@ -9,9 +9,19 @@ module PhlexKit
9
9
  end
10
10
  def view_template(&block)
11
11
  template(data: { phlex_kit__sheet_target: "content" }) do
12
- div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown" }) do
13
- div(class: "pk-sheet-backdrop", data: { action: "click->phlex-kit--sheet-content#close" })
14
- div(**mix({ class: [ "pk-sheet-content", fetch_option(SIDES, @side, :side) ].join(" "), role: "dialog", aria: { modal: "true" }, tabindex: "-1", data: { phlex_kit__sheet_content_target: "panel" } }, @attrs)) do
12
+ # data-pk-overlay-clone is a common marker read by other clone-based
13
+ # overlays (see alert_dialog_controller.js#topmost) to determine
14
+ # z-stacking across overlay families when they nest.
15
+ div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown", pk_overlay_clone: "" }) do
16
+ div(class: "pk-sheet-backdrop", data: { action: "click->phlex-kit--sheet-content#close mousedown->phlex-kit--sheet-content#overlayMousedown" })
17
+ panel_attrs = { class: [ "pk-sheet-content", fetch_option(SIDES, @side, :side) ].join(" "), data: { phlex_kit__sheet_content_target: "panel" } }
18
+ # Defaults only when the caller didn't supply their own — `mix`
19
+ # would fuse role="dialog region" / aria-modal="true false" /
20
+ # tabindex="-1 0" instead of overriding.
21
+ panel_attrs[:role] = "dialog" unless @attrs.key?(:role) || @attrs.key?("role")
22
+ panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
+ panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
24
+ div(**mix(panel_attrs, @attrs)) do
15
25
  block&.call
16
26
  if @show_close_button
17
27
  button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--sheet-content#close" }) do
@@ -15,6 +15,7 @@ export default class extends Controller {
15
15
  static targets = ["panel"]
16
16
 
17
17
  connect() {
18
+ this.restored = false
18
19
  this.opener = document.activeElement
19
20
  this.previousOverflow = document.body.style.overflow
20
21
  document.body.style.overflow = "hidden"
@@ -26,18 +27,57 @@ export default class extends Controller {
26
27
  }
27
28
 
28
29
  disconnect() {
30
+ this.restoreImmediate()
31
+ }
32
+
33
+ // Synchronously undoes everything connect() did: background inert,
34
+ // scroll lock, and the closed-notification to the source sheet
35
+ // controller. Called from disconnect() (the normal removal path) AND
36
+ // from the source phlex-kit--sheet controller's turbo:before-cache
37
+ // handler, which must restore state before Turbo's synchronous snapshot
38
+ // — disconnect() fires via MutationObserver, too late for that snapshot.
39
+ // Idempotent (guarded by `this.restored`) so whichever path runs second
40
+ // is a harmless no-op.
41
+ restoreImmediate() {
42
+ if (this.restored) return
43
+ this.restored = true
29
44
  this.#restoreInert()
30
45
  document.body.style.overflow = this.previousOverflow
31
46
  if (this.opener?.isConnected) this.opener.focus()
47
+ // Tell the source phlex-kit--sheet controller the clone is gone so it
48
+ // can flip the trigger's aria-expanded back (removal happens by many
49
+ // paths: close button, Escape, backdrop, before-cache).
50
+ document.dispatchEvent(new CustomEvent("phlex-kit:sheet-content:closed"))
32
51
  }
33
52
 
34
53
  close() {
35
54
  this.element.remove()
36
55
  }
37
56
 
57
+ // Backdrop mousedown would move focus to <body>, killing this
58
+ // element-scoped keydown listener (Escape after a drag-off-backdrop would
59
+ // stop working) — prevented here so focus stays inside the panel. The
60
+ // backdrop's click->close action still fires normally afterward.
61
+ overlayMousedown(event) {
62
+ event.preventDefault()
63
+ }
64
+
38
65
  keydown(event) {
66
+ // A native <dialog> nested inside this panel (e.g. a Dialog opened from
67
+ // within SheetContent) owns its own Escape handling; the keydown still
68
+ // bubbles through this element-scoped listener since dialog is a DOM
69
+ // descendant — ignore it so one Escape doesn't also close the sheet
70
+ // underneath.
71
+ if (event.key === "Escape" && event.target.closest("dialog[open]")) return
39
72
  if (event.key === "Escape") {
40
73
  event.preventDefault()
74
+ // Stop the keydown from reaching a lower/outer overlay's own
75
+ // document-level Escape listener (e.g. an alert_dialog this sheet is
76
+ // stacked on top of) — this element-scoped listener runs first during
77
+ // bubbling, and by the time a document-level listener would see it,
78
+ // this.close() has already removed the [data-pk-overlay-clone] marker
79
+ // the other overlay's #topmost() check relies on.
80
+ event.stopPropagation()
41
81
  this.close()
42
82
  return
43
83
  }
@@ -11,25 +11,55 @@ export default class extends Controller {
11
11
 
12
12
  initialize() {
13
13
  this.overlay = null
14
+ // Turbo snapshots synchronously right after dispatching this event —
15
+ // BEFORE Stimulus disconnect() runs (it fires via MutationObserver, too
16
+ // late to keep the snapshot from capturing an inerted, scroll-locked
17
+ // page). Reach into the clone's own controller and restore its state
18
+ // synchronously before removing it; that method is idempotent, so the
19
+ // content controller's own disconnect() (fired later, once the removal
20
+ // is observed) is a harmless no-op.
14
21
  this.clearOverlay = () => {
15
- if (this.overlay?.isConnected) this.overlay.remove()
22
+ if (this.overlay?.isConnected) {
23
+ const content = this.application.getControllerForElementAndIdentifier(this.overlay, "phlex-kit--sheet-content")
24
+ content?.restoreImmediate()
25
+ this.overlay.remove()
26
+ }
16
27
  this.overlay = null
17
28
  }
18
29
  }
19
30
 
20
31
  connect() {
21
32
  document.addEventListener("turbo:before-cache", this.clearOverlay)
33
+ // Popup semantics for AT: the trigger's real control announces that it
34
+ // opens a dialog, and expanded state tracks the clone's lifetime (the
35
+ // content controller signals removal via phlex-kit:sheet-content:closed).
36
+ // Drawer rides this same controller (see drawer.rb) — its trigger is
37
+ // marked .pk-drawer-trigger rather than .pk-sheet-trigger.
38
+ this.invoker = this.element.querySelector(
39
+ ".pk-sheet-trigger button, .pk-sheet-trigger a, .pk-sheet-trigger [tabindex], .pk-drawer-trigger button, .pk-drawer-trigger a, .pk-drawer-trigger [tabindex]"
40
+ )
41
+ this.invoker?.setAttribute("aria-haspopup", "dialog")
42
+ this.invoker?.setAttribute("aria-expanded", "false")
43
+ this.onOverlayClosed = () => {
44
+ if (!this.overlay?.isConnected) this.invoker?.setAttribute("aria-expanded", "false")
45
+ }
46
+ document.addEventListener("phlex-kit:sheet-content:closed", this.onOverlayClosed)
22
47
  if (this.openValue) this.open()
23
48
  }
24
49
 
25
50
  disconnect() {
26
51
  document.removeEventListener("turbo:before-cache", this.clearOverlay)
52
+ document.removeEventListener("phlex-kit:sheet-content:closed", this.onOverlayClosed)
27
53
  this.clearOverlay()
28
54
  }
29
55
 
30
56
  open() {
31
57
  if (this.overlay?.isConnected) return
58
+ // open: is one-shot — the reflected value sits in the Turbo snapshot, so
59
+ // a cache-restored reconnect would re-open a dismissed sheet.
60
+ this.openValue = false
32
61
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
33
62
  this.overlay = document.body.lastElementChild
63
+ this.invoker?.setAttribute("aria-expanded", "true")
34
64
  }
35
65
  }
@@ -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 {
@@ -11,12 +11,15 @@ module PhlexKit
11
11
 
12
12
  def view_template
13
13
  # A visible loader, not decoration: role=status + label, aria-hidden off.
14
- render Icon.new(:loader, size: nil, **mix({
14
+ base = {
15
15
  class: [ "pk-spinner", fetch_option(SIZES, @size, :size) ].compact.join(" "),
16
16
  role: "status",
17
- aria: { label: "Loading" },
18
17
  "aria-hidden": false
19
- }, @attrs))
18
+ }
19
+ # Default label only when the caller didn't supply their own — `mix`
20
+ # would fuse aria-label="Loading Saving" instead of overriding.
21
+ base[:aria] = { label: "Loading" } unless aria_labelled?
22
+ render Icon.new(:loader, size: nil, **mix(base, @attrs))
20
23
  end
21
24
  end
22
25
  end
@@ -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; }
@@ -6,6 +6,12 @@ module PhlexKit
6
6
  SIZES = { md: nil, sm: "sm" }.freeze
7
7
 
8
8
  def initialize(include_hidden: true, checked_value: "1", unchecked_value: "0", size: :md, wrapper: {}, **attrs)
9
+ # value: is silently clobbered below (the checkbox's real value comes
10
+ # from checked_value:) — fail loud rather than let a caller's value:
11
+ # vanish (mirrors ToggleGroupItem's unsupported-kwargs guard).
12
+ if attrs.key?(:value)
13
+ raise ArgumentError, "Switch does not support value: — the checkbox's value comes from checked_value:, pass that instead"
14
+ end
9
15
  @include_hidden = include_hidden
10
16
  @checked_value = checked_value
11
17
  @unchecked_value = unchecked_value
@@ -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
@@ -29,8 +29,14 @@ export default class extends Controller {
29
29
  // tab sets also answer to ArrowUp/ArrowDown.
30
30
  keydown(event) {
31
31
  const vertical = this.element.classList.contains("vertical")
32
- const nextKeys = vertical ? ["ArrowRight", "ArrowDown"] : ["ArrowRight"]
33
- const prevKeys = vertical ? ["ArrowLeft", "ArrowUp"] : ["ArrowLeft"]
32
+ // In RTL the tablist mirrors: the physical LEFT arrow moves to the NEXT
33
+ // trigger. The vertical Up/Down pair is unaffected (runtime dir check is
34
+ // reliable even after a dynamic flip — carousel's idiom).
35
+ const rtl = getComputedStyle(this.element).direction === "rtl"
36
+ const fwd = rtl ? "ArrowLeft" : "ArrowRight"
37
+ const back = rtl ? "ArrowRight" : "ArrowLeft"
38
+ const nextKeys = vertical ? [fwd, "ArrowDown"] : [fwd]
39
+ const prevKeys = vertical ? [back, "ArrowUp"] : [back]
34
40
  const triggers = this.triggerTargets.filter((el) => !el.disabled)
35
41
  if (triggers.length === 0) return
36
42
  const index = triggers.indexOf(document.activeElement)