phlex_kit 0.8.0 → 0.9.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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +11 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -2
  4. data/app/components/phlex_kit/accordion/accordion_content.rb +11 -2
  5. data/app/components/phlex_kit/accordion/accordion_controller.js +23 -4
  6. data/app/components/phlex_kit/alert/alert.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +11 -4
  9. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +123 -6
  10. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +10 -4
  11. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  12. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  13. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  14. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  15. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  16. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  17. data/app/components/phlex_kit/badge/badge.css +4 -4
  18. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  19. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  20. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  21. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  22. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  23. data/app/components/phlex_kit/button/button.css +6 -6
  24. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  25. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  26. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  27. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  28. data/app/components/phlex_kit/calendar/calendar_body.rb +12 -1
  29. data/app/components/phlex_kit/calendar/calendar_controller.js +151 -12
  30. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  31. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  32. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  34. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  35. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  36. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  37. data/app/components/phlex_kit/chart/chart_controller.js +37 -10
  38. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  39. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  40. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  41. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
  42. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  43. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  44. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  45. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  46. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  47. data/app/components/phlex_kit/combobox/combobox_controller.js +67 -11
  48. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  49. data/app/components/phlex_kit/command/command.css +4 -4
  50. data/app/components/phlex_kit/command/command_controller.js +51 -10
  51. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  52. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  53. data/app/components/phlex_kit/command/command_input.rb +6 -2
  54. data/app/components/phlex_kit/command/command_item.rb +4 -1
  55. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  56. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
  57. data/app/components/phlex_kit/context_menu/context_menu_controller.js +69 -2
  58. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
  59. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
  60. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  61. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  62. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  63. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  64. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  65. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  66. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  67. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  68. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  69. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +6 -5
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  72. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
  73. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
  74. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +99 -45
  75. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  76. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
  77. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
  78. data/app/components/phlex_kit/field/field.css +1 -1
  79. data/app/components/phlex_kit/field/field_error.rb +5 -6
  80. data/app/components/phlex_kit/form_field/form_field_controller.js +44 -12
  81. data/app/components/phlex_kit/form_field/form_field_error.rb +8 -1
  82. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  83. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  84. data/app/components/phlex_kit/input/input.css +1 -1
  85. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  86. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  87. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  88. data/app/components/phlex_kit/input_otp/input_otp_controller.js +43 -4
  89. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  90. data/app/components/phlex_kit/item/item.css +1 -1
  91. data/app/components/phlex_kit/item/item_group.rb +4 -2
  92. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  93. data/app/components/phlex_kit/marker/marker.css +1 -1
  94. data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
  95. data/app/components/phlex_kit/masked_input/masked_input_controller.js +34 -9
  96. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  97. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  98. data/app/components/phlex_kit/menubar/menubar_controller.js +119 -12
  99. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  100. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  101. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +7 -2
  102. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  103. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  104. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  105. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +11 -3
  106. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  107. data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
  108. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  109. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  110. data/app/components/phlex_kit/popover/popover.css +1 -1
  111. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  112. data/app/components/phlex_kit/resizable/resizable_controller.js +78 -3
  113. data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
  114. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  115. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  116. data/app/components/phlex_kit/select/select.css +3 -3
  117. data/app/components/phlex_kit/select/select.rb +11 -7
  118. data/app/components/phlex_kit/select/select_controller.js +35 -4
  119. data/app/components/phlex_kit/select/select_item.rb +5 -2
  120. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  121. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  122. data/app/components/phlex_kit/sidebar/sidebar.css +27 -15
  123. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  124. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  125. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  126. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  127. data/app/components/phlex_kit/slider/slider.css +3 -3
  128. data/app/components/phlex_kit/slider/slider.rb +3 -1
  129. data/app/components/phlex_kit/switch/switch.rb +6 -1
  130. data/app/components/phlex_kit/table/table.css +5 -3
  131. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  132. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  133. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  134. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
  135. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +49 -7
  136. data/app/components/phlex_kit/toast/toast.css +32 -14
  137. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  138. data/app/components/phlex_kit/toast/toast_region.rb +19 -5
  139. data/app/components/phlex_kit/toast/toaster_controller.js +20 -6
  140. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  141. data/app/components/phlex_kit/toggle/toggle.rb +9 -3
  142. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  143. data/app/components/phlex_kit/typography/heading.rb +9 -3
  144. data/app/components/phlex_kit/typography/typography.css +4 -3
  145. data/app/javascript/phlex_kit/controllers/index.js +0 -2
  146. data/lib/phlex_kit/version.rb +1 -1
  147. metadata +1 -2
  148. data/app/components/phlex_kit/select/select_item_controller.js +0 -14
