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
@@ -5,7 +5,9 @@ module PhlexKit
5
5
  class ContextMenuSub < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-context-menu-sub" }, @attrs), &)
8
+ # syncSub mirrors the CSS-driven reveal state onto the sub trigger's
9
+ # aria-expanded (same wiring as DropdownMenuSub / MenubarSub).
10
+ div(**mix({ class: "pk-context-menu-sub", data: { action: "mouseenter->phlex-kit--context-menu#syncSub mouseleave->phlex-kit--context-menu#syncSub focusin->phlex-kit--context-menu#syncSub focusout->phlex-kit--context-menu#syncSub" } }, @attrs), &)
9
11
  end
10
12
  end
11
13
  end
@@ -9,7 +9,9 @@ module PhlexKit
9
9
  # -1: roving focus reaches it via arrows (focus-within opens the sub);
10
10
  # tabindex 0 made it a stray tab stop inside the menu.
11
11
  tabindex: "-1",
12
- aria: { haspopup: "menu" },
12
+ # expanded starts false and is mirrored live by the controller's
13
+ # syncSub (the reveal itself is pure CSS :hover/:focus-within).
14
+ aria: { haspopup: "menu", expanded: "false" },
13
15
  data: { phlex_kit__context_menu_target: "menuItem" }
14
16
  }, @attrs)) do
15
17
  block&.call
@@ -23,16 +23,6 @@
23
23
  /* "Columns" dropdown rows. */
24
24
  .pk-data-table-column-toggle { position: relative; }
25
25
  .pk-data-table-column-toggle-icon { width: 1rem; height: 1rem; margin-inline-start: .25rem; }
26
- .pk-data-table-column-option {
27
- display: flex;
28
- align-items: center;
29
- gap: .5rem;
30
- border-radius: calc(var(--pk-radius) - 4px);
31
- padding: .375rem .5rem;
32
- font-size: .875rem;
33
- cursor: pointer;
34
- }
35
- .pk-data-table-column-option:hover { background: var(--pk-accent); }
36
26
 
37
27
  /* Sortable header link. */
38
28
  .pk-data-table-sort-head { color: var(--pk-text); white-space: nowrap; }
@@ -102,7 +92,7 @@
102
92
  .pk-data-table-page-disabled {
103
93
  display: inline-flex;
104
94
  align-items: center;
105
- height: 2.25rem;
95
+ height: 2rem;
106
96
  padding: 0 .75rem;
107
97
  font-size: .875rem;
108
98
  opacity: .5;
@@ -27,7 +27,10 @@ module PhlexKit
27
27
  # invalid ARIA. The change event bubbles from the hidden checkbox
28
28
  # to this row, where our toggle action picks it up.
29
29
  render DropdownMenuCheckboxItem.new(
30
- checked: true,
30
+ # visible: false starts the menu row unchecked for a column
31
+ # the host server-renders hidden (pk-hidden on its cells) —
32
+ # checked: true hardcoded meant the two could never agree.
33
+ checked: col.fetch(:visible, true),
31
34
  data: {
32
35
  column_key: col[:key].to_s,
33
36
  action: "change->phlex-kit--data-table-column-visibility#toggle"
@@ -20,7 +20,11 @@ export default class extends Controller {
20
20
  toggleAll(event) {
21
21
  const checked = event.target.checked;
22
22
  this.rowCheckboxTargets.forEach((cb) => {
23
+ if (cb.checked === checked) return;
23
24
  cb.checked = checked;
25
+ // Programmatic .checked flips fire no event — hosts (and the
26
+ // form-field wiring on PhlexKit::Checkbox) must still hear select-all.
27
+ cb.dispatchEvent(new Event("change", { bubbles: true }));
24
28
  });
25
29
  this.updateState();
26
30
  }
@@ -70,7 +70,10 @@ module PhlexKit
70
70
  def edge_item(disabled:, href:, label:, name:)
71
71
  if disabled
72
72
  li do
73
- span(class: "pk-data-table-page-disabled", aria: { disabled: "true", label: name }) { label }
73
+ # role-less span + aria-label is invalid ARIA (aria-label on a
74
+ # role-less element is not perceivable) — role + aria-disabled
75
+ # mirrors BreadcrumbPage's disabled-control pattern.
76
+ span(class: "pk-data-table-page-disabled", role: "link", aria: { disabled: "true", label: name }) { label }
74
77
  end
75
78
  else
76
79
  render PaginationItem.new(href: href, aria: { label: name }) { label }
@@ -4,21 +4,26 @@ module PhlexKit
4
4
  # (search/sort/direction/...) survive via `preserved_params:` hidden inputs,
5
5
  # mirroring DataTableSearch. See data_table.rb.
6
6
  class DataTablePerPageSelect < BaseComponent
7
- def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, **attrs)
7
+ # method:/action: are kit-owned (a GET form targeting `path:`) named
8
+ # kwargs so a caller override lands in @method/@action directly instead
9
+ # of fusing with the generated defaults via `mix` ("get post").
10
+ def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, method: "get", action: nil, **attrs)
8
11
  @path = path
9
12
  @name = name
10
13
  @value = value
11
14
  @frame_id = frame_id
12
15
  @options = options
13
16
  @preserved_params = preserved_params
17
+ @method = method
18
+ @action = action || @path
14
19
  @attrs = attrs
15
20
  end
16
21
 
17
22
  def view_template
18
23
  form_attrs = {
19
24
  class: "pk-data-table-per-page",
20
- action: @path,
21
- method: "get",
25
+ action: @action,
26
+ method: @method,
22
27
  # Reuse the search controller for its CSP-safe #submitNow action.
23
28
  data: { controller: "phlex-kit--data-table-search" }
24
29
  }
@@ -3,7 +3,10 @@ module PhlexKit
3
3
  # params survive via `preserved_params:` hidden inputs; focus/caret survive the
4
4
  # frame swap (see the search controller). See data_table.rb.
5
5
  class DataTableSearch < BaseComponent
6
- def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, **attrs)
6
+ # method:/action: are kit-owned (a GET form targeting `path:`) named
7
+ # kwargs so a caller override lands in @method/@action directly instead
8
+ # of fusing with the generated defaults via `mix` ("get post").
9
+ def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, method: "get", action: nil, **attrs)
7
10
  @path = path
