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
@@ -2,12 +2,16 @@ module PhlexKit
2
2
  # Muted helper text under a PhlexKit::FormField control. Hidden when empty.
3
3
  # See form_field.rb.
4
4
  class FormFieldHint < BaseComponent
5
- def initialize(**attrs)
5
+ # A default id (like FormFieldError) so the controller can wire the hint
6
+ # into every control's aria-describedby — without it SR users never heard
7
+ # the hint text. id: is a named kwarg to avoid mix id-fusion.
8
+ def initialize(id: nil, **attrs)
9
+ @id = id || "pk-form-field-hint-#{SecureRandom.hex(4)}"
6
10
  @attrs = attrs
7
11
  end
8
12
 
9
13
  def view_template(&block)
10
- p(**mix({ class: "pk-form-field-hint" }, @attrs), &block)
14
+ p(**mix({ id: @id, class: "pk-form-field-hint", data: { phlex_kit__form_field_target: "hint" } }, @attrs), &block)
11
15
  end
12
16
  end
13
17
  end
@@ -15,19 +15,27 @@
15
15
  position-area: block-end span-inline-end;
16
16
  margin: .25rem 0 0;
17
17
  position-try-fallbacks: flip-block;
18
+ flex-direction: column; gap: .125rem;
18
19
  width: 16rem;
19
20
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
20
21
  border-radius: var(--pk-radius); background: var(--pk-surface);
21
22
  color: var(--pk-text); padding: .625rem; font-size: .875rem;
22
23
  box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
23
24
  }
25
+ /* display must be gated on :popover-open — an unconditional author
26
+ `display: flex` would beat the UA's [popover] { display: none } (bit the
27
+ Basic docs example's inline style, which relied on this instead of the
28
+ component doing it). */
29
+ .pk-hover-card-content:popover-open { display: flex; }
24
30
  .pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
31
+ /* Gap margins are logical to match the logical position-area (and tooltip's
32
+ pattern) — physical margins landed on the wrong side in RTL. */
25
33
  .pk-hover-card-content.left {
26
- position-area: inline-start; margin: 0 .25rem 0 0;
34
+ position-area: inline-start; margin: 0; margin-inline-end: .25rem;
27
35
  position-try-fallbacks: flip-inline;
28
36
  }
29
37
  .pk-hover-card-content.right {
30
- position-area: inline-end; margin: 0 0 0 .25rem;
38
+ position-area: inline-end; margin: 0; margin-inline-start: .25rem;
31
39
  position-try-fallbacks: flip-inline;
32
40
  }
33
41
  /* Hiding must survive author display values (a host class like
@@ -10,6 +10,29 @@ import { Controller } from "@hotwired/stimulus"
10
10
  export default class extends Controller {
11
11
  static targets = ["trigger", "content"]
12
12
  static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
13
+
14
+ connect() {
15
+ // Turbo snapshots BEFORE disconnect, so a stale data-state="open" would
16
+ // otherwise resurrect host CSS keyed on the state hook after a cache
17
+ // restore — close now (popover_controller.js's contentTargetConnected /
18
+ // context_menu_controller.js's turbo:before-cache pattern combined).
19
+ this.onBeforeCache = () => {
20
+ if (this.contentTarget.matches(":popover-open")) {
21
+ clearTimeout(this.t)
22
+ this.#close()
23
+ }
24
+ }
25
+ document.addEventListener("turbo:before-cache", this.onBeforeCache)
26
+ }
27
+
28
+ contentTargetConnected(el) {
29
+ // A reconnect (Turbo cache restore, or the detach/reattach it's tested
30
+ // with) can hand us markup whose data-state was serialized while open —
31
+ // :popover-open does not survive the restore, so resync before anything
32
+ // reads the hook.
33
+ el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
34
+ }
35
+
13
36
  show() {
14
37
  clearTimeout(this.t)
15
38
  this.t = setTimeout(() => {
@@ -25,6 +48,7 @@ export default class extends Controller {
25
48
  disconnect() {
26
49
  clearTimeout(this.t)
27
50
  this.#unbindEscape()
51
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
28
52
  }
29
53
 
30
54
  #close() {
@@ -0,0 +1,9 @@
1
+ /* Co-located with icon.rb — PhlexKit::Icon. The .pk-icon class was emitted
2
+ with no rule anywhere (audit round 6): the bare SVG inherited the UA's
3
+ inline/baseline layout (descender gap under icons) and could be squashed
4
+ inside height-constrained flex scroll panes. */
5
+ .pk-icon {
6
+ display: inline-block;
7
+ vertical-align: middle;
8
+ flex: none;
9
+ }
@@ -20,7 +20,13 @@ module PhlexKit
20
20
 