@@ -5,7 +5,10 @@ module PhlexKit
5
5
  # Trigger + Content (+ Item / Label / Separator). Tailwind → vanilla
6
6
  # `.pk-dropdown-menu*` (dropdown_menu.css).
7
7
  class DropdownMenu < BaseComponent
8
- def initialize(**attrs)
8
+ # `open: true` renders the menu open on connect (matching Collapsible's
9
+ # kwarg -> Stimulus value -> connect flow).
10
+ def initialize(open: false, **attrs)
11
+ @open = open
9
12
  @attrs = attrs
10
13
  end
11
14
 
@@ -14,6 +17,7 @@ module PhlexKit
14
17
  class: "pk-dropdown-menu",
15
18
  data: {
16
19
  controller: "phlex-kit--dropdown-menu",
20
+ phlex_kit__dropdown_menu_open_value: @open,
17
21
  action: "click@window->phlex-kit--dropdown-menu#onClickOutside"
18
22
  }
19
23
  }, @attrs), &block)
@@ -14,9 +14,13 @@ module PhlexKit
14
14
  def view_template(&block)
15
15
  label(**mix({
16
16
  class: "pk-dropdown-menu-item pk-dropdown-menu-checkbox-item",
17
- role: "menuitemcheckbox"
17
+ role: "menuitemcheckbox",
18
+ tabindex: "-1",
19
+ aria: { checked: @checked ? "true" : "false" },
20
+ data: { phlex_kit__dropdown_menu_target: "menuItem" }
18
21
  }, @attrs)) do
19
- input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked)
22
+ input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
23
+ tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
20
24
  span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) { check_icon }
21
25
  yield if block
22
26
  end
@@ -14,7 +14,9 @@ module PhlexKit
14
14
 
15
15
  def view_template(&block)
16
16
  div(class: wrapper_classes, popover: "manual", data: { phlex_kit__dropdown_menu_target: "content" }) do
17
- div(**mix({ class: "pk-dropdown-menu-viewport" }, @attrs), &block)
17
+ # role="menu" on the items' direct parent — the rows render
18
+ # role="menuitem", which is invalid ARIA without a menu ancestor.
19
+ div(**mix({ class: "pk-dropdown-menu-viewport", role: "menu" }, @attrs), &block)
18
20
  end
19
21
  end
20
22
 
@@ -4,14 +4,26 @@ import { Controller } from "@hotwired/stimulus";
4
4
  // @floating-ui/dom dependency: the panel is a native [popover=manual] in the
5
5
  // top layer, anchor-positioned with viewport-edge flipping by
6
6
  // dropdown_menu.css (manual so this controller keeps owning click-outside,
