phlex_kit 0.8.1 → 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 (134) 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_controller.js +8 -2
  5. data/app/components/phlex_kit/alert/alert.css +3 -3
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +12 -8
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
  10. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  11. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  12. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  13. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  14. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  15. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  16. data/app/components/phlex_kit/badge/badge.css +4 -4
  17. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  18. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  19. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  20. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  21. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  22. data/app/components/phlex_kit/button/button.css +6 -6
  23. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  24. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  25. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  26. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  27. data/app/components/phlex_kit/calendar/calendar_body.rb +3 -0
  28. data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
  29. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  30. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  31. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  32. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  34. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  35. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  36. data/app/components/phlex_kit/chart/chart_controller.js +32 -9
  37. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  38. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  39. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  40. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  41. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  42. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  43. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  44. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  45. data/app/components/phlex_kit/combobox/combobox_controller.js +52 -9
  46. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  47. data/app/components/phlex_kit/command/command.css +4 -4
  48. data/app/components/phlex_kit/command/command_controller.js +51 -10
  49. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  50. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  51. data/app/components/phlex_kit/command/command_input.rb +6 -2
  52. data/app/components/phlex_kit/command/command_item.rb +4 -1
  53. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  54. data/app/components/phlex_kit/context_menu/context_menu_controller.js +18 -1
  55. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  56. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  57. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  58. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  59. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  60. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  61. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  62. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  63. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  64. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -3
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  69. data/app/components/phlex_kit/field/field.css +1 -1
  70. data/app/components/phlex_kit/field/field_error.rb +5 -6
  71. data/app/components/phlex_kit/form_field/form_field_controller.js +35 -16
  72. data/app/components/phlex_kit/form_field/form_field_error.rb +5 -1
  73. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  74. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  75. data/app/components/phlex_kit/input/input.css +1 -1
  76. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  77. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  78. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  79. data/app/components/phlex_kit/input_otp/input_otp_controller.js +13 -0
  80. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  81. data/app/components/phlex_kit/item/item.css +1 -1
  82. data/app/components/phlex_kit/item/item_group.rb +4 -2
  83. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  84. data/app/components/phlex_kit/marker/marker.css +1 -1
  85. data/app/components/phlex_kit/masked_input/masked_input_controller.js +10 -4
  86. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  87. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  88. data/app/components/phlex_kit/menubar/menubar_controller.js +70 -7
  89. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  90. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  91. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +4 -1
  92. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  93. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  94. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  95. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +7 -2
  96. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  97. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  98. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  99. data/app/components/phlex_kit/popover/popover.css +1 -1
  100. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  101. data/app/components/phlex_kit/resizable/resizable_controller.js +13 -0
  102. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  103. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  104. data/app/components/phlex_kit/select/select.css +3 -3
  105. data/app/components/phlex_kit/select/select.rb +4 -1
  106. data/app/components/phlex_kit/select/select_controller.js +25 -0
  107. data/app/components/phlex_kit/select/select_item.rb +5 -1
  108. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  109. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  110. data/app/components/phlex_kit/sidebar/sidebar.css +8 -8
  111. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  115. data/app/components/phlex_kit/slider/slider.css +1 -1
  116. data/app/components/phlex_kit/slider/slider.rb +3 -1
  117. data/app/components/phlex_kit/switch/switch.rb +6 -1
  118. data/app/components/phlex_kit/table/table.css +5 -3
  119. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  120. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  121. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  122. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +2 -2
  123. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
  124. data/app/components/phlex_kit/toast/toast.css +32 -14
  125. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  126. data/app/components/phlex_kit/toast/toast_region.rb +13 -3
  127. data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
  128. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  129. data/app/components/phlex_kit/toggle/toggle.rb +6 -2
  130. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  131. data/app/components/phlex_kit/typography/heading.rb +9 -3
  132. data/app/components/phlex_kit/typography/typography.css +4 -3
  133. data/lib/phlex_kit/version.rb +1 -1
  134. metadata +1 -1
@@ -1,28 +1,42 @@
1
1
  module PhlexKit
2
2
  # Per-page NativeSelect in a GET form that self-submits on change (targeting