21
21
  def view_template
22
22
  icon = Icons.fetch(@name, library: @library || PhlexKit.config.icon_library)
23
- svg(**mix(base_attrs(icon), @attrs)) do |s|
23
+ base = base_attrs(icon)
24
+ # `mix` merges duplicate string attrs ("16 24") — drop a generated attr
25
+ # whenever the caller supplies their own copy, so theirs wins.
26
+ %i[width height viewbox aria-hidden].each do |key|
27
+ base.delete(key) if @attrs.key?(key) || @attrs.key?(key.to_s)
28
+ end
29
+ svg(**mix(base, @attrs)) do |s|
24
30
  icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
25
31
  end
26
32
  end
@@ -38,7 +38,6 @@
38
38
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
39
39
  }
40
40
  .pk-input:disabled {
41
- pointer-events: none;
42
41
  cursor: not-allowed;
43
42
  opacity: .5;
44
43
  background: color-mix(in oklab, var(--pk-input) 80%, transparent);
@@ -59,7 +59,6 @@
59
59
  justify-content: center;
60
60
  gap: .5rem;
61
61
  flex: none;
62
- cursor: text;
63
62
  user-select: none;
64
63
  padding-block: .375rem;
65
64
  color: var(--pk-muted);
@@ -15,15 +15,18 @@ module PhlexKit
15
15
  end
16
16
 
17
17
  def view_template(&block)
18
- div(**mix({
18
+ base = {
19
19
  class: "pk-input-otp",
20
20
  role: "group",
21
- aria: { label: @label },
22
21
  data: {
23
22
  controller: "phlex-kit--input-otp",
24
23
  phlex_kit__input_otp_length_value: @length
25
24
  }
26
- }, @attrs)) do
25
+ }
26
+ # Default label only when the caller didn't supply their own — `mix`
27
+ # would fuse aria-label into a two-token value instead of overriding.
28
+ base[:aria] = { label: @label } unless aria_labelled?
29
+ div(**mix(base, @attrs)) do
27
30
  input(type: :hidden, name: @name, value: @value, data: { phlex_kit__input_otp_target: "value" }) if @name
28
31
  yield if block
29
32
  end
@@ -30,20 +30,32 @@ export default class extends Controller {
30
30
  const chars = this.valueTarget.value.split("")
31
31
  this.slotTargets.forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
32
32
  }
33
+ // Track each slot's last-known-good value so a rejected keystroke (see
34
+ // onInput) can restore it instead of leaving the slot blanked.
35
+ this.slotTargets.forEach((slot) => { slot.dataset.otpValue = slot.value })
33
36
  this.syncValue()
34
37
  }
35
38
 
