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
@@ -59,8 +59,12 @@ export default class extends Controller {
59
59
  defaultThemeColor(index) {
60
60
  const color = this.seriesColor(index)
61
61
  const translucentFill = ["line", "radar"].includes(this.optionsValue.type)
62
+ // color-mix, not hex + "33": the token may be any CSS color — the shipped
63
+ // zinc/neutral themes use oklch(...), and appending an alpha suffix to
64
+ // those produced invalid colors (fills silently broke).
65
+ const translucent = `color-mix(in oklab, ${color} 20%, transparent)`
62
66
  return {
63
- backgroundColor: this.isCircular() ? undefined : (translucentFill ? color + "33" : color),
67
+ backgroundColor: this.isCircular() ? undefined : (translucentFill ? translucent : color),
64
68
  hoverBackgroundColor: color,
65
69
  borderColor: color,
66
70
  borderWidth: 2,
@@ -76,15 +80,22 @@ export default class extends Controller {
76
80
 
77
81
  setDefaultColorsForChart() {
78
82
  const Chart = this.chartLibrary()
79
- Chart.defaults.color = this.getThemeColor("pk-muted") // font color
80
- Chart.defaults.borderColor = this.getThemeColor("pk-border") // border color
81
- Chart.defaults.backgroundColor = this.getThemeColor("pk-bg") // background color
83
+ // One computed-style resolve for all nine tokens (this runs on every
84
+ // connect; assigning identical values to Chart.defaults is free charts
85
+ // read defaults at construction so the style resolves are the only cost
86
+ // worth trimming).
87
+ const styles = getComputedStyle(document.documentElement)
88
+ const token = (name) => styles.getPropertyValue(`--${name}`).trim()
89
+
90
+ Chart.defaults.color = token("pk-muted") // font color
91
+ Chart.defaults.borderColor = token("pk-border") // border color
92
+ Chart.defaults.backgroundColor = token("pk-bg") // background color
82
93
 
83
94
  // tooltip colors
84
- Chart.defaults.plugins.tooltip.backgroundColor = this.getThemeColor("pk-surface")
85
- Chart.defaults.plugins.tooltip.borderColor = this.getThemeColor("pk-border")
86
- Chart.defaults.plugins.tooltip.titleColor = this.getThemeColor("pk-text")
87
- Chart.defaults.plugins.tooltip.bodyColor = this.getThemeColor("pk-muted")
95
+ Chart.defaults.plugins.tooltip.backgroundColor = token("pk-surface")
96
+ Chart.defaults.plugins.tooltip.borderColor = token("pk-border")
97
+ Chart.defaults.plugins.tooltip.titleColor = token("pk-text")
98
+ Chart.defaults.plugins.tooltip.bodyColor = token("pk-muted")
88
99
  Chart.defaults.plugins.tooltip.borderWidth = 1
89
100
 
90
101
  // legend
@@ -95,7 +106,7 @@ export default class extends Controller {
95
106
  Chart.defaults.plugins.legend.labels.borderRadius = 2
96
107
 
97
108
  // shadcn look: hairline grid, no axis borders, muted ticks
98
- Chart.defaults.scale.grid = { ...Chart.defaults.scale.grid, color: this.getThemeColor("pk-border"), drawTicks: false }
109
+ Chart.defaults.scale.grid = { ...Chart.defaults.scale.grid, color: token("pk-border"), drawTicks: false }
99
110
  Chart.defaults.scale.border = { ...Chart.defaults.scale.border, display: false }
100
111
  }
101
112
 
@@ -104,14 +115,30 @@ export default class extends Controller {
104
115
  this.initChart()
105
116
  }
106
117
 
107
- // The kit themes via <html data-theme="...">, not a class — watch that.
118
+ // The kit themes via <html data-theme="...">, not a class — but a class can
119
+ // re-theme too (.pk-dark, custom theme classes), so watch both. ANY root
120
+ // class mutation fires the observer though, so only rebuild when the
121
+ // resolved token values actually changed — rebuilding every chart on an
122
+ // unrelated class flip is the expensive path.
108
123
  initThemeObserver() {
124
+ this._themeKey = this.currentThemeKey()
109
125
  this.themeObserver = new MutationObserver(() => {
126
+ const key = this.currentThemeKey()
127
+ if (key === this._themeKey) return
128
+ this._themeKey = key
110
129
  this.refreshChart()
111
130
  })
112
131
  this.themeObserver.observe(document.documentElement, { attributeFilter: ["data-theme", "class"] })
113
132
  }
114
133
 
134
+ // Cheap fingerprint of the resolved theme: two tokens that differ between
135
+ // every light/dark pair. One style resolve per mutation vs a full chart
136
+ // rebuild.
137
+ currentThemeKey() {
138
+ const styles = getComputedStyle(document.documentElement)
139
+ return `${styles.getPropertyValue("--pk-bg")}|${styles.getPropertyValue("--pk-text")}`
140
+ }
141
+
115
142
  mergeOptionsWithDefaults() {
116
143
  return {
117
144
  ...this.optionsValue,
@@ -16,7 +16,8 @@
16
16
  height: 1rem;
17
17
  margin: 0;
18
18
  border: 1px solid var(--pk-input);
19
- border-radius: 4px;
19
+ /* Tracks the radius token — 4px at the default --pk-radius (0.625rem). */
20
+ border-radius: calc(var(--pk-radius) - 6px);
20
21
  background: color-mix(in oklab, var(--pk-input) 30%, transparent);
21
22
  cursor: pointer;
22
23
  transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
@@ -4,5 +4,5 @@
4
4
  .pk-clipboard-popover { position: absolute; bottom: calc(100% + .5rem); left: 50%; transform: translateX(-50%); z-index: 50; width: max-content; }
5
5
  .pk-clipboard-popover-inner {
6
6
  border: 1px solid var(--pk-border); border-radius: calc(var(--pk-radius) - 2px); background: var(--pk-surface);
7
- color: var(--pk-text); padding: .125rem .5rem; font-size: .875rem; box-shadow: 0 8px 24px rgb(0 0 0 / .3);
7
+ color: var(--pk-text); padding: .125rem .5rem; font-size: .875rem; box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
8
8
  }
@@ -5,6 +5,13 @@ import { Controller } from "@hotwired/stimulus"
5
5
  export default class extends Controller {
6
6
  static targets = ["trigger", "source", "successPopover", "errorPopover"]
7
7
 
8
+ disconnect() {
9
+ // Don't leave the 1.5s hide timer running into a dead/recycled element
10
+ // (Turbo can detach and re-attach this subtree mid-flash).
11
+ clearTimeout(this.timer)
12
+ this.hideAll()
13
+ }
14
+
8
15
  copy() {
9
16
  const el = this.sourceTarget.children[0]
10
17
  if (!el) { this.show(this.errorPopoverTarget); return }
@@ -6,7 +6,9 @@ module PhlexKit
6
6
  @attrs = attrs
7
7
  end
8
8
  def view_template(&block)
9
- div(class: "pk-clipboard-popover pk-hidden", data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
9
+ # role="status": the shown/hidden confirmation is a live region, so
10
+ # screen readers announce "Copied!" instead of a silent visual flash.
11
+ div(class: "pk-clipboard-popover pk-hidden", role: "status", data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
10
12
  div(**mix({ class: "pk-clipboard-popover-inner" }, @attrs), &block)
11
13
  end
12
14
  end
@@ -9,7 +9,10 @@ module PhlexKit
9
9
  @attrs = attrs
10
10
  end
11
11
  def view_template(&block)
12
- div(**mix({ class: "pk-codeblock", data: { syntax: @syntax } }, @attrs)) do
12
+ # tabindex=0 + role=region: the block scrolls (overflow:auto), so it must
13
+ # be keyboard-focusable (WCAG 2.1.1 scrollable-region-focusable). Pass
14
+ # `aria: { label: ... }` to name the region for AT.
15
+ div(**mix({ class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }, @attrs)) do
13
16
  pre do
14
17
  if block
15
18
  code(&block)
@@ -1,8 +1,21 @@
1
1
  module PhlexKit
2
+ # Collapsed region of a Collapsible. Pass `open:` matching the parent
3
+ # Collapsible's so a closed region is hidden before JS (no flash-open, and
4
+ # no stuck-open region without JS); the controller toggles `.pk-hidden`
5
+ # from there. The default id lets the trigger's aria-controls point here.
2
6
  class CollapsibleContent < BaseComponent
3
- def initialize(**attrs) = (@attrs = attrs)
7
+ def initialize(open: false, id: nil, **attrs)
8
+ @open = open
9
+ @id = id || "pk-collapsible-content-#{SecureRandom.hex(4)}"
10
+ @attrs = attrs
11
+ end
12
+
4
13
  def view_template(&)
5
- div(**mix({ class: "pk-collapsible-content", data: { phlex_kit__collapsible_target: "content" } }, @attrs), &)
14
+ div(**mix({
15
+ class: @open ? "pk-collapsible-content" : "pk-collapsible-content pk-hidden",
16
+ id: @id,
17
+ data: { phlex_kit__collapsible_target: "content" }
18
+ }, @attrs), &)
6
19
  end
7
20
  end
8
21
  end
@@ -2,20 +2,29 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--collapsible"
4
4
  export default class extends Controller {
5
- static targets = ["content"]
5
+ static targets = ["trigger", "content"]
6
6
  static values = { open: { type: Boolean, default: false } }
7
7
 
8
- connect() { this.openValue ? this.open() : this.close() }
8
+ connect() {
9
+ // The trigger renders aria-expanded from its own `open:` kwarg but can't
10
+ // know the content's id at render time — wire aria-controls here.
11
+ if (this.hasTriggerTarget && this.hasContentTarget && this.contentTarget.id) {
12
+ this.triggerTarget.setAttribute("aria-controls", this.contentTarget.id)
13
+ }
14
+ this.openValue ? this.open() : this.close()
15
+ }
9
16
  toggle() { this.openValue = !this.openValue }
10
17
  openValueChanged(isOpen) { isOpen ? this.open() : this.close() }
11
18
 
12
19
  open() {
13
20
  if (this.hasContentTarget) { this.contentTarget.classList.remove("pk-hidden"); this.openValue = true }
21
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true")
14
22
  this.element.dataset.state = "open";
15
23
  }
16
24
 
17
25
  close() {
18
26
  if (this.hasContentTarget) { this.contentTarget.classList.add("pk-hidden"); this.openValue = false }
27
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false")
19
28
  this.element.dataset.state = "closed";
20
29
  }
21
30
  }
@@ -1,8 +1,23 @@
1
1
  module PhlexKit
2
+ # Toggle for a Collapsible. Renders a plain wrapper carrying the click
3
+ # action — it deliberately has no role of its own, so nest a real focusable
4
+ # control (a <button>, e.g. PhlexKit::Button) inside it for keyboard
5
+ # operability and semantics, the same composition pattern as
6
+ # AttachmentTrigger. Pass `open:` matching the parent Collapsible's so
7
+ # aria-expanded is correct before JS; the controller keeps it in sync and
8
+ # wires aria-controls to the content's id in connect().
2
9
  class CollapsibleTrigger < BaseComponent
3
- def initialize(**attrs) = (@attrs = attrs)
10
+ def initialize(open: false, **attrs)
11
+ @open = open
12
+ @attrs = attrs
13
+ end
14
+
4
15
  def view_template(&)
5
- div(**mix({ class: "pk-collapsible-trigger", data: { action: "click->phlex-kit--collapsible#toggle" } }, @attrs), &)
16
+ div(**mix({
17
+ class: "pk-collapsible-trigger",
18
+ aria_expanded: @open ? "true" : "false",
19
+ data: { phlex_kit__collapsible_target: "trigger", action: "click->phlex-kit--collapsible#toggle" }
20
+ }, @attrs), &)
6
21
  end
7
22
  end
8
23
  end
@@ -41,7 +41,7 @@
41
41
  .pk-combobox-trigger:disabled,
42
42
  .pk-combobox-trigger[aria-disabled="true"] { pointer-events: none; opacity: .5; }
43
43
  .pk-combobox-trigger-content { overflow: hidden; text-overflow: ellipsis; }
44
- .pk-combobox-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-left: .5rem; opacity: .5; }
44
+ .pk-combobox-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-inline-start: .5rem; opacity: .5; }
45
45
 
46
46
  /* Dropdown panel — anchored below the combobox, flips above at the viewport
47
47
  bottom. Width: trigger width with a 15rem floor (was JS
@@ -58,7 +58,7 @@
58
58
  border-radius: var(--pk-radius);
59
59
  background: var(--pk-surface);
60
60
  color: var(--pk-text);
61
- box-shadow: 0 4px 16px rgba(0, 0, 0, .4);
61
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
62
62
  }
63
63
 
64
64
  /* Search row. */
@@ -69,7 +69,7 @@
69
69
  border-bottom: 1px solid var(--pk-border);
70
70
  color: var(--pk-muted);
71
71
  }
72
- .pk-combobox-search-icon { flex: none; width: 1rem; height: 1rem; margin-right: .5rem; opacity: .5; }
72
+ .pk-combobox-search-icon { flex: none; width: 1rem; height: 1rem; margin-inline-end: .5rem; opacity: .5; }
73
73
  .pk-combobox-search-input {
74
74
  width: 100%;
75
75
  height: 2rem;
@@ -95,8 +95,10 @@
95
95
  color: var(--pk-text);
96
96
  }
97
97
 
98
- /* Labelled group — the label renders from the `label` attribute, and the group
99
- hides itself (pure CSS) when filtering leaves no visible item inside. */
98
+ /* Labelled group — the label renders from the `data-label` attribute (the
99
+ invalid bare `label` attribute was dropped; aria-label carries it for AT),
100
+ and the group hides itself (pure CSS) when filtering leaves no visible item
101
+ inside. */
100
102
  .pk-combobox-group { display: none; }
101
103
  .pk-combobox-group:has(label:not(.pk-hidden)) {
102
104
  display: flex;
@@ -106,7 +108,7 @@
106
108
  border-bottom: 1px solid var(--pk-border);
107
109
  }
108
110
  .pk-combobox-group::before {
109
- content: attr(label);
111
+ content: attr(data-label);
110
112
  padding: .375rem .5rem;
111
113
  font-size: .75rem;
112
114
  font-weight: 500;
@@ -159,7 +161,7 @@
159
161
 
160
162
  /* Trailing ✓, visible while the row's input is checked. */
161
163
  .pk-combobox-item-indicator {
162
- margin-left: auto;
164
+ margin-inline-start: auto;
163
165
  width: 1rem;
164
166
  height: 1rem;
165
167
  flex: none;
@@ -314,7 +316,7 @@
314
316
  align-items: center;
315
317
  justify-content: center;
316
318
  flex: none;
317
- margin-left: auto;
319
+ margin-inline-start: auto;
318
320
  border: 0;
319
321
  border-radius: .125rem;
320
322
  padding: 0;
@@ -37,23 +37,37 @@ export default class extends Controller {
37
37
 
38
38
  selectedItemIndex = null
39
39
 
40
+ initialize() {
41
+ // Stimulus fires [target]Connected before connect(): anything
42
+ // itemTargetConnected uses must be initialized here.
43
+ this.itemIdCounter = 0
44
+ this.refocusing = false
45
+ }
46
+
40
47
  connect() {
41
48
  this.generateItemIds()
42
49
  this.updateTriggerContent()
43
50
  }
44
51
 
45
- // ARIA plumbing: every option gets an id derived from the listbox id so the
46
- // combobox element can point aria-activedescendant at the keyboard highlight,
47
- // and aria-controls is wired to the listbox (mirrors select_controller.js).
52
+ // ARIA plumbing: aria-controls is wired to the listbox (mirrors
53
+ // select_controller.js); the per-option ids come from itemTargetConnected.
48
54
  generateItemIds() {
55
+ this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-controls", this.listId()))
56
+ }
57
+
58
+ listId() {
49
59
  const list = this.hasListTarget ? this.listTarget : (this.hasPopoverTarget ? this.popoverTarget : this.element)
50
60
  if (!list.id) list.id = `pk-combobox-list-${Math.random().toString(36).slice(2, 10)}`
61
+ return list.id
62
+ }
51
63
 
52
- this.itemTargets.forEach((item, index) => {
53
- if (!item.id) item.id = `${list.id}-${index}`
54
- })
55
-
56
- this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-controls", list.id))
64
+ // Every option gets an id derived from the listbox id so the combobox
65
+ // element can point aria-activedescendant at the keyboard highlight. A
66
+ // target callback (not a connect() loop) so options added after connect —
67
+ // e.g. rendered in by the host — get ids too. Counter-based, so a removed
68
+ // option's id is never reissued to a later arrival.
69
+ itemTargetConnected(item) {
70
+ if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`
57
71
  }
58
72
 
59
73
  // The elements carrying the open/closed combobox state: the trigger button
@@ -120,7 +134,13 @@ export default class extends Controller {
120
134
 
121
135
  toggleAllItems() {
122
136
  const isChecked = this.toggleAllTarget.checked
123
- this.inputTargets.forEach(input => input.checked = isChecked)
137
+ this.inputTargets.forEach(input => {
138
+ if (input.checked === isChecked) return
139
+ input.checked = isChecked
140
+ // Programmatic .checked flips fire no event — host listeners and the
141
+ // form-field validation must still hear select-all (uncheck() does this).
142
+ input.dispatchEvent(new Event("change", { bubbles: true }))
143
+ })
124
144
  this.updateTriggerContent()
125
145
  }
126
146
 
@@ -207,7 +227,11 @@ export default class extends Controller {
207
227
  e.preventDefault()
208
228
  e.stopPropagation() // the button sits inside the trigger's click-to-open area
209
229
  }
210
- this.inputTargets.forEach(input => input.checked = false)
230
+ this.inputTargets.forEach(input => {
231
+ if (!input.checked) return
232
+ input.checked = false
233
+ input.dispatchEvent(new Event("change", { bubbles: true })) // see toggleAllItems
234
+ })
211
235
  if (this.hasToggleAllTarget) this.toggleAllTarget.checked = false
212
236
  this.updateTriggerContent()
213
237
  }
@@ -223,6 +247,14 @@ export default class extends Controller {
223
247
  }
224
248
 
225
249
  openPopover(event) {
250
+ // The remove-× on a chip and the clear-all button live inside the badge
251
+ // trigger, whose focusin (and click) actions open the popover — clicking
252
+ // them must not reopen it (stopPropagation covers click but focusin still
253
+ // fires when the button takes focus).
254
+ if (event?.target?.closest?.(".pk-combobox-badge-remove, .pk-combobox-clear-button")) return
255
+ // closePopover's focus restore fires the input/badge wrapper's
256
+ // focusin->openPopover action synchronously — don't bounce straight back open.
257
+ if (this.refocusing) return
226
258
  if (event) event.preventDefault()
227
259
  if (this.isOpen()) return
228
260
 
@@ -242,12 +274,31 @@ export default class extends Controller {
242
274
  }
243
275
 
244
276
  closePopover() {
277
+ // Hiding the popover while focus is inside it (search input, option) drops
278
+ // focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
279
+ // only when focus would actually be orphaned — a click elsewhere on the
280
+ // page (onClickOutside) must not have its focus stolen.
281
+ const focusWasInside = this.hasPopoverTarget && this.popoverTarget.contains(document.activeElement)
282
+
245
283
  this.setExpanded(false)
246
284
  // aria-activedescendant holds an element id; on close it must be removed,
247
285
  // not left pointing at a hidden option.
248
286
  this.clearActiveDescendant()
249
287
  if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
250
288
  this.updateTriggerContent() // reflect the selection into an input trigger
289
+
290
+ if (focusWasInside) {
291
+ const field = this.filterField()
292
+ // Input/badge layouts: back to the filter field (it lives outside the
293
+ // popover). Button layout: back to the trigger button (its search input
294
+ // is inside the popover, now hidden).
295
+ const anchor = (field && !this.popoverTarget.contains(field)) ? field : (this.hasTriggerTarget ? this.triggerTarget : null)
296
+ // focus() dispatches focusin synchronously; the flag stops the
297
+ // wrapper's focusin->openPopover action from reopening the panel.
298
+ this.refocusing = true
299
+ anchor?.focus()
300
+ this.refocusing = false
301
+ }
251
302
  }
252
303
 
253
304
  onClickOutside(event) {
@@ -267,7 +318,9 @@ export default class extends Controller {
267
318
  }
268
319
 
269
320
  filterItems(e) {
270
- if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) {
321
+ // Escape's keyup arrives after keydown.esc already closed the popover —
322
+ // re-running the filter then would wipe the highlight state for nothing.
323
+ if (["ArrowDown", "ArrowUp", "Tab", "Enter", "Escape"].includes(e.key)) {
271
324
  return
272
325
  }
273
326
 
@@ -287,6 +340,9 @@ export default class extends Controller {
287
340
 
288
341
  this.selectedItemIndex = null
289
342
  this.clearActiveDescendant()
343
+ // Also drop the highlight itself: a filtered-out option that kept
344
+ // aria-current="true" stayed the Enter target while invisible.
345
+ this.itemTargets.forEach(item => item.ariaCurrent = "false")
290
346
 
291
347
  this.inputTargets.forEach((input) => {
292
348
  const text = this.inputContent(input).toLowerCase()
@@ -1,14 +1,22 @@
1
1
  module PhlexKit
2
- # Labelled option group. Pass `label:` as a plain attribute CSS renders it via
3
- # ::before content(attr(label)), and the group auto-hides (pure CSS :has) when
4
- # filtering leaves it with no visible items. See combobox.rb.
2
+ # Labelled option group. Pass `label:` it renders as aria-label (a bare
3
+ # `label` attribute is invalid on <div> and silent to AT) plus data-label,
4
+ # which CSS renders via ::before content(attr(data-label)). The group
5
+ # auto-hides (pure CSS :has) when filtering leaves it with no visible items.
6
+ # See combobox.rb.
5
7
  class ComboboxListGroup < BaseComponent
6
- def initialize(**attrs)
8
+ def initialize(label: nil, **attrs)
9
+ @label = label
7
10
  @attrs = attrs
8
11
  end
9
12
 
10
13
  def view_template(&)
11
- div(**mix({ class: "pk-combobox-group", role: "group" }, @attrs), &)
14
+ div(**mix({
15
+ class: "pk-combobox-group",
16
+ role: "group",
17
+ aria: { label: @label },
18
+ data: { label: @label }
19
+ }, @attrs), &)
12
20
  end
13
21
  end
14
22
  end
@@ -19,7 +19,7 @@
19
19
  inset: 0;
20
20
  z-index: 50;
21
21
  pointer-events: auto;
22
- background: rgb(0 0 0 / .1);
22
+ background: color-mix(in srgb, var(--pk-overlay) 10%, transparent);
23
23
  backdrop-filter: blur(4px);
24
24
  }
25
25
 
@@ -37,7 +37,7 @@
37
37
  border-radius: .75rem;
38
38
  background: var(--pk-surface);
39
39
  color: var(--pk-text);
40
- box-shadow: 0 10px 30px rgb(0 0 0 / .35);
40
+ box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
41
41
  }
42
42
  .pk-command-dialog.xs { max-width: 24rem; }
43
43
  .pk-command-dialog.sm { max-width: 28rem; }
@@ -121,7 +121,7 @@
121
121
  .pk-command-item[data-disabled] { pointer-events: none; opacity: .5; }
122
122
 
123
123
  .pk-command-shortcut {
124
- margin-left: auto;
124
+ margin-inline-start: auto;
125
125
  font-size: .75rem;
126
126
  letter-spacing: .1em;
127
127
  color: var(--pk-muted);
@@ -136,5 +136,5 @@
136
136
  font-size: .875rem;
137
137
  }
138
138
  @supports not (backdrop-filter: blur(1px)) {
139
- .pk-command-overlay { background: rgb(0 0 0 / .5); }
139
+ .pk-command-overlay { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
140
140
  }
@@ -47,11 +47,19 @@ export default class extends Controller {
47
47
  }
48
48
 
49
49
  this.generateItemIds();
50
- this.inputTarget.focus();
50
+ // Only the cloned dialog overlay grabs focus on connect — an inline
51
+ // palette connecting at page load must not steal it.
52
+ if (this.isDialogClone()) this.inputTarget.focus();
51
53
  this.searchIndex = this.buildSearchIndex();
52
54
  this.toggleVisibility(this.emptyTargets, false);
53
55
  }
54
56
 
57
+ // The command-dialog controller clones its <template> content into <body>;
58
+ // the clone's wrapper carries this marker (CommandDialogContent renders it).
59
+ isDialogClone() {
60
+ return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
61
+ }
62
+
55
63
  // ARIA plumbing: every result gets an id derived from the listbox id so the
56
64
  // input can point aria-activedescendant at the keyboard highlight, and
57
65
  // aria-controls is wired to the listbox (mirrors select_controller.js).
@@ -67,15 +75,37 @@ export default class extends Controller {
67
75
  }
68
76
 
69
77
  dismiss() {
70
- // allow scroll on body
71
- document.body.style.removeProperty("overflow");
72
- const previous = this.previouslyFocused;
73
- // remove the element
74
- this.element.remove();
75
- // restore focus to wherever it was before the palette opened
76
- if (previous instanceof HTMLElement && document.contains(previous)) {
77
- previous.focus();
78
+ // Cloned dialog overlay: tear the clone down and hand focus back.
79
+ if (this.isDialogClone()) {
80
+ // allow scroll on body
81
+ document.body.style.removeProperty("overflow");
82
+ const previous = this.previouslyFocused;
83
+ // remove the element
84
+ this.element.remove();
85
+ // restore focus to wherever it was before the palette opened
86
+ if (previous instanceof HTMLElement && document.contains(previous)) {
87
+ previous.focus();
88
+ }
89
+ return;
78
90
  }
91
+
92
+ // Inline palette: removing the element would delete it permanently.
93
+ // Reset the query instead and let the host react via the dispatched
94
+ // phlex-kit--command:dismiss event.
95
+ if (this.hasInputTarget && this.inputTarget.value !== "") {
96
+ this.inputTarget.value = "";
97
+ this.deselectAll();
98
+ this.resetVisibility();
99
+ this.announceResultCount(null);
100
+ }
101
+ this.dispatch("dismiss");
102
+ }
103
+
104
+ // Guards the items' default href="#": without it an Enter-synthesized
105
+ // click() or a mouse click scrolls to top and appends # to the URL
106
+ // (mirrors dropdown_menu_controller's close() guard).
107
+ onItemClick(event) {
108
+ if (event?.target?.closest?.('a[href="#"]')) event.preventDefault();
79
109
  }
80
110
 
81
111
  // Focus trap for the cloned dialog overlay: Tab / Shift+Tab cycle among the
@@ -173,16 +203,25 @@ export default class extends Controller {
173
203
 
174
204
  // Upstream builds a Fuse index here; this keeps the same search() shape —
175
205
  // [{ item }] sorted best-match-first — using the fuzzy scorer above.
206
+ // Both data-value and data-text are searchable (an item's `text:` was
207
+ // rendered but never indexed); the better of the two scores wins.
176
208
  buildSearchIndex() {
177
209
  const items = this.itemTargets.map((el) => ({
178
210
  value: (el.dataset.value || "").toLowerCase(),
211
+ text: (el.dataset.text || "").toLowerCase(),
179
212
  element: el,
180
213
  }));
181
214
  return {
182
215
  search(query) {
183
216
  const q = query.toLowerCase();
184
217
  return items
185
- .map((item) => ({ item, score: fuzzyScore(q, item.value) }))
218
+ .map((item) => {
219
+ const scores = [item.value, item.text]
220
+ .filter(Boolean)
221
+ .map((field) => fuzzyScore(q, field))
222
+ .filter((score) => score !== null);
223
+ return { item, score: scores.length ? Math.max(...scores) : null };
224
+ })
186
225
  .filter((result) => result.score !== null)
187
226
  .sort((a, b) => b.score - a.score);
188
227
  },
@@ -225,6 +264,8 @@ export default class extends Controller {
225
264
  }
226
265
 
227
266
  this.toggleAriaSelected(visibleItems[this.selectedIndex], true);
267
+ // Keep the highlight visible inside the max-height scrollable list.
268
+ visibleItems[this.selectedIndex].scrollIntoView({ block: "nearest" });
228
269
 
229
270
  // Focus stays in the input; the highlighted option is exposed via
230
271
  // aria-activedescendant (result ids come from generateItemIds).
@@ -1,7 +1,9 @@
1
1
  module PhlexKit
2
2
  # Wrapper wiring a CommandDialogTrigger to its CommandDialogContent. The
3
- # outlet selector finds the live palette instance (if already open) so a
4
- # second trigger press just refocuses the input. See command.rb.
3
+ # controller tracks its own live clone (sheet pattern) so a second trigger
4
+ # press just refocuses the input per instance, unlike the old
5
+ # document-scoped outlet selector, which let one dialog adopt another's
6
+ # open palette. See command.rb.
5
7
  class CommandDialog < BaseComponent
6
8
  def initialize(**attrs)
7
9
  @attrs = attrs
@@ -9,10 +11,7 @@ module PhlexKit
9
11
 
10
12
  def view_template(&)
11
13
  div(**mix({
12
- data: {
13
- controller: "phlex-kit--command-dialog",
14
- phlex_kit__command_dialog_phlex_kit__command_outlet: "[data-phlex-kit--command-dialog-instance]"
15
- }
14
+ data: { controller: "phlex-kit--command-dialog" }
16
15
  }, @attrs), &)
17
16
  end
18
17
  end