phlex_kit 0.10.0 → 0.12.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -1
  5. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +38 -7
  7. data/app/components/phlex_kit/attachment/attachment.css +15 -0
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +8 -0
  10. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  11. data/app/components/phlex_kit/badge/badge.css +1 -1
  12. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
  13. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  14. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  15. data/app/components/phlex_kit/calendar/calendar_controller.js +56 -15
  16. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  17. data/app/components/phlex_kit/carousel/carousel_controller.js +59 -4
  18. data/app/components/phlex_kit/chart/chart.rb +10 -2
  19. data/app/components/phlex_kit/chart/chart_controller.js +37 -8
  20. data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
  21. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  22. data/app/components/phlex_kit/codeblock/codeblock.rb +11 -1
  23. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  24. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  25. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  26. data/app/components/phlex_kit/combobox/combobox_controller.js +72 -3
  27. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  28. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  29. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
  30. data/app/components/phlex_kit/command/command_controller.js +23 -2
  31. data/app/components/phlex_kit/command/command_input.rb +3 -2
  32. data/app/components/phlex_kit/command/command_item.rb +4 -0
  33. data/app/components/phlex_kit/command/command_list.rb +6 -2
  34. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  35. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  36. data/app/components/phlex_kit/context_menu/context_menu_controller.js +74 -12
  37. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  38. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  39. data/app/components/phlex_kit/data_table/data_table.css +1 -11
  40. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  41. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  42. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  43. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  44. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  45. data/app/components/phlex_kit/date_picker/date_picker.rb +19 -2
  46. data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -0
  47. data/app/components/phlex_kit/dialog/dialog.css +3 -1
  48. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  49. data/app/components/phlex_kit/dialog/dialog_controller.js +16 -2
  50. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  51. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  52. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +9 -2
  53. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  54. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +81 -13
  55. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  56. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  57. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  58. data/app/components/phlex_kit/form_field/form_field_controller.js +54 -25
  59. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  60. data/app/components/phlex_kit/hover_card/hover_card.css +10 -2
  61. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  62. data/app/components/phlex_kit/icon/icon.css +9 -0
  63. data/app/components/phlex_kit/icon/icon.rb +7 -1
  64. data/app/components/phlex_kit/input/input.css +0 -1
  65. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  66. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  67. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  68. data/app/components/phlex_kit/item/item.css +2 -1
  69. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  70. data/app/components/phlex_kit/label/label.css +1 -1
  71. data/app/components/phlex_kit/link/link.rb +1 -0
  72. data/app/components/phlex_kit/marker/marker.css +2 -1
  73. data/app/components/phlex_kit/marker/marker.rb +9 -2
  74. data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
  75. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  76. data/app/components/phlex_kit/menubar/menubar_controller.js +46 -16
  77. data/app/components/phlex_kit/message/message.rb +5 -0
  78. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
  79. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  80. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  81. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
  86. data/app/components/phlex_kit/popover/popover_controller.js +42 -1
  87. data/app/components/phlex_kit/progress/progress.css +8 -0
  88. data/app/components/phlex_kit/progress/progress.rb +14 -5
  89. data/app/components/phlex_kit/resizable/resizable_controller.js +3 -0
  90. data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -15
  91. data/app/components/phlex_kit/select/select.css +5 -2
  92. data/app/components/phlex_kit/select/select_controller.js +49 -4
  93. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  94. data/app/components/phlex_kit/separator/separator.rb +5 -1
  95. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  96. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  97. data/app/components/phlex_kit/sheet/sheet_content_controller.js +40 -0
  98. data/app/components/phlex_kit/sheet/sheet_controller.js +31 -1
  99. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  100. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  101. data/app/components/phlex_kit/slider/slider.css +15 -0
  102. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  103. data/app/components/phlex_kit/switch/switch.css +5 -0
  104. data/app/components/phlex_kit/switch/switch.rb +6 -0
  105. data/app/components/phlex_kit/table/table.css +2 -1
  106. data/app/components/phlex_kit/table/table.rb +5 -2
  107. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  108. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  109. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  110. data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
  111. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  112. data/app/components/phlex_kit/toast/toast.css +2 -1
  113. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  114. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  115. data/app/components/phlex_kit/toast/toast_controller.js +40 -21
  116. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  117. data/app/components/phlex_kit/toast/toast_region.rb +29 -10
  118. data/app/components/phlex_kit/toast/toaster_controller.js +17 -7
  119. data/app/components/phlex_kit/toggle_group/toggle_group.rb +17 -2
  120. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +16 -9
  121. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +20 -2
  122. data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
  123. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  124. data/app/components/phlex_kit/typography/heading.rb +7 -1
  125. data/app/components/phlex_kit/typography/list.rb +22 -0
  126. data/app/components/phlex_kit/typography/text.rb +7 -1
  127. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  128. data/lib/phlex_kit/base_component.rb +23 -0
  129. data/lib/phlex_kit/version.rb +1 -1
  130. data/lib/phlex_kit.rb +1 -0
  131. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f20449d41b77f26b6a4272eb5f4ebd116a225af9bf2dcb6bfec5899a5b117a2