7
- // Escape, and arrow/enter keyboard nav all unchanged from upstream).
7
+ // Escape, and keyboard nav). Keyboard nav moves REAL focus over the rows
8
+ // (upstream stamped aria-selected on menuitems, which AT ignores): :focus
9
+ // styles the highlight and :focus-within opens/closes submenus, so hidden
10
+ // sub-rows are skipped via a visibility filter. Escape returns focus to the
11
+ // trigger; Enter/Space activate the focused row (links, checkbox/radio
12
+ // labels, and sub triggers alike).
8
13
  export default class extends Controller {
9
14
  static targets = ["trigger", "content", "menuItem"];
10
15
  static values = { open: { type: Boolean, default: false } };
11
16
 
12
17
  connect() {
13
18
  this.boundHandleKeydown = this.#handleKeydown.bind(this);
14
- this.selectedIndex = -1;
19
+ // The visible control is the caller's button/link inside the trigger
20
+ // wrapper — that's what AT reads, so the popup wiring belongs on it.
21
+ this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
22
+ this.invoker.setAttribute("aria-haspopup", "menu");
23
+ this.invoker.setAttribute("aria-expanded", "false");
24
+ // `open:` on the Ruby side renders the value — start open (same
25
+ // kwarg -> value -> connect flow as Collapsible).
26
+ if (this.openValue) this.#open();
15
27
  }
16
28
 
17
29
  disconnect() {
@@ -24,6 +36,9 @@ export default class extends Controller {
24
36
  if (!this.contentTarget.matches(":popover-open")) return;
25
37
  if (this.element.contains(event.target)) return;
26
38
 
39
+ // Deliberate (matches Radix's modal dismiss): the outside click ONLY
40
+ // dismisses the menu — it is swallowed rather than also acting on
41
+ // whatever was under the pointer (e.g. navigating a link).
27
42
  event.preventDefault();
28
43
  this.close();
29
44
  }
@@ -34,65 +49,104 @@ export default class extends Controller {
34
49
 
35
50
  #open() {
36
51
  this.openValue = true;
37
- this.#deselectAll();
38
52
  this.#addEventListeners();
39
53
  this.contentTarget.showPopover();
54
+ this.invoker.setAttribute("aria-expanded", "true");
40
55
  }
41
56
 
42
- close() {
57
+ close(event) {
58
+ // Menu rows default to href="#"; without this an Enter/click on a
59
+ // non-link row scrolls to top and appends # to the URL.
60
+ if (event?.target?.closest?.('a[href="#"]')) event.preventDefault();
43
61
  this.openValue = false;
44
62
  this.#removeEventListeners();
45
63
  if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover();
64
+ this.invoker.setAttribute("aria-expanded", "false");
46
65
  }
47
66
 
48
- #handleKeydown(e) {
49
- if (this.menuItemTargets.length === 0) return;
50
-
51
- if (e.key === "ArrowDown") {
52
- e.preventDefault();
53
- this.#updateSelectedItem(1);
54
- } else if (e.key === "ArrowUp") {
55
- e.preventDefault();
56
- this.#updateSelectedItem(-1);
57
- } else if (e.key === "Enter" && this.selectedIndex !== -1) {
58
- e.preventDefault();
59
- this.menuItemTargets[this.selectedIndex].click();
60
- } else if (e.key === "Escape") {
61
- this.close();
62
- }
67
+ // Mirrors a checkbox/radio row's native input state onto the row's
68
+ // aria-checked (radios also reset their group's siblings).
69
+ syncChecked(event) {
70
+ const input = event.target;
71
+ const group = input.name
72
+ ? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
73
+ : [input];
74
+ group.forEach((i) => i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked));
63
75
  }
64
76
 
65
- #updateSelectedItem(direction) {
66
- this.menuItemTargets.forEach((item, index) => {
67
- if (item.getAttribute("aria-selected") === "true") this.selectedIndex = index;
68
- });
69
-
70
- if (this.selectedIndex >= 0) {
71
- this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);
72
- }
73
-
74
- this.selectedIndex += direction;
75
-
76
- if (this.selectedIndex < 0) {
77
- this.selectedIndex = this.menuItemTargets.length - 1;
78
- } else if (this.selectedIndex >= this.menuItemTargets.length) {
79
- this.selectedIndex = 0;
77
+ #handleKeydown(e) {
78
+ const items = this.#items();
79
+ if (items.length === 0) return;
80
+ const index = items.indexOf(document.activeElement);
81
+
82
+ switch (e.key) {
83
+ case "ArrowDown":
84
+ e.preventDefault();
85
+ items[(index + 1) % items.length]?.focus();
86
+ break;
87
+ case "ArrowUp":
88
+ e.preventDefault();
89
+ items[index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length]?.focus();
90
+ break;
91
+ case "Home":
92
+ e.preventDefault();
93
+ items[0]?.focus();
94
+ break;
95
+ case "End":
96
+ e.preventDefault();
97
+ items[items.length - 1]?.focus();
98
+ break;
99
+ case "Enter":
100
+ case " ":
101
+ // Explicit click so label rows (checkbox/radio) activate too —
102
+ // labels have no native keyboard activation.
103
+ if (index >= 0) {
104
+ e.preventDefault();
105
+ items[index].click();
106
+ }
107
+ break;
108
+ case "Escape":
109
+ e.preventDefault();
110
+ this.close();
111
+ this.invoker.focus();
112
+ break;
113
+ case "ArrowRight":
114
+ // On a sub trigger, enter the submenu (focus reveals it via
115
+ // :focus-within, making its rows visible to the roving nav).
116
+ // Same keyboard grammar as context_menu.
117
+ if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
118
+ e.preventDefault();
119
+ this.#enterSub(document.activeElement);
120
+ }
121
+ break;
122
+ case "ArrowLeft": {
123
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
124
+ const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
125
+ if (sub) {
126
+ e.preventDefault();
127
+ sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
128
+ }
129
+ break;
130
+ }
80
131
  }
81
-
82
- this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);
83
132
  }