8
11
  @name = name
9
12
  @value = value
@@ -11,11 +14,13 @@ module PhlexKit
11
14
  @placeholder = placeholder
12
15
  @debounce = debounce
13
16
  @preserved_params = preserved_params
17
+ @method = method
18
+ @action = action || @path
14
19
  @attrs = attrs
15
20
  end
16
21
 
17
22
  def view_template
18
- form(**mix({ class: "pk-data-table-search", method: "get", action: @path, data: form_data }, @attrs)) do
23
+ form(**mix({ class: "pk-data-table-search", method: @method, action: @action, data: form_data }, @attrs)) do
19
24
  render Input.new(
20
25
  type: :search,
21
26
  name: @name,
@@ -5,7 +5,12 @@ module PhlexKit
5
5
  # controller and the calendar's outlet points at its id, so picking a day
6
6
  # writes the formatted date into the field. Upstream's popover_options
7
7
  # (hover trigger via @floating-ui) are gone — the kit popover is click-only,
8
- # CSS-positioned. Tailwind → vanilla `.pk-date-picker*` (date_picker.css).
8
+ # CSS-positioned. The phlex-kit--date-picker controller on the root div
9
+ # listens for the calendar's change event and closes the popover once a
10
+ # selection is COMPLETE (single: any pick; range: the second end only;
11
+ # multiple: never), returning focus to the input — see
12
+ # date_picker_controller.js and popover_controller.js#close.
13
+ # Tailwind → vanilla `.pk-date-picker*` (date_picker.css).
9
14
  class DatePicker < BaseComponent
10
15
  def initialize(
11
16
  id: nil,
@@ -31,6 +36,12 @@ module PhlexKit
31
36
  # until the first interaction.
32
37
  @value = value || format_selected_date
33
38
  @placeholder = placeholder
39
+ # An id smuggled through input_attrs would MERGE with the generated one
40
+ # (mix fuses duplicate string attrs), breaking label[for] and the
41
+ # calendar outlet selector — the top-level id: kwarg is the one path.
42
+ if input_attrs.key?(:id) || input_attrs.key?("id")
43
+ raise ArgumentError, "DatePicker: pass id: as the top-level kwarg, not input_attrs[:id]"
44
+ end
34
45
  @input_attrs = input_attrs
35
46
  @calendar_attrs = calendar_attrs
36
47
  @trigger_attrs = trigger_attrs
@@ -39,7 +50,13 @@ module PhlexKit
39
50
  end
40
51
 
41
52
  def view_template
42
- div(**mix({ class: "pk-date-picker" }, @attrs)) do
53
+ div(**mix({
54
+ class: "pk-date-picker",
55
+ data: {
56
+ controller: "phlex-kit--date-picker",
57
+ action: "phlex-kit--calendar:change->phlex-kit--date-picker#onCalendarChange"
58
+ }
59
+ }, @attrs)) do
43
60
  render Popover.new do
44
61
  render PopoverTrigger.new(**trigger_attrs) do
45
62
  div(class: "pk-date-picker-field") do
@@ -0,0 +1,28 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Bridges the nested Calendar's change event to the wrapping Popover's
4
+ // close(), so picking a date collapses the panel instead of leaving the
5
+ // user to click outside/Escape after every pick (shadcn's DatePicker
6
+ // behavior). Only closes on a COMPLETE selection: single mode closes on
7
+ // any pick, range mode only once the second end (rangeEnd) is set — closing
8
+ // on the first end would strand the user before they can pick the second —
9
+ // and multiple mode never auto-closes since picking more than one date is
10
+ // the entire point. See date_picker.rb for the data-action wiring and
11
+ // popover_controller.js for close()/focusTrigger().
12
+ export default class extends Controller {
13
+ onCalendarChange(e) {
14
+ const { mode, rangeEnd } = e.detail
15
+ if (mode === "multiple") return
16
+ // calendar_controller.js clears rangeEnd via `this.rangeEndValue = null`,
17
+ // which goes through Element#setAttribute — the DOM stringifies a null
18
+ // argument to the literal "null", so a genuinely-unset rangeEnd reads
19
+ // back as that string, not JS null/undefined/"".
20
+ if (mode === "range" && (!rangeEnd || rangeEnd === "null")) return
21
+ this.popoverController()?.close()
22
+ }
23
+
24
+ popoverController() {
25
+ const popoverEl = this.element.querySelector(".pk-popover")
26
+ return popoverEl && this.application.getControllerForElementAndIdentifier(popoverEl, "phlex-kit--popover")
27
+ }
28
+ }
@@ -11,7 +11,9 @@
11
11
  --pk-dialog-spacing: 1rem;
12
12
  position: fixed; z-index: 50; width: 100%;
13
13
  max-width: min(24rem, calc(100% - 2rem));
14
- max-height: 100vh;
14
+ /* Breathing room like the horizontal inset — 100vh touched the screen
15
+ edges and overflowed behind mobile browser chrome (dvh tracks it). */
16
+ max-height: calc(100dvh - 2rem);
15
17
  overflow-y: auto; gap: 1rem;
16
18
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
17
19
  border-radius: calc(var(--pk-radius) + 2px); background: var(--pk-surface); color: var(--pk-text);
@@ -21,7 +21,7 @@ module PhlexKit
21
21
  aria = {}
22
22
  aria[:labelledby] = @labelledby if @labelledby
23
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
24
+ dialog(**mix({ class: cls, aria: aria, data: { phlex_kit__dialog_target: "dialog", action: "pointerdown->phlex-kit--dialog#backdropPointerdown click->phlex-kit--dialog#backdropClick" } }, @attrs)) do
25
25
  yield
26
26
  if @show_close_button
27
27
  button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--dialog#dismiss" }) do
@@ -37,6 +37,9 @@ export default class extends Controller {
37
37
  }
38
38
  open(e) {
39
39
  e?.preventDefault()
40
+ // open: is one-shot — the reflected value sits in the Turbo snapshot, so
41
+ // a cache-restored reconnect would re-open a dismissed dialog.
42
+ this.openValue = false
40
43
  this.dialogTarget.showModal()
41
44
  // Save/restore rather than removeProperty: a host page (or another
42
45
  // component) may have set body overflow itself.
@@ -44,15 +47,26 @@ export default class extends Controller {
44
47
  document.body.style.overflow = "hidden"
45
48
  }
46
49
  dismiss() { this.dialogTarget.close() }
50
+ // Only a press that STARTED on the backdrop may dismiss: a drag-select
51
+ // beginning inside the panel and released over the backdrop fires the
52
+ // click on <dialog> with outside coordinates — that must not close it
53
+ // (Radix guards on pointerdown origin the same way).
54
+ backdropPointerdown(e) {
55
+ this.pressStartedOutside = e.target === this.dialogTarget && !this.#insideBox(e)
56
+ }
47
57
  // Only a click on the ::backdrop dismisses. e.target is the <dialog> for
48
58
  // backdrop clicks, but ALSO for clicks on the dialog's own padding/flex
49
59
  // gaps — disambiguate by testing the click point against the dialog's box.
50
60
  backdropClick(e) {
51
61
  if (e.target !== this.dialogTarget) return
52
62
  if (e.detail === 0) return // synthetic/keyboard click — no coordinates
63
+ if (!this.pressStartedOutside) return
64
+ this.pressStartedOutside = false
65
+ if (!this.#insideBox(e)) this.dismiss()
66
+ }
67
+ #insideBox(e) {
53
68
  const r = this.dialogTarget.getBoundingClientRect()
54
- const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
55
- if (!inside) this.dismiss()
69
+ return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
56
70
  }
57
71
  handleClose = () => { document.body.style.overflow = this.previousOverflow ?? "" }
58
72
  }
@@ -12,7 +12,7 @@ module PhlexKit
12
12
  end
13
13
 
14
14
  def view_template(&)
15
- div(**mix({ data: { controller: "phlex-kit--sheet", phlex_kit__sheet_open_value: @open } }, @attrs), &)
15
+ div(**mix({ data: { controller: "phlex-kit--sheet", phlex_kit__sheet_open_value: @open.to_s } }, @attrs), &)
16
16
  end
17
17
  end
18
18
  end
@@ -12,9 +12,16 @@ 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", action: "keydown->phlex-kit--sheet-content#keydown" }) do
16
- div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close" })
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
15
+ div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown", pk_overlay_clone: "" }) do
16
+ div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close mousedown->phlex-kit--sheet-content#overlayMousedown" })
17
+ panel_attrs = { class: "pk-drawer #{fetch_option(SIDES, @side, :side)}", 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
18
25
  div(class: "pk-drawer-handle", aria: { hidden: "true" })
19
26
  yield if block
20
27
  end
@@ -20,14 +20,17 @@
20
20
  position-try-fallbacks: flip-block;
21
21
  }
22
22
  .pk-dropdown-menu-content-up { position-area: block-start span-inline-end; margin: 0 0 4px; }
23
+ /* Gap margins are logical to match the logical position-area — physical
24
+ margins landed on the wrong side in RTL (and flip-inline flips logical
25
+ margins along with the placement). */
23
26
  .pk-dropdown-menu-content-right {
24
27
  position-area: inline-end span-block-end;
25
- margin: 0 0 0 4px;
28
+ margin: 0; margin-inline-start: 4px;
26
29
  position-try-fallbacks: flip-inline;
27
30
  }
28
31
  .pk-dropdown-menu-content-left {
29
32
  position-area: inline-start span-block-end;
30
- margin: 0 4px 0 0;
33
+ margin: 0; margin-inline-end: 4px;
31
34
  position-try-fallbacks: flip-inline;
32
35
  }
33
36
  .pk-dropdown-menu-viewport {
@@ -113,6 +116,10 @@
113
116
  the viewport right edge. */
114
117
  .pk-dropdown-menu-sub { anchor-name: --pk-dropdown-menu-sub; anchor-scope: --pk-dropdown-menu-sub; }
115
118
  .pk-dropdown-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
119
+ /* The chevron points toward where the submenu opens (inline-end). In RTL that
120
+ is visually LEFT — mirror it. Ancestor [dir="rtl"] (not :dir()) so Chrome
121
+ re-styles it on a dynamic dir flip too. */
122
+ [dir="rtl"] .pk-dropdown-menu-sub-chevron { transform: scaleX(-1); }
116
123
  .pk-dropdown-menu-sub-content {
117
124
  position: fixed;
118
125
  position-anchor: --pk-dropdown-menu-sub;
@@ -18,7 +18,9 @@ module PhlexKit
18
18
  data: {
19
19
  controller: "phlex-kit--dropdown-menu",
20
20
  phlex_kit__dropdown_menu_open_value: @open,
21
- action: "click@window->phlex-kit--dropdown-menu#onClickOutside"
21
+ # focusout: tabbing (or otherwise moving real focus) out of the
22
+ # menu closes the open [popover=manual] panel (menubar's pattern).
23
+ action: "click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
22
24
  }
23
25
  }, @attrs), &block)
24
26
  end
@@ -16,20 +16,59 @@ export default class extends Controller {
16
16
 
17
17
  connect() {
18
18
  this.boundHandleKeydown = this.#handleKeydown.bind(this);
19
+ // Outstanding syncSub rAF handles — cancelled on disconnect so a queued
20
+ // frame never touches a torn-down menu (menubar's pattern).
21
+ this.subFrames = new Set();
19
22
  // The visible control is the caller's button/link inside the trigger
20
23
  // wrapper — that's what AT reads, so the popup wiring belongs on it.
21
24
  this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
25
+ // A Turbo snapshot (or a detach/reattach that mimics one) serializes
26
+ // aria-expanded="true" on sub triggers even though no submenu can be
27
+ // open at connect time — sweep every stale marker back to false
28
+ // (menubar's pattern). Runs before the invoker's own reset below so
29
+ // both are covered by one mental model.
30
+ this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"));
31
+ // Same shape for checkbox/radio rows: their `checked` DOM property
32
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
33
+ // from it — resync every row from its live input.
34
+ this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
35
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked);
36
+ });
22
37
  this.invoker.setAttribute("aria-haspopup", "menu");
