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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f17bf0029690be129f5e5d77449b9ebb5632d22151ed3508789683b135a7e03f
4
- data.tar.gz: 2145e97971f4fba1e283d979f454562839a76bae767c29755d3e9ff01639e306
3
+ metadata.gz: f52a630c21854a2d2823951ef321a7749ff01adf9d5f60b0c3483aa635407aaa
4
+ data.tar.gz: c0a594263a95f57bf56b20955054369cc251d58fb6ecbc2bd22b45aa3b4dd278
5
5
  SHA512:
6
- metadata.gz: 67b7a4e80b51796f3bfc1177be569bf13d064e01b491c5be427baf7275ca87f4fd45d243dfd5f3bdf49fd48735b00fe084ea17e93d48cca62d5b28401c33ca0e
7
- data.tar.gz: fd0c992227463320e3869ac7917b8ac63c7bbbd5bc468388de7ef00cbec003e0f333c37dde32711c8fed263bb95abbdde2b9bef1789990e8070f4b2b6fc16d29
6
+ metadata.gz: 5c1b054a1ce8c3c1f01e4530dccdda7e23b013e2e0b3ccd966959d9419b784c462a2da725c9eefbb78b2d71675edc2d553de42745675c768c5621b31c0a41685
7
+ data.tar.gz: ee5a88c2c3615582fd51ca3b75d9574aba7dfedadca8e1fe2879370e052b311f2904a6ca3469ceba6fd256392becec583d3ae14c0ccb8df6c6196ab2b7cddd17
@@ -140,6 +140,13 @@
140
140
  --pk-chart-3: #2563eb;
141
141
  --pk-chart-4: #1d4ed8;
142
142
  --pk-chart-5: #1e40af;
143
+ /* Shadow/overlay base colors — bare colors; each rule applies its own alpha
144
+ via color-mix(in srgb, var(--pk-*) N%, transparent). shadcn/ui keeps
145
+ shadows black in both modes (tailwind's default shadow color), so these
146
+ are identical in every theme block; restated in the light blocks per the
147
+ cascade-trap convention anyway. */
148
+ --pk-shadow-color: #000000;
149
+ --pk-overlay: #000000;
143
150
  --pk-radius: 0.625rem;
144
151
  --pk-font-sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
145
152
  --pk-font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
@@ -163,6 +170,8 @@
163
170
  --pk-green: #16a34a;
164
171
  --pk-amber: #d97706;
165
172
  --pk-red: #e40014;
173
+ --pk-shadow-color: #000000;
174
+ --pk-overlay: #000000;
166
175
  color-scheme: light;
167
176
  }
168
177
 
@@ -183,6 +192,8 @@
183
192
  --pk-green: #16a34a;
184
193
  --pk-amber: #d97706;
185
194
  --pk-red: #e40014;
195
+ --pk-shadow-color: #000000;
196
+ --pk-overlay: #000000;
186
197
  color-scheme: light;
187
198
  }
188
199
  }
@@ -5,8 +5,8 @@
5
5
  .pk-accordion-heading { margin: 0; display: flex; font-size: inherit; font-weight: inherit; line-height: inherit; }
6
6
  .pk-accordion-trigger {
7
7
  width: 100%; display: flex; flex: 1; align-items: center; justify-content: space-between;
8
- padding-block: 1rem; font-size: .875rem; font-weight: 500; text-align: left;
9
- background: none; border: 0; cursor: pointer; color: inherit; transition: all .15s ease;
8
+ padding-block: 1rem; font-size: .875rem; font-weight: 500; text-align: start;
9
+ background: none; border: 0; cursor: pointer; color: inherit; transition: color .15s ease, background-color .15s ease, text-decoration-color .15s ease;
10
10
  }
11
11
  .pk-accordion-trigger:hover { text-decoration: underline; }
12
12
  .pk-accordion-trigger-label { flex: 1; }
@@ -1,10 +1,19 @@
1
1
  module PhlexKit
2
2
  class AccordionContent < BaseComponent
3
- def initialize(**attrs) = (@attrs = attrs)
3
+ # Pass `open: true` alongside AccordionItem(open: true) so the content is
4
+ # readable without JS — the closed-state hidden + height:0 otherwise makes
5
+ # a server-rendered open item unreachable pre-hydration.
6
+ def initialize(open: false, **attrs)
7
+ @open = open
8
+ @attrs = attrs
9
+ end
10
+
4
11
  def view_template(&)