84
133
 
85
- #toggleAriaSelected(element, isSelected) {
86
- if (isSelected) {
87
- element.setAttribute("aria-selected", "true");
88
- } else {
89
- element.removeAttribute("aria-selected");
90
- }
134
+ #enterSub(trigger) {
135
+ trigger.focus();
136
+ const panel = trigger.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-content");
137
+ if (!panel) return;
138
+ const first = [...panel.querySelectorAll('[role^="menuitem"]')]
139
+ .find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0);
140
+ first?.focus();
91
141
  }
92
142
 
93
- #deselectAll() {
94
- this.menuItemTargets.forEach((item) => this.#toggleAriaSelected(item, false));
95
- this.selectedIndex = -1;
143
+ // Rows inside a closed submenu are display:none (revealed by CSS on
144
+ // hover/focus-within) focus() on them silently fails and the roving
145
+ // navigation would jam at the submenu boundary.
146
+ #items() {
147
+ return this.menuItemTargets.filter(
148
+ (el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0,
149
+ );
96
150
  }
97
151
 
98
152
  #addEventListeners() {
@@ -6,7 +6,9 @@ module PhlexKit
6
6
  end
7
7
 
8
8
  def view_template(&block)
9
- h3(**mix({ class: "pk-dropdown-menu-label" }, @attrs), &block)
9
+ # div, not a heading: role="menu" only allows menuitem/group/separator
10
+ # children, so an <h3> is invalid ARIA there (shadcn uses a div too).
11
+ div(**mix({ class: "pk-dropdown-menu-label" }, @attrs), &block)
10
12
  end
11
13
  end
12
14
  end
@@ -13,9 +13,13 @@ module PhlexKit
13
13
  def view_template(&block)
14
14
  label(**mix({
15
15
  class: "pk-dropdown-menu-item pk-dropdown-menu-radio-item",
16
- role: "menuitemradio"
16
+ role: "menuitemradio",
17
+ tabindex: "-1",
18
+ aria: { checked: @checked ? "true" : "false" },
19
+ data: { phlex_kit__dropdown_menu_target: "menuItem" }
17
20
  }, @attrs)) do
18
- input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked)
21
+ input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
22
+ tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
19
23
  span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) do
20
24
  # Deliberately NOT a PhlexKit::Icon: a filled selection dot is
21
25
  # geometry, not icon-library vocabulary — it stays identical across
@@ -7,8 +7,11 @@ module PhlexKit
7
7
  div(**mix({
8
8
  class: "pk-dropdown-menu-item pk-dropdown-menu-sub-trigger",
9
9
  role: "menuitem",
10
- tabindex: "0",
11
- aria: { haspopup: "menu" }
10
+ # -1: roving focus reaches it via arrows (focus-within opens the sub);
11
+ # tabindex 0 made it a stray tab stop inside the menu.
12
+ tabindex: "-1",
13
+ aria: { haspopup: "menu" },
14
+ data: { phlex_kit__dropdown_menu_target: "menuItem" }
12
15
  }, @attrs)) do
13
16
  block&.call
14
17
  render Icon.new(:chevron_right, size: nil, class: "pk-dropdown-menu-sub-chevron")
@@ -110,7 +110,7 @@
110
110
  font-size: .875rem;
111
111
  font-weight: 400;
112
112
  line-height: 1.5;
113
- text-align: left;
113
+ text-align: start;
114
114
  }