4
- data.tar.gz: fd83a994ef1cc33452c19b8520934db31750babacb7508511dffb9c41b08a9dd
3
+ metadata.gz: 832d7d74c2bcda4dcddef6ab391ee7afff8c15a3f011b57392dfb0e5d73b9922
4
+ data.tar.gz: 730dd9af5257a9350c551d9b5573d919cd04dd462a19f38d55558aa282d16822
5
5
  SHA512:
6
- metadata.gz: f08a4051f646822945f9f1bf72b52cfce151509322032dccba9ec7166db4bf5a99bead12adeec4969edf0fc94df5b55dc902424322fa6a351abd18d92c04496b
7
- data.tar.gz: 83b2a4ea332492c19ce059611e534fc57c79fa39e136e9bd00e2cf535fe09c791f75c0c81bc12056f2678f9a1bcf4bd18bd8246b79cd7377e0f990dbd4472c50
6
+ metadata.gz: 545d9af3c5b4ef67ff2793618825b17fcfff6a42fd7a7e97ca2b294d447d71ded63858094dc32f938dcdabd9c39ac4c8696bdf68da4b74cf1692bf62770d8081
7
+ data.tar.gz: 2a98a887feff3cf7a2144baeb9f380738880f2c100e6ed3ecc3ab2a4a1d94038efe06531b725eede6e1e419c4f9a282b109b6cb2db49328f32962819a17417c3
@@ -44,6 +44,7 @@
44
44
  @import url("form/form.css");
45
45
  @import url("form_field/form_field.css");
46
46
  @import url("hover_card/hover_card.css");
47
+ @import url("icon/icon.css");
47
48
  @import url("input/input.css");
48
49
  @import url("input_group/input_group.css");
49
50
  @import url("input_otp/input_otp.css");
@@ -9,6 +9,13 @@
9
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
+ /* background/border are stripped above — reinstate the kit focus ring or
13
+ keyboard focus falls back to the UA outline. */
14
+ .pk-accordion-trigger:focus-visible {
15
+ outline: none;
16
+ border-radius: calc(var(--pk-radius) - 2px);
17
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
18
+ }
12
19
  .pk-accordion-trigger-label { flex: 1; }
13
20
  .pk-accordion-icon { display: inline-flex; opacity: .5; transition: transform .2s ease; }
14
21
  .pk-accordion-icon svg { width: 1rem; height: 1rem; }
@@ -22,7 +22,9 @@
22
22
  gap: 1rem;
23
23
  width: calc(100% - 2rem);
24
24
  max-width: 32rem;
25
- max-height: 100vh;
25
+ /* Breathing room like dialog.css's max-height 100vh touched the screen
26
+ edges and overflowed behind mobile browser chrome (dvh tracks it). */
27
+ max-height: calc(100dvh - 2rem);
26
28
  overflow-y: auto;
27
29
  border: 1px solid var(--pk-border);
28
30
  border-radius: var(--pk-radius);
@@ -20,9 +20,20 @@ module PhlexKit
20
20
  # mousedown on the overlay is prevented so a stray click can't move
21
21
  # focus out of the trap. tabindex="-1" on the panel is the focus
22
22
  # fallback when the dialog has no focusable children.
23
- div(data: { controller: "phlex-kit--alert-dialog" }) do
23
+ # data-pk-overlay-clone is a common marker read across clone-based
24
+ # overlay families (see #topmost in alert_dialog_controller.js) so
25
+ # Escape resolves to whichever overlay is actually topmost, even when
26
+ # a different overlay type (e.g. a Sheet) is nested/stacked on top.
27
+ div(data: { controller: "phlex-kit--alert-dialog", pk_overlay_clone: "" }) do
24
28
  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)
29
+ panel_attrs = { class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" ") }
30
+ # Defaults only when the caller didn't supply their own — `mix`
31
+ # would fuse role="alertdialog dialog" / aria-modal="true false" /
32
+ # tabindex="-1 0" instead of overriding.
33
+ panel_attrs[:role] = "alertdialog" unless @attrs.key?(:role) || @attrs.key?("role")
34
+ panel_attrs[:"aria-modal"] = "true" unless aria_key_set?(:modal)
35
+ panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
36
+ div(**mix(panel_attrs, @attrs), &block)
26
37
  end