23
38
  this.invoker.setAttribute("aria-expanded", "false");
39
+ // Associate trigger and menu for AT (the dialog controller wires its
40
+ // ids the same way).
41
+ if (!this.contentTarget.id) this.contentTarget.id = `pk-dropdown-menu-${Math.random().toString(36).slice(2, 8)}`;
42
+ this.invoker.setAttribute("aria-controls", this.contentTarget.id);
43
+ // Turbo snapshots BEFORE disconnect — an open menu (reflected in the
44
+ // open value) would be resurrected from the page cache on restore.
45
+ this.boundBeforeCache = () => this.close();
46
+ document.addEventListener("turbo:before-cache", this.boundBeforeCache);
24
47
  // `open:` on the Ruby side renders the value — start open (same
25
48
  // kwarg -> value -> connect flow as Collapsible).
26
49
  if (this.openValue) this.#open();
27
50
  }
28
51
 
29
52
  disconnect() {
53
+ document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
54
+ this.subFrames.forEach((id) => cancelAnimationFrame(id));
55
+ this.subFrames.clear();
30
56
  this.#removeEventListeners();
31
57
  }
32
58
 
59
+ // Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
60
+ // sub trigger's aria-expanded. rAF: on mouseleave/focusout the pseudo-class
61
+ // state isn't settled until the event finishes dispatching.
62
+ syncSub(e) {
63
+ const sub = e.currentTarget;
64
+ const id = requestAnimationFrame(() => {
65
+ this.subFrames.delete(id);
66
+ sub.querySelector(":scope > [aria-haspopup]")
67
+ ?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"));
68
+ });
69
+ this.subFrames.add(id);
70
+ }
71
+
33
72
  onClickOutside(event) {
34
73
  // Gate on the live :popover-open state, not the stored flag — a stale
35
74
  // flag is how a close on an already-closed panel becomes an open.
@@ -47,6 +86,18 @@ export default class extends Controller {
47
86
  this.contentTarget.matches(":popover-open") ? this.close() : this.#open();
48
87
  }
49
88
 
89
+ // Tabbing (or otherwise moving real focus) out of the menu closes it —
90
+ // menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
91
+ // null when the window itself blurs — leave the menu alone then. The
92
+ // trigger + content are both DOM children of this.element even though the
93
+ // content is a top-layer popover, so one containment check covers both.
94
+ onFocusout(e) {
95
+ if (!this.contentTarget.matches(":popover-open")) return;
96
+ const to = e.relatedTarget;
97
+ if (!to || this.element.contains(to)) return;
98
+ this.close();
99
+ }
100
+
50
101
  #open() {
51
102
  this.openValue = true;
52
103
  this.#addEventListeners();
@@ -75,6 +126,16 @@ export default class extends Controller {
75
126
  }
76
127
 
77
128
  #handleKeydown(e) {
129
+ // This listener is document-level (see #addEventListeners) so it keeps
130
+ // receiving events even once real focus has left the menu (e.g. a click
131
+ // into an unrelated text field the onFocusout listener didn't catch) —
132
+ // scope the roving-nav keys to when focus is actually within the widget.
133
+ // this.element (not just contentTarget) so ArrowDown still works right
134
+ // after a mouse-driven open, when focus is still on the invoker button
135
+ // (#open() doesn't force focus into the first row). Escape stays global
136
+ // while the menu is open (APG).
137
+ if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return;
138
+
78
139
  const items = this.#items();
79
140
  if (items.length === 0) return;
80
141
  const index = items.indexOf(document.activeElement);
@@ -111,20 +172,27 @@ export default class extends Controller {
111
172
  this.invoker.focus();
112
173
  break;
113
174
  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
175
  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();
176
+ // Submenus open inline-end visually LEFT in RTL so the enter key
177
+ // follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
178
+ // (and the reverse in LTR). Same keyboard grammar as context_menu.
179
+ // Runtime dir check is reliable after a dynamic flip.
180
+ const rtl = getComputedStyle(this.element).direction === "rtl";
181
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight";
182
+ if (e.key === enterKey) {
183
+ // On a sub trigger, enter the submenu (focus reveals it via
184
+ // :focus-within, making its rows visible to the roving nav).
185
+ if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
186
+ e.preventDefault();
187
+ this.#enterSub(document.activeElement);
188
+ }
189
+ } else {
190
+ // Inside a submenu, step back to its trigger (closes it on focus-out).
191
+ const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
192
+ if (sub) {
193
+ e.preventDefault();
194
+ sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
195
+ }
128
196
  }
129
197
  break;
130
198
  }