115
115
  .pk-field-description a { text-decoration: underline; text-underline-offset: 4px; color: inherit; }
116
116
  .pk-field-description a:hover { color: var(--pk-brand); }
@@ -1,8 +1,8 @@
1
1
  module PhlexKit
2
2
  # Validation message(s) for a Field, ported from shadcn/ui's FieldError.
3
3
  # Pass `errors:` an array of message strings (deduped; one renders plain,
4
- # several render a list) or give a block. Renders nothing when empty
5
- # matching theirs. Distinct from FormFieldError, which is the Stimulus
4
+ # several render a list) or give a block both render (block after the
5
+ # list) when both are given. Renders nothing when empty — matching theirs. Distinct from FormFieldError, which is the Stimulus
6
6
  # live-validation slot; server-rendered errors belong here. See field.rb.
7
7
  class FieldError < BaseComponent
8
8
  def initialize(errors: nil, **attrs)
@@ -14,15 +14,14 @@ module PhlexKit
14
14
  return if @errors.empty? && !block
15
15
 
16
16
  div(**mix({ class: "pk-field-error", role: "alert", data: { slot: "field-error" } }, @attrs)) do
17
- if block
18
- yield
19
- elsif @errors.length == 1
17
+ if @errors.length == 1
20
18
  plain @errors.first
21
- else
19
+ elsif @errors.length > 1
22
20
  ul(class: "pk-field-error-list") do
23
21
  @errors.each { |message| li { message } }
24
22
  end
25
23
  end
24
+ yield if block
26
25
  end
27
26
  end
28
27
  end