27
38
  end
28
39
  end
@@ -29,7 +29,17 @@ export default class extends Controller {
29
29
  this.previousOverflow = document.body.style.overflow;
30
30
  document.body.style.overflow = "hidden";
31
31
  this.onKeydown = (event) => this.keydown(event);
32
- this.beforeCache = () => this.element.remove();
32
+ // Turbo snapshots synchronously right after dispatching this event —
33
+ // BEFORE Stimulus disconnect() runs (it fires via MutationObserver, too
34
+ // late). Restore inert + scroll lock here so the snapshot never captures
35
+ // a fully-inerted, scroll-locked page; disconnect()'s later restore
36
+ // becomes a harmless no-op (#restoreInert clears `this.inerted`, and
37
+ // reassigning the same overflow value is idempotent).
38
+ this.beforeCache = () => {
39
+ this.#restoreInert();
40
+ document.body.style.overflow = this.previousOverflow;
41
+ this.element.remove();
42
+ };
33
43
  document.addEventListener("keydown", this.onKeydown);
34
44
  document.addEventListener("turbo:before-cache", this.beforeCache);
35
45
  this.#inertOthers();
@@ -40,7 +50,16 @@ export default class extends Controller {
40
50
  }
41
51
 
42
52
  disconnect() {
43
- if (this.hasContentTarget) return;
53
+ if (this.hasContentTarget) {
54
+ // The source element (holding the template) can disconnect while a
55
+ // clone it spawned is still live in <body> — e.g. Turbo replacing this
56
+ // region out from under an open dialog. Remove the orphan rather than
57
+ // leaving a modal nothing can dismiss. Idempotent: if the clone
58
+ // already removed itself (Cancel, Escape, its own before-cache),
59
+ // this.clone is disconnected and the isConnected check no-ops.
60
+ if (this.clone?.isConnected) this.clone.remove();
61
+ return;
62
+ }
44
63
  document.removeEventListener("keydown", this.onKeydown);
45
64
  document.removeEventListener("turbo:before-cache", this.beforeCache);
46
65
  this.#restoreInert();
@@ -52,6 +71,9 @@ export default class extends Controller {
52
71
  // Double-open guard: Enter+click (or a double-click) on the trigger must
53
72
  // not stack a second clone over the first.
54
73
  if (this.clone?.isConnected) return;
74
+ // open: is one-shot — the reflected value sits in the Turbo snapshot, so
75
+ // a cache-restored reconnect would re-open a dialog the user dismissed.
76
+ this.openValue = false;
55
77
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
56
78
  this.clone = document.body.lastElementChild;
57
79
  }
@@ -73,6 +95,12 @@ export default class extends Controller {
73
95
  // handle the keyboard, or one Escape would close every layer at once.
74
96
  if (!this.#topmost()) return;
75
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).
103
+ if (event.target.closest("dialog[open]")) return;
76
104
  event.preventDefault();
77
105
  this.dismiss();
78
106
  return;
@@ -101,12 +129,15 @@ export default class extends Controller {
101
129
  }
102
130
  }
103
131
 
104
- // Clones are appended as direct <body> children whose direct child is the
105
- // [role="alertdialog"] panel (source elements only hold a <template>, whose
106
- // content never matches querySelector). The last such clone is the topmost.
132
+ // Clones are appended as direct <body> children. Other clone-based overlay
133
+ // families (sheet/drawer content) stamp the same [data-pk-overlay-clone]
134
+ // marker on their own clone root, so this checks z-stacking across overlay
135
+ // TYPES, not just other alert dialogs — a sheet opened from inside an
136
+ // alert dialog (or vice versa) is a later sibling and must absorb Escape
137
+ // first; only the last overlay clone overall may act on it.
107
138
  #topmost() {
108
- const clones = document.body.querySelectorAll(':scope > [data-controller~="phlex-kit--alert-dialog"] > [role="alertdialog"]');
109
- return clones.length > 0 && clones[clones.length - 1].parentElement === this.element;
139
+ const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
140
+ return clones.length > 0 && clones[clones.length - 1] === this.element;
110
141
  }
111
142
 
112
143
  #panel() {
@@ -118,6 +118,21 @@
118
118
  -webkit-text-fill-color: transparent;
119
119
  animation: pk-shimmer 2s linear infinite;
120
120
  }
