phlex_kit 0.9.1 → 0.11.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/_tokens.css +8 -0
  3. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  4. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  5. data/app/components/phlex_kit/alert/alert.rb +1 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +15 -0
  7. data/app/components/phlex_kit/attachment/attachment.css +6 -3
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
  10. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
  11. data/app/components/phlex_kit/avatar/avatar.css +2 -2
  12. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  13. data/app/components/phlex_kit/badge/badge.css +1 -1
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
  15. data/app/components/phlex_kit/bubble/bubble.css +1 -1
  16. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
  18. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  19. data/app/components/phlex_kit/button_group/button_group.css +16 -16
  20. data/app/components/phlex_kit/calendar/calendar.css +10 -2
  21. data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
  22. data/app/components/phlex_kit/carousel/carousel.css +4 -4
  23. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  24. data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
  25. data/app/components/phlex_kit/chart/chart.rb +8 -2
  26. data/app/components/phlex_kit/clipboard/clipboard_controller.js +26 -3
  27. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  28. data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
  29. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  30. data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
  31. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
  32. data/app/components/phlex_kit/combobox/combobox.css +3 -3
  33. data/app/components/phlex_kit/combobox/combobox.rb +3 -3
  34. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  35. data/app/components/phlex_kit/combobox/combobox_controller.js +32 -10
  36. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  37. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  38. data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
  39. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +7 -1
  40. data/app/components/phlex_kit/command/command.css +2 -2
  41. data/app/components/phlex_kit/command/command_controller.js +63 -35
  42. data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
  43. data/app/components/phlex_kit/command/command_group.rb +5 -2
  44. data/app/components/phlex_kit/command/command_item.rb +4 -0
  45. data/app/components/phlex_kit/command/command_list.rb +6 -2
  46. data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
  47. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  49. data/app/components/phlex_kit/data_table/data_table.css +0 -10
  50. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  51. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  52. data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
  53. data/app/components/phlex_kit/dialog/dialog.css +5 -3
  54. data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
  55. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  56. data/app/components/phlex_kit/dialog/dialog_controller.js +22 -2
  57. data/app/components/phlex_kit/drawer/drawer.css +4 -4
  58. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
  59. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
  60. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  62. data/app/components/phlex_kit/empty/empty.css +1 -1
  63. data/app/components/phlex_kit/field/field.css +2 -1
  64. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  65. data/app/components/phlex_kit/form_field/form_field_controller.js +29 -1
  66. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  67. data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
  68. data/app/components/phlex_kit/icon/icon.css +9 -0
  69. data/app/components/phlex_kit/icon/icon.rb +7 -1
  70. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  71. data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
  72. data/app/components/phlex_kit/marker/marker.rb +15 -2
  73. data/app/components/phlex_kit/masked_input/masked_input_controller.js +35 -15
  74. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  75. data/app/components/phlex_kit/menubar/menubar_controller.js +28 -1
  76. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
  77. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
  78. data/app/components/phlex_kit/message/message.rb +5 -0
  79. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +12 -5
  80. data/app/components/phlex_kit/native_select/native_select.css +4 -2
  81. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +7 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +11 -2
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +11 -2
  86. data/app/components/phlex_kit/popover/popover_controller.js +31 -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 +52 -5
  90. data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
  91. data/app/components/phlex_kit/scroll_area/scroll_area.rb +23 -4
  92. data/app/components/phlex_kit/select/select.css +5 -3
  93. data/app/components/phlex_kit/select/select_content.rb +9 -3
  94. data/app/components/phlex_kit/select/select_controller.js +59 -6
  95. data/app/components/phlex_kit/select/select_item.rb +5 -1
  96. data/app/components/phlex_kit/separator/separator.rb +12 -2
  97. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  98. data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
  99. data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
  100. data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
  101. data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
  102. data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
  103. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  104. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  105. data/app/components/phlex_kit/slider/slider.css +15 -0
  106. data/app/components/phlex_kit/switch/switch.css +5 -0
  107. data/app/components/phlex_kit/table/table.css +2 -1
  108. data/app/components/phlex_kit/table/table.rb +5 -2
  109. data/app/components/phlex_kit/tabs/tabs.css +2 -2
  110. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  111. data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
  112. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  113. data/app/components/phlex_kit/tabs/tabs_trigger.rb +15 -3
  114. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  115. data/app/components/phlex_kit/toast/toast.css +3 -2
  116. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  117. data/app/components/phlex_kit/toast/toast_controller.js +13 -5
  118. data/app/components/phlex_kit/toast/toast_region.rb +19 -9
  119. data/app/components/phlex_kit/toast/toaster_controller.js +34 -12
  120. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
  121. data/app/components/phlex_kit/toggle_group/toggle_group.rb +7 -2
  122. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +13 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
  124. data/app/components/phlex_kit/tooltip/tooltip.css +7 -3
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  126. data/app/components/phlex_kit/typography/heading.rb +13 -5
  127. data/app/components/phlex_kit/typography/list.rb +22 -0
  128. data/app/components/phlex_kit/typography/text.rb +7 -1
  129. data/lib/phlex_kit/base_component.rb +13 -0
  130. data/lib/phlex_kit/version.rb +1 -1
  131. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 805728288b072fe1663456e789ee106d9ca8398c53b1761255cd0167fd031677