5
12
  # role="region" + aria-labelledby → trigger id (wired by the controller
6
13
  # in connect(), which also assigns the ids), per the APG accordion pattern.
7
- div(**mix({ class: "pk-accordion-content", role: "region", data: { phlex_kit__accordion_target: "content", state: "closed" }, style: "height: 0px;", hidden: true }, @attrs), &)
14
+ base = { class: "pk-accordion-content", role: "region", data: { phlex_kit__accordion_target: "content", state: @open ? "open" : "closed" } }
15
+ base.merge!(style: "height: 0px;", hidden: true) unless @open
16
+ div(**mix(base, @attrs), &)
8
17
  end
9
18
  end
10
19
  end
@@ -15,8 +15,11 @@ export default class extends Controller {
15
15
 
16
16
  connect() {
17
17
  this.wireAria()
18
+ // Apply the initial state without animating (the initial valueChanged
19
+ // callback is skipped — it fires before connect()).
18
20
  const d = this.animationDurationValue
19
21
  this.animationDurationValue = 0
22
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", this.openValue ? "true" : "false")
20
23
  this.openValue ? this.open() : this.close()
21
24
  this.animationDurationValue = d
22
25
  }
@@ -34,11 +37,18 @@ export default class extends Controller {
34
37
  if (!root || root.dataset.type !== "single") return
35
38
  root.querySelectorAll('[data-controller~="phlex-kit--accordion"]').forEach((el) => {
36
39
  if (el === this.element) return
40
+ // Skip items of a NESTED accordion — querySelectorAll descends into
41
+ // item content, and opening an outer item collapsed inner ones too.
42
+ if (el.closest(".pk-accordion") !== root) return
37
43
  const ctrl = this.application.getControllerForElementAndIdentifier(el, "phlex-kit--accordion")
38
44
  if (ctrl && ctrl.openValue) ctrl.openValue = false
39
45
  })
40
46
  }
41
- openValueChanged(isOpen) {
47
+ openValueChanged(isOpen, wasOpen) {
48
+ // Stimulus fires the initial value callback BEFORE connect() — before
49
+ // connect zeroes the duration — so a server-rendered open item visibly
50
+ // animated open on every page load. connect() applies the initial state.
51
+ if (wasOpen === undefined) return
42
52
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
43
53
  isOpen ? this.open() : this.close()
44
54
  }
@@ -73,14 +83,23 @@ export default class extends Controller {
73
83
  if (this.hasIconTarget) this.rotate()
74
84
  }
75
85
 
86
+ // Honor prefers-reduced-motion: collapse the grow/shrink to a jump-cut
87
+ // (duration 0 keeps the finished-promise bookkeeping intact).
88
+ get effectiveDuration() {
89
+ return matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : this.animationDurationValue
90
+ }
91
+
76
92
  reveal() {
77
93
  const c = this.contentTarget
78
94
  c.removeAttribute("hidden")
79
95
  c.dataset.state = "open"
80
96
  const h = c.scrollHeight
81
- c.animate([{ height: c.style.height || "0px" }, { height: `${h}px` }],
82
- { duration: this.animationDurationValue, easing: "ease-in-out" })
97
+ const a = c.animate([{ height: c.style.height || "0px" }, { height: `${h}px` }],
98
+ { duration: this.effectiveDuration, easing: "ease-in-out" })
83
99
  c.style.height = `${h}px`
100
+ // Settle at auto, not the frozen pixel height — with overflow hidden a
101
+ // later content growth (or a resize that rewraps text) silently clipped.
102
+ a.finished.then(() => { if (c.dataset.state === "open") c.style.height = "auto" }).catch(() => {})
84
103
  }
85
104
 
86
105
  hide() {
@@ -88,7 +107,7 @@ export default class extends Controller {
88
107
  c.dataset.state = "closed"
89
108
  const from = c.scrollHeight
90
109
  const a = c.animate([{ height: `${from}px` }, { height: "0px" }],
91
- { duration: this.animationDurationValue, easing: "ease-in-out" })
110
+ { duration: this.effectiveDuration, easing: "ease-in-out" })
92
111
  c.style.height = "0px"
93
112
  a.finished.then(() => { if (c.dataset.state === "closed") c.setAttribute("hidden", "") }).catch(() => {})
94
113
  }
@@ -14,13 +14,13 @@
14
14
  padding: .5rem .625rem;
15
15
  font-size: .875rem;
16
16
  line-height: 1.25rem;
17
- text-align: left;
17
+ text-align: start;
18
18
  background: var(--pk-surface);
19
19
  color: var(--pk-text);
20
20
  }
21
21
  .pk-alert:has(> .pk-alert-action) {
22
22
  position: relative;
23
- padding-right: 4.5rem;
23
+ padding-inline-end: 4.5rem;
24
24
  }
25
25
  .pk-alert:has(> svg) {
26
26
  grid-template-columns: auto 1fr;
@@ -66,5 +66,5 @@
66
66
  .pk-alert-action {
67
67
  position: absolute;
68
68
  top: .5rem;
69
- right: .5rem;
69
+ inset-inline-end: .5rem;
70
70
  }
@@ -8,7 +8,7 @@
8
8
  position: fixed;
9
9
  inset: 0;
10
10
  z-index: 50;
11
- background: rgb(0 0 0 / .1);
11
+ background: color-mix(in srgb, var(--pk-overlay) 10%, transparent);
12
12
  backdrop-filter: blur(4px);
13
13
  }
14
14
  .pk-alert-dialog-panel {
@@ -30,7 +30,7 @@
30
30
  color: var(--pk-text);
31
31
  --pk-dialog-spacing: 1.5rem;
32
32
  padding: var(--pk-dialog-spacing);
33
- box-shadow: 0 10px 40px rgba(0, 0, 0, .5);
33
+ box-shadow: 0 10px 40px color-mix(in srgb, var(--pk-shadow-color) 50%, transparent);
34
34
  }
35
35
  .pk-alert-dialog-panel.sm { max-width: 24rem; --pk-dialog-spacing: 1rem; gap: .75rem; }
36
36
  .pk-alert-dialog-panel.sm .pk-alert-dialog-title { font-size: 1rem; }
@@ -77,5 +77,5 @@
77
77
  background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent);
