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
@@ -1,10 +1,13 @@
1
- /* Co-located with context_menu.rb. CSS-positioned at the cursor. Matches the
2
- dropdown-menu grammar (shadcn's context menu shares the same part set):
3
- gap-based rows, destructive variant, hidden-input checkbox/radio rows with
4
- :has() indicators, pure-CSS hover submenus. Tokens global. */
5
- .pk-context-menu { position: relative; }
1
+ /* Co-located with context_menu.rb. The panel is a native [popover=manual] in
2
+ the top layer, placed at the cursor in viewport coordinates and clamped by
3
+ the controller (a cursor point isn't an element, so it can't use anchor
4
+ positioning). Submenus DO anchor to their row and flip at the viewport
5
+ right edge. Matches the dropdown-menu grammar (shadcn's context menu
6
+ shares the same part set): gap-based rows, destructive variant,
7
+ hidden-input checkbox/radio rows with :has() indicators, pure-CSS hover
8
+ submenus. Tokens global. */
6
9
  .pk-context-menu-content {
7
- position: absolute; z-index: 50; min-width: 8rem; overflow: visible;
10
+ position: fixed; inset: auto; margin: 0; min-width: 8rem; overflow: visible;
8
11
  border: 1px solid var(--pk-border); border-radius: calc(var(--pk-radius) - 2px); background: var(--pk-surface);
9
12
  color: var(--pk-text); padding: .25rem; box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
10
13
  }
@@ -64,16 +67,22 @@
64
67
  box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
65
68
  }
66
69
 
67
- /* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS. */
68
- .pk-context-menu-sub { position: relative; }
70
+ /* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS.
71
+ The row is its own scoped anchor; the panel flips to the other side at
72
+ the viewport right edge. */
73
+ .pk-context-menu-sub { anchor-name: --pk-context-menu-sub; anchor-scope: --pk-context-menu-sub; }
69
74
  .pk-context-menu-sub-chevron { margin-left: auto; width: 1rem; height: 1rem; }
70
75
  .pk-context-menu-sub-content {
71
- position: absolute;
72
- top: -0.25rem;
73
- left: 100%;
74
- z-index: 60;
76
+ position: fixed;
77
+ position-anchor: --pk-context-menu-sub;
78
+ inset: auto;
79
+ position-area: inline-end span-block-end;
80
+ margin-top: -0.25rem;
81
+ position-try-fallbacks: flip-inline;
75
82
  min-width: 10rem;
76
- padding-left: .25rem; /* hover bridge over the gap */
83
+ /* hover bridge over the gap — symmetric because flip tactics flip
84
+ margins/insets but not padding, so the flipped side needs one too */
85
+ padding: 0 .25rem;
77
86
  display: none;
78
87
  }
