phlex_kit 0.8.1 → 0.9.1

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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b7f736afc68bb09cd4706468a8a92b9b4d6085844e0e6813b12181c5b28a9fc
4
- data.tar.gz: 480e8e1bc05eb96f3d05a409664737207d76a384cd2ea82d6c401c4298c42c45
3
+ metadata.gz: 805728288b072fe1663456e789ee106d9ca8398c53b1761255cd0167fd031677
4
+ data.tar.gz: 1a18b9d368989240e0f16f794ece1a7f8ca1cc9e5dd551ba38dcd002236a5f0e
5
5
  SHA512:
6
- metadata.gz: 62a50bb438b7c8814f8f32e91619b24f9ba41a3a668991c384b56bcc9fa3f317c9967dc12411a52372476e59b35ad3ef0675f11ad94a0f640d2619d25f02d8e9
7
- data.tar.gz: 385f3b984cde9d92cd1e3e21dd78c95583e327f7a57a914183c64504029bf72cb515f29eb155311cd8ecb3cbb2cda053af205e31a161ddb2031333e0ad5f9013
6
+ metadata.gz: 5b75e4ed5be4c81705389b3e3c9573986e581d04a5a97c8834c58af9f6fdd6977f02599e1aa4708454cf19ee684fd39a76439d26ec165cd47f839aa668f99d13
7
+ data.tar.gz: 262df1a9e8f8ecb796844c8135b706ffc953baacdb7ac863024041eb7ac28a428dd1d0379fa9cd6779e26b0ea6069a244a7ed7b94d6f4f2c3cf72cf433f9095a
@@ -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; }
@@ -83,13 +83,19 @@ export default class extends Controller {
83
83
  if (this.hasIconTarget) this.rotate()
84
84
  }
85
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
+
86
92
  reveal() {
87
93
  const c = this.contentTarget
88
94
  c.removeAttribute("hidden")
89
95
  c.dataset.state = "open"
90
96
  const h = c.scrollHeight
91
97
  const a = c.animate([{ height: c.style.height || "0px" }, { height: `${h}px` }],
92
- { duration: this.animationDurationValue, easing: "ease-in-out" })
98
+ { duration: this.effectiveDuration, easing: "ease-in-out" })
93
99
  c.style.height = `${h}px`
94
100
  // Settle at auto, not the frozen pixel height — with overflow hidden a
95
101
  // later content growth (or a resize that rewraps text) silently clipped.
@@ -101,7 +107,7 @@ export default class extends Controller {
101
107
  c.dataset.state = "closed"
102
108
  const from = c.scrollHeight
103
109
  const a = c.animate([{ height: `${from}px` }, { height: "0px" }],
104
- { duration: this.animationDurationValue, easing: "ease-in-out" })
110
+ { duration: this.effectiveDuration, easing: "ease-in-out" })
105
111
  c.style.height = "0px"
106
112
  a.finished.then(() => { if (c.dataset.state === "closed") c.setAttribute("hidden", "") }).catch(() => {})
107
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,13 +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
16
- # keydown on the clone root: focus is trapped inside the panel, so
17
- # Escape/Tab always bubble here; tabindex="-1" on the panel is the
18
- # focus fallback when the dialog has no focusable children.
19
- div(data: { controller: "phlex-kit--alert-dialog", action: "keydown->phlex-kit--alert-dialog#keydown" }) do
20
- div(class: "pk-alert-dialog-overlay", "aria-hidden": "true")
21
- div(role: "alertdialog", "aria-modal": "true", tabindex: "-1", class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" "), &block)
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.
23
+ div(data: { controller: "phlex-kit--alert-dialog" }) do
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)
22
26
  end
23
27
  end
24
28
  end
@@ -6,10 +6,14 @@ import { Controller } from "@hotwired/stimulus";
6
6
  // clone's lifetime (mirroring sheet_content_controller): scroll lock, initial
7
7
  // focus (the Cancel action when present — APG wants the least destructive
8
8
  // control), Tab focus trap, Escape-to-dismiss, focus restore to the opener,
9
- // and aria-labelledby/-describedby wiring to Title/Description. connect and
10
- // disconnect exactly bracket the clone, so removal by any path restores state.
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.
11
15
  const FOCUSABLE =
12
- 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
16
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
13
17
 