36
39
  onInput(e) {
37
40
  const slot = e.target
41
+ const isNumeric = slot.getAttribute("inputmode") === "numeric"
42
+ const priorValue = slot.dataset.otpValue || ""
38
43
  let text = slot.value.replace(/\s/g, "")
39
44
  // Typing rejects the same characters paste strips — a letter keyed into
40
45
  // an inputmode=numeric slot must not land.
41
- if (slot.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
46
+ if (isNumeric) text = text.replace(/\D/g, "")
42
47
 
43
48
  if (text.length > 1) {
44
49
  if (e.data && e.data.length === 1) {
50
+ if (isNumeric && !/\d/.test(e.data)) {
51
+ // Rejected keystroke into an already-filled/selected slot: restore
52
+ // the prior digit rather than blanking it, and don't advance focus.
53
+ slot.value = priorValue
54
+ return
55
+ }
45
56
  // one keystroke into an already-filled slot: keep the newest char
46
57
  slot.value = e.data
58
+ slot.dataset.otpValue = e.data
47
59
  this.focusSlot(this.slotTargets.indexOf(slot) + 1)
48
60
  this.syncValue()
49
61
  } else {
@@ -55,7 +67,16 @@ export default class extends Controller {
55
67
  return
56
68
  }
57
69
 
70
+ if (!text && e.data) {
71
+ // A keystroke was fully rejected by numeric filtering (e.g. a letter
72
+ // typed into a filled/selected slot) — restore the slot's prior value
73
+ // instead of blanking it, and don't advance focus.
74
+ slot.value = priorValue
75
+ return
76
+ }
77
+
58
78
  slot.value = text
79
+ slot.dataset.otpValue = text
59
80
  if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
60
81
  this.syncValue()
61
82
  }
@@ -66,6 +87,7 @@ export default class extends Controller {
66
87
  e.preventDefault()
67
88
  const prev = this.slotTargets[index - 1]
68
89
  prev.value = ""
90
+ prev.dataset.otpValue = ""
69
91
  prev.focus()
70
92
  this.syncValue()
71
93
  } else if (e.key === "ArrowLeft" && index > 0) {
@@ -92,7 +114,7 @@ export default class extends Controller {
92
114
  fillFrom(slot, text) {
93
115
  const chars = text.split("")
94
116
  const start = this.slotTargets.indexOf(slot)
95
- this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
117
+ this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) { s.value = chars[i]; s.dataset.otpValue = chars[i] } })
96
118
  this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
97
119
  this.syncValue()
98
120
  }
@@ -34,7 +34,8 @@ a.pk-item:hover { background: var(--pk-surface-2); }
34
34
  transform: translateY(.125rem);
35
35
  align-self: flex-start;
36
36
  }
37
- .pk-item-media.icon svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
37
+ /* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
38
+ .pk-item-media.icon svg:not([width]) { width: 1rem; height: 1rem; }
38
39
  .pk-item-media.image {
39
40
  width: 2.5rem;
40
41
  height: 2.5rem;
@@ -15,7 +15,8 @@
15
15
  font-weight: 500;
16
16
  }
17
17
  .pk-kbd-group { display: inline-flex; align-items: center; gap: .25rem; font-family: inherit; }
18
- .pk-kbd svg:not([class*="size-"]) { width: .75rem; height: .75rem; }
18
+ /* Glyphs default to size-3; explicit width attrs (Icon size:) win. */
19
+ .pk-kbd svg:not([width]) { width: .75rem; height: .75rem; }
19
20
  /* Inside an (inverted) tooltip the chip tints from the page background. */
20
21
  .pk-tooltip-content .pk-kbd {
21
22
  background: color-mix(in oklab, var(--pk-bg) 10%, transparent);
@@ -9,4 +9,4 @@
9
9
  line-height: 1;
10
10
  user-select: none;
11
11
  }
12
- .pk-label:has(+ :disabled), .pk-label:has(:disabled) { cursor: not-allowed; opacity: .5; }
12
+ .pk-label:has(+ :disabled), .pk-label:has(:disabled), :disabled + .pk-label { cursor: not-allowed; opacity: .5; }
@@ -20,6 +20,7 @@ module PhlexKit
20
20
  }.freeze
21
21
 