121
+ /* Same two fallback arms as _tokens.css's .pk-shimmer (:49-54): never let the
122
+ filename disappear under reduced motion or without relative color syntax
123
+ (Safari <16.4 / Chrome <119). */
124
+ @media (prefers-reduced-motion: reduce) {
125
+ .pk-attachment[data-state="uploading"] .pk-attachment-title,
126
+ .pk-attachment[data-state="processing"] .pk-attachment-title {
127
+ animation: none; background-image: none; -webkit-text-fill-color: currentColor;
128
+ }
129
+ }
130
+ @supports not (color: oklch(from white l c h)) {
131
+ .pk-attachment[data-state="uploading"] .pk-attachment-title,
132
+ .pk-attachment[data-state="processing"] .pk-attachment-title {
133
+ animation: none; background-image: none; -webkit-text-fill-color: currentColor;
134
+ }
135
+ }
121
136
  .pk-attachment[data-state="error"] { border-color: color-mix(in oklab, var(--pk-red) 40%, transparent); }
122
137
  .pk-attachment[data-state="error"] .pk-attachment-media { color: var(--pk-red); background: color-mix(in oklab, var(--pk-red) 12%, transparent); }
123
138
  .pk-attachment[data-state="error"] .pk-attachment-description { color: var(--pk-red); }
@@ -11,9 +11,14 @@ module PhlexKit
11
11
  div(**mix({
12
12
  class: "pk-attachment-group",
13
13
  role: "group",
14
+ # at-start + at-end = no edge fades. Server-rendered so a group that
15
+ # FITS shows no false "more content" fade pre-hydration / with JS off;
16
+ # the controller's first update() removes them when there's overflow.
14
17
  data: {
15
18
  controller: "phlex-kit--scroll-fade",
16
- phlex_kit__scroll_fade_axis_value: "x"
19
+ phlex_kit__scroll_fade_axis_value: "x",
20
+ at_start: "",
21
+ at_end: ""
17
22
  }
18
23
  }, @attrs), &)
19
24
  end
@@ -4,8 +4,16 @@ module PhlexKit
4
4
  # stay independently clickable. Give it an aria-label; pass `as: :a` +
5
5
  # `href:` for a link. See attachment.rb.
6
6
  class AttachmentTrigger < BaseComponent
7
+ AS_TAGS = %i[button a].freeze
8
+
7
9
  def initialize(as: :button, href: nil, **attrs)
8
10
  @as = as.to_sym
11
+ unless AS_TAGS.include?(@as)
12
+ raise ArgumentError, "unknown AttachmentTrigger as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
13
+ end
14
+ if @as == :a && href.nil?
15
+ raise ArgumentError, "AttachmentTrigger requires href: when as: :a"
16
+ end
9
17
  @href = href
10
18
  @attrs = attrs
11
19
  end
@@ -4,12 +4,19 @@ module PhlexKit
4
4
  # svg glyph (hidden automatically on xs/sm avatars). Recolor via style/class
5
5
  # — defaults to the brand fill. See avatar.rb.
6
6
  class AvatarBadge < BaseComponent
7
- def initialize(**attrs)
7
+ # label: gives the color-only presence dot an accessible alternative
8
+ # (a .pk-sr-only span, like AttachmentAction's). No default — a purely
9
+ # decorative dot stays silent.
10
+ def initialize(label: nil, **attrs)
11
+ @label = label
8
12
  @attrs = attrs
9
13
  end
10
14
 
11
15
  def view_template(&block)
12
- span(**mix({ class: "pk-avatar-badge" }, @attrs), &block)
16
+ span(**mix({ class: "pk-avatar-badge" }, @attrs)) do
17
+ span(class: "pk-sr-only") { @label } if @label
18
+ block&.call
19
+ end
13
20
  end
14
21
  end
15
22
  end
@@ -15,7 +15,7 @@
15
15
  overflow: hidden;
16
16
  white-space: nowrap;
17
17
  height: 1.25rem;
18
- border-radius: 2rem;
18
+ border-radius: 999px; /* fully-round pill (matches avatar/progress), not a --pk-radius derivative */
19
19
  border: 1px solid transparent;
20
20
  padding: .125rem .5rem;
21
21
  font-size: .75rem;
@@ -3,6 +3,7 @@
3
3
  display: flex; flex-wrap: wrap; align-items: center;
4
4
  gap: .375rem; word-break: break-word;
5
5
  font-size: .875rem; color: var(--pk-muted);
6
+ list-style: none; margin: 0; padding: 0;
6
7
  }
7
8
  .pk-breadcrumb-item { display: inline-flex; align-items: center; gap: .25rem; }
8
9
  .pk-breadcrumb-link { color: inherit; text-decoration: none; transition: color .15s ease; }