3
- # the table's turbo-frame when `frame_id:` is given). See data_table.rb.
3
+ # the table's turbo-frame when `frame_id:` is given). Extra filter params
4
+ # (search/sort/direction/...) survive via `preserved_params:` hidden inputs,
5
+ # mirroring DataTableSearch. See data_table.rb.
4
6
  class DataTablePerPageSelect < BaseComponent
5
- def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], **attrs)
7
+ def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, **attrs)
6
8
  @path = path
7
9
  @name = name
8
10
  @value = value
9
11
  @frame_id = frame_id
10
12
  @options = options
13
+ @preserved_params = preserved_params
11
14
  @attrs = attrs
12
15
  end
13
16
 
14
17
  def view_template
15
- form_attrs = { class: "pk-data-table-per-page", action: @path, method: "get" }
16
- form_attrs[:data] = { turbo_frame: @frame_id } if @frame_id
18
+ form_attrs = {
19
+ class: "pk-data-table-per-page",
20
+ action: @path,
21
+ method: "get",
22
+ # Reuse the search controller for its CSP-safe #submitNow action.
23
+ data: { controller: "phlex-kit--data-table-search" }
24
+ }
25
+ form_attrs[:data][:turbo_frame] = @frame_id if @frame_id
17
26
 
18
27
  form(**mix(form_attrs, @attrs)) do
19
- render NativeSelect.new(name: @name, onchange: safe("this.form.requestSubmit()")) do
28
+ render NativeSelect.new(name: @name, data: { action: "change->phlex-kit--data-table-search#submitNow" }) do
20
29
  @options.each do |opt|
21
30
  option_attrs = { value: opt.to_s }
22
31
  option_attrs[:selected] = true if opt.to_s == @value.to_s
23
32
  option(**option_attrs) { plain opt.to_s }
24
33
  end
25
34
  end
35
+ @preserved_params.each do |k, v|
36
+ next if v.nil? || (v.respond_to?(:empty?) && v.empty?)
37
+ next if k.to_s == @name
38
+ input(type: :hidden, name: k.to_s, value: v.to_s)
39
+ end
26
40
  end
27
41
  end
28
42
  end
@@ -32,6 +32,13 @@ export default class extends Controller {
32
32
  this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue);
33
33
  }
34
34
 
35
+ // Immediate, undebounced submit — used by change-driven controls like the
36
+ // per-page select (CSP-safe replacement for an inline onchange handler).
37
+ submitNow() {
38
+ clearTimeout(this.timer);
39
+ this.element.requestSubmit();
40
+ }
41
+
35
42
  captureBeforeRender() {
36
43
  const input = this.input();
37
44
  if (!input || document.activeElement !== input) return;
@@ -62,6 +69,11 @@ export default class extends Controller {
62
69
  }
63
70
 
64
71
  key() {
65
- return this.element.action || "_";
72
+ // The key must survive the Turbo Frame swap (the form element is replaced),
73
+ // so it's derived from stable markup: the form id when the caller set one,
74
+ // else action URL + search-input name — two same-path forms (e.g. search
75
+ // alongside the per-page select) must not share a focus-restore slot.
76
+ if (this.element.id) return `#${this.element.id}`;
77
+ return `${this.element.action || "_"}::${this.input()?.name ?? ""}`;
66
78
  }
67
79
  }
@@ -24,10 +24,13 @@ module PhlexKit
24
24
  @id = id || "date-picker-#{SecureRandom.hex(4)}"
25
25
  @name = name
26
26
  @label = label
27
- @value = value || selected_date&.to_s
28
- @placeholder = placeholder
29
27
  @selected_date = selected_date
30
28
  @date_format = date_format
29
+ # Seed the input with the same date_format the calendar controller
30
+ # writes — a bare `selected_date.to_s` (ISO) would mismatch the format
31
+ # until the first interaction.
32
+ @value = value || format_selected_date
33
+ @placeholder = placeholder
31
34
  @input_attrs = input_attrs
32
35
  @calendar_attrs = calendar_attrs
33
36
  @trigger_attrs = trigger_attrs