22
22
  SIZES = {
23
+ xs: "xs",
23
24
  sm: "sm",
24
25
  md: nil,
25
26
  lg: "lg",
@@ -25,7 +25,8 @@ a.pk-marker, button.pk-marker {
25
25
  font-size: .875rem;
26
26
  }
27
27
  a.pk-marker:hover, button.pk-marker:hover { color: var(--pk-text); }
28
- .pk-marker svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
28
+ /* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
29
+ .pk-marker svg:not([width]) { width: 1rem; height: 1rem; }
29
30
  .pk-marker-icon { display: inline-flex; flex: none; width: 1rem; height: 1rem; }
30
31
  .pk-marker-content { min-width: 0; overflow-wrap: break-word; }
31
32
  .pk-marker-content a { text-decoration: underline; text-underline-offset: 3px; color: inherit; }
@@ -10,13 +10,20 @@ module PhlexKit
10
10
  # unknown values used to fall through silently to <div> — fail loud instead.
11
11
  AS_TAGS = %i[div button].freeze
12
12
 
13
- def initialize(variant: :default, href: nil, as: :div, **attrs)
13
+ # type: is a named kwarg (not a mix default) because `mix` would *fuse* a
14
+ # caller type: "submit" with the generated "button" into an invalid
15
+ # two-token value the browser resolves back to submit.
16
+ def initialize(variant: :default, href: nil, as: :div, type: :button, **attrs)
14
17
  @variant = variant.to_sym
15
18
  @href = href
16
19
  @as = as.to_sym
17
20
  unless AS_TAGS.include?(@as)
18
21
  raise ArgumentError, "unknown Marker as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
19
22
  end
23
+ if @href && @as != :div
24
+ raise ArgumentError, "Marker href: renders an <a> — it can't combine with as: #{@as.inspect}"
25
+ end
26
+ @type = type
20
27
  @attrs = attrs
21
28
  end
22
29
 
@@ -25,7 +32,7 @@ module PhlexKit
25
32
  if @href
26
33
  a(**mix({ class: classes, href: @href }, @attrs), &)
27
34
  elsif @as == :button
28
- button(**mix({ class: classes, type: "button" }, @attrs), &)
35
+ button(**mix({ class: classes, type: @type }, @attrs), &)
29
36
  else
30
37
  div(**mix({ class: classes }, @attrs), &)
31
38
  end
@@ -26,13 +26,27 @@ export default class extends Controller {
26
26
  apply() {
27
27
  const el = this.element
28
28
  // The whole value is rewritten below, which throws the caret to the end —
29
- // remember how many maskable chars sit before it and re-seat it after
30
- // the same count in the masked output (mid-field edits keep their place).
29
+ // re-seat it after the same number of TOKEN-filled (user) characters.
30
+ // Counting is done in token space, not "any alphanumeric" space: mask
31
+ // literals can themselves be alphanumeric (the "1" in "+1 (###) ###-####"),
32
+ // and counting them as user input reordered every subsequent keystroke.
31
33
  const caret = el.selectionStart ?? el.value.length
32
- const rawBefore = this.maskable(el.value.slice(0, caret)).length
34
+ const filledBefore = this.maskFill(this.maskable(el.value.slice(0, caret))).tokenPos.length
33
35
 
34
- const raw = this.maskable(el.value)
36
+ const { out, tokenPos } = this.maskFill(this.maskable(el.value))
37
+ el.value = out
38
+
39
+ if (document.activeElement === el) {
40
+ const pos = filledBefore === 0 ? 0 : tokenPos[filledBefore - 1] + 1
41
+ el.setSelectionRange(pos, pos)
42
+ }
43
+ }
44
+
45
+ // Fill the mask from `raw`, returning the output and the output indices
46
+ // that were filled from user input (vs emitted as mask literals).
47
+ maskFill(raw) {
35
48
  let out = "", i = 0
49
+ const tokenPos = []
36
50
  for (const t of this.mask) {
37
51
  if (i >= raw.length) break
38
52
  if (t === "#" || t === "A" || t === "*") {
@@ -41,19 +55,11 @@ export default class extends Controller {
41
55
  const re = t === "#" ? /\d/ : t === "A" ? /[A-Za-z]/ : /[0-9A-Za-z]/
42
56
  while (i < raw.length && !re.test(raw[i])) i++
43
57
  if (i >= raw.length) break
58
+ tokenPos.push(out.length)
44
59
  out += raw[i++]
45
60
  } else { out += t; if (raw[i] === t) i++ }
46
61
  }
47
- el.value = out
48
-
49
- if (document.activeElement === el) {
50
- let pos = 0, seen = 0
51
- while (pos < out.length && seen < rawBefore) {
52
- if (/[0-9A-Za-z]/.test(out[pos])) seen++
53
- pos++
54
- }
55
- el.setSelectionRange(pos, pos)
56
- }
62
+ return { out, tokenPos }
57
63
  }
58
64
  // Transliterate full-width digits (0-9, U+FF10–FF19 — common IME numeric
59
65
  // output) to ASCII before the charset filter would silently drop them.
@@ -129,6 +129,9 @@
129
129
  the viewport right edge. */
130
130
  .pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
131
131
  .pk-menubar-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
132
+ /* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
133
+ [dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
134
+ [dir="rtl"] .pk-menubar-sub-chevron { transform: scaleX(-1); }
132
135
  .pk-menubar-sub-content {
133
136
  position: fixed;
134
137
  position-anchor: --pk-menubar-sub;
@@ -42,6 +42,18 @@ export default class extends Controller {
42
42
  if (!panel.id) panel.id = `pk-menu-panel-${++uid}`
43
43
  trigger.setAttribute("aria-controls", panel.id)
44
44
  }
45
+ // A Turbo snapshot serializes aria-expanded="true" even though the
46
+ // panel's :popover-open does not survive the restore — no panel can be
47
+ // open at connect time, so stamp every expanded marker back to false
48
+ // (only on elements that already carry it: plain nav links must not
49
+ // grow an aria-expanded). Covers the trigger and CSS-revealed subs.
50
+ menu.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
51
+ // Same shape for checkbox/radio rows: their `checked` DOM property
52
+ // survives a reconnect but a hand-edited/replayed aria-checked can drift
53
+ // from it — resync every row from its live input.
54
+ menu.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
55
+ i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
56
+ })
45
57
  if (this.roving) this.applyRoving()
46
58
  }
47
59
 
@@ -101,8 +113,15 @@ export default class extends Controller {
101
113
  if (focus) (this.items(menu)[0] ?? this.trigger(menu))?.focus()
102
114
  }
103
115
 
116
+ // Also wired directly as the click handler on plain nav links (see
117
+ // navigation_menu_link.rb) — `opts` is then the click Event rather than an
118
+ // options hash. Guard the default href="#" (dropdown_menu_controller.js's
119
+ // close() does the same) so a link without a real destination doesn't
120
+ // scroll-to-top/append a hash; a link WITH a real href is left to navigate
121
+ // normally, closing whatever panel is open on the way out.
104
122
  close(opts = {}) {
105
123
  clearTimeout(this.graceTimer)
124
+ if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault?.()
106
125
  const menu = this.openMenu
107
126
  if (!menu) return
108
127
  const panel = this.panel(menu)
@@ -141,10 +160,14 @@ export default class extends Controller {
141
160
  e.preventDefault()
142
161
  this.show(menu, true)
143
162
  } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
144
- // Closed bar: left/right move focus between the triggers (APG).
163
+ // Closed bar: left/right move focus between the triggers (APG). In RTL
164
+ // the bar mirrors, so the physical LEFT arrow moves to the next
165
+ // trigger. Runtime dir check is reliable after a dynamic flip.
145
166
  e.preventDefault()
167
+ const rtl = getComputedStyle(this.element).direction === "rtl"
168
+ const step = (e.key === "ArrowRight" ? 1 : -1) * (rtl ? -1 : 1)
146
169
  const menus = this.menuTargets
147
- const next = menus[(menus.indexOf(menu) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length]
170
+ const next = menus[(menus.indexOf(menu) + step + menus.length) % menus.length]
148
171
  const target = next ? this.trigger(next) : null
149
172
  target?.focus()
150
173
  if (this.roving && target) this.applyRoving(target)
@@ -184,23 +207,30 @@ export default class extends Controller {
184
207
  }
185
208
  break
186
209
  case "ArrowRight":
187
- e.preventDefault()
188
- // On a sub trigger, enter the submenu (focus reveals it via
189
- // :focus-within) instead of jumping to the next top-level menu.
190
- if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
191
- this.enterSub(document.activeElement)
192
- } else {
193
- this.shift(1)
194
- }
195
- break
196
210
  case "ArrowLeft": {
197
211
  e.preventDefault()
198
- // Inside a submenu, step back to its trigger instead of switching menus.
199
- const sub = document.activeElement?.closest(".pk-menubar-sub-content")
200
- if (sub) {
201
- sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
212
+ // Both the submenu (opens inline-end = visually LEFT in RTL) and the
213
+ // top-level menu traversal follow visual direction: the "enter/next"
214
+ // key is ArrowLeft in RTL, ArrowRight in LTR. Runtime dir check is
215
+ // reliable after a dynamic flip.
216
+ const rtl = getComputedStyle(this.element).direction === "rtl"
217
+ const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
218
+ if (e.key === enterKey) {
219
+ // On a sub trigger, enter the submenu (focus reveals it via
220
+ // :focus-within) instead of jumping to the next top-level menu.
221
+ if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
222
+ this.enterSub(document.activeElement)
223
+ } else {
224
+ this.shift(1)
225
+ }
202
226
  } else {
203
- this.shift(-1)
227
+ // Inside a submenu, step back to its trigger instead of switching menus.
228
+ const sub = document.activeElement?.closest(".pk-menubar-sub-content")
229
+ if (sub) {
230
+ sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
231
+ } else {
232
+ this.shift(-1)
233
+ }
204
234
  }
205
235
  break
206
236
  }
@@ -3,8 +3,13 @@ module PhlexKit
3
3
  # align: :end reverses the row. Compose Message > MessageContent (+ Avatar/
4
4
  # Header/Footer); group several in a MessageGroup.
5
5
  class Message < BaseComponent
6
+ ALIGNS = %i[start end].freeze
7
+
6
8
  def initialize(align: :start, **attrs)
7
9
  @align = align.to_sym
10
+ # Fail loud like sibling Bubble: message.css only styles data-align="end",
11
+ # so an unknown value silently rendered a start-aligned row.
12
+ raise KeyError, "unknown Message align #{@align}" unless ALIGNS.include?(@align)
8
13
  @attrs = attrs
9
14
  end
10
15
  def view_template(&)
@@ -378,6 +378,9 @@ export default class extends Controller {
378
378
  button.setAttribute("data-active", active ? "true" : "false");
379
379
  // Remove the inert button from the tab order so there are no ghost stops.
380
380
  button.setAttribute("tabindex", active ? "0" : "-1");
381
+ // opacity-only hiding leaves it in the accessibility tree — a SR
382
+ // virtual cursor would still find a phantom jump button.
383
+ button.setAttribute("aria-hidden", active ? "false" : "true");
381
384
  });
382
385
  }
383
386
  }
@@ -33,6 +33,20 @@
33
33
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
34
34
  }
35
35
  .pk-native-select-field:disabled { cursor: not-allowed; opacity: .5; }
36
+ /* [size] (a multi-row list box, NativeSelect.new(size: N)) or [multiple]
37
+ renders as a scrollable option list, not a single-line control — the fixed
38
+ 2rem row height squashes it and there's no single line for the absolute
39
+ chevron to sit in, so auto-height the field and hide the chevron.
40
+ Note: [size="1"] is a single-line dropdown, not a list box — exclude it. */
41
+ .pk-native-select-field[size]:not([size="1"]),
42
+ .pk-native-select-field[multiple] {
43
+ height: auto;
44
+ padding-inline-end: .625rem;
45
+ }
46
+ .pk-native-select:has(.pk-native-select-field[size]:not([size="1"])) .pk-native-select-icon,
47
+ .pk-native-select:has(.pk-native-select-field[multiple]) .pk-native-select-icon {
48
+ display: none;
49
+ }
36
50
  .pk-native-select-field.sm {
37
51
  height: 1.75rem;
38
52
  padding-top: .125rem;
@@ -21,20 +21,29 @@ module PhlexKit
21
21
  sm: "sm"
22
22
  }.freeze
23
23
 
24
+ # An Integer size: is the NATIVE <select size> (multi-row list box) and
25
+ # passes straight through — the kit's visual sizes stay symbols.
24
26
  def initialize(size: :default, **attrs)
25
- @size = size.to_sym
27
+ if size.is_a?(Integer)
28
+ @native_size = size
29
+ @size = :default
30
+ else
31
+ @size = size.to_sym
32
+ end
26
33
  @attrs = attrs
27
34
  end
28
35
 
29
36
  def view_template(&block)
30
37
  div(class: "pk-native-select") do
31
- select(**mix({
38
+ base = {
32
39
  class: classes,
33
40
  data: {
34
41
  phlex_kit__form_field_target: "input",
35
42
  action: "change->phlex-kit--form-field#onChange invalid->phlex-kit--form-field#onInvalid"
36
43
  }
37
- }, @attrs), &block)
44
+ }
45
+ base[:size] = @native_size if @native_size
46
+ select(**mix(base, @attrs), &block)
38
47
  render PhlexKit::NativeSelectIcon.new
39
48
  end
40
49
  end
@@ -23,7 +23,10 @@ module PhlexKit
23
23
  # mouseenter->cancelClose: re-entering the nav over list padding /
24
24
  # whitespace must cancel the pending mouseleave grace-close (only
25
25
  # trigger/panel mouseenter did before).
26
- action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown mouseleave->phlex-kit--menubar#closeSoon mouseenter->phlex-kit--menubar#cancelClose"
26
+ # focusout->onFocusout: a keyboard user tabbing past the panel's
27
+ # last link must close it (menubar wires the same handler; closeSoon
28
+ # is pointer-only and never fires for this path).
29
+ action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout mouseleave->phlex-kit--menubar#closeSoon mouseenter->phlex-kit--menubar#cancelClose"
27
30
  }