@@ -3,13 +3,11 @@ module PhlexKit
3
3
  # the child BubbleContent via a data-slot selector; align: :end flips it to the
4
4
  # trailing edge. Compose Bubble > BubbleContent (+ BubbleReactions).
5
5
  class Bubble < BaseComponent
6
- VARIANTS = %i[default secondary muted tinted outline ghost destructive].freeze
7
- ALIGNS = %i[start end].freeze
6
+ VARIANTS = %i[default secondary muted tinted outline ghost destructive].to_h { |v| [ v, v ] }.freeze
7
+ ALIGNS = %i[start end].to_h { |v| [ v, v ] }.freeze
8
8
  def initialize(variant: :default, align: :start, **attrs)
9
- @variant = variant.to_sym
10
- raise KeyError, "unknown Bubble variant #{@variant}" unless VARIANTS.include?(@variant)
11
- @align = align.to_sym
12
- raise KeyError, "unknown Bubble align #{@align}" unless ALIGNS.include?(@align)
9
+ @variant = fetch_option(VARIANTS, variant.to_sym, :variant)
10
+ @align = fetch_option(ALIGNS, align.to_sym, :align)
13
11
  @attrs = attrs
14
12
  end
15
13
  def view_template(&)
@@ -1,12 +1,10 @@
1
1
  module PhlexKit
2
2
  class BubbleReactions < BaseComponent
3
- SIDES = %i[top bottom].freeze
4
- ALIGNS = %i[start end].freeze
3
+ SIDES = %i[top bottom].to_h { |v| [ v, v ] }.freeze
4
+ ALIGNS = %i[start end].to_h { |v| [ v, v ] }.freeze
5
5
  def initialize(side: :bottom, align: :end, **attrs)
6
- @side = side.to_sym
7
- raise KeyError, "unknown BubbleReactions side #{@side}" unless SIDES.include?(@side)
8
- @align = align.to_sym
9
- raise KeyError, "unknown BubbleReactions align #{@align}" unless ALIGNS.include?(@align)
6
+ @side = fetch_option(SIDES, side.to_sym, :side)
7
+ @align = fetch_option(ALIGNS, align.to_sym, :align)
10
8
  @attrs = attrs
11
9
  end
12
10
  def view_template(&)