@@ -45,7 +48,7 @@ module PhlexKit
45
48
  end
46
49
  end
47
50
  render PopoverContent.new(**@content_attrs) do
48
- render Calendar.new(input_id: "##{@id}", selected_date: @selected_date, date_format: @date_format, **@calendar_attrs)
51
+ render Calendar.new(input_id: input_selector, selected_date: @selected_date, date_format: @date_format, **@calendar_attrs)
49
52
  end
50
53
  end
51
54
  end
@@ -53,6 +56,50 @@ module PhlexKit
53
56
 
54
57
  private
55
58
 
59
+ # The outlet takes a CSS selector. "#id" is only valid for CSS-identifier
60
+ # ids — anything else (Rails-style "user:due.date" etc) must go through a
61
+ # quoted attribute selector or querySelector throws.
62
+ def input_selector
63
+ return "##{@id}" if @id.match?(/\A[A-Za-z][\w-]*\z/)
64
+
65
+ %([id="#{@id.gsub(/[\\"]/) { |c| "\\#{c}" }}"])
66
+ end
67
+
68
+ # Mirrors the calendar controller's formatDate tokens (calendar_controller.js)
69
+ # so the server-rendered input matches what JS writes after a pick.
70
+ FORMAT_TOKENS = /yyyy|MMMM|MM|dd|HH|mm|ss|EEEE|PPPP|do/
71
+
72
+ def format_selected_date
73
+ return nil unless @selected_date
74
+
75
+ date = @selected_date.respond_to?(:strftime) ? @selected_date : Date.parse(@selected_date.to_s)
76
+ day = date.day
77
+ suffix = day_suffix(day)
78
+ day_name = Date::DAYNAMES[date.wday]
79
+ month_name = Date::MONTHNAMES[date.month]
80
+ map = {
81
+ "yyyy" => date.year.to_s,
82
+ "MMMM" => month_name,
83
+ "MM" => Kernel.format("%02d", date.month),
84
+ "dd" => Kernel.format("%02d", day),
85
+ "HH" => Kernel.format("%02d", date.respond_to?(:hour) ? date.hour : 0),
86
+ "mm" => Kernel.format("%02d", date.respond_to?(:min) ? date.min : 0),
87
+ "ss" => Kernel.format("%02d", date.respond_to?(:sec) ? date.sec : 0),
88
+ "EEEE" => day_name,
89
+ "do" => "#{day}#{suffix}",
90
+ "PPPP" => "#{day_name}, #{month_name} #{day}#{suffix}, #{date.year}"
91
+ }
92
+ @date_format.gsub(FORMAT_TOKENS) { |token| map[token] }
93
+ rescue Date::Error
94
+ @selected_date.to_s
95
+ end
96
+
97
+ def day_suffix(day)
98
+ return "th" if day > 3 && day < 21
99
+
100
+ { 1 => "st", 2 => "nd", 3 => "rd" }.fetch(day % 10, "th")
101
+ end
102
+
56
103
  def trigger_attrs
57
104
  mix({ class: "pk-date-picker-trigger" }, @trigger_attrs)
58
105
  end
@@ -17,12 +17,12 @@
17
17
  border-radius: .75rem; background: var(--pk-surface); color: var(--pk-text);
18
18
  padding: var(--pk-dialog-spacing);
19
19
  font-size: .875rem;
20
- box-shadow: 0 10px 30px rgb(0 0 0 / .35);
20
+ box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
21
21
  }
22
22
  .pk-dialog[open] { display: flex; flex-direction: column; }
23
- .pk-dialog::backdrop { background: rgb(0 0 0 / .1); backdrop-filter: blur(4px); }
23
+ .pk-dialog::backdrop { background: color-mix(in srgb, var(--pk-overlay) 10%, transparent); backdrop-filter: blur(4px); }
24
24
  @supports not (backdrop-filter: blur(1px)) {
25
- .pk-dialog::backdrop { background: rgb(0 0 0 / .5); }
25
+ .pk-dialog::backdrop { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
26
26
  }
27
27
  .pk-dialog.xs { max-width: min(16rem, calc(100% - 2rem)); }
28
28
  .pk-dialog.sm { max-width: min(20rem, calc(100% - 2rem)); }
@@ -51,6 +51,6 @@
51
51
  }
