phlex_kit 0.12.0 → 0.14.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
  3. data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +1 -1
  4. data/app/components/phlex_kit/accordion/accordion_trigger.rb +1 -1
  5. data/app/components/phlex_kit/alert/alert.rb +5 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +2 -2
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
  10. data/app/components/phlex_kit/attachment/attachment.css +7 -1
  11. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
  12. data/app/components/phlex_kit/avatar/avatar.css +4 -0
  13. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
  16. data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  18. data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
  19. data/app/components/phlex_kit/calendar/calendar.css +5 -0
  20. data/app/components/phlex_kit/calendar/calendar_controller.js +38 -9
  21. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
  22. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
  23. data/app/components/phlex_kit/carousel/carousel.css +7 -0
  24. data/app/components/phlex_kit/carousel/carousel.rb +8 -4
  25. data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
  26. data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
  27. data/app/components/phlex_kit/chart/chart.rb +1 -1
  28. data/app/components/phlex_kit/chart/chart_controller.js +5 -1
  29. data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
  30. data/app/components/phlex_kit/checkbox/checkbox.rb +19 -1
  31. data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
  32. data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
  33. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
  34. data/app/components/phlex_kit/codeblock/codeblock.rb +2 -2
  35. data/app/components/phlex_kit/combobox/combobox.rb +1 -1
  36. data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
  37. data/app/components/phlex_kit/combobox/combobox_controller.js +60 -5
  38. data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
  39. data/app/components/phlex_kit/command/command_controller.js +69 -2
  40. data/app/components/phlex_kit/command/command_dialog_content.rb +15 -6
  41. data/app/components/phlex_kit/command/command_empty.rb +5 -3
  42. data/app/components/phlex_kit/command/command_separator.rb +1 -1
  43. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
  44. data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
  45. data/app/components/phlex_kit/context_menu/context_menu_controller.js +42 -1
  46. data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
  47. data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
  48. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
  49. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
  50. data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
  51. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
  52. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
  53. data/app/components/phlex_kit/data_table/data_table.css +4 -0
  54. data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
  55. data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
  56. data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
  57. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
  58. data/app/components/phlex_kit/date_picker/date_picker_controller.js +3 -5
  59. data/app/components/phlex_kit/drawer/drawer.css +4 -0
  60. data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  62. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
  63. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
  64. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -5
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
  69. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
  72. data/app/components/phlex_kit/empty/empty.rb +1 -0
  73. data/app/components/phlex_kit/field/field.rb +4 -1
  74. data/app/components/phlex_kit/field/field_error.rb +4 -1
  75. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
  76. data/app/components/phlex_kit/icon/icon.rb +6 -2
  77. data/app/components/phlex_kit/input_group/input_group.css +6 -3
  78. data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
  79. data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
  80. data/app/components/phlex_kit/item/item.css +9 -1
  81. data/app/components/phlex_kit/item/item_separator.rb +1 -1
  82. data/app/components/phlex_kit/label/label.css +6 -1
  83. data/app/components/phlex_kit/link/link.rb +10 -1
  84. data/app/components/phlex_kit/menubar/menubar.rb +8 -4
  85. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
  86. data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
  87. data/app/components/phlex_kit/menubar/menubar_controller.js +36 -0
  88. data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
  89. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
  90. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  91. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
  92. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
  93. data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
  94. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
  95. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
  96. data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
  97. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
  98. data/app/components/phlex_kit/pagination/pagination.css +4 -0
  99. data/app/components/phlex_kit/progress/progress.css +19 -0
  100. data/app/components/phlex_kit/progress/progress.rb +29 -13
  101. data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
  102. data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
  103. data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
  104. data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -1
  105. data/app/components/phlex_kit/select/select.rb +1 -1
  106. data/app/components/phlex_kit/select/select_content.rb +7 -5
  107. data/app/components/phlex_kit/select/select_controller.js +10 -0
  108. data/app/components/phlex_kit/select/select_separator.rb +1 -1
  109. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  110. data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
  111. data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
  115. data/app/components/phlex_kit/switch/switch.rb +5 -2
  116. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
  117. data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
  118. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
  119. data/app/components/phlex_kit/toast/toast_controller.js +52 -2
  120. data/app/components/phlex_kit/toast/toast_item.rb +2 -2
  121. data/app/components/phlex_kit/toast/toaster_controller.js +63 -7
  122. data/app/components/phlex_kit/toggle/toggle.rb +1 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
  124. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
  126. data/app/components/phlex_kit/typography/typography.css +5 -2
  127. data/lib/phlex_kit/base_component.rb +9 -0
  128. data/lib/phlex_kit/version.rb +1 -1
  129. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 832d7d74c2bcda4dcddef6ab391ee7afff8c15a3f011b57392dfb0e5d73b9922