28
31
  }, @attrs), &)
29
32
  end
@@ -11,7 +11,14 @@ module PhlexKit
11
11
  end
12
12
 
13
13
  def view_template(&)
14
- a(**mix({ class: "pk-navigation-menu-link", href: @href, data: { action: "mouseenter->phlex-kit--menubar#switch" } }, @attrs), &)
14
+ a(**mix({
15
+ class: "pk-navigation-menu-link",
16
+ href: @href,
17
+ # click: closes the open panel on real activation (and swallows the
18
+ # default href="#" the way dropdown_menu_controller.js's close() does
19
+ # — see menubar_controller.js#close).
20
+ data: { action: "mouseenter->phlex-kit--menubar#switch click->phlex-kit--menubar#close" }
21
+ }, @attrs), &)
15
22
  end
16
23
  end
17
24
  end
@@ -2,9 +2,17 @@ module PhlexKit
2
2
  # Page navigation. Ported from ruby_ui's RubyUI::Pagination. Compose Pagination >
3
3
  # PaginationContent > PaginationItem(href:, active:) / PaginationEllipsis.
4
4
  class Pagination < BaseComponent
5
- def initialize(**attrs) = (@attrs = attrs)
5
+ # label: is a named kwarg (not a mix default) because `mix` would *fuse* a
6
+ # caller aria-label with the hardcoded "pagination" ("pagination Résultats").
7
+ def initialize(label: "pagination", **attrs)
8
+ @label = label
9
+ @attrs = attrs
10
+ end
11
+
6
12
  def view_template(&)