78
78
  }
79
79
  @supports not (backdrop-filter: blur(1px)) {
80
- .pk-alert-dialog-overlay { background: rgb(0 0 0 / .5); }
80
+ .pk-alert-dialog-overlay { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
81
81
  }
@@ -1,7 +1,9 @@
1
1
  module PhlexKit
2
2
  # The modal body, held in a <template> and cloned into <body> on open. The
3
3
  # cloned div carries its own `phlex-kit--alert-dialog` controller so Cancel
4
- # (#dismiss) can remove it. Holds Header + Footer. See alert_dialog.rb.
4
+ # (#dismiss) can remove it. Holds Header + Footer. Caller **attrs land on the
5
+ # visible panel (the role="alertdialog" div), not the inert <template>.
6
+ # See alert_dialog.rb.
5
7
  class AlertDialogContent < BaseComponent
6
8
  # size => panel modifier; :sm is shadcn's compact variant.
7
9
  SIZES = { default: nil, sm: "sm" }.freeze
@@ -12,10 +14,15 @@ module PhlexKit
12
14
  end
13
15
 
14
16
  def view_template(&block)
15
- template(**mix({ data: { phlex_kit__alert_dialog_target: "content" } }, @attrs)) do
17
+ template(data: { phlex_kit__alert_dialog_target: "content" }) do
18
+ # The clone's controller listens for keydown on `document` while open
19
+ # (an element-scoped listener dies the moment focus escapes to <body>);
20
+ # mousedown on the overlay is prevented so a stray click can't move
21
+ # focus out of the trap. tabindex="-1" on the panel is the focus
22
+ # fallback when the dialog has no focusable children.
16
23
  div(data: { controller: "phlex-kit--alert-dialog" }) do
17
- div(class: "pk-alert-dialog-overlay", "aria-hidden": "true")
18
- div(role: "alertdialog", "aria-modal": "true", class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" "), &block)
24
+ div(class: "pk-alert-dialog-overlay", "aria-hidden": "true", data: { action: "mousedown->phlex-kit--alert-dialog#overlayMousedown" })
25
+ div(**mix({ role: "alertdialog", "aria-modal": "true", tabindex: "-1", class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" ") }, @attrs), &block)
19
26
  end
20
27
  end
21
28
  end