52
52
  .pk-overlay-close:hover { opacity: 1; }
53
53
  @media (min-width: 640px) {
54
- .pk-dialog-header { text-align: left; }
54
+ .pk-dialog-header { text-align: start; }
55
55
  .pk-dialog-footer { flex-direction: row; justify-content: flex-end; }
56
56
  }
@@ -24,10 +24,26 @@ export default class extends Controller {
24
24
  }
25
25
  disconnect() {
26
26
  this.dialogTarget.removeEventListener("close", this.handleClose)
27
- document.body.style.removeProperty("overflow")
27
+ if (this.dialogTarget.open) this.handleClose()
28
+ }
29
+ open(e) {
30
+ e?.preventDefault()
31
+ this.dialogTarget.showModal()
32
+ // Save/restore rather than removeProperty: a host page (or another
33
+ // component) may have set body overflow itself.
34
+ this.previousOverflow = document.body.style.overflow
35
+ document.body.style.overflow = "hidden"
28
36
  }
29
- open(e) { e?.preventDefault(); this.dialogTarget.showModal(); document.body.style.overflow = "hidden" }
30
37
  dismiss() { this.dialogTarget.close() }
31
- backdropClick(e) { if (e.target === this.dialogTarget) this.dismiss() }
32
- handleClose = () => { document.body.style.removeProperty("overflow") }
38
+ // Only a click on the ::backdrop dismisses. e.target is the <dialog> for
39
+ // backdrop clicks, but ALSO for clicks on the dialog's own padding/flex
40
+ // gaps — disambiguate by testing the click point against the dialog's box.
41
+ backdropClick(e) {
42
+ if (e.target !== this.dialogTarget) return
43
+ if (e.detail === 0) return // synthetic/keyboard click — no coordinates
44
+ const r = this.dialogTarget.getBoundingClientRect()
45
+ const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
46
+ if (!inside) this.dismiss()
47
+ }
48
+ handleClose = () => { document.body.style.overflow = this.previousOverflow ?? "" }
33
49
  }
@@ -9,7 +9,7 @@
9
9
  position: fixed;
10
10
  inset: 0;
11
11
  z-index: 50;
12
- background: rgb(0 0 0 / .1);
12
+ background: color-mix(in srgb, var(--pk-overlay) 10%, transparent);
13
13
  backdrop-filter: blur(4px);
14
14
  }
15
15
  .pk-drawer {
@@ -87,8 +87,8 @@
87
87
  padding: 1rem;
88
88
  }
89
89
  @media (min-width: 768px) {
90
- .pk-drawer-header { text-align: left; }
90
+ .pk-drawer-header { text-align: start; }
91
91
  }
92
92
  @supports not (backdrop-filter: blur(1px)) {
93
- .pk-drawer-overlay { background: rgb(0 0 0 / .5); }
93
+ .pk-drawer-overlay { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
94
94
  }
@@ -37,7 +37,7 @@
37
37
  background: var(--pk-surface);
38
38
  color: var(--pk-text);
39
39
  padding: .25rem;
40
- box-shadow: 0 4px 16px rgba(0, 0, 0, .4);
40
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
41
41
  }
42
42
 