7
- nav(**mix({ class: "pk-pagination", role: "navigation", aria: { label: "pagination" } }, @attrs), &)
13
+ base = { class: "pk-pagination", role: "navigation" }
14
+ base[:aria] = { label: @label } unless aria_labelled?
15
+ nav(**mix(base, @attrs), &)
8
16
  end
9
17
  end
10
18
  end
@@ -15,7 +15,9 @@ module PhlexKit
15
15
  # custom (possibly localized) label: speaks for itself — hardcoding
16
16
  # "Go to next page" over it would make AT announce the wrong language.
17
17
  base = { href: @href, class: "pk-button ghost pk-pagination-next" }
18
- base[:aria] = { label: "Go to next page" } if @label == DEFAULT_LABEL
18
+ # ...and never over a caller-supplied aria label `mix` would fuse
19
+ # the two strings instead of overriding.
20
+ base[:aria] = { label: "Go to next page" } if @label == DEFAULT_LABEL && !aria_labelled?
19
21
  li do
20
22
  a(**mix(base, @attrs)) do
21
23
  span(class: "pk-pagination-label") { @label }
@@ -16,7 +16,9 @@ module PhlexKit
16
16
  # custom (possibly localized) label: speaks for itself — hardcoding
17
17
  # "Go to previous page" over it would make AT announce the wrong language.
18
18
  base = { href: @href, class: "pk-button ghost pk-pagination-previous" }
19
- base[:aria] = { label: "Go to previous page" } if @label == DEFAULT_LABEL
19
+ # ...and never over a caller-supplied aria label `mix` would fuse
20
+ # the two strings instead of overriding.
21
+ base[:aria] = { label: "Go to previous page" } if @label == DEFAULT_LABEL && !aria_labelled?
20
22
  li do
21
23
  a(**mix(base, @attrs)) do
22
24
  render Icon.new(:chevron_left, size: nil)