@@ -1,24 +1,141 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Ported from ruby_ui's phlex-kit--alert-dialog controller. open() clones the
4
- // <template> content into <body> as a modal (the clone carries its own instance
5
- // of this controller); dismiss() removes it. The only change from upstream: lock
6
- // body scroll via inline style rather than a Tailwind `overflow-hidden` class.
4
+ // <template> content into <body> as a modal; the clone carries its own
5
+ // instance of this controller, which owns the full modal contract for the
6
+ // clone's lifetime (mirroring sheet_content_controller): scroll lock, initial
7
+ // focus (the Cancel action when present — APG wants the least destructive
8
+ // control), Tab focus trap, Escape-to-dismiss, focus restore to the opener,
9
+ // background inert, and aria-labelledby/-describedby wiring to
10
+ // Title/Description. Escape/Tab are handled by a document-level keydown
11
+ // listener (an element-scoped one dies the moment focus escapes to <body>);
12
+ // the overlay's mousedown guard keeps focus from leaving the trap. connect and
13
+ // disconnect exactly bracket the clone, so removal by any path (Cancel,
14
+ // Escape, turbo:before-cache) restores state.
15
+ const FOCUSABLE =
16
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
17
+
7
18
  export default class extends Controller {
8
19
  static targets = ["content"];
9
20
  static values = { open: { type: Boolean, default: false } };
10
21
 
11
22
  connect() {
12
- if (this.openValue) this.open();
23
+ // The source element holds the <template>; the clone in <body> does not.
24
+ if (this.hasContentTarget) {
25
+ if (this.openValue) this.open();
26
+ return;
27
+ }
28
+ this.opener = document.activeElement;
29
+ this.previousOverflow = document.body.style.overflow;
30
+ document.body.style.overflow = "hidden";
31
+ this.onKeydown = (event) => this.keydown(event);
32
+ this.beforeCache = () => this.element.remove();
33
+ document.addEventListener("keydown", this.onKeydown);
34
+ document.addEventListener("turbo:before-cache", this.beforeCache);
35
+ this.#inertOthers();
36
+ this.#wireAria();
37
+ const focusables = this.#focusables();
38
+ const cancel = focusables.find((el) => (el.dataset.action || "").includes("#dismiss"));
39
+ (cancel || focusables[0] || this.#panel())?.focus();
40
+ }
41
+
42
+ disconnect() {
43
+ if (this.hasContentTarget) return;
44
+ document.removeEventListener("keydown", this.onKeydown);
45
+ document.removeEventListener("turbo:before-cache", this.beforeCache);
46
+ this.#restoreInert();
47
+ document.body.style.overflow = this.previousOverflow;
48
+ if (this.opener?.isConnected) this.opener.focus();
13
49
  }
14
50
 
15
51
  open() {
52
+ // Double-open guard: Enter+click (or a double-click) on the trigger must
53
+ // not stack a second clone over the first.
54
+ if (this.clone?.isConnected) return;
16
55
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
17
- document.body.style.overflow = "hidden";
56
+ this.clone = document.body.lastElementChild;
18
57
  }
19
58
 
20
59
  dismiss() {
21
- document.body.style.overflow = "";
22
60
  this.element.remove();
23
61
  }
62
+
63
+ // Overlay mousedown would move focus to <body>, killing an element-scoped
64
+ // trap; prevented here so focus stays inside the panel (the overlay is
65
+ // deliberately NOT a dismiss surface on an alert dialog).
66
+ overlayMousedown(event) {
67
+ event.preventDefault();
68
+ }
69
+
70
+ keydown(event) {
71
+ if (event.key === "Escape") {
72
+ event.preventDefault();
73
+ this.dismiss();
74
+ return;
75
+ }
76
+ if (event.key !== "Tab") return;
77
+ const focusables = this.#focusables();
78
+ if (focusables.length === 0) {
79
+ event.preventDefault();
80
+ this.#panel()?.focus();
81
+ return;
82
+ }
83
+ // Focus somehow left the dialog (e.g. programmatically): pull it back in.
84
+ if (!this.element.contains(document.activeElement)) {
85
+ event.preventDefault();
86
+ focusables[0].focus();
87
+ return;
88
+ }
89
+ const first = focusables[0];
90
+ const last = focusables[focusables.length - 1];
91
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === this.#panel())) {
92
+ event.preventDefault();
93
+ last.focus();
94
+ } else if (!event.shiftKey && document.activeElement === last) {
95
+ event.preventDefault();
96
+ first.focus();
97
+ }
98
+ }
99
+
100
+ #panel() {
101
+ return this.element.querySelector('[role="alertdialog"]');
102
+ }
103
+
104
+ #focusables() {
105
+ const panel = this.#panel();
106
+ if (!panel) return [];
107
+ return [...panel.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0);
108
+ }
109
+
110
+ // Make everything behind the modal inert (the clone is <body>'s last child,
111
+ // so its siblings are the whole page). Prior inert state is saved per
112
+ // element and restored on disconnect.
113
+ #inertOthers() {
114
+ this.inerted = [];
115
+ for (const el of document.body.children) {
116
+ if (el === this.element) continue;
117
+ if (["SCRIPT", "STYLE", "LINK", "TEMPLATE"].includes(el.tagName)) continue;
118
+ this.inerted.push([el, el.inert]);
119
+ el.inert = true;
120
+ }
121
+ }
122
+
123
+ #restoreInert() {
124
+ for (const [el, wasInert] of this.inerted || []) el.inert = wasInert;
125
+ this.inerted = null;
126
+ }
127
+
128
+ #wireAria() {
129
+ const panel = this.#panel();
130
+ if (!panel) return;
131
+ const wire = (attr, selector) => {
132
+ if (panel.getAttribute(attr)) return;
133
+ const el = this.element.querySelector(selector);
134
+ if (!el) return;
135
+ if (!el.id) el.id = `pk-alert-dialog-${Math.random().toString(36).slice(2, 10)}`;
136
+ panel.setAttribute(attr, el.id);
137
+ };
138
+ wire("aria-labelledby", ".pk-alert-dialog-title");
139
+ wire("aria-describedby", ".pk-alert-dialog-description");
140
+ }
24
141
  }