@@ -14,7 +14,7 @@ export default class extends Controller {
14
14
 
15
15
  connect() {
16
16
  if (this.hasErrorTarget) {
17
- if (this.errorTarget.textContent) {
17
+ if (this.errorTarget.textContent.trim()) {
18
18
  this.shouldValidateValue = true;
19
19
  } else {
20
20
  this.errorTarget.classList.add("pk-hidden");
@@ -23,36 +23,68 @@ export default class extends Controller {
23
23
  }
24
24
 
25
25
  onInvalid(error) {
26
+ // Only suppress the native validation bubble when we can show our own
27
+ // message — a FormField without a FormFieldError previously ate the
28
+ // bubble AND crashed on the missing target, so a required-but-empty
29
+ // form gave zero feedback and never submitted.
30
+ if (!this.hasErrorTarget) return;
26
31
  error.preventDefault();
27
32
 
28
33
  this.shouldValidateValue = true;
29
- this.#setErrorMessage();
34
+ this.#setErrorMessage(error);
30
35
  }
31
36
 
32
- onInput() {
33
- this.#setErrorMessage();
37
+ onInput(event) {
38
+ this.#setErrorMessage(event);
34
39
  }
35
40
 
36
- onChange() {
37
- this.#setErrorMessage();
41
+ onChange(event) {
42
+ this.#setErrorMessage(event);
38
43
  }
39
44
 
40
- #setErrorMessage() {
41
- if (!this.shouldValidateValue) return;
45
+ // The event's own control, when it's one of ours — radio/checkbox groups
46
+ // register several input targets and validity/aria-invalid must land on
47
+ // the control that fired, not always the first.
48
+ #inputFor(event) {
49
+ if (event && this.inputTargets.includes(event.target)) return event.target;
50
+ return this.inputTarget;
51
+ }
52
+
53
+ #setErrorMessage(event) {
54
+ if (!this.shouldValidateValue || !this.hasErrorTarget) return;
42
55
 
43
- if (this.inputTarget.validity.valid) {
56
+ const input = this.#inputFor(event);
57
+ // aria-invalid drives the red ring for LIVE validation too (input.css
58
+ // only matched server-rendered attrs); aria-describedby ties the message
59
+ // to the control for AT.
60
+ if (input.validity.valid) {
61
+ input.removeAttribute("aria-invalid");
62
+ this.#describeBy(input, false);
44
63
  this.errorTarget.textContent = "";
45
64
  this.errorTarget.classList.add("pk-hidden");
46
65
  } else {
47
- this.errorTarget.textContent = this.#getValidationMessage();
66
+ input.setAttribute("aria-invalid", "true");
67
+ this.#describeBy(input, true);
68
+ this.errorTarget.textContent = this.#getValidationMessage(input);
48
69
  this.errorTarget.classList.remove("pk-hidden");
49
70
  }
50
71
  }
51
72
 
52
- #getValidationMessage() {
73
+ // Append/remove the error's id on the control's aria-describedby without
74
+ // clobbering pre-existing tokens (e.g. a hint's id).
75
+ #describeBy(input, present) {
76
+ const id = this.errorTarget.id;
77
+ if (!id) return;
78
+ const tokens = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter((t) => t && t !== id);
79
+ if (present) tokens.push(id);
80
+ if (tokens.length) input.setAttribute("aria-describedby", tokens.join(" "));
81
+ else input.removeAttribute("aria-describedby");
82
+ }
83
+
84
+ #getValidationMessage(input) {
53
85
  let errorMessage;
54
86
 
55
- const { validity, dataset, validationMessage } = this.inputTarget;
87
+ const { validity, dataset, validationMessage } = input;
56
88
 
57
89
  if (validity.tooLong) errorMessage = dataset.tooLong;
58
90
  if (validity.tooShort) errorMessage = dataset.tooShort;
@@ -5,13 +5,20 @@ module PhlexKit
5
5
  # with server-rendered model errors — non-empty content arms validation on
6
6
  # connect. See form_field.rb.
7
7
  class FormFieldError < BaseComponent
8
- def initialize(**attrs)
8
+ def initialize(id: nil, **attrs)
9
+ # A default id (like SelectContent / CommandList) so the controller can
10
+ # always wire aria-describedby from the control to this message.
11
+ @id = id || "pk-form-field-error-#{SecureRandom.hex(4)}"
9
12
  @attrs = attrs
10
13
  end
11
14
 
12
15
  def view_template(&block)
16
+ # role="alert": the controller swaps this text live on invalid/input —
17
+ # without a live region the announcement never reaches screen readers.
13
18
  p(**mix({
19
+ id: @id,
14
20
  class: "pk-form-field-error",
21
+ role: "alert",
15
22
  data: { phlex_kit__form_field_target: "error" }
16
23
  }, @attrs), &block)
17
24
  end
@@ -19,7 +19,7 @@
19
19
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
20
20
  border-radius: var(--pk-radius); background: var(--pk-surface);
21
21
  color: var(--pk-text); padding: .625rem; font-size: .875rem;
22
- box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
22
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
23
23
  }
24
24
  .pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
25
25
  .pk-hover-card-content.left {
@@ -4,6 +4,9 @@ import { Controller } from "@hotwired/stimulus"
4
4
  // with a small open/close delay. The panel is a native [popover=manual]
5
5
  // (top layer; manual because the controller owns open/close — hover
6
6
  // semantics want no light dismiss), anchor-positioned by hover_card.css.
7
+ // Manual popovers get no built-in Escape dismiss, so a window keydown
8
+ // listener (bound only while open, cf. tooltip_controller.js) closes the
9
+ // card immediately — the keyboard/touch escape hatch for a hover surface.
7
10
  export default class extends Controller {
8
11
  static targets = ["trigger", "content"]
9
12
  static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
@@ -12,14 +15,35 @@ export default class extends Controller {
12
15
  this.t = setTimeout(() => {
13
16
  if (!this.contentTarget.matches(":popover-open")) this.contentTarget.showPopover()
14
17
  this.contentTarget.dataset.state = "open"
18
+ this.#bindEscape()
15
19
  }, this.openDelayValue)
16
20
  }
17
21
  hide() {
18
22
  clearTimeout(this.t)
19
- this.t = setTimeout(() => {
20
- if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
21
- this.contentTarget.dataset.state = "closed"
22
- }, this.closeDelayValue)
23
+ this.t = setTimeout(() => this.#close(), this.closeDelayValue)
24
+ }
25
+ disconnect() {
26
+ clearTimeout(this.t)
27
+ this.#unbindEscape()
28
+ }
29
+
30
+ #close() {
31
+ if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
32
+ this.contentTarget.dataset.state = "closed"
33
+ this.#unbindEscape()
34
+ }
35
+ #bindEscape() {
36
+ if (this.onKeydown) return
37
+ this.onKeydown = (e) => {
38
+ if (e.key !== "Escape") return
39
+ clearTimeout(this.t)
40
+ this.#close()
41
+ }
42
+ window.addEventListener("keydown", this.onKeydown)
43
+ }
44
+ #unbindEscape() {
45
+ if (!this.onKeydown) return
46
+ window.removeEventListener("keydown", this.onKeydown)
47
+ this.onKeydown = null
23
48
  }
24
- disconnect() { clearTimeout(this.t) }
25
49
  }
@@ -30,7 +30,7 @@
30
30
  font: inherit;
31
31
  font-size: .875rem;
32
32
  font-weight: 500;
33
- margin-right: .5rem;
33
+ margin-inline-end: .5rem;
34
34
  }
35
35
  .pk-input:focus-visible {
36
36
  outline: none;
@@ -24,7 +24,9 @@
24
24
  flex-direction: column;
25
25
  align-items: stretch;
26
26
  }
27
- .pk-input-group:focus-within {
27
+ /* Ring only when the CONTROL is focused (shadcn gates on the control's
28
+ focus-visible) — :focus-within would also light it for addon buttons. */
29
+ .pk-input-group:has(.pk-input:focus-visible, .pk-textarea:focus-visible) {
28
30
  border-color: var(--pk-ring);
29
31
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
30
32
  }
@@ -48,8 +50,8 @@
48
50
  .pk-input-group .pk-input:disabled,
49
51
  .pk-input-group .pk-textarea:disabled { background: transparent; }
50
52
  /* Tighten the control's near-side padding beside an inline addon. */
51
- .pk-input-group:has(> .pk-input-group-addon.start) > .pk-input { padding-left: .375rem; }
52
- .pk-input-group:has(> .pk-input-group-addon.end) > .pk-input { padding-right: .375rem; }
53
+ .pk-input-group:has(> .pk-input-group-addon.start) > .pk-input { padding-inline-start: .375rem; }
54
+ .pk-input-group:has(> .pk-input-group-addon.end) > .pk-input { padding-inline-end: .375rem; }
53
55
 
54
56
  .pk-input-group-addon {
55
57
  display: flex;
@@ -64,8 +66,8 @@
64
66
  font-size: .875rem;
65
67
  font-weight: 500;
66
68
  }
67
- .pk-input-group-addon.start { order: -1; padding-left: .5rem; }
68
- .pk-input-group-addon.end { order: 99; padding-right: .5rem; }
69
+ .pk-input-group-addon.start { order: -1; padding-inline-start: .5rem; }
70
+ .pk-input-group-addon.end { order: 99; padding-inline-end: .5rem; }
69
71
  .pk-input-group-addon.block-start,
70
72
  .pk-input-group-addon.block-end {
71
73
  width: 100%;
@@ -78,8 +80,8 @@
78
80
  .pk-input-group-addon.block-end.bordered { border-top: 1px solid var(--pk-border); padding-top: .5rem; }
79
81
  .pk-input-group-addon svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
80
82
  .pk-input-group-addon > kbd { border-radius: calc(var(--pk-radius) - 5px); }
81
- .pk-input-group-addon.start:has(> .pk-button) { margin-left: -0.3rem; }
82
- .pk-input-group-addon.end:has(> .pk-button) { margin-right: -0.3rem; }
83
+ .pk-input-group-addon.start:has(> .pk-button) { margin-inline-start: -0.3rem; }
84
+ .pk-input-group-addon.end:has(> .pk-button) { margin-inline-end: -0.3rem; }
83
85
 
84
86
  .pk-button.pk-input-group-button {
85
87
  gap: .25rem;
@@ -17,7 +17,11 @@ module PhlexKit
17
17
  end
18
18
 
19
19
  def view_template(&)
20
- div(**mix({ class: "pk-input-group-addon #{fetch_option(ALIGNS, @align, :align)}", role: "group" }, @attrs), &)
20
+ # No role: shadcn puts role="group" on its addon, but an unnamed group
21
+ # nested inside the InputGroup's own group is noise for AT — a text or
22
+ # icon addon needs no role, and interactive addon children (buttons)
23
+ # carry their own semantics.
24
+ div(**mix({ class: "pk-input-group-addon #{fetch_option(ALIGNS, @align, :align)}" }, @attrs), &)
21
25
  end
22
26
  end
23
27
  end
@@ -16,7 +16,7 @@
16
16
  outline: none;
17
17
  transition: border-color .15s ease, box-shadow .15s ease;
18
18
  }
19
- .pk-input-otp-slot:not(:first-child) { margin-left: -1px; }
19
+ .pk-input-otp-slot:not(:first-child) { margin-inline-start: -1px; }
20
20
  .pk-input-otp-group .pk-input-otp-slot:first-child {
21
21
  border-top-left-radius: var(--pk-radius);
22
22
  border-bottom-left-radius: var(--pk-radius);
@@ -10,6 +10,12 @@ export default class extends Controller {
10
10
  static values = { length: Number }
11
11
 
12
12
  connect() {
13
+ // Slots compose without knowing their position, so `length:` can drift
14
+ // from the actual slot count — flag the mismatch instead of silently
15
+ // truncating/underfilling codes.
16
+ if (this.hasLengthValue && this.lengthValue !== this.slotTargets.length) {
17
+ console.warn(`phlex-kit--input-otp: length is ${this.lengthValue} but ${this.slotTargets.length} slots rendered`, this.element)
18
+ }
13
19
  // Slots compose without knowing their position — label them here so AT
14
20
  // announces "Digit N of M" instead of anonymous edit fields.
15
21
  this.slotTargets.forEach((slot, i) => {
@@ -17,12 +23,39 @@ export default class extends Controller {
17
23
  slot.setAttribute("aria-label", `Digit ${i + 1} of ${this.slotTargets.length}`)
18
24
  }
19
25
  })
26
+ // A server-provided value renders on the hidden input only — distribute
27
+ // it into the empty slots BEFORE syncing, or syncValue would join the
28
+ // empty slots and wipe the value with a spurious change event.
29
+ if (this.hasValueTarget && this.valueTarget.value && this.slotTargets.every((slot) => !slot.value)) {
30
+ const chars = this.valueTarget.value.split("")
31
+ this.slotTargets.forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
32
+ }
20
33
  this.syncValue()
21
34
  }
22
35
 
23
36
  onInput(e) {
24
37
  const slot = e.target
25
- slot.value = slot.value.replace(/\s/g, "").slice(-1)
38
+ let text = slot.value.replace(/\s/g, "")
39
+ // Typing rejects the same characters paste strips — a letter keyed into
40
+ // an inputmode=numeric slot must not land.
41
+ if (slot.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
42
+
43
+ if (text.length > 1) {
44
+ if (e.data && e.data.length === 1) {
45
+ // one keystroke into an already-filled slot: keep the newest char
46
+ slot.value = e.data
47
+ this.focusSlot(this.slotTargets.indexOf(slot) + 1)
48
+ this.syncValue()
49
+ } else {
50
+ // OTP autofill (autocomplete="one-time-code") delivers the whole
51
+ // code into the focused slot — distribute it like a paste instead
52
+ // of discarding all but one digit.
53
+ this.fillFrom(slot, text)
54
+ }
55
+ return
56
+ }
57
+
58
+ slot.value = text
26
59
  if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
27
60
  this.syncValue()
28
61
  }
@@ -50,10 +83,16 @@ export default class extends Controller {
50
83
  // Numeric slots reject non-digits on typing; pasted text like
51
84
  // "code: 123456" should distribute only the digits.
52
85
  if (e.target.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
86
+ if (!text.length) return
87
+ this.fillFrom(e.target, text)
88
+ }
89
+
90
+ // Distribute a multi-character string across the slots starting at `slot`
91
+ // (shared by paste and autofill), then park focus after the last filled one.
92
+ fillFrom(slot, text) {
53
93
  const chars = text.split("")
54
- if (!chars.length) return
55
- const start = this.slotTargets.indexOf(e.target)
56
- this.slotTargets.slice(start).forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
94
+ const start = this.slotTargets.indexOf(slot)
95
+ this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
57
96
  this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
58
97
  this.syncValue()
59
98
  }