4
- data.tar.gz: 730dd9af5257a9350c551d9b5573d919cd04dd462a19f38d55558aa282d16822
3
+ metadata.gz: 60c4cbd0615b995e62c9dee3f19ae2c2f48ff53c5ec7f8294bf757edb7f68a25
4
+ data.tar.gz: b95a0dcc6976d561bb1e8d005eac32607e771386d236735b3e45e4316b0ef4c7
5
5
  SHA512:
6
- metadata.gz: 545d9af3c5b4ef67ff2793618825b17fcfff6a42fd7a7e97ca2b294d447d71ded63858094dc32f938dcdabd9c39ac4c8696bdf68da4b74cf1692bf62770d8081
7
- data.tar.gz: 2a98a887feff3cf7a2144baeb9f380738880f2c100e6ed3ecc3ab2a4a1d94038efe06531b725eede6e1e419c4f9a282b109b6cb2db49328f32962819a17417c3
6
+ metadata.gz: e76f08c320a673fbcda5e136148933c12e26cbc9a9b97f8f253382bf9b9f11f26255de504cb27b67de2b1f0de8b6d40bb1f8e257b1da5c809259c5575c0489da
7
+ data.tar.gz: 130fb551c26417da890ec8da4dc1cc06187ae4dc6916129e3c8a862e3da14c3317e94d6c15ce0d8afa23e353aeacd7449e7d5aaa38ca43049a3a105932d68c30
@@ -11,7 +11,9 @@ module PhlexKit
11
11
  def view_template(&)
12
12
  # role="region" + aria-labelledby → trigger id (wired by the controller
13
13
  # in connect(), which also assigns the ids), per the APG accordion pattern.
14
- base = { class: "pk-accordion-content", role: "region", data: { phlex_kit__accordion_target: "content", state: @open ? "open" : "closed" } }
14
+ base = { class: "pk-accordion-content", data: { phlex_kit__accordion_target: "content", state: @open ? "open" : "closed" } }
15
+ # Default only when the caller didn't supply their own — `mix` fuses.
16
+ base[:role] = "region" unless attr_set?(:role)
15
17
  base.merge!(style: "height: 0px;", hidden: true) unless @open
16
18
  div(**mix(base, @attrs), &)
17
19
  end
@@ -13,7 +13,7 @@ module PhlexKit
13
13
  # aria-expanded reflects the render-time open state; the controller keeps
14
14
  # it in sync and wires aria-controls to the content's id in connect().
15
15
  base = {
16
- type: "button", class: "pk-accordion-trigger",
16
+ type: :button, class: "pk-accordion-trigger",
17
17
  aria_expanded: @open ? "true" : "false", disabled: @disabled,
18
18
  data: { phlex_kit__accordion_target: "trigger", action: "click->phlex-kit--accordion#toggle" }
19
19
  }
@@ -12,7 +12,7 @@ module PhlexKit
12
12
  # aria-expanded reflects the render-time open state; the controller keeps
13
13
  # it in sync and wires aria-controls to the content's id in connect().
14
14
  base = {
15
- type: "button", class: "pk-accordion-trigger",
15
+ type: :button, class: "pk-accordion-trigger",
16
16
  aria_expanded: @open ? "true" : "false", disabled: @disabled,
17
17
  data: { phlex_kit__accordion_target: "trigger", action: "click->phlex-kit--accordion#toggle" }
18
18
  }
@@ -29,7 +29,11 @@ module PhlexKit
29
29
  end
30
30
 
31
31
  def view_template(&block)
32
- div(**mix({ class: classes, role: "alert" }, @attrs), &block)
32
+ base = { class: classes }
33
+ # Default only when the caller didn't supply their own — `mix` fuses
34
+ # (downgrading to role: "status" is a standard polite-live-region use).
35
+ base[:role] = "alert" unless attr_set?(:role)
36
+ div(**mix(base, @attrs), &block)
33
37
  end
34
38
 
35
39
  private
@@ -30,9 +30,9 @@ module PhlexKit
30
30
  # Defaults only when the caller didn't supply their own — `mix`
31
31
  # would fuse role="alertdialog dialog" / aria-modal="true false" /
32
32
  # tabindex="-1 0" instead of overriding.
33
- panel_attrs[:role] = "alertdialog" unless @attrs.key?(:role) || @attrs.key?("role")
33
+ panel_attrs[:role] = "alertdialog" unless attr_set?(:role)
34
34
  panel_attrs[:"aria-modal"] = "true" unless aria_key_set?(:modal)
35
- panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
35
+ panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
36
36
  div(**mix(panel_attrs, @attrs), &block)
37
37
  end
38
38
  end