43
43
  .pk-dropdown-menu-item {
@@ -76,7 +76,7 @@
76
76
 
77
77
  /* Keyboard hint, pushed to the row's end. */
78
78
  .pk-dropdown-menu-shortcut {
79
- margin-left: auto;
79
+ margin-inline-start: auto;
80
80
  font-size: .75rem;
81
81
  letter-spacing: .1em;
82
82
  color: var(--pk-muted);
@@ -112,7 +112,7 @@
112
112
  The row is its own scoped anchor; the panel flips to the other side at
113
113
  the viewport right edge. */
114
114
  .pk-dropdown-menu-sub { anchor-name: --pk-dropdown-menu-sub; anchor-scope: --pk-dropdown-menu-sub; }
115
- .pk-dropdown-menu-sub-chevron { margin-left: auto; width: 1rem; height: 1rem; }
115
+ .pk-dropdown-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
116
116
  .pk-dropdown-menu-sub-content {
117
117
  position: fixed;
118
118
  position-anchor: --pk-dropdown-menu-sub;
@@ -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)
@@ -21,6 +21,9 @@ export default class extends Controller {
21
21
  this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
22
22
  this.invoker.setAttribute("aria-haspopup", "menu");
23
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();
24
27
  }
25
28
 
26
29
  disconnect() {
@@ -33,6 +36,9 @@ export default class extends Controller {
33
36
  if (!this.contentTarget.matches(":popover-open")) return;
34
37
  if (this.element.contains(event.target)) return;
35
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).
36
42
  event.preventDefault();
37
43
  this.close();
38
44
  }
@@ -104,9 +110,36 @@ export default class extends Controller {
104
110
  this.close();
105
111
  this.invoker.focus();
106
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
+ }
107
131
  }
108
132
  }
109
133
 
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();
141
+ }
142
+
110
143
  // Rows inside a closed submenu are display:none (revealed by CSS on
111
144
  // hover/focus-within) — focus() on them silently fails and the roving
112
145
  // navigation would jam at the submenu boundary.
@@ -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
@@ -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");
@@ -31,41 +31,60 @@ export default class extends Controller {
31
31
  error.preventDefault();
32
32
 
33
33
  this.shouldValidateValue = true;
34
- this.#setErrorMessage();
34
+ this.#setErrorMessage(error);
35
35
  }
36
36
 
37
- onInput() {
38
- this.#setErrorMessage();
37
+ onInput(event) {
38
+ this.#setErrorMessage(event);
39
39
  }
40
40
 
41
- onChange() {
42
- this.#setErrorMessage();
41
+ onChange(event) {
42
+ this.#setErrorMessage(event);
43
43
  }
44
44
 
45
- #setErrorMessage() {
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) {
46
54
  if (!this.shouldValidateValue || !this.hasErrorTarget) return;
47
55
 
56
+ const input = this.#inputFor(event);
48
57
  // aria-invalid drives the red ring for LIVE validation too (input.css
49
58
  // only matched server-rendered attrs); aria-describedby ties the message
50
59
  // to the control for AT.
51
- if (this.errorTarget.id) {
52
- this.inputTarget.setAttribute("aria-describedby", this.errorTarget.id);
53
- }
54
- if (this.inputTarget.validity.valid) {
55
- this.inputTarget.removeAttribute("aria-invalid");
60
+ if (input.validity.valid) {
61
+ input.removeAttribute("aria-invalid");
62
+ this.#describeBy(input, false);
56
63
  this.errorTarget.textContent = "";
57
64
  this.errorTarget.classList.add("pk-hidden");
58
65
  } else {
59
- this.inputTarget.setAttribute("aria-invalid", "true");
60
- this.errorTarget.textContent = this.#getValidationMessage();
66
+ input.setAttribute("aria-invalid", "true");
67
+ this.#describeBy(input, true);
68
+ this.errorTarget.textContent = this.#getValidationMessage(input);
61
69
  this.errorTarget.classList.remove("pk-hidden");
62
70
  }
63
71
  }
64
72
 
65
- #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) {
66
85
  let errorMessage;
67
86
 
68
- const { validity, dataset, validationMessage } = this.inputTarget;
87
+ const { validity, dataset, validationMessage } = input;
69
88
 
70
89
  if (validity.tooLong) errorMessage = dataset.tooLong;
71
90
  if (validity.tooShort) errorMessage = dataset.tooShort;
@@ -5,7 +5,10 @@ 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
 
@@ -13,6 +16,7 @@ module PhlexKit
13
16
  # role="alert": the controller swaps this text live on invalid/input —
14
17
  # without a live region the announcement never reaches screen readers.
15
18
  p(**mix({
19
+ id: @id,
16
20
  class: "pk-form-field-error",
17
21
  role: "alert",
18
22
  data: { phlex_kit__form_field_target: "error" }
@@ -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,6 +23,13 @@ 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