79
88
  .pk-context-menu-sub-viewport {
@@ -89,3 +98,6 @@
89
98
  .pk-context-menu-sub:focus-within > .pk-context-menu-sub-content { display: block; }
90
99
  .pk-context-menu-sub:hover > .pk-context-menu-sub-trigger,
91
100
  .pk-context-menu-sub:focus-within > .pk-context-menu-sub-trigger { background: var(--pk-accent); }
101
+ /* Hiding must survive author display values (a host class like
102
+ display:grid would beat the UA [popover] { display: none }). */
103
+ .pk-context-menu-content:not(:popover-open) { display: none; }
@@ -2,7 +2,7 @@ module PhlexKit
2
2
  class ContextMenuContent < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template(&)
5
- div(**mix({ class: "pk-context-menu-content pk-hidden", role: "menu", tabindex: "-1", aria: { orientation: "vertical" }, data: { phlex_kit__context_menu_target: "content", state: "closed" } }, @attrs), &)
5
+ div(**mix({ class: "pk-context-menu-content", popover: "manual", role: "menu", tabindex: "-1", aria: { orientation: "vertical" }, data: { phlex_kit__context_menu_target: "content", state: "closed" } }, @attrs), &)
6
6
  end
7
7
  end
8
8
  end
@@ -1,28 +1,87 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Connects to data-controller="phlex-kit--context-menu". Opens at the cursor on
4
- // contextmenu; closes on outside click or Escape. CSS positioning (no floating-ui).
3
+ // Connects to data-controller="phlex-kit--context-menu". Opens at the cursor
4
+ // on contextmenu (at the trigger's rect for the keyboard Menu key / Shift+F10,
5
+ // which fire with clientX/Y of 0) and focuses the first item; ArrowDown/
6
+ // ArrowUp/Home/End rove over the items (skipping [data-disabled]); closes on
7
+ // outside click or Escape, returning focus to the trigger. The panel is a
8
+ // native [popover=manual] in the top layer (context_menu.css); it is placed
9
+ // in viewport coordinates after showPopover() — a hidden popover has no
10
+ // size — and clamped so it never overflows the viewport (no floating-ui).
5
11
  export default class extends Controller {
6
- static targets = ["content"]
12
+ static targets = ["content", "trigger", "menuItem"]
13
+
7
14
  connect() {
8
15
  this.onDoc = (e) => { if (!this.contentTarget.contains(e.target)) this.close() }
9
- this.onKey = (e) => { if (e.key === "Escape") this.close() }
16
+ this.onKey = (e) => this.keydown(e)
10
17
  }
11
- disconnect() { document.removeEventListener("click", this.onDoc); document.removeEventListener("keydown", this.onKey) }
18
+
19
+ disconnect() {
20
+ document.removeEventListener("click", this.onDoc)
21
+ document.removeEventListener("keydown", this.onKey)
22
+ }
23
+
12
24
  open(e) {
13
25
  e.preventDefault()
14
- const r = this.element.getBoundingClientRect()
15
- this.contentTarget.style.left = `${e.clientX - r.left}px`
16
- this.contentTarget.style.top = `${e.clientY - r.top}px`
17
- this.contentTarget.classList.remove("pk-hidden")
18
- this.contentTarget.dataset.state = "open"
26
+ let x = e.clientX
27
+ let y = e.clientY
28
+ if (x === 0 && y === 0) {
29
+ const t = this.anchor().getBoundingClientRect()
30
+ x = t.left
31
+ y = t.bottom
32
+ }
33
+ const c = this.contentTarget
34
+ if (!c.matches(":popover-open")) c.showPopover()
35
+ x = Math.max(4, Math.min(x, window.innerWidth - c.offsetWidth - 4))
36
+ y = Math.max(4, Math.min(y, window.innerHeight - c.offsetHeight - 4))
37
+ c.style.left = `${x}px`
38
+ c.style.top = `${y}px`
39
+ c.dataset.state = "open"
19
40
  document.addEventListener("click", this.onDoc)
20
41
  document.addEventListener("keydown", this.onKey)
42
+ this.items()[0]?.focus()
21
43
  }
22
- close() {
23
- this.contentTarget.classList.add("pk-hidden")
44
+
45
+ close(opts = {}) {
46
+ if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
24
47
  this.contentTarget.dataset.state = "closed"
25
48
  document.removeEventListener("click", this.onDoc)
26
49
  document.removeEventListener("keydown", this.onKey)
50
+ if (opts.refocus === true) this.anchor().focus()
51
+ }
52
+
53
+ keydown(e) {
54
+ const items = this.items()
55
+ const index = items.indexOf(document.activeElement)
56
+ switch (e.key) {
57
+ case "Escape":
58
+ e.preventDefault()
59
+ this.close({ refocus: true })
60
+ break
61
+ case "ArrowDown":
62
+ e.preventDefault()
63
+ items[(index + 1) % items.length]?.focus()
64
+ break
65
+ case "ArrowUp":
66
+ e.preventDefault()
67
+ items[index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length]?.focus()
68
+ break
69
+ case "Home":
70
+ e.preventDefault()
71
+ items[0]?.focus()
72
+ break
73
+ case "End":
74
+ e.preventDefault()
75
+ items[items.length - 1]?.focus()
76
+ break
77
+ }
78
+ }
79
+
80
+ anchor() {
81
+ return this.hasTriggerTarget ? this.triggerTarget : this.element
82
+ }
83
+
84
+ items() {
85
+ return this.menuItemTargets.filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
27
86
  }
28
87
  }
@@ -17,9 +17,13 @@ module PhlexKit
17
17
  end
18
18
 
19
19
  def view_template(&block)
20
- classes = [ "pk-context-menu-item", VARIANTS.fetch(@variant) ].compact.join(" ")
21
- a(**mix({ href: @href, role: "menuitem", tabindex: "-1", class: classes,
22
- data: { action: "click->phlex-kit--context-menu#close", phlex_kit__context_menu_target: "menuItem", disabled: @disabled } }, @attrs)) do
20
+ classes = [ "pk-context-menu-item", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
21
+ base = { href: @href, role: "menuitem", tabindex: "-1", class: classes,
22
+ data: { action: ("click->phlex-kit--context-menu#close" unless @disabled),
23
+ phlex_kit__context_menu_target: "menuItem",
24
+ disabled: (@disabled ? "true" : nil) }.compact }
25
+ base[:aria] = { disabled: "true" } if @disabled
26
+ a(**mix(base, @attrs)) do
23
27
  if @checked
24
28
  span(class: "pk-context-menu-check") do
25
29
  render Icon.new(:check, size: nil)
@@ -5,7 +5,7 @@ module PhlexKit
5
5
  @attrs = attrs
6
6
  end
7
7
  def view_template(&)
8
- div(**mix({ class: ["pk-context-menu-label", (@inset ? "inset" : nil)].compact.join(" ") }, @attrs), &)
8
+ div(**mix({ class: [ "pk-context-menu-label", (@inset ? "inset" : nil) ].compact.join(" ") }, @attrs), &)
9
9
  end
10
10
  end
11
11
  end
@@ -2,7 +2,9 @@ module PhlexKit
2
2
  class ContextMenuTrigger < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template(&)
5
- div(**mix({ class: "pk-context-menu-trigger", data: { phlex_kit__context_menu_target: "trigger", action: "contextmenu->phlex-kit--context-menu#open" } }, @attrs), &)
5
+ # tabindex 0 so the keyboard context-menu key (Menu / Shift+F10) can
6
+ # reach it, and so Escape has somewhere to return focus to.
7
+ div(**mix({ class: "pk-context-menu-trigger", tabindex: "0", data: { phlex_kit__context_menu_target: "trigger", action: "contextmenu->phlex-kit--context-menu#open" } }, @attrs), &)
6
8
  end
7
9
  end
8
10
  end
@@ -12,7 +12,7 @@ export default class extends Controller {
12
12
  const root = this.element.closest('[data-controller~="phlex-kit--data-table"]');
13
13
  if (!root) return;
14
14
  root
15
- .querySelectorAll(`[data-column="${key}"]`)
15
+ .querySelectorAll(`[data-column="${CSS.escape(key)}"]`)
16
16
  .forEach((el) => el.classList.toggle("pk-hidden", !visible));
17
17
  }
18
18
  }
@@ -10,7 +10,6 @@ export default class extends Controller {
10
10
  "selectAll",
11
11
  "rowCheckbox",
12
12
  "selectionSummary",
13
- "selectionBar",
14
13
  "bulkActions",
15
14
  ];
16
15
 
@@ -45,6 +44,18 @@ export default class extends Controller {
45
44
  const total = this.rowCheckboxTargets.length;
46
45
  const selected = this.rowCheckboxTargets.filter((cb) => cb.checked).length;
47
46
 
47
+ // table.css styles .pk-table-row[data-state="selected"] — set it so
48
+ // selected rows actually get the highlight.
49
+ this.rowCheckboxTargets.forEach((cb) => {
50
+ const row = cb.closest(".pk-table-row");
51
+ if (!row) return;
52
+ if (cb.checked) {
53
+ row.setAttribute("data-state", "selected");
54
+ } else {
55
+ row.removeAttribute("data-state");
56
+ }
57
+ });
58
+
48
59
  if (this.hasSelectAllTarget) {
49
60
  this.selectAllTarget.checked = total > 0 && selected === total;
50
61
  this.selectAllTarget.indeterminate = selected > 0 && selected < total;
@@ -56,22 +56,24 @@ module PhlexKit
56
56
  end
57
57
 
58
58
  def prev_item
59
- if current <= 1
60
- li do
61
- span(class: "pk-data-table-page-disabled") { @prev_label }
62
- end
63
- else
64
- render PaginationItem.new(href: page_href(current - 1)) { @prev_label }
65
- end
59
+ edge_item(disabled: current <= 1, href: page_href(current - 1),
60
+ label: @prev_label, name: "Go to previous page")
66
61
  end
67
62
 
68
63
  def next_item
69
- if current >= total
64
+ edge_item(disabled: current >= total, href: page_href(current + 1),
65
+ label: @next_label, name: "Go to next page")
66
+ end
67
+
68
+ # The default labels are bare glyphs ("<"/">") — give AT a real name, and
69
+ # announce the disabled edge instead of rendering an anonymous span.
70
+ def edge_item(disabled:, href:, label:, name:)
71
+ if disabled
70
72
  li do
71
- span(class: "pk-data-table-page-disabled") { @next_label }
73
+ span(class: "pk-data-table-page-disabled", aria: { disabled: "true", label: name }) { label }
72
74
  end
73
75
  else
74
- render PaginationItem.new(href: page_href(current + 1)) { @next_label }
76
+ render PaginationItem.new(href: href, aria: { label: name }) { label }
75
77
  end
76
78
  end
77
79
 
@@ -10,6 +10,7 @@ module PhlexKit
10
10
  def view_template
11
11
  div(**mix({
12
12
  class: "pk-data-table-selection-summary",
13
+ role: "status", # live region — selection changes are announced to AT
13
14
  data: { phlex_kit__data_table_target: "selectionSummary" }
14
15
  }, @attrs)) do
15
16
  plain "0 of #{@total_on_page} row(s) selected."
@@ -15,8 +15,12 @@ module PhlexKit
15
15
  @attrs = attrs
16
16
  end
17
17
 
18
+ ARIA_SORT = { "asc" => "ascending", "desc" => "descending" }.freeze
19
+
18
20
  def view_template
19
- render TableHead.new(**mix({ class: "pk-data-table-sort-head" }, @attrs)) do
21
+ defaults = { class: "pk-data-table-sort-head" }
22
+ defaults[:aria] = { sort: ARIA_SORT.fetch(current_direction) } if current_direction
23
+ render TableHead.new(**mix(defaults, @attrs)) do
20
24
  a(href: sort_href, class: "pk-data-table-sort-link") do
21
25
  plain @label
22
26
  sort_icon
@@ -1,19 +1,27 @@
1
1
  module PhlexKit
2
2
  # The <dialog> box. `size:` picks the width tier (md = shadcn's default
3
3
  # max-w-sm); `show_close_button: false` drops the top-right ✕ (their
4
- # showCloseButton). See dialog.rb.
4
+ # showCloseButton). `labelledby:`/`describedby:` render aria-labelledby /
5
+ # aria-describedby for callers who assign their own ids to DialogTitle /
6
+ # DialogDescription; when absent, the phlex-kit--dialog controller auto-wires
7
+ # them in connect(). See dialog.rb.
5
8
  class DialogContent < BaseComponent
6
9
  SIZES = { xs: "xs", sm: "sm", md: nil, lg: "lg", xl: "xl", full: "full" }.freeze
7
10
 
8
- def initialize(size: :md, show_close_button: true, **attrs)
11
+ def initialize(size: :md, show_close_button: true, labelledby: nil, describedby: nil, **attrs)
9
12
  @size = size.to_sym
10
13
  @show_close_button = show_close_button
14
+ @labelledby = labelledby
15
+ @describedby = describedby
11
16
  @attrs = attrs
12
17
  end
13
18
 
14
19
  def view_template(&block)
15
- cls = [ "pk-dialog", SIZES.fetch(@size) ].compact.join(" ")
16
- dialog(**mix({ class: cls, data: { phlex_kit__dialog_target: "dialog", action: "click->phlex-kit--dialog#backdropClick" } }, @attrs)) do
20
+ cls = [ "pk-dialog", fetch_option(SIZES, @size, :size) ].compact.join(" ")
21
+ aria = {}
22
+ aria[:labelledby] = @labelledby if @labelledby
23
+ aria[:describedby] = @describedby if @describedby
24
+ dialog(**mix({ class: cls, aria: aria, data: { phlex_kit__dialog_target: "dialog", action: "click->phlex-kit--dialog#backdropClick" } }, @attrs)) do
17
25
  yield
18
26
  if @show_close_button
19
27
  button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--dialog#dismiss" }) do
@@ -1,14 +1,27 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--dialog". Ported from ruby_ui.
4
+ // connect() auto-wires the dialog's accessible name: when the caller didn't
5
+ // pass labelledby:/describedby: to DialogContent, the title/description parts
6
+ // get generated ids and the <dialog> points at them.
4
7
  export default class extends Controller {
5
8
  static targets = ["dialog"]
6
9
  static values = { open: { type: Boolean, default: false } }
7
10
 
8
11
  connect() {
9
12
  this.dialogTarget.addEventListener("close", this.handleClose)
13
+ this.#wireAria("aria-labelledby", ".pk-dialog-title")
14
+ this.#wireAria("aria-describedby", ".pk-dialog-description")
10
15
  if (this.openValue) this.open()
11
16
  }
17
+
18
+ #wireAria(attr, selector) {
19
+ if (this.dialogTarget.getAttribute(attr)) return
20
+ const el = this.dialogTarget.querySelector(selector)
21
+ if (!el) return
22
+ if (!el.id) el.id = `pk-dialog-${attr.slice(5)}-${Math.random().toString(36).slice(2, 10)}`
23
+ this.dialogTarget.setAttribute(attr, el.id)
24
+ }
12
25
  disconnect() {
13
26
  this.dialogTarget.removeEventListener("close", this.handleClose)
14
27
  document.body.style.removeProperty("overflow")
@@ -12,9 +12,9 @@ module PhlexKit
12
12
 
13
13
  def view_template(&block)
14
14
  template(data: { phlex_kit__sheet_target: "content" }) do
15
- div(data: { controller: "phlex-kit--sheet-content" }) do
15
+ div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown" }) do
16
16
  div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close" })
17
- div(**mix({ class: "pk-drawer #{SIDES.fetch(@side)}", role: "dialog", aria: { modal: "true" } }, @attrs)) do
17
+ div(**mix({ class: "pk-drawer #{fetch_option(SIDES, @side, :side)}", role: "dialog", aria: { modal: "true" }, tabindex: "-1", data: { phlex_kit__sheet_content_target: "panel" } }, @attrs)) do
18
18
  div(class: "pk-drawer-handle", aria: { hidden: "true" })
19
19
  yield if block
20
20
  end
@@ -1,13 +1,25 @@
1
1
  /* Co-located with dropdown_menu.rb — UI::DropdownMenu (+ Trigger / Content / Item
2
- / Label / Separator). ruby_ui's structure + Stimulus kept; @floating-ui dropped
3
- in favour of CSS positioning (panel below the trigger, or above with
4
- `side: :top`). Tailwind vanilla. Theme tokens come from the global stylesheet. */
5
- .pk-dropdown-menu { position: relative; display: inline-block; }
6
- .pk-dropdown-menu-trigger { display: inline-block; }
2
+ / Label / Separator). ruby_ui's structure + Stimulus kept; @floating-ui dropped.
3
+ The panel is a native [popover=manual] in the top layer, anchor-positioned to
4
+ the trigger (Baseline 2026); position-try-fallbacks flips it above at the
5
+ viewport bottom (`side: :top` starts above and flips down). Submenus anchor
6
+ to their row the same way and flip at the right edge. Tailwind vanilla.
7
+ Theme tokens come from the global stylesheet. */
8
+ .pk-dropdown-menu { display: inline-block; anchor-scope: --pk-dropdown-menu; }
9
+ .pk-dropdown-menu-trigger { display: inline-block; anchor-name: --pk-dropdown-menu; }
7
10
 
8
- .pk-dropdown-menu-content { position: absolute; top: 100%; left: 0; z-index: 50; margin-top: 4px; }
9
- .pk-dropdown-menu-content-up { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 4px; }
10
- .pk-dropdown-menu-content.hidden { display: none; }
11
+ .pk-dropdown-menu-content {
12
+ position: fixed;
13
+ position-anchor: --pk-dropdown-menu;
14
+ inset: auto;
15
+ /* the bordered box is the inner viewport — strip the [popover] UA chrome
16
+ (border: solid, padding, Canvas background) from this wrapper */
17
+ border: 0; padding: 0; background: transparent; color: inherit; overflow: visible;
18
+ position-area: block-end span-inline-end;
19
+ margin: 4px 0 0;
20
+ position-try-fallbacks: flip-block;
21
+ }
22
+ .pk-dropdown-menu-content-up { position-area: block-start span-inline-end; margin: 0 0 4px; }
11
23
  .pk-dropdown-menu-viewport {
12
24
  min-width: 10rem;
13
25
  border-radius: calc(var(--pk-radius) - 2px);
@@ -85,19 +97,28 @@
85
97
  box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
86
98
  }
87
99
 
88
- /* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS. */
89
- .pk-dropdown-menu-sub { position: relative; }
100
+ /* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS.
101
+ The row is its own scoped anchor; the panel flips to the other side at
102
+ the viewport right edge. */
103
+ .pk-dropdown-menu-sub { anchor-name: --pk-dropdown-menu-sub; anchor-scope: --pk-dropdown-menu-sub; }
90
104
  .pk-dropdown-menu-sub-chevron { margin-left: auto; width: 1rem; height: 1rem; }
91
105
  .pk-dropdown-menu-sub-content {
92
- position: absolute;
93
- top: -0.25rem;
94
- left: 100%;
95
- z-index: 60;
106
+ position: fixed;
107
+ position-anchor: --pk-dropdown-menu-sub;
108
+ inset: auto;
109
+ position-area: inline-end span-block-end;
110
+ margin-top: -0.25rem;
111
+ position-try-fallbacks: flip-inline;
96
112
  min-width: 10rem;
97
- padding-left: .25rem; /* hover bridge over the gap */
113
+ /* hover bridge over the gap — symmetric because flip tactics flip
114
+ margins/insets but not padding, so the flipped side needs one too */
115
+ padding: 0 .25rem;
98
116
  display: none;
99
117
  }
100
118
  .pk-dropdown-menu-sub:hover > .pk-dropdown-menu-sub-content,
101
119
  .pk-dropdown-menu-sub:focus-within > .pk-dropdown-menu-sub-content { display: block; }
102
120
  .pk-dropdown-menu-sub:hover > .pk-dropdown-menu-sub-trigger,
103
121
  .pk-dropdown-menu-sub:focus-within > .pk-dropdown-menu-sub-trigger { background: var(--pk-accent); }
122
+ /* Hiding must survive author display values (a host class like
123
+ display:grid would beat the UA [popover] { display: none }). */
124
+ .pk-dropdown-menu-content:not(:popover-open) { display: none; }
@@ -1,5 +1,6 @@
1
1
  module PhlexKit
2
- # The dropdown panel. Hidden until the trigger toggles it. `side:` is :bottom
2
+ # The dropdown panel a native [popover=manual] the trigger toggles,
3
+ # anchor-positioned with viewport-edge flipping. `side:` is :bottom
3
4
  # (default, opens below) or :top (opens above — for triggers near the bottom of
4
5
  # the viewport, e.g. the sidebar footer). See dropdown_menu.rb.
5
6
  class DropdownMenuContent < BaseComponent
@@ -9,7 +10,7 @@ module PhlexKit
9
10
  end
10
11
 
11
12
  def view_template(&block)
12
- div(class: wrapper_classes, data: { phlex_kit__dropdown_menu_target: "content" }) do
13
+ div(class: wrapper_classes, popover: "manual", data: { phlex_kit__dropdown_menu_target: "content" }) do
13
14
  div(**mix({ class: "pk-dropdown-menu-viewport" }, @attrs), &block)
14
15
  end
15
16
  end
@@ -17,7 +18,7 @@ module PhlexKit
17
18
  private
18
19
 
19
20
  def wrapper_classes
20
- [ "pk-dropdown-menu-content", ("pk-dropdown-menu-content-up" if @side == :top), "hidden" ].compact.join(" ")
21
+ [ "pk-dropdown-menu-content", ("pk-dropdown-menu-content-up" if @side == :top) ].compact.join(" ")
21
22
  end
22
23
  end
23
24
  end
@@ -1,10 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- // Ported from ruby_ui's phlex-kit--dropdown-menu controller. The ONE change from
4
- // upstream: the @floating-ui/dom dependency is removed the panel is positioned
5
- // with plain CSS (.ui-dropdown-menu-content { position:absolute }), so this app
6
- // needs no npm/build step. Toggle, click-outside, and arrow/enter keyboard nav
7
- // are unchanged.
3
+ // Ported from ruby_ui's phlex-kit--dropdown-menu 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
6
+ // dropdown_menu.css (manual so this controller keeps owning click-outside,
7
+ // Escape, and arrow/enter keyboard nav — all unchanged from upstream).
8
8
  export default class extends Controller {
9
9
  static targets = ["trigger", "content", "menuItem"];
10
10
  static values = { open: { type: Boolean, default: false } };
@@ -27,23 +27,20 @@ export default class extends Controller {
27
27
  }
28
28
 
29
29
  toggle() {
30
- this.contentTarget.classList.contains("hidden") ? this.#open() : this.close();
30
+ this.contentTarget.matches(":popover-open") ? this.close() : this.#open();
31
31
  }
32
32
 
33
33
  #open() {
34
34
  this.openValue = true;
35
35
  this.#deselectAll();
36
36
  this.#addEventListeners();
37
- // Lift the open menu above siblings (the container has no static z-index).
38
- this.element.style.zIndex = "50";
39
- this.contentTarget.classList.remove("hidden");
37
+ this.contentTarget.showPopover();
40
38
  }
41
39
 
42
40
  close() {
43
41
  this.openValue = false;
44
42
  this.#removeEventListeners();
45
- this.element.style.zIndex = "";
46
- this.contentTarget.classList.add("hidden");
43
+ if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover();
47
44
  }
48
45
 
49
46
  #handleKeydown(e) {
@@ -15,7 +15,7 @@ module PhlexKit
15
15
  base = {
16
16
  role: "menuitem",
17
17
  tabindex: "-1",
18
- class: [ "pk-dropdown-menu-item", VARIANTS.fetch(@variant) ].compact.join(" "),
18
+ class: [ "pk-dropdown-menu-item", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" "),
19
19
  data: { phlex_kit__dropdown_menu_target: "menuItem", action: "click->phlex-kit--dropdown-menu#close" }
20
20
  }
21
21
  base[:href] = @href unless @as == :div
@@ -8,7 +8,7 @@ module PhlexKit
8
8
  @attrs = attrs
9
9
  end
10
10
  def view_template(&)
11
- div(**mix({ class: ["pk-empty-media", VARIANTS.fetch(@variant)].compact.join(" ") }, @attrs), &)
11
+ div(**mix({ class: [ "pk-empty-media", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ") }, @attrs), &)
12
12
  end
13
13
  end
14
14
  end
@@ -26,7 +26,7 @@ module PhlexKit
26
26
  end
27
27
 
28
28
  def view_template(&)
29
- data = { slot: "field", orientation: ORIENTATIONS.fetch(@orientation) }
29
+ data = { slot: "field", orientation: fetch_option(ORIENTATIONS, @orientation, :orientation) }
30
30
  data[:invalid] = "true" if @invalid
31
31
  data[:disabled] = "true" if @disabled
32
32
  div(**mix({ class: "pk-field", role: "group", data: data }, @attrs), &)
@@ -11,7 +11,7 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&)
14
- legend(**mix({ class: "pk-field-legend #{VARIANTS.fetch(@variant)}" }, @attrs), &)
14
+ legend(**mix({ class: "pk-field-legend #{fetch_option(VARIANTS, @variant, :variant)}" }, @attrs), &)
15
15
  end
16
16
  end
17
17
  end
@@ -1,16 +1,35 @@
1
1
  /* Co-located with hover_card.rb. Matched to shadcn/ui's current hover-card
2
2
  (nova): 16rem popover panel, rounded-lg, p-2.5, text-sm, foreground-tint
3
- ring. side: top/left/right flip the CSS position (bottom default).
4
- Tokens from the global stylesheet. */
5
- .pk-hover-card { position: relative; display: inline-block; }
6
- .pk-hover-card-trigger { display: inline-block; }
3
+ ring. Tokens from the global stylesheet.
4
+ The panel is a native [popover=manual] in the top layer, anchor-positioned
5
+ to the trigger (Baseline 2026); position-try-fallbacks flips it to the
6
+ opposite side at the viewport edge. side: top/left/right pick the
7
+ position-area (bottom default). anchor-scope keeps sibling hover cards
8
+ from resolving to each other's anchors. */
9
+ .pk-hover-card { display: inline-block; anchor-scope: --pk-hover-card; }
10
+ .pk-hover-card-trigger { display: inline-block; anchor-name: --pk-hover-card; }
7
11
  .pk-hover-card-content {
8
- position: absolute; z-index: 50; top: calc(100% + .25rem); left: 0; width: 16rem;
12
+ position: fixed;
13
+ position-anchor: --pk-hover-card;
14
+ inset: auto;
15
+ position-area: block-end span-inline-end;
16
+ margin: .25rem 0 0;
17
+ position-try-fallbacks: flip-block;
18
+ width: 16rem;
9
19
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
10
20
  border-radius: var(--pk-radius); background: var(--pk-surface);
11
21
  color: var(--pk-text); padding: .625rem; font-size: .875rem;
12
22
  box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
13
23
  }
14
- .pk-hover-card-content.top { top: auto; bottom: calc(100% + .25rem); }
15
- .pk-hover-card-content.left { top: 50%; left: auto; right: calc(100% + .25rem); transform: translateY(-50%); }
16
- .pk-hover-card-content.right { top: 50%; left: calc(100% + .25rem); transform: translateY(-50%); }
24
+ .pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
25
+ .pk-hover-card-content.left {
26
+ position-area: inline-start; margin: 0 .25rem 0 0;
27
+ position-try-fallbacks: flip-inline;
28
+ }
29
+ .pk-hover-card-content.right {
30
+ position-area: inline-end; margin: 0 0 0 .25rem;
31
+ position-try-fallbacks: flip-inline;
32
+ }
33
+ /* Hiding must survive author display values (a host class like
34
+ display:grid would beat the UA [popover] { display: none }). */
35
+ .pk-hover-card-content:not(:popover-open) { display: none; }
@@ -1,5 +1,6 @@
1
1
  module PhlexKit
2
- # Hover/focus-triggered floating card, positioned with CSS. Ported from ruby_ui's
2
+ # Hover/focus-triggered floating card native [popover] + CSS anchor
3
+ # positioning with viewport-edge flipping. Ported from ruby_ui's
3
4
  # RubyUI::HoverCard. Compose HoverCard > (HoverCardTrigger + HoverCardContent).
4
5
  class HoverCard < BaseComponent
5
6
  def initialize(**attrs) = (@attrs = attrs)
@@ -10,8 +10,8 @@ module PhlexKit
10
10
  end
11
11
 
12
12
  def view_template(&)
13
- classes = [ "pk-hover-card-content", "pk-hidden", SIDES.fetch(@side) ].compact.join(" ")
14
- div(**mix({ class: classes, data: { phlex_kit__hover_card_target: "content", state: "closed" } }, @attrs), &)
13
+ classes = [ "pk-hover-card-content", fetch_option(SIDES, @side, :side) ].compact.join(" ")
14
+ div(**mix({ class: classes, popover: "manual", data: { phlex_kit__hover_card_target: "content", state: "closed" } }, @attrs), &)
15
15
  end
16
16
  end
17
17
  end