@@ -5,16 +5,22 @@ module PhlexKit
5
5
  # `.pk-aspect-ratio*` (aspect_ratio.css).
6
6
  class AspectRatio < BaseComponent
7
7
  def initialize(aspect_ratio: "16/9", **attrs)
8
- unless aspect_ratio.is_a?(String) && aspect_ratio.include?("/")
9
- raise ArgumentError, %(aspect_ratio must be a "w/h" string (e.g. "16/9"))
8
+ # Both terms must be positive integers — "16/0" yields Infinity% and
9
+ # "a/b" NaN% padding, silently breaking the layout.
10
+ unless aspect_ratio.is_a?(String) && aspect_ratio.match?(%r{\A[1-9]\d*/[1-9]\d*\z})
11
+ raise ArgumentError, %(aspect_ratio must be a "w/h" string of positive integers (e.g. "16/9"))
10
12
  end
11
13
  @aspect_ratio = aspect_ratio
12
14
  @attrs = attrs
13
15
  end
14
16
 
15
17
  def view_template(&block)
16
- div(class: "pk-aspect-ratio", style: "padding-bottom: #{padding_bottom}%") do
17
- div(**mix({ class: "pk-aspect-ratio-inner" }, @attrs), &block)
18
+ # **mix on the ROOT (kit rule) — caller attrs previously landed on the
19
+ # inner div and could never reach the actual root element. The trailing
20
+ # ";" matters: mix joins duplicate string attrs with a space, so a
21
+ # caller style: would otherwise fuse into one invalid declaration.
22
+ div(**mix({ class: "pk-aspect-ratio", style: "padding-bottom: #{padding_bottom}%;" }, @attrs)) do
23
+ div(class: "pk-aspect-ratio-inner", &block)
18
24
  end
19
25
  end
20
26
 
@@ -92,7 +92,7 @@
92
92
  .pk-attachment.vertical .pk-attachment-actions {
93
93
  position: absolute;
94
94
  top: .75rem;
95
- right: .75rem;
95
+ inset-inline-end: .75rem;
96
96
  }