@@ -5,7 +5,9 @@ module PhlexKit
5
5
  class DropdownMenuSub < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-dropdown-menu-sub" }, @attrs), &)
8
+ # syncSub mirrors the CSS-driven reveal state onto the sub trigger's
9
+ # aria-expanded (same wiring as MenubarSub).
10
+ div(**mix({ class: "pk-dropdown-menu-sub", data: { action: "mouseenter->phlex-kit--dropdown-menu#syncSub mouseleave->phlex-kit--dropdown-menu#syncSub focusin->phlex-kit--dropdown-menu#syncSub focusout->phlex-kit--dropdown-menu#syncSub" } }, @attrs), &)
9
11
  end
10
12
  end
11
13
  end
@@ -10,7 +10,9 @@ module PhlexKit
10
10
  # -1: roving focus reaches it via arrows (focus-within opens the sub);
11
11
  # tabindex 0 made it a stray tab stop inside the menu.
12
12
  tabindex: "-1",
13
- aria: { haspopup: "menu" },
13
+ # expanded starts false and is mirrored live by the controller's
14
+ # syncSub (the reveal itself is pure CSS :hover/:focus-within).
15
+ aria: { haspopup: "menu", expanded: "false" },
14
16
  data: { phlex_kit__dropdown_menu_target: "menuItem" }