14
18
  export default class extends Controller {
15
19
  static targets = ["content"];
@@ -24,6 +28,11 @@ export default class extends Controller {
24
28
  this.opener = document.activeElement;
25
29
  this.previousOverflow = document.body.style.overflow;
26
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();
27
36
  this.#wireAria();
28
37
  const focusables = this.#focusables();
29
38
  const cancel = focusables.find((el) => (el.dataset.action || "").includes("#dismiss"));
@@ -32,6 +41,9 @@ export default class extends Controller {
32
41
 
33
42
  disconnect() {
34
43
  if (this.hasContentTarget) return;
44
+ document.removeEventListener("keydown", this.onKeydown);
45
+ document.removeEventListener("turbo:before-cache", this.beforeCache);
46
+ this.#restoreInert();
35
47
  document.body.style.overflow = this.previousOverflow;
36
48
  if (this.opener?.isConnected) this.opener.focus();
37
49
  }
@@ -48,6 +60,13 @@ export default class extends Controller {
48
60
  this.element.remove();
49
61
  }
50
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
+
51
70
  keydown(event) {
52
71
  if (event.key === "Escape") {
53
72
  event.preventDefault();
@@ -61,6 +80,12 @@ export default class extends Controller {
61
80
  this.#panel()?.focus();
62
81
  return;
63
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
+ }
64
89
  const first = focusables[0];
65
90
  const last = focusables[focusables.length - 1];
66
91
  if (event.shiftKey && (document.activeElement === first || document.activeElement === this.#panel())) {
@@ -82,6 +107,24 @@ export default class extends Controller {
82
107
  return [...panel.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0);
83
108
  }
84
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
+
85
128
  #wireAria() {
86
129
  const panel = this.#panel();
87
130
  if (!panel) return;
@@ -16,8 +16,10 @@ module PhlexKit
16
16
 
17
17
  def view_template(&block)
18
18
  # **mix on the ROOT (kit rule) — caller attrs previously landed on the
19
- # inner div and could never reach the actual root element.
20
- div(**mix({ class: "pk-aspect-ratio", style: "padding-bottom: #{padding_bottom}%" }, @attrs)) do
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
21
23
  div(class: "pk-aspect-ratio-inner", &block)
22
24
  end
23
25
  end
@@ -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(&)
@@ -37,8 +37,8 @@
37
37
  /* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
38
38
  .pk-button svg { flex-shrink: 0; pointer-events: none; }
39
39
  .pk-button svg:not([width]) { width: 1rem; height: 1rem; }
40
- .pk-button:has(> [data-icon="inline-start"]) { padding-left: .5rem; }
41
- .pk-button:has(> [data-icon="inline-end"]) { padding-right: .5rem; }
40
+ .pk-button:has(> [data-icon="inline-start"]) { padding-inline-start: .5rem; }
41
+ .pk-button:has(> [data-icon="inline-end"]) { padding-inline-end: .5rem; }
42
42
 
43
43
  /* Sizes (md is the base above). */
44
44
  .pk-button.xs {
@@ -46,15 +46,15 @@
46
46
  border-radius: min(calc(var(--pk-radius) - 2px), 10px);
47
47
  }
48
48
  .pk-button.xs svg:not([width]) { width: .75rem; height: .75rem; }
49
- .pk-button.xs:has(> [data-icon="inline-start"]) { padding-left: .375rem; }
50
- .pk-button.xs:has(> [data-icon="inline-end"]) { padding-right: .375rem; }
49
+ .pk-button.xs:has(> [data-icon="inline-start"]) { padding-inline-start: .375rem; }
50
+ .pk-button.xs:has(> [data-icon="inline-end"]) { padding-inline-end: .375rem; }
51
51
  .pk-button.sm {
52
52
  height: 1.75rem; gap: .25rem; padding: .25rem .625rem; font-size: .8rem;
53
53
  border-radius: min(calc(var(--pk-radius) - 2px), 12px);
54
54
  }
55
55
  .pk-button.sm svg:not([width]) { width: .875rem; height: .875rem; }
56
- .pk-button.sm:has(> [data-icon="inline-start"]) { padding-left: .375rem; }
57
- .pk-button.sm:has(> [data-icon="inline-end"]) { padding-right: .375rem; }
56
+ .pk-button.sm:has(> [data-icon="inline-start"]) { padding-inline-start: .375rem; }
57
+ .pk-button.sm:has(> [data-icon="inline-end"]) { padding-inline-end: .375rem; }
58
58
  .pk-button.lg { height: 2.25rem; padding: .375rem .625rem; }
59
59
  .pk-button.xl { height: 3rem; padding: .625rem 1.5rem; font-size: 1rem; }
60
60
 
@@ -14,7 +14,7 @@
14
14
  .pk-button-group:not(.vertical) > :not(:first-child) {
15
15
  border-top-left-radius: 0;
16
16
  border-bottom-left-radius: 0;
17
- border-left: 0;
17
+ border-inline-start: 0;
18
18
  }
19
19
  .pk-button-group:not(.vertical) > :not(:last-child) {
20
20
  border-top-right-radius: 0;
@@ -47,7 +47,7 @@
47
47
  .pk-button-group:not(.vertical) > :not(:first-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
48
48
  border-top-left-radius: 0;
49
49
  border-bottom-left-radius: 0;
50
- border-left: 0;
50
+ border-inline-start: 0;
51
51
  }
52
52
  .pk-button-group:not(.vertical) > :not(:last-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
53
53
  .pk-button-group:not(.vertical) > :not(:last-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
@@ -8,7 +8,9 @@ module PhlexKit
8
8
  end
9
9
 
10
10
  def view_template
11
- div(**mix({ class: "pk-button-group-separator", role: "separator", aria: { hidden: true } }, @attrs))
11
+ # Purely decorative — aria-hidden only (role="separator" would
12
+ # contradict aria-hidden and confuse AT).
13
+ div(**mix({ class: "pk-button-group-separator", aria: { hidden: true } }, @attrs))
12
14
  end
13
15
  end
14
16
  end
@@ -53,8 +53,8 @@
53
53
  opacity: .5;
54
54
  transition: background-color .15s ease, opacity .15s ease;
55
55
  }
56
- .pk-calendar-prev { left: .25rem; }
57
- .pk-calendar-next { right: .25rem; }
56
+ .pk-calendar-prev { inset-inline-start: .25rem; }
57
+ .pk-calendar-next { inset-inline-end: .25rem; }
58
58
  .pk-calendar-prev:hover, .pk-calendar-next:hover { background: var(--pk-accent); opacity: 1; }
59
59
  .pk-calendar-prev:focus-visible, .pk-calendar-next:focus-visible {
60
60
  outline: none;
@@ -64,7 +64,9 @@ module PhlexKit
64
64
  private
65
65
 
66
66
  def render_dropdown_caption
67
- view_year = parse_year(@selected_date) || parse_year(@range_start) || Time.now.year
67
+ view_seed = @selected_date || @range_start || @selected_dates.first
68
+ view_year = parse_year(view_seed) || Time.now.year
69
+ view_month = parse_month(view_seed) || Time.now.month # 1-12
68
70
  first_year = @from_year || parse_year(@min_date) || (view_year - 10)
69
71
  last_year = @to_year || parse_year(@max_date) || (view_year + 10)
70
72
 
@@ -74,14 +76,15 @@ module PhlexKit
74
76
  aria: { label: "Month" },
75
77
  data: { phlex_kit__calendar_target: "monthSelect", action: "change->phlex-kit--calendar#setMonth" }
76
78
  ) do
77
- MONTHS.each_with_index { |name, index| option(value: index.to_s) { name } }
79
+ # Mark the view month selected so the caption is right pre-JS.
80
+ MONTHS.each_with_index { |name, index| option(value: index.to_s, selected: index == view_month - 1) { name } }
78
81
  end
79
82
  select(
80
83
  class: "pk-native-select-field pk-calendar-dropdown",
81
84
  aria: { label: "Year" },
82
85
  data: { phlex_kit__calendar_target: "yearSelect", action: "change->phlex-kit--calendar#setYear" }
83
86
  ) do
84
- (first_year..last_year).each { |year| option(value: year.to_s) { year.to_s } }
87
+ (first_year..last_year).each { |year| option(value: year.to_s, selected: year == view_year) { year.to_s } }
85
88
  end
86
89
  end
87
90
  end
@@ -90,6 +93,10 @@ module PhlexKit
90
93
  value.to_s[/\A(\d{4})/, 1]&.to_i
91
94
  end
92
95
 
96
+ def parse_month(value)
97
+ value.to_s[/\A\d{4}-(\d{2})/, 1]&.to_i
98
+ end
99
+
93
100
  def calendar_attrs
94
101
  data = {
95
102
  controller: "phlex-kit--calendar",