97
97
  .pk-attachment.vertical .pk-attachment-action {
98
98
  background: color-mix(in oklab, var(--pk-bg) 60%, transparent);
@@ -1,8 +1,12 @@
1
1
  module PhlexKit
2
- # Small ghost icon button in an Attachment (remove/download). Give it an
3
- # aria-label; block content is the icon (defaults to ×). See attachment.rb.
2
+ # Small ghost icon button in an Attachment (remove/download). Block content
3
+ # is the icon (defaults to ×). `label:` renders a pk-sr-only span so the
4
+ # icon-only button always has an accessible name (default "Remove") —
5
+ # override it per action, or pass `label: nil` when supplying your own
6
+ # aria-label. See attachment.rb.
4
7
  class AttachmentAction < BaseComponent
5
- def initialize(**attrs)
8
+ def initialize(label: "Remove", **attrs)
9
+ @label = label
6
10
  @attrs = attrs
7
11
  end
8
12
 
@@ -13,6 +17,7 @@ module PhlexKit
13
17
  else
14
18
  render Icon.new(:x, size: nil)
15
19
  end
20
+ span(class: "pk-sr-only") { @label } if @label
16
21
  end
17
22
  end
18
23
  end
@@ -10,12 +10,12 @@ module PhlexKit
10
10
  @attrs = attrs
11
11
  end
12
12
 
13
- def view_template
13
+ def view_template(&block)
14
14
  base = { class: "pk-attachment-trigger" }
15
15
  if @as == :a
16
- a(**mix(base.merge(href: @href), @attrs))
16
+ a(**mix(base.merge(href: @href), @attrs), &block)
17
17
  else
18
- button(**mix(base.merge(type: :button), @attrs))
18
+ button(**mix(base.merge(type: :button), @attrs), &block)
19
19
  end
20
20
  end
21
21
  end
@@ -11,11 +11,19 @@ export default class extends Controller {
11
11
  connect() {
12
12
  this._onScroll = () => this.update()
13
13
  this.element.addEventListener("scroll", this._onScroll, { passive: true })
14
+ // Content changes (items added/removed, images sizing in) and container
15
+ // resizes shift the overflow edges — without these the masks go stale.
16
+ this._resizeObserver = new ResizeObserver(() => this.update())
17
+ this._resizeObserver.observe(this.element)
18
+ this._mutationObserver = new MutationObserver(() => this.update())
19
+ this._mutationObserver.observe(this.element, { childList: true, subtree: true })
14
20
  this.update()
15
21
  }
16
22
 
17
23
  disconnect() {
18
24
  this.element.removeEventListener("scroll", this._onScroll)
25
+ this._resizeObserver?.disconnect()
26
+ this._mutationObserver?.disconnect()
19
27
  }
20
28
 
21
29
  update() {
@@ -2,9 +2,11 @@
2
2
  Group / GroupCount). Matched to shadcn/ui's current avatar (nova): the root
3
3
  is NOT overflow-clipped (a Badge hangs over the edge) — image and fallback
4
4
  round themselves, and a ::after overlay draws the hairline border, blended
5
- darken (light mode) / lighten (dark mode) so it hugs any photo. The
6
- controller toggles `.hidden` between image and fallback. shadcn ships
7
- sm/default/lg; xs/xl are kit extras. */
5
+ darken (light mode) / lighten (dark mode) so it hugs any photo. The image
6
+ is absolutely stacked over the in-flow fallback (both can coexist without
7
+ squashing pre-hydration / no-JS); the controller then toggles `.pk-hidden`
8
+ between them as progressive enhancement. shadcn ships sm/default/lg;
9
+ xs/xl are kit extras. */
8
10
  .pk-avatar {
9
11
  position: relative;
10
12
  display: inline-flex;
@@ -33,7 +35,14 @@
33
35
  .pk-avatar.lg { height: 2.5rem; width: 2.5rem; font-size: 1rem; }
34
36
  .pk-avatar.xl { height: 5rem; width: 5rem; font-size: 1.875rem; }
35
37
 
38
+ /* Stacked over the fallback: pre-hydration both render, and as flex siblings
39
+ they squashed into half-circles. Absolutely positioning the image lets a
40
+ loaded image simply cover the fallback with no JS at all; on 404 the
41
+ fallback stays visible underneath (and the controller removes the broken
42
+ image once it runs). */
36
43
  .pk-avatar-image {
44
+ position: absolute;
45
+ inset: 0;
37
46
  aspect-ratio: 1 / 1;
38
47
  height: 100%;
39
48
  width: 100%;
@@ -50,7 +59,9 @@
50
59
  background: var(--pk-surface-2);
51
60
  color: var(--pk-muted);
52
61
  }
53
- .pk-avatar-image.hidden, .pk-avatar-fallback.hidden { display: none; }
62
+ /* Local restatement of the kit-wide .pk-hidden utility (_tokens.css) so the
63
+ controller's toggle also holds if avatar.css is consumed standalone. */
64
+ .pk-avatar-image.pk-hidden, .pk-avatar-fallback.pk-hidden { display: none; }
54
65
 
55
66
  /* Status dot pinned to the bottom-right corner; sized by the avatar's size
56
67
  modifier, glyph hidden when there's no room (shadcn's AvatarBadge). */
@@ -84,7 +95,7 @@
84
95
  .pk-avatar-group { display: flex; }
85
96
  .pk-avatar-group .pk-avatar,
86
97
  .pk-avatar-group .pk-avatar-group-count { box-shadow: 0 0 0 2px var(--pk-bg); }
87
- .pk-avatar-group > :not(:first-child) { margin-left: -.5rem; }
98
+ .pk-avatar-group > :not(:first-child) { margin-inline-start: -.5rem; }
88
99
 
89
100
  .pk-avatar-group-count {
90
101
  position: relative;
@@ -1,8 +1,11 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- // Ported verbatim from ruby_ui's phlex-kit--avatar controller. Shows the image once
4
- // it has loaded (else the fallback). With no image target (fallback-only avatar)
5
- // connect() no-ops and the fallback stays visible.
3
+ // Ported from ruby_ui's phlex-kit--avatar controller. Shows the image once it
4
+ // has loaded (else the fallback). With no image target (fallback-only avatar)
5
+ // connect() no-ops and the fallback stays visible. Purely progressive
6
+ // enhancement: the CSS stacks the image over the fallback, so with no JS a
7
+ // loaded image covers the fallback and a failed one leaves it showing. Uses
8
+ // the kit's .pk-hidden utility (not a bare .hidden).
6
9
  export default class extends Controller {
7
10
  static targets = ["image", "fallback"];
8
11
 
@@ -19,12 +22,12 @@ export default class extends Controller {
19
22
  }
20
23
 
21
24
  showImage() {
22
- this.imageTargets.forEach((image) => image.classList.remove("hidden"));
23
- this.fallbackTargets.forEach((fallback) => fallback.classList.add("hidden"));
25
+ this.imageTargets.forEach((image) => image.classList.remove("pk-hidden"));
26
+ this.fallbackTargets.forEach((fallback) => fallback.classList.add("pk-hidden"));
24
27
  }
25
28
 
26
29
  showFallback() {
27
- this.imageTargets.forEach((image) => image.classList.add("hidden"));
28
- this.fallbackTargets.forEach((fallback) => fallback.classList.remove("hidden"));
30
+ this.imageTargets.forEach((image) => image.classList.add("pk-hidden"));
31
+ this.fallbackTargets.forEach((fallback) => fallback.classList.remove("pk-hidden"));
29
32
  }
30
33
  }
@@ -26,8 +26,8 @@
26
26
  }
27
27
  /* shadcn forces glyphs to size-3 with `!` — override Spinner's own sizing. */
28
28
  .pk-badge > svg { width: .75rem !important; height: .75rem !important; pointer-events: none; }
29
- .pk-badge:has(> [data-icon="inline-start"]) { padding-left: .375rem; }
30
- .pk-badge:has(> [data-icon="inline-end"]) { padding-right: .375rem; }
29
+ .pk-badge:has(> [data-icon="inline-start"]) { padding-inline-start: .375rem; }
30
+ .pk-badge:has(> [data-icon="inline-end"]) { padding-inline-end: .375rem; }
31
31
  .pk-badge:focus-visible {
32
32
  outline: none;
33
33
  border-color: var(--pk-ring);
@@ -36,8 +36,8 @@
36
36
 
37
37
  /* Sizes (md is the base above) — kit extras. */
38
38
  .pk-badge.sm { height: 1rem; padding: 0 .375rem; font-size: .7rem; }
39
- .pk-badge.sm:has(> [data-icon="inline-start"]) { padding-left: .25rem; }
40
- .pk-badge.sm:has(> [data-icon="inline-end"]) { padding-right: .25rem; }
39
+ .pk-badge.sm:has(> [data-icon="inline-start"]) { padding-inline-start: .25rem; }
40
+ .pk-badge.sm:has(> [data-icon="inline-end"]) { padding-inline-end: .25rem; }
41
41
  .pk-badge.lg { height: 1.5rem; padding: .125rem .75rem; font-size: .875rem; }
42
42
 
43
43
  .pk-badge.primary { background: var(--pk-brand); color: var(--pk-brand-ink); }
@@ -2,7 +2,9 @@ module PhlexKit
2
2
  class BreadcrumbEllipsis < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template
5
- span(**mix({ class: "pk-breadcrumb-ellipsis", role: "presentation", aria: { hidden: true } }, @attrs)) do
5
+ # No aria-hidden on the wrapper it would swallow the sr-only text.
6
+ # The Icon renders its own aria-hidden="true"; only the glyph is hidden.
7
+ span(**mix({ class: "pk-breadcrumb-ellipsis", role: "presentation" }, @attrs)) do
6
8
  render Icon.new(:ellipsis, size: nil)
7
9
  span(class: "pk-sr-only") { "More" }
8
10
  end
@@ -18,7 +18,7 @@
18
18
  padding: .5rem .75rem; font-size: .875rem; line-height: 1.625;
19
19
  }
20
20
  .pk-bubble[data-align="end"] > .pk-bubble-content { align-self: flex-end; }
21
- button.pk-bubble-content { text-align: left; font: inherit; }
21
+ button.pk-bubble-content { text-align: start; font: inherit; }
22
22
  a.pk-bubble-content { color: inherit; text-decoration: none; }
23
23
  button.pk-bubble-content, a.pk-bubble-content {
24
24
  cursor: pointer; outline: none;
@@ -69,5 +69,5 @@ button.pk-bubble-content, a.pk-bubble-content {
69
69
  .pk-bubble-reactions:has(button) { padding: 0; }
70
70
  .pk-bubble-reactions[data-side="bottom"] { bottom: 0; transform: translateY(75%); }
71
71
  .pk-bubble-reactions[data-side="top"] { top: 0; transform: translateY(-75%); }
72
- .pk-bubble-reactions[data-align="end"] { right: .75rem; }
73
- .pk-bubble-reactions[data-align="start"] { left: .75rem; }
72
+ .pk-bubble-reactions[data-align="end"] { inset-inline-end: .75rem; }
73
+ .pk-bubble-reactions[data-align="start"] { inset-inline-start: .75rem; }
@@ -4,10 +4,12 @@ module PhlexKit
4
4
  # trailing edge. Compose Bubble > BubbleContent (+ BubbleReactions).
5
5
  class Bubble < BaseComponent
6
6
  VARIANTS = %i[default secondary muted tinted outline ghost destructive].freeze
7
+ ALIGNS = %i[start end].freeze
7
8
  def initialize(variant: :default, align: :start, **attrs)
8
9
  @variant = variant.to_sym
9
10
  raise KeyError, "unknown Bubble variant #{@variant}" unless VARIANTS.include?(@variant)
10
11
  @align = align.to_sym
12
+ raise KeyError, "unknown Bubble align #{@align}" unless ALIGNS.include?(@align)
11
13
  @attrs = attrs
12
14
  end
13
15
  def view_template(&)
@@ -1,7 +1,11 @@
1
1
  module PhlexKit
2
2
  class BubbleContent < BaseComponent
3
+ # `as:` is dispatched dynamically — whitelist the sensible text containers
4
+ # so it can't reach arbitrary (including private) methods.
5
+ AS_TAGS = %i[div p blockquote].freeze
3
6
  def initialize(as: :div, **attrs)
4
- @as = as
7
+ @as = as.to_sym
8
+ raise ArgumentError, "unknown BubbleContent as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})" unless AS_TAGS.include?(@as)
5
9
  @attrs = attrs
6
10
  end
7
11
  def view_template(&)
@@ -1,8 +1,12 @@
1
1
  module PhlexKit
2
2
  class BubbleReactions < BaseComponent
3
+ SIDES = %i[top bottom].freeze
4
+ ALIGNS = %i[start end].freeze
3
5
  def initialize(side: :bottom, align: :end, **attrs)
4
6
  @side = side.to_sym
7
+ raise KeyError, "unknown BubbleReactions side #{@side}" unless SIDES.include?(@side)
5
8
  @align = align.to_sym
9
+ raise KeyError, "unknown BubbleReactions align #{@align}" unless ALIGNS.include?(@align)
6
10
  @attrs = attrs
7
11
  end
8
12
  def view_template(&)