@@ -95,12 +95,18 @@ export default class extends Controller {
95
95
  // handle the keyboard, or one Escape would close every layer at once.
96
96
  if (!this.#topmost()) return;
97
97
  if (event.key === "Escape") {
98
- // A native <dialog> nested inside this panel (e.g. a Dialog opened
99
- // from within AlertDialogContent) owns its own Escape handling; this
100
- // document-level listener still sees the keydown since it bubbles all
101
- // the way up ignore it so one Escape doesn't also dismiss the alert
102
- // dialog underneath (mirrors sheet_content_controller's guard).
98
+ // Yield to any inner overlay that owns this Escape so one keypress
99
+ // doesn't also dismiss the alert dialog underneath: a native <dialog>
100
+ // (its own Escape handling), or an open [popover] menu — Select /
101
+ // DropdownMenu / Combobox, all popover=manual rendered inside the
102
+ // panel. Two signals make the outcome independent of whether the inner
103
+ // handler fires before or after this document-level one:
104
+ // event.defaultPrevented catches an inner handler (menu, native dialog)
105
+ // that already consumed the key; the live :popover-open check catches a
106
+ // menu still open because this handler ran first.
107
+ if (event.defaultPrevented) return;
103
108
  if (event.target.closest("dialog[open]")) return;
109
+ if (this.#panel()?.querySelector("[popover]:popover-open")) return;
104
110
  event.preventDefault();
105
111
  this.dismiss();
106
112
  return;
@@ -140,8 +146,12 @@ export default class extends Controller {
140
146
  return clones.length > 0 && clones[clones.length - 1] === this.element;
141
147
  }
142
148
 
149
+ // Select the panel structurally, not by role: AlertDialogContent lets the
150
+ // caller override role: (the default is skipped when attr_set?(:role)), so a
151
+ // role-based lookup would fail to find a custom-role panel and silently
152
+ // break the whole modal contract (focus trap, aria wiring, dismiss).
143
153
  #panel() {
144
- return this.element.querySelector('[role="alertdialog"]');
154
+ return this.element.querySelector(".pk-alert-dialog-panel");
145
155
  }
146
156
 
147
157
  #focusables() {
@@ -8,7 +8,10 @@ module PhlexKit
8
8
  end
9
9
 
10
10
  def view_template(&)
11
- div(**mix({ class: "pk-alert-dialog-media", aria: { hidden: "true" } }, @attrs), &)
11
+ base = { class: "pk-alert-dialog-media" }
12
+ # Default only when the caller didn't supply their own — `mix` fuses.
13
+ base[:aria] = { hidden: "true" } unless aria_key_set?(:hidden)
14
+ div(**mix(base, @attrs), &)
12
15
  end
13
16
  end
14
17
  end
@@ -16,10 +16,20 @@ 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. 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
19
+ # inner div and could never reach the actual root element. Emit the
20
+ # generated padding-bottom in the SAME form the caller used for style:,
21
+ # because mix only composes same-typed values it JOINS two strings
22
+ # (the trailing ";" keeps the fused declaration valid) and MERGES two
23
+ # hashes, but a string base against a caller hash (or vice versa) is
24
+ # replaced wholesale, silently dropping our ratio and collapsing the box.
25
+ caller_style = @attrs[:style] || @attrs["style"]
26
+ generated_style =
27
+ if caller_style.is_a?(Hash)
28
+ { padding_bottom: "#{padding_bottom}%" }
29
+ else
30
+ "padding-bottom: #{padding_bottom}%;"
31
+ end
32
+ div(**mix({ class: "pk-aspect-ratio", style: generated_style }, @attrs)) do
23
33
  div(class: "pk-aspect-ratio-inner", &block)
24
34
  end
25
35
  end
@@ -118,6 +118,12 @@
118
118
  -webkit-text-fill-color: transparent;
119
119
  animation: pk-shimmer 2s linear infinite;
120
120
  }
121
+ /* Same RTL arm as _tokens.css's .pk-shimmer — the sweep follows reading
122
+ direction. */
123
+ [dir="rtl"] .pk-attachment[data-state="uploading"] .pk-attachment-title,
124
+ [dir="rtl"] .pk-attachment[data-state="processing"] .pk-attachment-title {
125
+ animation-direction: reverse;
126
+ }
121
127
  /* Same two fallback arms as _tokens.css's .pk-shimmer (:49-54): never let the
122
128
  filename disappear under reduced motion or without relative color syntax
123
129
  (Safari <16.4 / Chrome <119). */
@@ -163,7 +169,7 @@
163
169
  gap: .5rem;
164
170
  overflow-x: auto;
165
171
  scroll-snap-type: x mandatory;
166
- padding: 2px; /* room for card rings */
172
+ padding: 3px; /* room for card rings (the trigger focus ring is 3px) */
167
173
  mask-image: linear-gradient(var(--pk-scroll-fade-dir),
168
174
  transparent 0, black var(--pk-scroll-fade-size),
169
175
  black calc(100% - var(--pk-scroll-fade-size)), transparent 100%);
@@ -32,9 +32,10 @@ export default class extends Controller {
32
32
  // Math.abs: in RTL scrollLeft runs 0..negative, so the raw value would
33
33
  // read as permanently "at start" — abs tracks the logical inline start.
34
34
  const scrolled = Math.abs(el.scrollLeft)
35
- // <= 2, not <= 1: scroll-snap + the 2px ring padding leave the group
36
- // resting at scrollLeft 2, which must still count as "at start".
37
- el.toggleAttribute("data-at-start", scrolled <= 2)
35
+ // <= 3, not <= 1: scroll-snap + the 3px ring padding (bumped from 2px so
36
+ // the 3px card focus ring isn't clipped) leave the group resting at
37
+ // scrollLeft ~3, which must still count as "at start".
38
+ el.toggleAttribute("data-at-start", scrolled <= 3)
38
39
  el.toggleAttribute("data-at-end", scrolled + el.clientWidth >= el.scrollWidth - 1)
39
40
  } else {
40
41
  el.toggleAttribute("data-at-start", el.scrollTop <= 1)
@@ -111,7 +111,11 @@
111
111
  font-size: .875rem;
112
112
  }
113
113
  .pk-avatar-group-count > svg { width: 1rem; height: 1rem; }
114
+ .pk-avatar-group:has(.pk-avatar.xs) .pk-avatar-group-count { width: 1rem; height: 1rem; font-size: .5rem; }
115
+ .pk-avatar-group:has(.pk-avatar.xs) .pk-avatar-group-count > svg { width: .5rem; height: .5rem; }
114
116
  .pk-avatar-group:has(.pk-avatar.sm) .pk-avatar-group-count { width: 1.5rem; height: 1.5rem; font-size: .75rem; }
115
117
  .pk-avatar-group:has(.pk-avatar.sm) .pk-avatar-group-count > svg { width: .75rem; height: .75rem; }
116
118
  .pk-avatar-group:has(.pk-avatar.lg) .pk-avatar-group-count { width: 2.5rem; height: 2.5rem; font-size: 1rem; }
117
119
  .pk-avatar-group:has(.pk-avatar.lg) .pk-avatar-group-count > svg { width: 1.25rem; height: 1.25rem; }
120
+ .pk-avatar-group:has(.pk-avatar.xl) .pk-avatar-group-count { width: 5rem; height: 5rem; font-size: 1.875rem; }
121
+ .pk-avatar-group:has(.pk-avatar.xl) .pk-avatar-group-count > svg { width: 2.5rem; height: 2.5rem; }
@@ -13,3 +13,7 @@
13
13
  .pk-breadcrumb-separator > svg { width: .875rem; height: .875rem; }
14
14
  .pk-breadcrumb-ellipsis { display: flex; height: 1.25rem; width: 1.25rem; align-items: center; justify-content: center; }
15
15
  .pk-breadcrumb-ellipsis > svg { width: 1rem; height: 1rem; }
16
+
17
+ /* RTL: the default chevron separator points along the reading direction —
18
+ mirror the icon (custom text/block separators are left alone). */
19
+ [dir="rtl"] .pk-breadcrumb-separator > svg { transform: scaleX(-1); }
@@ -3,6 +3,13 @@ module PhlexKit
3
3
  # Breadcrumb > BreadcrumbList > BreadcrumbItem (+ Link/Page/Separator/Ellipsis).
4
4
  class Breadcrumb < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
- def view_template(&) = nav(**mix({ class: "pk-breadcrumb", aria: { label: "breadcrumb" } }, @attrs), &)
6
+
7
+ def view_template(&)
8
+ base = { class: "pk-breadcrumb" }
9
+ # Default only when the caller didn't supply their own accessible name
10
+ # (localization, two breadcrumbs on a page) — `mix` would fuse them.
11
+ base[:aria] = { label: "breadcrumb" } unless aria_labelled?
12
+ nav(**mix(base, @attrs), &)
13
+ end
7
14
  end
8
15
  end
@@ -2,7 +2,7 @@ module PhlexKit
2
2
  class BreadcrumbPage < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template(&)
5
- span(**mix({ class: "pk-breadcrumb-page", role: "link", aria: { disabled: true, current: "page" } }, @attrs), &)
5
+ span(**mix({ class: "pk-breadcrumb-page", role: "link", aria: { disabled: "true", current: "page" } }, @attrs), &)
6
6
  end
7
7
  end
8
8
  end
@@ -2,7 +2,7 @@ module PhlexKit
2
2
  class BreadcrumbSeparator < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
4
  def view_template(&block)
5
- li(**mix({ class: "pk-breadcrumb-separator", role: "presentation", aria: { hidden: true } }, @attrs)) do
5
+ li(**mix({ class: "pk-breadcrumb-separator", role: "presentation", aria: { hidden: "true" } }, @attrs)) do
6
6
  if block then yield else render(Icon.new(:chevron_right, size: nil)) end
7
7
  end
8
8
  end
@@ -10,7 +10,11 @@ module PhlexKit
10
10
  @attrs = attrs
11
11
  end
12
12
  def view_template(&)
13
- send(@as, **mix({ class: "pk-bubble-content", data: { slot: "bubble-content" } }, @attrs), &)
13
+ base = { class: "pk-bubble-content", data: { slot: "bubble-content" } }
14
+ # An interactive bubble inside a <form> must not be an implicit
15
+ # type="submit" (same default PhlexKit::Button ships).
16
+ base[:type] = :button if @as == :button && !attr_set?(:type)
17
+ send(@as, **mix(base, @attrs), &)
14
18
  end
15
19
  end
16
20
  end
@@ -10,7 +10,7 @@ module PhlexKit
10
10
  def view_template
11
11
  # Purely decorative — aria-hidden only (role="separator" would
12
12
  # contradict aria-hidden and confuse AT).
13
- div(**mix({ class: "pk-button-group-separator", aria: { hidden: true } }, @attrs))
13
+ div(**mix({ class: "pk-button-group-separator", aria: { hidden: "true" } }, @attrs))
14
14
  end
15
15
  end
16
16
  end
@@ -166,3 +166,8 @@
166
166
  border-end-end-radius: var(--pk-cell-radius);
167
167
  }
168
168
  .pk-calendar-cell:has(> .range-start.range-end) { border-radius: var(--pk-cell-radius); }
169
+
170
+ /* RTL: the nav buttons swap sides via inset-inline-*; mirror the chevrons
171
+ so each arrow keeps pointing outward (toward its travel direction). */
172
+ [dir="rtl"] .pk-calendar-prev svg,
173
+ [dir="rtl"] .pk-calendar-next svg { transform: scaleX(-1); }
@@ -50,27 +50,39 @@ export default class extends Controller {
50
50
 
51
51
  nextMonth(e) {
52
52
  e.preventDefault();
53
- this.viewDateValue = this.clampViewIso(this.adjustMonth(1));
53
+ this.applyViewIso(this.clampViewIso(this.adjustMonth(1)));
54
54
  }
55
55
 
56
56
  prevMonth(e) {
57
57
  e.preventDefault();
58
- this.viewDateValue = this.clampViewIso(this.adjustMonth(-1));
58
+ this.applyViewIso(this.clampViewIso(this.adjustMonth(-1)));
59
59
  }
60
60
 
61
- // month/year dropdown caption (native selects)
61
+ // month/year dropdown caption (native selects). When clampViewIso clamps
62
+ // the pick back to the CURRENT view, the value write is a same-value
63
+ // no-op and Stimulus fires no valueChanged — render directly so the
64
+ // dropdown snaps back from the disallowed choice instead of desyncing
65
+ // from the grid.
62
66
  setMonth(e) {
63
67
  const date = this.viewDate();
64
68
  date.setDate(2);
65
69
  date.setMonth(Number(e.target.value));
66
- this.viewDateValue = this.clampViewIso(this.isoDate(date));
70
+ this.applyViewIso(this.clampViewIso(this.isoDate(date)));
67
71
  }
68
72
 
69
73
  setYear(e) {
70
74
  const date = this.viewDate();
71
75
  date.setDate(2);
72
76
  date.setFullYear(Number(e.target.value));
73
- this.viewDateValue = this.clampViewIso(this.isoDate(date));
77
+ this.applyViewIso(this.clampViewIso(this.isoDate(date)));
78
+ }
79
+
80
+ applyViewIso(iso) {
81
+ if (iso === this.viewDateValue) {
82
+ this.updateCalendar();
83
+ return;
84
+ }
85
+ this.viewDateValue = iso;
74
86
  }
75
87
 
76
88
  // --- month-navigation bounds (min/max dates + the year dropdown's range) ---
@@ -144,9 +156,11 @@ export default class extends Controller {
144
156
  const end = this.parseDate(this.rangeEndValue);
145
157
 
146
158
  if (!start || (start && end)) {
147
- // begin a fresh range
159
+ // begin a fresh range. undefined (not null) clears the value: Stimulus
160
+ // removes the attribute for undefined, while null goes through
161
+ // setAttribute and serializes as the literal string "null".
148
162
  this.rangeStartValue = this.isoDate(candidate);
149
- this.rangeEndValue = null;
163
+ this.rangeEndValue = undefined;
150
164
  } else if (candidate < start) {
151
165
  this.rangeStartValue = this.isoDate(candidate);
152
166
  } else {
@@ -182,10 +196,17 @@ export default class extends Controller {
182
196
  return;
183
197
  }
184
198
 
185
- // update the viewDateValue to the selected date's month (this re-renders)
199
+ // update the viewDateValue to the selected date's month. Arm the
200
+ // double-render guard ONLY when the write actually changes the value:
201
+ // Stimulus skips valueChanged for a same-value attribute write
202
+ // (StringMapObserver compares before invoking), so arming on a
203
+ // same-month selection would leave a stale flag that swallows the next
204
+ // genuine navigation's re-render.
186
205
  const newViewDate = new Date(selectedDate);
187
206
  newViewDate.setDate(2); // avoid month-length/timezone edges
188
- this.viewDateValue = this.isoDate(newViewDate);
207
+ const newViewIso = this.isoDate(newViewDate);
208
+ this._viewRendered = newViewIso !== this.viewDateValue;
209
+ this.viewDateValue = newViewIso;
189
210
 
190
211
  this.updateCalendar();
191
212
 
@@ -197,6 +218,13 @@ export default class extends Controller {
197
218
  }
198
219
 
199
220
  viewDateValueChanged(value, prevValue) {
221
+ // Month-cross paths render synchronously right after writing
222
+ // viewDateValue (focus must land in the NEW grid); this async echo
223
+ // (MutationObserver) would render the same view a second time.
224
+ if (this._viewRendered) {
225
+ this._viewRendered = false;
226
+ return;
227
+ }
200
228
  this.updateCalendar();
201
229
  }
202
230
 
@@ -334,6 +362,7 @@ export default class extends Controller {
334
362
  // below would hit the OLD month, and the target day (outside it) would be
335
363
  // missing → early return, stranding focus on <body>. Drive the re-render
336
364
  // ourselves and hand it the target day so ensureGridTabStop focuses it.
365
+ this._viewRendered = true;
337
366
  this.viewDateValue = iso;
338
367
  this.updateCalendar(iso);
339
368
  return;
@@ -6,17 +6,19 @@ module PhlexKit
6
6
  end
7
7
 
8
8
  def view_template
9
- button(**mix({
9
+ base = {
10
10
  type: :button,
11
11
  name: "next-month",
12
12
  class: "pk-calendar-next",
13
- aria: { label: "Go to next month" },
14
13
  data: {
15
14
  action: "click->phlex-kit--calendar#nextMonth",
16
15
  # target so the controller can disable it at the max-date/to-year bound
17
16
  phlex_kit__calendar_target: "nextButton"
18
17
  }
19
- }, @attrs)) do
18
+ }
19
+ # Default only when the caller didn't supply their own — `mix` fuses.
20
+ base[:aria] = { label: "Go to next month" } unless aria_labelled?
21
+ button(**mix(base, @attrs)) do
20
22
  icon
21
23
  end
22
24
  end
@@ -6,17 +6,19 @@ module PhlexKit
6
6
  end
7
7
 
8
8
  def view_template
9
- button(**mix({
9
+ base = {
10
10
  type: :button,
11
11
  name: "previous-month",
12
12
  class: "pk-calendar-prev",
13
- aria: { label: "Go to previous month" },
14
13
  data: {
15
14
  action: "click->phlex-kit--calendar#prevMonth",
16
15
  # target so the controller can disable it at the min-date/from-year bound
17
16
  phlex_kit__calendar_target: "prevButton"
18
17
  }
19
- }, @attrs)) do
18
+ }
19
+ # Default only when the caller didn't supply their own — `mix` fuses.
20
+ base[:aria] = { label: "Go to previous month" } unless aria_labelled?
21
+ button(**mix(base, @attrs)) do
20
22
  icon
21
23
  end
22
24
  end
@@ -39,3 +39,10 @@
39
39
  .pk-carousel.is-vertical .pk-carousel-previous { top: -3rem; left: 50%; transform: translateX(-50%) rotate(90deg); }
40
40
  .pk-carousel.is-vertical .pk-carousel-next { bottom: -3rem; left: 50%; transform: translateX(-50%) rotate(90deg); }
41
41
  .pk-carousel-previous svg, .pk-carousel-next svg { width: 1rem; height: 1rem; }
42
+
43
+ /* RTL (horizontal only): the scroll engine is direction-aware and the
44
+ buttons swap sides via inset-inline-*; mirror the arrow glyphs so they
45
+ point along the actual travel direction. Vertical carousels rotate the
46
+ whole button, which is direction-neutral. */
47
+ [dir="rtl"] .pk-carousel.is-horizontal .pk-carousel-previous svg,
48
+ [dir="rtl"] .pk-carousel.is-horizontal .pk-carousel-next svg { transform: scaleX(-1); }
@@ -16,10 +16,14 @@ module PhlexKit
16
16
  end
17
17
 
18
18
  def view_template(&)
19
- div(**mix({
20
- class: [ "pk-carousel", fetch_option(ORIENTATIONS, @orientation, :orientation) ].join(" "),
21
- role: "region",
22
- aria: { roledescription: "carousel" },
19
+ base = {
20
+ class: [ "pk-carousel", fetch_option(ORIENTATIONS, @orientation, :orientation) ].join(" ")
21
+ }
22
+ # Defaults only when the caller didn't supply their own — `mix` fuses
23
+ # (a localized roledescription would announce both strings).
24
+ base[:role] = "region" unless attr_set?(:role)
25
+ base[:aria] = { roledescription: "carousel" } unless aria_key_set?(:roledescription)
26
+ div(**mix(base, {
23
27
  data: {
24
28
  controller: "phlex-kit--carousel",
25
29
  phlex_kit__carousel_options_value: JSON.generate(default_options.merge(@options)),
@@ -20,8 +20,8 @@ const OFFSET_EPSILON = 0.5;
20
20
  // axis from the options value) moves the track directly, so only
21
21
  // @hotwired/stimulus is needed. Button disabled state mirrors embla's
22
22
  // canScrollNext/canScrollPrev, and pointer drag/swipe (threshold + velocity,
23
- // rubber-band at the ends when not looping, click suppression after a drag)
24
- // replaces embla's gesture engine.
23
+ // rubber-band at the ends, click suppression after a drag) replaces embla's
24
+ // gesture engine.
25
25
  export default class extends Controller {
26
26
  static values = {
27
27
  options: {
@@ -36,7 +36,14 @@ export default class extends Controller {
36
36
  this.track = this.viewportTarget.firstElementChild;
37
37
  this.drag = null;
38
38
  this.suppressClick = false;
39
- this._onResize = () => this._applyTransform();
39
+ // connect() re-derives reflected state: a Turbo snapshot can capture
40
+ // mid-drag markup (dragging class kills the transition; the inline
41
+ // transition:none was set for the drag's duration only).
42
+ this.viewportTarget.classList.remove("dragging");
43
+ this.track?.style.removeProperty("transition");
44
+ // _update, not just _applyTransform: a resize can change _maxOffset,
45
+ // which feeds the buttons' disabled state as well as the transform.
46
+ this._onResize = () => this._update();
40
47
  this._onPointerDown = this._onPointerDown.bind(this);
41
48
  this._onPointerMove = this._onPointerMove.bind(this);
42
49
  this._onPointerUp = this._onPointerUp.bind(this);
@@ -46,7 +53,7 @@ export default class extends Controller {
46
53
  // window resize misses container-only resizes (flex/grid reflow,
47
54
  // sidebar toggles) — observe the viewport itself.
48
55
  if (typeof ResizeObserver !== "undefined") {
49
- this._resizeObserver = new ResizeObserver(() => this._applyTransform());
56
+ this._resizeObserver = new ResizeObserver(() => this._update());
50
57
  this._resizeObserver.observe(this.viewportTarget);
51
58
  }
52
59
  this.viewportTarget.addEventListener("pointerdown", this._onPointerDown);
@@ -77,18 +84,36 @@ export default class extends Controller {
77
84
  }
78
85
 
79
86
  scrollNext() {
80
- // Non-loop multi-up: the track can hit its max scrollable offset before
81
- // the index reaches the last slide (_offsetOf clamps). Once there, index
82
- // still has room to climb but there's nothing left to reveal — bail so
83
- // neither clicks (button already disables via _update) nor ArrowRight
84
- // (keyNext, which routes here) produce dead no-op advances.
85
- if (!this.options.loop && this._offsetOf(this.index) >= this._maxOffset() - OFFSET_EPSILON) {
87
+ // Multi-up: the track can hit its max scrollable offset before the index
88
+ // reaches the last slide (_offsetOf clamps). Once there, index still has
89
+ // room to climb but there's nothing left to reveal — advancing anyway
90
+ // produces dead no-op clicks. Non-loop bails (button already disables
91
+ // via _update); loop wraps straight back to the start.
92
+ if (this._offsetOf(this.index) >= this._maxOffset() - OFFSET_EPSILON) {
93
+ if (this.options.loop) this._goTo(0);
86
94
  return;
87
95
  }
88
96
  this._goTo(this.index + 1);
89
97
  }
90
98
 
91
99
  scrollPrev() {
100
+ if (this.options.loop) {
101
+ // Mirror of scrollNext's wrap: from the start, jump to the FIRST index
102
+ // that reaches the max offset (not count - 1 — with multi-up layouts
103
+ // every later index clamps to the same offset, so landing on count - 1
104
+ // would make the following Prev presses dead clicks).
105
+ if (this._offsetOf(this.index) <= OFFSET_EPSILON) {
106
+ this._goTo(this._lastReachableIndex());
107
+ return;
108
+ }
109
+ // A resize can strand the index in the clamped zone past the last
110
+ // reachable position — step to just before the clamp so Prev moves.
111
+ const last = this._lastReachableIndex();
112
+ if (this.index > last) {
113
+ this._goTo(last - 1);
114
+ return;
115
+ }
116
+ }
92
117
  this._goTo(this.index - 1);
93
118
  }
94
119
 
@@ -124,14 +149,18 @@ export default class extends Controller {
124
149
 
125
150
  _update() {
126
151
  this._applyTransform();
152
+ // Loop mode wraps, so the buttons stay enabled as long as there is any
153
+ // travel to wrap through — with every slide already visible (_maxOffset
154
+ // is 0) a wrap is a permanent no-op, so disable then too.
155
+ const canLoop = this.slides.length > 1 && this._maxOffset() > OFFSET_EPSILON;
127
156
  const canNext = this.options.loop
128
- ? this.slides.length > 1
157
+ ? canLoop
129
158
  // Offset-based, not index-based: with multi-up layouts the track
130
159
  // reaches its max scrollable offset (_maxOffset) before this.index
131
160
  // reaches the last slide, and _offsetOf clamps to that max — an
132
161
  // index-based check left Next enabled for several dead clicks.
133
162
  : this._offsetOf(this.index) < this._maxOffset() - OFFSET_EPSILON;
134
- const canPrev = this.options.loop ? this.slides.length > 1 : this.index > 0;
163
+ const canPrev = this.options.loop ? canLoop : this.index > 0;
135
164
  this.nextButtonTargets.forEach((button) => (button.disabled = !canNext));
136
165
  this.prevButtonTargets.forEach((button) => (button.disabled = !canPrev));
137
166
  }
@@ -173,16 +202,16 @@ export default class extends Controller {
173
202
  if (!drag.moved) return;
174
203
 
175
204
  let offset = drag.offset - drag.delta;
176
- if (!this.options.loop) {
177
- // rubber-band past the ends
178
- const max = this._offsetOf(this.slides.length - 1);
179
- if (offset < 0) offset = offset / 3;
180
- if (offset > max) offset = max + (offset - max) / 3;
181
- }
205
+ // Rubber-band past the ends — in loop mode too: the translate engine has
206
+ // no cloned slides, so past-the-end is blank space either way (releasing
207
+ // still wraps via scrollNext/scrollPrev when looping).
208
+ const max = this._maxOffset();
209
+ if (offset < 0) offset = offset / 3;
210
+ if (offset > max) offset = max + (offset - max) / 3;
182
211
  this._translateTo(offset);
183
212
  }
184
213
 
185
- _onPointerUp() {
214
+ _onPointerUp(e) {
186
215
  const drag = this.drag;
187
216
  this.drag = null;
188
217
  this.viewportTarget.removeEventListener("pointermove", this._onPointerMove);
@@ -192,7 +221,10 @@ export default class extends Controller {
192
221
 
193
222
  this.track.style.transition = "";
194
223
  this.viewportTarget.classList.remove("dragging");
195
- this.suppressClick = true; // a drag must not activate links in the slide
224
+ // A drag must not activate links in the slide — but a cancelled pointer
225
+ // sequence never fires a click, so arming the flag on pointercancel
226
+ // would silently swallow the next (e.g. keyboard-initiated) click.
227
+ this.suppressClick = e?.type !== "pointercancel";
196
228
 
197
229
  const elapsed = Math.max(performance.now() - drag.startedAt, 1);
198
230
  const velocity = Math.abs(drag.delta) / elapsed;
@@ -257,6 +289,18 @@ export default class extends Controller {
257
289
  return Math.max(0, trackSize - viewportSize);
258
290
  }
259
291
 
292
+ // First index whose (clamped) offset reaches the max scrollable offset —
293
+ // the last position Next can travel to. count - 1 for single-up layouts;
294
+ // earlier with multi-up layouts, where the trailing slides all clamp to
295
+ // the same offset.
296
+ _lastReachableIndex() {
297
+ const max = this._maxOffset();
298
+ for (let i = 0; i < this.slides.length; i += 1) {
299
+ if (this._offsetOf(i) >= max - OFFSET_EPSILON) return i;
300
+ }
301
+ return Math.max(0, this.slides.length - 1);
302
+ }
303
+
260
304
  _slideSize() {
261
305
  const slide = this.slides[this.index];
262
306
  if (!slide) return 1;
@@ -7,11 +7,11 @@ module PhlexKit
7
7
  end
8
8
 
9
9
  def view_template(&)
10
- div(**mix({
11
- role: "group",
12
- aria: { roledescription: "slide" },
13
- class: "pk-carousel-item"
14
- }, @attrs), &)
10
+ base = { class: "pk-carousel-item" }
11
+ # Defaults only when the caller didn't supply their own — `mix` fuses.
12
+ base[:role] = "group" unless attr_set?(:role)
13
+ base[:aria] = { roledescription: "slide" } unless aria_key_set?(:roledescription)
14
+ div(**mix(base, @attrs), &)
15
15
  end
16
16
  end
17
17
  end