4
- data.tar.gz: 1a18b9d368989240e0f16f794ece1a7f8ca1cc9e5dd551ba38dcd002236a5f0e
3
+ metadata.gz: caadd4e868a63939f22013699b89f9b1557dfa9569022301602c0f1cb07020f0
4
+ data.tar.gz: fa3ba42e9566f171e1fdee7ddcbfc475397bf404152031adbca2fd0cacbbda61
5
5
  SHA512:
6
- metadata.gz: 5b75e4ed5be4c81705389b3e3c9573986e581d04a5a97c8834c58af9f6fdd6977f02599e1aa4708454cf19ee684fd39a76439d26ec165cd47f839aa668f99d13
7
- data.tar.gz: 262df1a9e8f8ecb796844c8135b706ffc953baacdb7ac863024041eb7ac28a428dd1d0379fa9cd6779e26b0ea6069a244a7ed7b94d6f4f2c3cf72cf433f9095a
6
+ metadata.gz: 76304ba104026fe236758b3fa93014b516ec84e65e6baefd307853da30bb7dc9d38d1f6fff0d2c7639ede3ee9116642beed8a615c24963ea3e0462c0ca89656a
7
+ data.tar.gz: 0c8d0dfd56d4e4b873b35149dd7c362010c97a02a4642de0b72d80606301be27dea505849753aa8754e081c38e49484b3358524267d3df497a12b8c5d9e9ccd0
@@ -1,6 +1,14 @@
1
1
  /* Kit utilities used by the interactive controllers (Stimulus toggles these). */
2
2
  .pk-hidden { display: none !important; }
3
3
 
4
+ /* Kit-wide screen-reader-only utility — used by many components
5
+ (breadcrumb, dialog, sheet, sidebar, …), so it lives here rather
6
+ than in any one component's stylesheet. */
7
+ .pk-sr-only {
8
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
9
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
10
+ }
11
+
4
12
  /* Shimmer — shadcn's shimmer util: a highlight band sweeping across text, for
5
13
  loading / "thinking…" states. Pure CSS, built on currentColor: the text keeps
6
14
  its own color (any color) and the highlight derives from it via relative
@@ -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; }
@@ -15,7 +15,7 @@ module PhlexKit
15
15
  # shadcn ships default/destructive; success/warning are kit extras kept from
16
16
  # ruby_ui, restyled to the same text-tint grammar. `VARIANTS.fetch` fails loud.
17
17
  class Alert < BaseComponent
18
- # variant => extra class appended after the base `ui-alert` (nil = neutral).
18
+ # variant => extra class appended after the base `pk-alert` (nil = neutral).
19
19
  VARIANTS = {
20
20
  default: nil,
21
21
  warning: "warning",
@@ -52,6 +52,9 @@ export default class extends Controller {
52
52
  // Double-open guard: Enter+click (or a double-click) on the trigger must
53
53
  // not stack a second clone over the first.
54
54
  if (this.clone?.isConnected) return;
55
+ // open: is one-shot — the reflected value sits in the Turbo snapshot, so
56
+ // a cache-restored reconnect would re-open a dialog the user dismissed.
57
+ this.openValue = false;
55
58
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
56
59
  this.clone = document.body.lastElementChild;
57
60
  }
@@ -68,6 +71,10 @@ export default class extends Controller {
68
71
  }
69
72
 
70
73
  keydown(event) {
74
+ // Every open clone has its own document-level listener; with stacked
75
+ // alert dialogs (a trigger inside a panel) only the topmost clone may
76
+ // handle the keyboard, or one Escape would close every layer at once.
77
+ if (!this.#topmost()) return;
71
78
  if (event.key === "Escape") {
72
79
  event.preventDefault();
73
80
  this.dismiss();
@@ -97,6 +104,14 @@ export default class extends Controller {
97
104
  }
98
105
  }
99
106
 
107
+ // Clones are appended as direct <body> children whose direct child is the
108
+ // [role="alertdialog"] panel (source elements only hold a <template>, whose
109
+ // content never matches querySelector). The last such clone is the topmost.
110
+ #topmost() {
111
+ const clones = document.body.querySelectorAll(':scope > [data-controller~="phlex-kit--alert-dialog"] > [role="alertdialog"]');
112
+ return clones.length > 0 && clones[clones.length - 1].parentElement === this.element;
113
+ }
114
+
100
115
  #panel() {
101
116
  return this.element.querySelector('[role="alertdialog"]');
102
117
  }
@@ -142,21 +142,24 @@
142
142
  /* Scrollable, snapping row with an edge fade (horizontal scroll-fade). */