15
17
  }, @attrs)) do
16
18
  block&.call
@@ -6,7 +6,7 @@ module PhlexKit
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
 
8
8
  def view_template(&block)
9
- div(**mix({ class: "pk-field-separator", data: { content: block ? "true" : "false" } }, @attrs)) do
9
+ div(**mix({ class: "pk-field-separator" }, @attrs)) do
10
10
  render Separator.new(class: "pk-field-separator-line")
11
11
  span(class: "pk-field-separator-content", &block) if block
12
12
  end
@@ -9,10 +9,21 @@ import { Controller } from "@hotwired/stimulus";
9
9
  // back to the browser's validationMessage.
10
10
  // Connects to data-controller="phlex-kit--form-field"
11
11
  export default class extends Controller {
12
- static targets = ["input", "error"];
12
+ static targets = ["input", "error", "hint"];
13
13
  static values = { shouldValidate: false };
14
14
 
15
15
  connect() {
16
+ // Hints are static description text — wire them onto every control's
17
+ // aria-describedby up front (errors join/leave dynamically via
18
+ // #describeBy, which preserves foreign tokens like these).
19
+ this.hintTargets.forEach((hint) => {
20
+ if (!hint.id) return;
21
+ this.inputTargets.forEach((input) => {
22
+ const tokens = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean);
23
+ if (!tokens.includes(hint.id)) tokens.push(hint.id);
24
+ input.setAttribute("aria-describedby", tokens.join(" "));
25
+ });
26
+ });
16
27
  if (this.hasErrorTarget) {
17
28
  if (this.errorTarget.textContent.trim()) {
18
29
  this.shouldValidateValue = true;
@@ -37,43 +48,61 @@ export default class extends Controller {
37
48
  if (!this.hasErrorTarget) return;
38
49
  error.preventDefault();
39
50
 
40
- this.shouldValidateValue = true;
41
- this.#setErrorMessage(error);
42
- }
51
+ // preventDefault also cancels the browser's focus-first-invalid — restore
52
+ // it for the first invalid control of the submit pass (all invalid events
53
+ // fire in the same task, so the microtask reset scopes "first" correctly).
54
+ if (!this._focusedInvalid) {
55
+ this._focusedInvalid = true;
56
+ queueMicrotask(() => { this._focusedInvalid = false; });
57
+ error.target.focus({ preventScroll: true });
58
+ error.target.scrollIntoView({ block: "nearest" });
59
+ }
43
60
 
44
- onInput(event) {
45
- this.#setErrorMessage(event);
61
+ this.shouldValidateValue = true;
62
+ this.#setErrorMessage();
46
63
  }
47
64
 
48
- onChange(event) {
49
- this.#setErrorMessage(event);
65
+ onInput(_event) {
66
+ this.#setErrorMessage();
50
67
  }
51
68
 
52
- // The event's own control, when it's one of ours — radio/checkbox groups
53
- // register several input targets and validity/aria-invalid must land on
54
- // the control that fired, not always the first.
55
- #inputFor(event) {
56
- if (event && this.inputTargets.includes(event.target)) return event.target;
57
- return this.inputTarget;
69
+ onChange(_event) {
70
+ this.#setErrorMessage();
58
71
  }
59
72
 
60
- #setErrorMessage(event) {
73
+ // A FormField can carry several input targets (radio/checkbox groups, or
74
+ // independent controls like two required checkboxes sharing one error).
75
+ // Recompute across ALL of them on every validity event — deciding from the
76
+ // event's own target alone left a shared error stuck on/off based on
77
+ // whichever control happened to fire last, out of step with the others'
78
+ // own (correctly-updated) aria-invalid.
79
+ #setErrorMessage() {
61
80
  if (!this.shouldValidateValue || !this.hasErrorTarget) return;
62
81
 
63
- const input = this.#inputFor(event);
64
82
  // aria-invalid drives the red ring for LIVE validation too (input.css
65
- // only matched server-rendered attrs); aria-describedby ties the message
66
- // to the control for AT.
67
- if (input.validity.valid) {
68
- input.removeAttribute("aria-invalid");
83
+ // only matched server-rendered attrs); each control reflects its own
84
+ // validity regardless of which one fired the event. Radio buttons
85
+ // sharing a `name` share native validity, so they naturally agree here.
86
+ let firstInvalid = null;
87
+ this.inputTargets.forEach((input) => {
88
+ if (input.validity.valid) {
89
+ input.removeAttribute("aria-invalid");
90
+ } else {
91
+ input.setAttribute("aria-invalid", "true");
92
+ firstInvalid ||= input;
93
+ }
94
+ // Clear the shared error's id everywhere first — it's re-added below,
95
+ // only on the one control whose message is currently shown.
69
96
  this.#describeBy(input, false);
97
+ });
98
+
99
+ if (firstInvalid) {
100
+ this.#describeBy(firstInvalid, true);
101
+ this.errorTarget.textContent = this.#getValidationMessage(firstInvalid);
102
+ this.errorTarget.classList.remove("pk-hidden");
103
+ } else {
70
104
  this.errorTarget.textContent = "";
71
105
  this.errorTarget.classList.add("pk-hidden");
72
- } else {
73
- input.setAttribute("aria-invalid", "true");
74
- this.#describeBy(input, true);
75
- this.errorTarget.textContent = this.#getValidationMessage(input);
76
- this.errorTarget.classList.remove("pk-hidden");
77
106
  }
78
107
  }
79
108