@@ -159,7 +159,9 @@ export default class extends Controller {
159
159
 
160
160
  toggleMultipleDay(iso) {
161
161
  const day = this.isoDate(this.parseDate(iso));
162
- const current = [...this.selectedDatesValue];
162
+ // Normalized copy: server-supplied entries may be datetime strings
163
+ // (Time#to_s) — indexOf against the bare isoDate must still match.
164
+ const current = this.normalizeDates(this.selectedDatesValue);
163
165
  const index = current.indexOf(day);
164
166
  if (index >= 0) {
165
167
  current.splice(index, 1);
@@ -241,7 +243,10 @@ export default class extends Controller {
241
243
  return this.isoDate(date);
242
244
  }
243
245
 
244
- updateCalendar() {
246
+ // `focusDay` overrides the day to refocus after re-render: on a keyboard
247
+ // month cross the target day lives in the NEW grid, not the pre-render
248
+ // activeElement, so onKeydown passes it explicitly.
249
+ updateCalendar(focusDay = null) {
245
250
  if (this.hasTitleTarget) {
246
251
  this.titleTarget.textContent = this.monthAndYear();
247
252
  }
@@ -249,9 +254,11 @@ export default class extends Controller {
249
254
  this.syncNavButtons();
250
255
  // innerHTML replacement drops focus — remember the focused day so
251
256
  // keyboard selection/month hops don't strand focus on <body>.
252
- const focusedDay = this.calendarTarget.contains(document.activeElement)
253
- ? document.activeElement.dataset?.day
254
- : null;
257
+ const focusedDay =
258
+ focusDay ||
259
+ (this.calendarTarget.contains(document.activeElement)
260
+ ? document.activeElement.dataset?.day
261
+ : null);
255
262
  this.calendarTarget.innerHTML = this.calendarHTML();
256
263
  this.ensureGridTabStop(focusedDay);
257
264
  }
@@ -280,7 +287,11 @@ export default class extends Controller {
280
287
  // boundary re-renders the grid on the target month. Enter/Space activate
281
288
  // natively (the days are real <button>s).
282
289
  onKeydown(e) {
283
- const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
290
+ // In RTL the day grid mirrors: the physical LEFT arrow advances one day.
291
+ // The week steps (Up/Down) and the PageUp/Down/Home/End jumps are
292
+ // unaffected. Runtime dir check is reliable after a dynamic flip.
293
+ const rtl = getComputedStyle(this.element).direction === "rtl";
294
+ const STEPS = { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1, ArrowUp: -7, ArrowDown: 7 };
284
295
  const day = e.target.closest?.("[data-day]");
285
296
  if (!day) return;
286
297
 
@@ -303,11 +314,29 @@ export default class extends Controller {
303
314
  }
304
315
  e.preventDefault();
305
316
 
306
- const iso = this.isoDate(target);
307
- if (this.isDateDisabled(iso)) return; // min/max/booked boundary: stay put
317
+ let iso = this.isoDate(target);
318
+ if (this.isDateDisabled(iso)) {
319
+ // A min/max boundary holds focus in place; an isolated booked date is
320
+ // SKIPPED in the travel direction (APG date-grid) — arrows only, the
321
+ // month/row jumps (PageUp/Home/End) still stay put.
322
+ if (!(e.key in STEPS) || this.outOfRange(target)) return;
323
+ do {
324
+ target.setDate(target.getDate() + STEPS[e.key]);
325
+ if (this.outOfRange(target)) return;
326
+ } while (this.isBooked(target));
327
+ iso = this.isoDate(target);
328
+ }
308
329
 
309
330
  if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
310
- this.viewDateValue = iso; // re-renders the grid on the target month
331
+ // Crossing a month re-renders the grid on the target month. Stimulus
332
+ // value-changed callbacks fire via MutationObserver (async), so the grid
333
+ // is NOT re-rendered synchronously after this assignment — querying it
334
+ // below would hit the OLD month, and the target day (outside it) would be
335
+ // missing → early return, stranding focus on <body>. Drive the re-render
336
+ // ourselves and hand it the target day so ensureGridTabStop focuses it.
337
+ this.viewDateValue = iso;
338
+ this.updateCalendar(iso);
339
+ return;
311
340
  }
312
341
  const next = this.calendarTarget.querySelector(`[data-day="${iso}"]:not([disabled])`);
313
342
  if (!next) return;
@@ -412,7 +441,7 @@ export default class extends Controller {
412
441
  );
413
442
  }
414
443
  if (this.modeValue === "multiple") {
415
- return this.selectedDatesValue.includes(this.isoDate(day));
444
+ return this.normalizeDates(this.selectedDatesValue).includes(this.isoDate(day));
416
445
  }
417
446
  const selectedDate = this.selectedDate();
418
447
  return selectedDate && day.toDateString() === selectedDate.toDateString();
@@ -428,7 +457,17 @@ export default class extends Controller {
428
457
  }
429
458
 
430
459
  isBooked(day) {
431
- return this.disabledDatesValue.includes(this.isoDate(day));
460
+ return this.normalizeDates(this.disabledDatesValue).includes(this.isoDate(day));
461
+ }
462
+
463
+ // disabled_dates / selected_dates entries may be Time#to_s or datetime ISO
464
+ // strings — every other date input tolerates those via parseDate, so the
465
+ // membership arrays must be normalized the same way (exact string equality
466
+ // silently no-oped for anything but bare yyyy-MM-dd).
467
+ normalizeDates(values) {
468
+ return values
469
+ .map((d) => { const parsed = this.parseDate(d); return parsed ? this.isoDate(parsed) : null; })
470
+ .filter(Boolean);
432
471
  }
433
472
 
434
473
  monthAndYear() {
@@ -575,14 +614,16 @@ export default class extends Controller {
575
614
  isDateDisabled(date) {
576
615
  const candidate = this.parseDate(date);
577
616
  if (!candidate) return false;
617
+ return this.outOfRange(candidate) || this.isBooked(candidate);
618
+ }
578
619
 
620
+ // min/max bounds only (not booked entries) — the keyboard skip needs to
621
+ // distinguish a hard boundary from a hole in the middle of the grid.
622
+ outOfRange(candidate) {
579
623
  const minDate = this.minDate();
580
624
  if (minDate && this.startOfDay(candidate) < this.startOfDay(minDate)) return true;
581
-
582
625
  const maxDate = this.maxDate();
583
- if (maxDate && this.startOfDay(candidate) > this.startOfDay(maxDate)) return true;
584
-
585
- return this.disabledDatesValue.includes(this.isoDate(candidate));
626
+ return !!(maxDate && this.startOfDay(candidate) > this.startOfDay(maxDate));
586
627
  }
587
628
 
588
629
  parseDate(value) {
@@ -35,12 +35,13 @@ module PhlexKit
35
35
  end
36
36
 
37
37
  # Arrow keys follow the scroll axis: left/right for horizontal, up/down
38
- # for vertical.
38
+ # for vertical. Horizontal arrows route through keyNext/keyPrev, which
39
+ # flip in RTL (the next slide sits to the physical left there).
39
40
  def keyboard_action
40
41
  if @orientation == :vertical
41
42
  "keydown.down->phlex-kit--carousel#scrollNext:prevent keydown.up->phlex-kit--carousel#scrollPrev:prevent"
42
43
  else
43
- "keydown.right->phlex-kit--carousel#scrollNext:prevent keydown.left->phlex-kit--carousel#scrollPrev:prevent"
44
+ "keydown.right->phlex-kit--carousel#keyNext:prevent keydown.left->phlex-kit--carousel#keyPrev:prevent"
44
45
  end
45
46
  end
46
47
  end
@@ -10,6 +10,11 @@ const DEFAULT_OPTIONS = {
10
10
  const DRAG_DISTANCE_THRESHOLD = 0.25;
11
11
  const DRAG_VELOCITY_THRESHOLD = 0.5;
12
12
 
13
+ // Subpixel slop when comparing the current offset to the max scrollable
14
+ // offset — layout rounding can leave a fraction of a px short of the true
15
+ // max, which would otherwise read as "can still scroll".
16
+ const OFFSET_EPSILON = 0.5;
17
+
13
18
  // Ported from ruby_ui's carousel controller with the embla-carousel dependency
14
19
  // removed: a minimal translate-based engine (scrollNext/scrollPrev, loop, x/y
15
20
  // axis from the options value) moves the track directly, so only
@@ -36,6 +41,7 @@ export default class extends Controller {
36
41
  this._onPointerMove = this._onPointerMove.bind(this);
37
42
  this._onPointerUp = this._onPointerUp.bind(this);
38
43
  this._onClickCapture = this._onClickCapture.bind(this);
44
+ this._onDragStart = this._onDragStart.bind(this);
39
45
  window.addEventListener("resize", this._onResize);
40
46
  // window resize misses container-only resizes (flex/grid reflow,
41
47
  // sidebar toggles) — observe the viewport itself.
@@ -45,6 +51,10 @@ export default class extends Controller {
45
51
  }
46
52
  this.viewportTarget.addEventListener("pointerdown", this._onPointerDown);
47
53
  this.viewportTarget.addEventListener("click", this._onClickCapture, true);
54
+ // Firefox starts a native image drag on pointerdown-over-<img>, which
55
+ // cancels the pointer sequence mid-swipe; -webkit-user-drag (CSS) is
56
+ // WebKit-only, so suppress it here for every browser.
57
+ this.viewportTarget.addEventListener("dragstart", this._onDragStart);
48
58
  this._update();
49
59
  }
50
60
 
@@ -54,6 +64,7 @@ export default class extends Controller {
54
64
  this._resizeObserver = null;
55
65
  this.viewportTarget.removeEventListener("pointerdown", this._onPointerDown);
56
66
  this.viewportTarget.removeEventListener("click", this._onClickCapture, true);
67
+ this.viewportTarget.removeEventListener("dragstart", this._onDragStart);
57
68
  // a disconnect mid-drag must drop the move/up listeners and drag state
58
69
  this.viewportTarget.removeEventListener("pointermove", this._onPointerMove);
59
70
  this.viewportTarget.removeEventListener("pointerup", this._onPointerUp);
@@ -66,6 +77,14 @@ export default class extends Controller {
66
77
  }
67
78
 
68
79
  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) {
86
+ return;
87
+ }
69
88
  this._goTo(this.index + 1);
70
89
  }
71
90
 
@@ -73,6 +92,16 @@ export default class extends Controller {
73
92
  this._goTo(this.index - 1);
74
93
  }
75
94
 
95
+ // Physical arrow keys (horizontal axis only): in RTL the next slide sits
96
+ // to the physical LEFT, so the right arrow must travel toward "previous".
97
+ keyNext() {
98
+ this._rtl() ? this.scrollPrev() : this.scrollNext();
99
+ }
100
+
101
+ keyPrev() {
102
+ this._rtl() ? this.scrollNext() : this.scrollPrev();
103
+ }
104
+
76
105
  get slides() {
77
106
  return this.track ? Array.from(this.track.children) : [];
78
107
  }
@@ -95,8 +124,13 @@ export default class extends Controller {
95
124
 
96
125
  _update() {
97
126
  this._applyTransform();
98
- const last = this.slides.length - 1;
99
- const canNext = this.options.loop ? this.slides.length > 1 : this.index < last;
127
+ const canNext = this.options.loop
128
+ ? this.slides.length > 1
129
+ // Offset-based, not index-based: with multi-up layouts the track
130
+ // reaches its max scrollable offset (_maxOffset) before this.index
131
+ // reaches the last slide, and _offsetOf clamps to that max — an
132
+ // index-based check left Next enabled for several dead clicks.
133
+ : this._offsetOf(this.index) < this._maxOffset() - OFFSET_EPSILON;
100
134
  const canPrev = this.options.loop ? this.slides.length > 1 : this.index > 0;
101
135
  this.nextButtonTargets.forEach((button) => (button.disabled = !canNext));
102
136
  this.prevButtonTargets.forEach((button) => (button.disabled = !canPrev));
@@ -129,6 +163,8 @@ export default class extends Controller {
129
163
  const drag = this.drag;
130
164
  if (!drag) return;
131
165
  drag.delta = this._pointerPos(e) - drag.start;
166
+ // Normalize to scroll-axis space: in RTL a rightward drag reveals next.
167
+ if (this._rtl()) drag.delta = -drag.delta;
132
168
  if (!drag.moved && Math.abs(drag.delta) > 5) {
133
169
  drag.moved = true;
134
170
  this.track.style.transition = "none";
@@ -177,19 +213,38 @@ export default class extends Controller {
177
213
  e.stopPropagation();
178
214
  }
179
215
 
216
+ // Firefox-only: a pointer drag that starts on an <img> slide fires a
217
+ // native HTML5 dragstart, which cancels the in-flight pointer sequence
218
+ // (pointercancel) and aborts the swipe. Chrome/Safari never fire it here
219
+ // because carousel.css already sets -webkit-user-drag: none.
220
+ _onDragStart(e) {
221
+ if (e.target.closest("img")) {
222
+ e.preventDefault();
223
+ }
224
+ }
225
+
180
226
  // --- geometry ---
181
227
 
182
228
  _pointerPos(e) {
183
229
  return this.options.axis === "y" ? e.clientY : e.clientX;
184
230
  }
185
231
 
232
+ // In RTL the horizontal track lays slides right-to-left: later slides have
233
+ // SMALLER offsetLeft and the track must translate in +x to reveal them.
234
+ // All geometry below works in a direction-neutral "offset along the scroll
235
+ // axis" space; _rtl() flips the signs at the physical boundaries only.
236
+ _rtl() {
237
+ return this.options.axis !== "y" && getComputedStyle(this.element).direction === "rtl";
238
+ }
239
+
186
240
  _offsetOf(index) {
187
241
  const slide = this.slides[index];
188
242
  const first = this.slides[0];
189
243
  if (!slide || !first) return 0;
190
- const raw = this.options.axis === "y"
244
+ let raw = this.options.axis === "y"
191
245
  ? slide.offsetTop - first.offsetTop
192
246
  : slide.offsetLeft - first.offsetLeft;
247
+ if (this._rtl()) raw = -raw;
193
248
  // Never scroll past the content edge: with multi-up layouts the last
194
249
  // slides' offsets overshoot the max scrollable offset (track − viewport).
195
250
  return Math.min(Math.max(0, raw), this._maxOffset());
@@ -211,6 +266,6 @@ export default class extends Controller {
211
266
  _translateTo(offset) {
212
267
  this.track.style.transform = this.options.axis === "y"
213
268
  ? `translate3d(0, ${-offset}px, 0)`
214
- : `translate3d(${-offset}px, 0, 0)`;
269
+ : `translate3d(${this._rtl() ? offset : -offset}px, 0, 0)`;
215
270
  }
216
271
  }
@@ -19,13 +19,21 @@ module PhlexKit
19
19
  end
20
20
 
21
21
  def view_template(&)
22
- canvas(**mix({
22
+ base = {
23
23
  class: "pk-chart",
24
24
  data: {
25
25
  controller: "phlex-kit--chart",
26
26
  phlex_kit__chart_options_value: JSON.generate(@options)
27
27
  }
28
- }, @attrs), &)
28
+ }
29
+ # Defaults only when the caller didn't supply their own — `mix` would
30
+ # fuse role="img figure" instead of overriding.
31
+ # Canvas content is invisible to AT — announce it as an image with at
32
+ # least a generic name; pass aria: { label: "…" } (or block fallback
33
+ # content) to describe the actual data.
34
+ base[:role] = "img" unless @attrs.key?(:role) || @attrs.key?("role")
35
+ base[:aria] = { label: "Chart" } unless aria_labelled?
36
+ canvas(**mix(base, @attrs), &)
29
37
  end
30
38
  end
31
39
  end