143
143
  .pk-attachment-group {
144
144
  --pk-scroll-fade-size: 2rem;
145
+ /* Gradient directions can't be logical — follow the inline axis by hand. */
146
+ --pk-scroll-fade-dir: to right;
145
147
  display: flex;
146
148
  gap: .5rem;
147
149
  overflow-x: auto;
148
150
  scroll-snap-type: x mandatory;
149
151
  padding: 2px; /* room for card rings */
150
- mask-image: linear-gradient(to right,
152
+ mask-image: linear-gradient(var(--pk-scroll-fade-dir),
151
153
  transparent 0, black var(--pk-scroll-fade-size),
152
154
  black calc(100% - var(--pk-scroll-fade-size)), transparent 100%);
153
155
  }
156
+ .pk-attachment-group:dir(rtl) { --pk-scroll-fade-dir: to left; }
154
157
  .pk-attachment-group[data-at-start] {
155
- mask-image: linear-gradient(to right,
158
+ mask-image: linear-gradient(var(--pk-scroll-fade-dir),
156
159
  black calc(100% - var(--pk-scroll-fade-size)), transparent 100%);
157
160
  }
158
161
  .pk-attachment-group[data-at-end] {
159
- mask-image: linear-gradient(to right, transparent 0, black var(--pk-scroll-fade-size));
162
+ mask-image: linear-gradient(var(--pk-scroll-fade-dir), transparent 0, black var(--pk-scroll-fade-size));
160
163
  }
161
164
  .pk-attachment-group[data-at-start][data-at-end] { mask-image: none; }
162
165
  .pk-attachment-group > .pk-attachment { flex: none; scroll-snap-align: start; }
@@ -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,13 @@ 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
9
14
  @href = href
10
15
  @attrs = attrs
11
16
  end
@@ -29,8 +29,13 @@ export default class extends Controller {
29
29
  update() {
30
30
  const el = this.element
31
31
  if (this.axisValue === "x") {
32
- el.toggleAttribute("data-at-start", el.scrollLeft <= 1)
33
- el.toggleAttribute("data-at-end", el.scrollLeft + el.clientWidth >= el.scrollWidth - 1)
32
+ // Math.abs: in RTL scrollLeft runs 0..negative, so the raw value would
33
+ // read as permanently "at start" abs tracks the logical inline start.
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)
38
+ el.toggleAttribute("data-at-end", scrolled + el.clientWidth >= el.scrollWidth - 1)
34
39
  } else {
35
40
  el.toggleAttribute("data-at-start", el.scrollTop <= 1)
36
41
  el.toggleAttribute("data-at-end", el.scrollTop + el.clientHeight >= el.scrollHeight - 1)
@@ -67,8 +67,8 @@
67
67
  modifier, glyph hidden when there's no room (shadcn's AvatarBadge). */
68
68
  .pk-avatar-badge {
69
69
  position: absolute;
70
- right: 0;
71
- bottom: 0;
70
+ inset-inline-end: 0;
71
+ inset-block-end: 0;
72
72
  z-index: 10;
73
73
  display: inline-flex;
74
74
  align-items: center;
@@ -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;
@@ -12,7 +12,3 @@
12
12
  .pk-breadcrumb-separator > svg { width: .875rem; height: .875rem; }
13
13
  .pk-breadcrumb-ellipsis { display: flex; height: 1.25rem; width: 1.25rem; align-items: center; justify-content: center; }
14
14
  .pk-breadcrumb-ellipsis > svg { width: 1rem; height: 1rem; }
15
- .pk-sr-only {
16
- position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
17
- overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
18
- }
@@ -14,7 +14,7 @@
14
14
  .pk-message[data-align="end"] .pk-bubble { align-self: flex-end; }
15
15
  .pk-bubble-content {
16
16
  width: fit-content; max-width: 100%; min-width: 0; overflow: hidden;
17
- overflow-wrap: break-word; border: 1px solid transparent; border-radius: .75rem;
17
+ overflow-wrap: break-word; border: 1px solid transparent; border-radius: calc(var(--pk-radius) + 2px);
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; }
@@ -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,8 +1,9 @@
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
+ # `as:` is dispatched dynamically — whitelist the documented containers
4
+ # (incl. the interactive button/a contract bubble.css styles) so it can't
5
+ # reach arbitrary (including private) methods.
6
+ AS_TAGS = %i[div p blockquote button a].freeze
6
7
  def initialize(as: :div, **attrs)
7
8
  @as = as.to_sym
8
9
  raise ArgumentError, "unknown BubbleContent as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})" unless AS_TAGS.include?(@as)
@@ -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(&)
@@ -12,25 +12,25 @@
12
12
 
13
13
  /* horizontal (default) */
14
14
  .pk-button-group:not(.vertical) > :not(:first-child) {
15
- border-top-left-radius: 0;
16
- border-bottom-left-radius: 0;
15
+ border-start-start-radius: 0;
16
+ border-end-start-radius: 0;
17
17
  border-inline-start: 0;
18
18
  }
19
19
  .pk-button-group:not(.vertical) > :not(:last-child) {
20
- border-top-right-radius: 0;
21
- border-bottom-right-radius: 0;
20
+ border-start-end-radius: 0;
21
+ border-end-end-radius: 0;
22
22
  }
23
23
 
24
24
  /* vertical */
25
25
  .pk-button-group.vertical { flex-direction: column; }
26
26
  .pk-button-group.vertical > :not(:first-child) {
27
- border-top-left-radius: 0;
28
- border-top-right-radius: 0;
27
+ border-start-start-radius: 0;
28
+ border-start-end-radius: 0;
29
29
  border-top: 0;
30
30
  }
31
31
  .pk-button-group.vertical > :not(:last-child) {
32
- border-bottom-left-radius: 0;
33
- border-bottom-right-radius: 0;
32
+ border-end-start-radius: 0;
33
+ border-end-end-radius: 0;
34
34
  }
35
35
 
36
36
  /* Nested groups keep their own corners — undo the squaring the parent applied. */
@@ -45,25 +45,25 @@
45
45
  absolutely-positioned panels are untouched. */
46
46
  .pk-button-group:not(.vertical) > :not(:first-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
47
47
  .pk-button-group:not(.vertical) > :not(:first-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
48
- border-top-left-radius: 0;
49
- border-bottom-left-radius: 0;
48
+ border-start-start-radius: 0;
49
+ border-end-start-radius: 0;
50
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) {
54
- border-top-right-radius: 0;
55
- border-bottom-right-radius: 0;
54
+ border-start-end-radius: 0;
55
+ border-end-end-radius: 0;
56
56
  }
57
57
  .pk-button-group.vertical > :not(:first-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
58
58
  .pk-button-group.vertical > :not(:first-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
59
- border-top-left-radius: 0;
60
- border-top-right-radius: 0;
59
+ border-start-start-radius: 0;
60
+ border-start-end-radius: 0;
61
61
  border-top: 0;
62
62
  }
63
63
  .pk-button-group.vertical > :not(:last-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
64
64
  .pk-button-group.vertical > :not(:last-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
65
- border-bottom-left-radius: 0;
66
- border-bottom-right-radius: 0;
65
+ border-end-start-radius: 0;
66
+ border-end-end-radius: 0;
67
67
  }
68
68
  /* .pk-select defaults to width:100%; as a group segment it hugs its trigger
69
69
  and lets siblings (the input) take the leftover flex space — shadcn's
@@ -155,6 +155,14 @@
155
155
  background: var(--pk-accent);
156
156
  }
157
157
  .pk-calendar-cell:has(> .range-cap) { background: var(--pk-accent); }
158
- .pk-calendar-cell:has(> .range-start.range-cap) { border-radius: var(--pk-cell-radius) 0 0 var(--pk-cell-radius); }
159
- .pk-calendar-cell:has(> .range-end.range-cap) { border-radius: 0 var(--pk-cell-radius) var(--pk-cell-radius) 0; }
158
+ .pk-calendar-cell:has(> .range-start.range-cap) {
159
+ border-radius: 0;
160
+ border-start-start-radius: var(--pk-cell-radius);
161
+ border-end-start-radius: var(--pk-cell-radius);
162
+ }
163
+ .pk-calendar-cell:has(> .range-end.range-cap) {
164
+ border-radius: 0;
165
+ border-start-end-radius: var(--pk-cell-radius);
166
+ border-end-end-radius: var(--pk-cell-radius);
167
+ }
160
168
  .pk-calendar-cell:has(> .range-start.range-end) { border-radius: var(--pk-cell-radius); }
@@ -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);
@@ -303,8 +305,18 @@ export default class extends Controller {
303
305
  }
304
306
  e.preventDefault();
305
307
 
306
- const iso = this.isoDate(target);
307
- if (this.isDateDisabled(iso)) return; // min/max/booked boundary: stay put
308
+ let iso = this.isoDate(target);
309
+ if (this.isDateDisabled(iso)) {
310
+ // A min/max boundary holds focus in place; an isolated booked date is
311
+ // SKIPPED in the travel direction (APG date-grid) — arrows only, the
312
+ // month/row jumps (PageUp/Home/End) still stay put.
313
+ if (!(e.key in STEPS) || this.outOfRange(target)) return;
314
+ do {
315
+ target.setDate(target.getDate() + STEPS[e.key]);
316
+ if (this.outOfRange(target)) return;
317
+ } while (this.isBooked(target));
318
+ iso = this.isoDate(target);
319
+ }
308
320
 
309
321
  if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
310
322
  this.viewDateValue = iso; // re-renders the grid on the target month
@@ -412,7 +424,7 @@ export default class extends Controller {
412
424
  );
413
425
  }
414
426
  if (this.modeValue === "multiple") {
415
- return this.selectedDatesValue.includes(this.isoDate(day));
427
+ return this.normalizeDates(this.selectedDatesValue).includes(this.isoDate(day));
416
428
  }
417
429
  const selectedDate = this.selectedDate();
418
430
  return selectedDate && day.toDateString() === selectedDate.toDateString();
@@ -428,7 +440,17 @@ export default class extends Controller {
428
440
  }
429
441
 
430
442
  isBooked(day) {
431
- return this.disabledDatesValue.includes(this.isoDate(day));
443
+ return this.normalizeDates(this.disabledDatesValue).includes(this.isoDate(day));
444
+ }
445
+
446
+ // disabled_dates / selected_dates entries may be Time#to_s or datetime ISO
447
+ // strings — every other date input tolerates those via parseDate, so the
448
+ // membership arrays must be normalized the same way (exact string equality
449
+ // silently no-oped for anything but bare yyyy-MM-dd).
450
+ normalizeDates(values) {
451
+ return values
452
+ .map((d) => { const parsed = this.parseDate(d); return parsed ? this.isoDate(parsed) : null; })
453
+ .filter(Boolean);
432
454
  }
433
455
 
434
456
  monthAndYear() {
@@ -575,14 +597,16 @@ export default class extends Controller {
575
597
  isDateDisabled(date) {
576
598
  const candidate = this.parseDate(date);
577
599
  if (!candidate) return false;
600
+ return this.outOfRange(candidate) || this.isBooked(candidate);
601
+ }
578
602
 
603
+ // min/max bounds only (not booked entries) — the keyboard skip needs to
604
+ // distinguish a hard boundary from a hole in the middle of the grid.
605
+ outOfRange(candidate) {
579
606
  const minDate = this.minDate();
580
607
  if (minDate && this.startOfDay(candidate) < this.startOfDay(minDate)) return true;
581
-
582
608
  const maxDate = this.maxDate();
583
- if (maxDate && this.startOfDay(candidate) > this.startOfDay(maxDate)) return true;
584
-
585
- return this.disabledDatesValue.includes(this.isoDate(candidate));
609
+ return !!(maxDate && this.startOfDay(candidate) > this.startOfDay(maxDate));
586
610
  }
587
611
 
588
612
  parseDate(value) {
@@ -16,14 +16,14 @@
16
16
  transition: transform .3s ease;
17
17
  will-change: transform;
18
18
  }
19
- .pk-carousel.is-horizontal .pk-carousel-track { margin-left: -1rem; }
19
+ .pk-carousel.is-horizontal .pk-carousel-track { margin-inline-start: -1rem; }
20
20
  .pk-carousel.is-vertical .pk-carousel-track { flex-direction: column; margin-top: -1rem; }
21
21
 
22
22
  .pk-carousel-item {
23
23
  min-width: 0;
24
24
  flex: 0 0 100%;
25
25
  }
26
- .pk-carousel.is-horizontal .pk-carousel-item { padding-left: 1rem; }
26
+ .pk-carousel.is-horizontal .pk-carousel-item { padding-inline-start: 1rem; }
27
27
  .pk-carousel.is-vertical .pk-carousel-item { padding-top: 1rem; }
28
28
 
29
29
  /* Edge buttons (an outline icon .pk-button; same specificity, later in the
@@ -34,8 +34,8 @@
34
34
  height: 2rem;
35
35
  border-radius: 9999px;
36
36
  }
37
- .pk-carousel.is-horizontal .pk-carousel-previous { left: -3rem; top: 50%; transform: translateY(-50%); }
38
- .pk-carousel.is-horizontal .pk-carousel-next { right: -3rem; top: 50%; transform: translateY(-50%); }
37
+ .pk-carousel.is-horizontal .pk-carousel-previous { inset-inline-start: -3rem; top: 50%; transform: translateY(-50%); }
38
+ .pk-carousel.is-horizontal .pk-carousel-next { inset-inline-end: -3rem; top: 50%; transform: translateY(-50%); }
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; }
@@ -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
@@ -73,6 +73,16 @@ export default class extends Controller {
73
73
  this._goTo(this.index - 1);
74
74
  }
75
75
 
76
+ // Physical arrow keys (horizontal axis only): in RTL the next slide sits
77
+ // to the physical LEFT, so the right arrow must travel toward "previous".
78
+ keyNext() {
79
+ this._rtl() ? this.scrollPrev() : this.scrollNext();
80
+ }
81
+
82
+ keyPrev() {
83
+ this._rtl() ? this.scrollNext() : this.scrollPrev();
84
+ }
85
+
76
86
  get slides() {
77
87
  return this.track ? Array.from(this.track.children) : [];
78
88
  }
@@ -129,6 +139,8 @@ export default class extends Controller {
129
139
  const drag = this.drag;
130
140
  if (!drag) return;
131
141
  drag.delta = this._pointerPos(e) - drag.start;
142
+ // Normalize to scroll-axis space: in RTL a rightward drag reveals next.
143
+ if (this._rtl()) drag.delta = -drag.delta;
132
144
  if (!drag.moved && Math.abs(drag.delta) > 5) {
133
145
  drag.moved = true;
134
146
  this.track.style.transition = "none";
@@ -183,13 +195,22 @@ export default class extends Controller {
183
195
  return this.options.axis === "y" ? e.clientY : e.clientX;
184
196
  }
185
197
 
198
+ // In RTL the horizontal track lays slides right-to-left: later slides have
199
+ // SMALLER offsetLeft and the track must translate in +x to reveal them.
200
+ // All geometry below works in a direction-neutral "offset along the scroll
201
+ // axis" space; _rtl() flips the signs at the physical boundaries only.
202
+ _rtl() {
203
+ return this.options.axis !== "y" && getComputedStyle(this.element).direction === "rtl";
204
+ }
205
+
186
206
  _offsetOf(index) {
187
207
  const slide = this.slides[index];
188
208
  const first = this.slides[0];
189
209
  if (!slide || !first) return 0;
190
- const raw = this.options.axis === "y"
210
+ let raw = this.options.axis === "y"
191
211
  ? slide.offsetTop - first.offsetTop
192
212
  : slide.offsetLeft - first.offsetLeft;
213
+ if (this._rtl()) raw = -raw;
193
214
  // Never scroll past the content edge: with multi-up layouts the last
194
215
  // slides' offsets overshoot the max scrollable offset (track − viewport).
195
216
  return Math.min(Math.max(0, raw), this._maxOffset());
@@ -211,6 +232,6 @@ export default class extends Controller {
211
232
  _translateTo(offset) {
212
233
  this.track.style.transform = this.options.axis === "y"
213
234
  ? `translate3d(0, ${-offset}px, 0)`
214
- : `translate3d(${-offset}px, 0, 0)`;
235
+ : `translate3d(${this._rtl() ? offset : -offset}px, 0, 0)`;
215
236
  }
216
237
  }
@@ -19,13 +19,19 @@ module PhlexKit
19
19
  end
20
20
 
21
21
  def view_template(&)
22
- canvas(**mix({
22
+ base = {
23
23
  class: "pk-chart",
24
+ # Canvas content is invisible to AT — announce it as an image with at
25
+ # least a generic name; pass aria: { label: "…" } (or block fallback
26
+ # content) to describe the actual data.
27
+ role: "img",
24
28
  data: {
25
29
  controller: "phlex-kit--chart",
26
30
  phlex_kit__chart_options_value: JSON.generate(@options)
27
31
  }
28
- }, @attrs), &)
32
+ }
33
+ base[:aria] = { label: "Chart" } unless aria_labelled?
34
+ canvas(**mix(base, @attrs), &)
29
35
  end
30
36
  end
31
37
  end
@@ -3,19 +3,42 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="phlex-kit--clipboard". Copies the source text and
4
4
  // flashes a success/error popover (CSS-positioned, no @floating-ui). Ported from ruby_ui.
5
5
  export default class extends Controller {
6
- static targets = ["trigger", "source", "successPopover", "errorPopover"]
6
+ // No "trigger" target: the click->#copy action alone wires the trigger.
7
+ static targets = ["source", "successPopover", "errorPopover"]
8
+
9
+ connect() {
10
+ // Turbo snapshots at turbo:before-cache, BEFORE disconnect — hide the
11
+ // flash now or a restore shows a permanently stuck "Copied!" popover.
12
+ document.addEventListener("turbo:before-cache", this.beforeCache)
13
+ }
7
14
 
8
15
  disconnect() {
9
16
  // Don't leave the 1.5s hide timer running into a dead/recycled element
10
17
  // (Turbo can detach and re-attach this subtree mid-flash).
18
+ clearTimeout(this.timer)
19
+ document.removeEventListener("turbo:before-cache", this.beforeCache)
20
+ this.hideAll()
21
+ }
22
+
23
+ beforeCache = () => {
11
24
  clearTimeout(this.timer)
12
25
  this.hideAll()
13
26
  }
14
27
 
15
28
  copy() {
29
+ // A source usually wraps one element (input/code/…), but bare text is
30
+ // legal too — fall back to the source's own text instead of erroring.
16
31
  const el = this.sourceTarget.children[0]
17
- if (!el) { this.show(this.errorPopoverTarget); return }
18
- const text = el.tagName === "INPUT" ? el.value : el.innerText
32
+ // TEXTAREA reads .value like INPUT — innerText would return the initial
33
+ // content, not the user-edited value.
34
+ const text = el
35
+ ? (["INPUT", "TEXTAREA"].includes(el.tagName) ? el.value : el.innerText)
36
+ : this.sourceTarget.textContent.trim()
37
+ if (!text) { this.show(this.errorPopoverTarget); return }
38
+ // navigator.clipboard is undefined in insecure contexts (plain-HTTP
39
+ // hosts, some webviews) — that's the error popover's job, not an
40
+ // unhandled TypeError's.
41
+ if (!navigator.clipboard) { this.show(this.errorPopoverTarget); return }
19
42
  navigator.clipboard.writeText(text)
20
43
  .then(() => this.show(this.successPopoverTarget))
21
44
  .catch(() => this.show(this.errorPopoverTarget))
@@ -1,6 +1,6 @@
1
1
  module PhlexKit
2
2
  class ClipboardTrigger < BaseComponent
3
3
  def initialize(**attrs) = (@attrs = attrs)
4
- def view_template(&) = div(**mix({ class: "pk-clipboard-trigger", data: { phlex_kit__clipboard_target: "trigger", action: "click->phlex-kit--clipboard#copy" } }, @attrs), &)
4
+ def view_template(&) = div(**mix({ class: "pk-clipboard-trigger", data: { action: "click->phlex-kit--clipboard#copy" } }, @attrs), &)
5
5
  end
6
6
  end