phlex_kit 0.9.0 → 0.10.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +8 -0
  3. data/app/components/phlex_kit/alert/alert.rb +1 -1
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +12 -0
  5. data/app/components/phlex_kit/attachment/attachment.css +6 -3
  6. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
  7. data/app/components/phlex_kit/avatar/avatar.css +2 -2
  8. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
  9. data/app/components/phlex_kit/bubble/bubble.css +1 -1
  10. data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
  11. data/app/components/phlex_kit/button_group/button_group.css +16 -16
  12. data/app/components/phlex_kit/calendar/calendar.css +10 -2
  13. data/app/components/phlex_kit/carousel/carousel.css +4 -4
  14. data/app/components/phlex_kit/clipboard/clipboard_controller.js +18 -2
  15. data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
  16. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
  17. data/app/components/phlex_kit/combobox/combobox.css +3 -3
  18. data/app/components/phlex_kit/combobox/combobox.rb +3 -3
  19. data/app/components/phlex_kit/combobox/combobox_controller.js +17 -9
  20. data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
  21. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +3 -1
  22. data/app/components/phlex_kit/command/command.css +2 -2
  23. data/app/components/phlex_kit/command/command_controller.js +57 -34
  24. data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
  25. data/app/components/phlex_kit/command/command_group.rb +5 -2
  26. data/app/components/phlex_kit/dialog/dialog.css +2 -2
  27. data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
  28. data/app/components/phlex_kit/dialog/dialog_controller.js +9 -0
  29. data/app/components/phlex_kit/drawer/drawer.css +4 -4
  30. data/app/components/phlex_kit/empty/empty.css +1 -1
  31. data/app/components/phlex_kit/field/field.css +2 -1
  32. data/app/components/phlex_kit/form_field/form_field_controller.js +7 -0
  33. data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
  34. data/app/components/phlex_kit/marker/marker.rb +6 -0
  35. data/app/components/phlex_kit/masked_input/masked_input_controller.js +17 -3
  36. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  37. data/app/components/phlex_kit/menubar/menubar_controller.js +22 -1
  38. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
  39. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
  40. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +9 -5
  41. data/app/components/phlex_kit/native_select/native_select.css +4 -2
  42. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  43. data/app/components/phlex_kit/pagination/pagination_next.rb +9 -2
  44. data/app/components/phlex_kit/pagination/pagination_previous.rb +9 -2
  45. data/app/components/phlex_kit/popover/popover_controller.js +9 -1
  46. data/app/components/phlex_kit/resizable/resizable_controller.js +49 -5
  47. data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
  48. data/app/components/phlex_kit/scroll_area/scroll_area.rb +20 -4
  49. data/app/components/phlex_kit/select/select.css +0 -1
  50. data/app/components/phlex_kit/select/select_content.rb +9 -3
  51. data/app/components/phlex_kit/select/select_controller.js +32 -2
  52. data/app/components/phlex_kit/select/select_item.rb +5 -1
  53. data/app/components/phlex_kit/separator/separator.rb +7 -1
  54. data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
  55. data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
  56. data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
  57. data/app/components/phlex_kit/tabs/tabs.css +2 -2
  58. data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
  59. data/app/components/phlex_kit/tabs/tabs_trigger.rb +4 -1
  60. data/app/components/phlex_kit/toast/toast.css +1 -1
  61. data/app/components/phlex_kit/toast/toast_controller.js +6 -1
  62. data/app/components/phlex_kit/toast/toaster_controller.js +19 -5
  63. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
  64. data/app/components/phlex_kit/toggle_group/toggle_group.rb +6 -1
  65. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +8 -0
  66. data/app/components/phlex_kit/tooltip/tooltip.css +2 -2
  67. data/app/components/phlex_kit/typography/heading.rb +6 -4
  68. data/lib/phlex_kit/version.rb +1 -1
  69. metadata +1 -1
@@ -35,8 +35,16 @@ function fuzzyScore(query, value) {
35
35
  export default class extends Controller {
36
36
  static targets = ["input", "group", "item", "empty", "separator", "list", "liveRegion"];
37
37
 
38
- connect() {
38
+ initialize() {
39
+ // Stimulus fires [target]Connected before connect(): anything
40
+ // itemTargetConnected uses must be initialized here (mirrors
41
+ // combobox_controller.js).
42
+ this.itemIdCounter = 0;
43
+ this.searchEntries = [];
39
44
  this.selectedIndex = -1;
45
+ }
46
+
47
+ connect() {
40
48
  // Remember what had focus before the palette grabbed it (for a cloned
41
49
  // dialog this is the element focused when the overlay was inserted), so
42
50
  // dismiss() can hand focus back instead of dropping it on <body>.
@@ -50,7 +58,6 @@ export default class extends Controller {
50
58
  // Only the cloned dialog overlay grabs focus on connect — an inline
51
59
  // palette connecting at page load must not steal it.
52
60
  if (this.isDialogClone()) this.inputTarget.focus();
53
- this.searchIndex = this.buildSearchIndex();
54
61
  this.toggleVisibility(this.emptyTargets, false);
55
62
  }
56
63
 
@@ -60,23 +67,46 @@ export default class extends Controller {
60
67
  return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
61
68
  }
62
69
 
63
- // ARIA plumbing: every result gets an id derived from the listbox id so the
64
- // input can point aria-activedescendant at the keyboard highlight, and
65
- // aria-controls is wired to the listbox (mirrors select_controller.js).
70
+ // ARIA plumbing: aria-controls is wired to the listbox (mirrors
71
+ // select_controller.js); the per-result ids come from itemTargetConnected.
66
72
  generateItemIds() {
73
+ this.inputTarget.setAttribute("aria-controls", this.listId());
74
+ }
75
+
76
+ listId() {
67
77
  const list = this.hasListTarget ? this.listTarget : this.element;
68
78
  if (!list.id) list.id = `pk-command-list-${Math.random().toString(36).slice(2, 10)}`;
79
+ return list.id;
80
+ }
69
81
 
70
- this.itemTargets.forEach((item, index) => {
71
- if (!item.id) item.id = `${list.id}-${index}`;
82
+ // Every result gets an id derived from the listbox id so the input can
83
+ // point aria-activedescendant at the keyboard highlight, and a search-index
84
+ // entry so filtering can find it. A target callback (not a connect() loop)
85
+ // so items rendered in by the host after load are id'd and findable too.
86
+ // Counter-based, so a removed item's id is never reissued to a later
87
+ // arrival (mirrors combobox_controller.js).
88
+ itemTargetConnected(item) {
89
+ if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
90
+ this.searchEntries.push({
91
+ value: (item.dataset.value || "").toLowerCase(),
92
+ text: (item.dataset.text || "").toLowerCase(),
93
+ element: item,
72
94
  });
95
+ }
73
96
 
74
- this.inputTarget.setAttribute("aria-controls", list.id);
97
+ itemTargetDisconnected(item) {
98
+ this.searchEntries = this.searchEntries.filter(
99
+ (entry) => entry.element !== item,
100
+ );
75
101
  }
76
102
 
77
103
  dismiss() {
78
104
  // Cloned dialog overlay: tear the clone down and hand focus back.
79
105
  if (this.isDialogClone()) {
106
+ // Dispatched BEFORE teardown: the command-dialog controller listens on
107
+ // the overlay and un-inerts the page here — focus() below would be a
108
+ // silent no-op while previouslyFocused sits under an inert ancestor.
109
+ this.dispatch("dismiss");
80
110
  // allow scroll on body
81
111
  document.body.style.removeProperty("overflow");
82
112
  const previous = this.previouslyFocused;
@@ -157,7 +187,7 @@ export default class extends Controller {
157
187
 
158
188
  this.toggleVisibility(this.itemTargets, false);
159
189
 
160
- const results = this.searchIndex.search(query);
190
+ const results = this.searchItems(query);
161
191
  results.forEach((result) =>
162
192
  this.toggleVisibility([result.item.element], true),
163
193
  );
@@ -201,31 +231,24 @@ export default class extends Controller {
201
231
  this.toggleVisibility(this.emptyTargets, false);
202
232
  }
203
233
 
204
- // Upstream builds a Fuse index here; this keeps the same search() shape —
205
- // [{ item }] sorted best-match-first — using the fuzzy scorer above.
206
- // Both data-value and data-text are searchable (an item's `text:` was
207
- // rendered but never indexed); the better of the two scores wins.
208
- buildSearchIndex() {
209
- const items = this.itemTargets.map((el) => ({
210
- value: (el.dataset.value || "").toLowerCase(),
211
- text: (el.dataset.text || "").toLowerCase(),
212
- element: el,
213
- }));
214
- return {
215
- search(query) {
216
- const q = query.toLowerCase();
217
- return items
218
- .map((item) => {
219
- const scores = [item.value, item.text]
220
- .filter(Boolean)
221
- .map((field) => fuzzyScore(q, field))
222
- .filter((score) => score !== null);
223
- return { item, score: scores.length ? Math.max(...scores) : null };
224
- })
225
- .filter((result) => result.score !== null)
226
- .sort((a, b) => b.score - a.score);
227
- },
228
- };
234
+ // Upstream builds a Fuse index here; this keeps the same result shape —
235
+ // [{ item }] sorted best-match-first — using the fuzzy scorer above over
236
+ // the live searchEntries (maintained by itemTargetConnected/Disconnected,
237
+ // so host-rendered items are searchable). Both data-value and data-text are
238
+ // searchable (an item's `text:` was rendered but never indexed); the better
239
+ // of the two scores wins.
240
+ searchItems(query) {
241
+ const q = query.toLowerCase();
242
+ return this.searchEntries
243
+ .map((item) => {
244
+ const scores = [item.value, item.text]
245
+ .filter(Boolean)
246
+ .map((field) => fuzzyScore(q, field))
247
+ .filter((score) => score !== null);
248
+ return { item, score: scores.length ? Math.max(...scores) : null };
249
+ })
250
+ .filter((result) => result.score !== null)
251
+ .sort((a, b) => b.score - a.score);
229
252
  }
230
253
 
231
254
  handleKeydown(e) {
@@ -6,14 +6,27 @@ import { Controller } from "@hotwired/stimulus";
6
6
  // upstream's document-scoped outlet (which let one dialog adopt another's
7
7
  // open palette), each instance tracks its own clone — sheet_controller's
8
8
  // pattern — and clears it on turbo:before-cache so a cached page never
9
- // restores a stale overlay + scroll lock.
9
+ // restores a stale overlay + scroll lock. While the overlay is up, the page
10
+ // behind it is inert-ed per element with prior state saved (the same modal
11
+ // contract as alert_dialog/sheet/sidebar — the helper is duplicated per
12
+ // controller by design); restore runs on dismiss (via the overlay's
13
+ // phlex-kit--command:dismiss event), disconnect, AND turbo:before-cache.
10
14
  // Connects to data-controller="phlex-kit--command-dialog"
11
15
  export default class extends Controller {
12
16
  static targets = ["content"];
13
17
 
14
18
  initialize() {
15
19
  this.overlay = null;
20
+ this.inerted = null;
21
+ // The command controller's dismiss() dispatches this on the overlay
22
+ // BEFORE it removes the clone / restores focus — un-inert first so the
23
+ // opener is focusable again.
24
+ this.restoreInert = () => {
25
+ for (const [el, wasInert] of this.inerted || []) el.inert = wasInert;
26
+ this.inerted = null;
27
+ };
16
28
  this.clearOverlay = () => {
29
+ this.restoreInert();
17
30
  if (this.overlay?.isConnected) {
18
31
  this.overlay.remove();
19
32
  document.body.style.removeProperty("overflow");
@@ -49,7 +62,23 @@ export default class extends Controller {
49
62
 
50
63
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
51
64
  this.overlay = document.body.lastElementChild;
65
+ this.overlay.addEventListener("phlex-kit--command:dismiss", this.restoreInert);
52
66
  // prevent scroll on body
53
67
  document.body.style.overflow = "hidden";
68
+ this.#inertOthers();
69
+ }
70
+
71
+ // Make everything behind the overlay inert (the clone is <body>'s last
72
+ // child, so its siblings are the whole page). Prior inert state is saved
73
+ // per element and restored by restoreInert. The command controller's Tab
74
+ // trap on the clone stays as belt and braces.
75
+ #inertOthers() {
76
+ this.inerted = [];
77
+ for (const el of document.body.children) {
78
+ if (el === this.overlay) continue;
79
+ if (["SCRIPT", "STYLE", "LINK", "TEMPLATE"].includes(el.tagName)) continue;
80
+ this.inerted.push([el, el.inert]);
81
+ el.inert = true;
82
+ }
54
83
  }
55
84
  }
@@ -5,6 +5,9 @@ module PhlexKit
5
5
  class CommandGroup < BaseComponent
6
6
  def initialize(title: nil, **attrs)
7
7
  @title = title
8
+ # Names the role="group" items wrapper via aria-labelledby → heading id
9
+ # (matches cmdk/shadcn; id pattern mirrors CommandList).
10
+ @heading_id = "pk-command-group-heading-#{SecureRandom.hex(4)}" if title
8
11
  @attrs = attrs
9
12
  end
10
13
 
@@ -22,11 +25,11 @@ module PhlexKit
22
25
  private
23
26
 
24
27
  def render_header
25
- div(group_heading: @title) { @title }
28
+ div(id: @heading_id, group_heading: @title) { @title }
26
29
  end
27
30
 
28
31
  def render_items(&)
29
- div(group_items: "", role: "group", &)
32
+ div(group_items: "", role: "group", aria_labelledby: @heading_id, &)
30
33
  end
31
34
  end
32
35
  end
@@ -14,7 +14,7 @@
14
14
  max-height: 100vh;
15
15
  overflow-y: auto; gap: 1rem;
16
16
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
17
- border-radius: .75rem; background: var(--pk-surface); color: var(--pk-text);
17
+ border-radius: calc(var(--pk-radius) + 2px); background: var(--pk-surface); color: var(--pk-text);
18
18
  padding: var(--pk-dialog-spacing);
19
19
  font-size: .875rem;
20
20
  box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
@@ -35,7 +35,7 @@
35
35
  margin: 0 calc(-1 * var(--pk-dialog-spacing)) calc(-1 * var(--pk-dialog-spacing));
36
36
  padding: var(--pk-dialog-spacing);
37
37
  border-top: 1px solid var(--pk-border);
38
- border-radius: 0 0 .75rem .75rem;
38
+ border-radius: 0 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px);
39
39
  background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent);
40
40
  }
41
41
  .pk-dialog-title { font-size: 1rem; font-weight: 500; line-height: 1; }
@@ -7,7 +7,7 @@ module PhlexKit
7
7
  end
8
8
 
9
9
  def view_template(&)
10
- span(**mix({ style: "display: contents", data: { action: "click->phlex-kit--dialog#dismiss" } }, @attrs), &)
10
+ span(**mix({ style: "display: contents;", data: { action: "click->phlex-kit--dialog#dismiss" } }, @attrs), &)
11
11
  end
12
12
  end
13
13
  end
@@ -10,6 +10,9 @@ export default class extends Controller {
10
10
 
11
11
  connect() {
12
12
  this.dialogTarget.addEventListener("close", this.handleClose)
13
+ // Turbo snapshots at turbo:before-cache, BEFORE disconnect — close now or
14
+ // the cached page restores a non-modal <dialog open> with scroll locked.
15
+ document.addEventListener("turbo:before-cache", this.beforeCache)
13
16
  this.#wireAria("aria-labelledby", ".pk-dialog-title")
14
17
  this.#wireAria("aria-describedby", ".pk-dialog-description")
15
18
  if (this.openValue) this.open()
@@ -24,8 +27,14 @@ export default class extends Controller {
24
27
  }
25
28
  disconnect() {
26
29
  this.dialogTarget.removeEventListener("close", this.handleClose)
30
+ document.removeEventListener("turbo:before-cache", this.beforeCache)
27
31
  if (this.dialogTarget.open) this.handleClose()
28
32
  }
33
+ beforeCache = () => {
34
+ if (!this.dialogTarget.open) return
35
+ this.dialogTarget.close()
36
+ this.handleClose() // the close event is a queued task — too late for the snapshot
37
+ }
29
38
  open(e) {
30
39
  e?.preventDefault()
31
40
  this.dialogTarget.showModal()
@@ -27,7 +27,7 @@
27
27
  bottom: 0;
28
28
  max-height: 85vh;
29
29
  border-top: 1px solid var(--pk-border);
30
- border-radius: .75rem .75rem 0 0;
30
+ border-radius: calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px) 0 0;
31
31
  animation: pk-drawer-up .3s ease;
32
32
  }
33
33
  .pk-drawer.top {
@@ -35,7 +35,7 @@
35
35
  top: 0;
36
36
  max-height: 85vh;
37
37
  border-bottom: 1px solid var(--pk-border);
38
- border-radius: 0 0 .75rem .75rem;
38
+ border-radius: 0 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px);
39
39
  animation: pk-drawer-down .3s ease;
40
40
  }
41
41
  .pk-drawer.left,
@@ -48,13 +48,13 @@
48
48
  .pk-drawer.left {
49
49
  left: 0;
50
50
  border-right: 1px solid var(--pk-border);
51
- border-radius: 0 .75rem .75rem 0;
51
+ border-radius: 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px) 0;
52
52
  animation: pk-drawer-from-left .3s ease;
53
53
  }
54
54
  .pk-drawer.right {
55
55
  right: 0;
56
56
  border-left: 1px solid var(--pk-border);
57
- border-radius: .75rem 0 0 .75rem;
57
+ border-radius: calc(var(--pk-radius) + 2px) 0 0 calc(var(--pk-radius) + 2px);
58
58
  animation: pk-drawer-from-right .3s ease;
59
59
  }
60
60
  @keyframes pk-drawer-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
@@ -5,7 +5,7 @@
5
5
  .pk-empty {
6
6
  display: flex; width: 100%; min-width: 0; flex: 1; flex-direction: column;
7
7
  align-items: center; justify-content: center; gap: 1rem;
8
- border: 0 dashed var(--pk-border); border-radius: .75rem;
8
+ border: 0 dashed var(--pk-border); border-radius: calc(var(--pk-radius) + 2px);
9
9
  padding: 1.5rem; text-align: center; text-wrap: balance;
10
10
  }
11
11
  .pk-empty.outline { border-width: 1px; }
@@ -146,7 +146,8 @@
146
146
  display: flex;
147
147
  flex-direction: column;
148
148
  gap: .25rem;
149
- margin: 0 0 0 1rem;
149
+ margin: 0;
150
+ margin-inline-start: 1rem;
150
151
  padding: 0;
151
152
  list-style: disc;
152
153
  }
@@ -16,6 +16,13 @@ export default class extends Controller {
16
16
  if (this.hasErrorTarget) {
17
17
  if (this.errorTarget.textContent.trim()) {
18
18
  this.shouldValidateValue = true;
19
+ // A server-rendered error is already visible — give the control(s)
20
+ // the same aria wiring onInvalid applies, instead of leaving
21
+ // aria-invalid/aria-describedby unset until the first input/change.
22
+ this.inputTargets.forEach((input) => {
23
+ input.setAttribute("aria-invalid", "true");
24
+ this.#describeBy(input, true);
25
+ });
19
26
  } else {
20
27
  this.errorTarget.classList.add("pk-hidden");
21
28
  }
@@ -18,12 +18,12 @@
18
18
  }
19
19
  .pk-input-otp-slot:not(:first-child) { margin-inline-start: -1px; }
20
20
  .pk-input-otp-group .pk-input-otp-slot:first-child {
21
- border-top-left-radius: var(--pk-radius);
22
- border-bottom-left-radius: var(--pk-radius);
21
+ border-start-start-radius: var(--pk-radius);
22
+ border-end-start-radius: var(--pk-radius);
23
23
  }
24
24
  .pk-input-otp-group .pk-input-otp-slot:last-child {
25
- border-top-right-radius: var(--pk-radius);
26
- border-bottom-right-radius: var(--pk-radius);
25
+ border-start-end-radius: var(--pk-radius);
26
+ border-end-end-radius: var(--pk-radius);
27
27
  }
28
28
  .pk-input-otp-slot:focus {
29
29
  position: relative;
@@ -6,11 +6,17 @@ module PhlexKit
6
6
  # underline and brighten on hover. `.pk-marker*` (marker.css).
7
7
  class Marker < BaseComponent
8
8
  VARIANTS = { default: nil, border: "border", separator: "separator" }.freeze
9
+ # The elements this component knows how to render (href: forces <a>);
10
+ # unknown values used to fall through silently to <div> — fail loud instead.
11
+ AS_TAGS = %i[div button].freeze
9
12
 
10
13
  def initialize(variant: :default, href: nil, as: :div, **attrs)
11
14
  @variant = variant.to_sym
12
15
  @href = href
13
16
  @as = as.to_sym
17
+ unless AS_TAGS.include?(@as)
18
+ raise ArgumentError, "unknown Marker as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
19
+ end
14
20
  @attrs = attrs
15
21
  end
16
22
 
@@ -11,20 +11,27 @@ export default class extends Controller {
11
11
  if (!this.mask) return
12
12
  // Skip IME composition updates — masking mid-composition mangles the text.
13
13
  this.onInput = (e) => { if (!e.isComposing) this.apply() }
14
+ // Some browsers (Safari) fire the final input event with isComposing still
15
+ // true, so IME-committed text would never be masked — catch the commit here.
16
+ this.onCompositionEnd = () => this.apply()
14
17
  this.element.addEventListener("input", this.onInput)
18
+ this.element.addEventListener("compositionend", this.onCompositionEnd)
15
19
  // A server-prefilled value renders unmasked — format it once up front.
16
20
  if (this.element.value) this.apply()
17
21
  }
18
- disconnect() { if (this.onInput) this.element.removeEventListener("input", this.onInput) }
22
+ disconnect() {
23
+ if (this.onInput) this.element.removeEventListener("input", this.onInput)
24
+ if (this.onCompositionEnd) this.element.removeEventListener("compositionend", this.onCompositionEnd)
25
+ }
19
26
  apply() {
20
27
  const el = this.element
21
28
  // The whole value is rewritten below, which throws the caret to the end —
22
29
  // remember how many maskable chars sit before it and re-seat it after
23
30
  // the same count in the masked output (mid-field edits keep their place).
24
31
  const caret = el.selectionStart ?? el.value.length
25
- const rawBefore = el.value.slice(0, caret).replace(/[^0-9A-Za-z]/g, "").length
32
+ const rawBefore = this.maskable(el.value.slice(0, caret)).length
26
33
 
27
- const raw = el.value.replace(/[^0-9A-Za-z]/g, "")
34
+ const raw = this.maskable(el.value)
28
35
  let out = "", i = 0
29
36
  for (const t of this.mask) {
30
37
  if (i >= raw.length) break
@@ -48,4 +55,11 @@ export default class extends Controller {
48
55
  el.setSelectionRange(pos, pos)
49
56
  }
50
57
  }
58
+ // Transliterate full-width digits (0-9, U+FF10–FF19 — common IME numeric
59
+ // output) to ASCII before the charset filter would silently drop them.
60
+ maskable(value) {
61
+ return value
62
+ .replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xFEE0))
63
+ .replace(/[^0-9A-Za-z]/g, "")
64
+ }
51
65
  }
@@ -16,7 +16,9 @@ module PhlexKit
16
16
  role: "menubar",
17
17
  data: {
18
18
  controller: "phlex-kit--menubar",
19
- action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown"
19
+ # focusout: tabbing out of the bar closes the open [popover=manual]
20
+ # panel (which no focus trap holds open).
21
+ action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
20
22
  }
21
23
  }, @attrs), &)
22
24
  end
@@ -28,6 +28,9 @@ export default class extends Controller {
28
28
  // set here. Roving tabindex applies only in menubar mode: a nav's
29
29
  // triggers and links stay natural tab stops.
30
30
  this.roving = this.element.dataset.hoverOpen === undefined
31
+ // Outstanding syncSub rAF handles — cancelled on disconnect so a queued
32
+ // frame never touches a torn-down menubar.
33
+ this.subFrames = new Set()
31
34
  }
32
35
 
33
36
  // Pair each trigger with its panel for AT (aria-controls), and fold the
@@ -48,6 +51,8 @@ export default class extends Controller {
48
51
 
49
52
  disconnect() {
50
53
  clearTimeout(this.graceTimer)
54
+ this.subFrames.forEach((id) => cancelAnimationFrame(id))
55
+ this.subFrames.clear()
51
56
  }
52
57
 
53
58
  toggle(e) {
@@ -112,6 +117,20 @@ export default class extends Controller {
112
117
  this.close()
113
118
  }
114
119
 
120
+ // Tabbing out of the bar: close the open [popover=manual] panel once focus
121
+ // has left both the bar AND the panel (panels are top-layer popovers, but
122
+ // remain DOM children — check both anyway). Focus moving INTO the panel
123
+ // during normal navigation must not close it. relatedTarget is null when
124
+ // the window itself blurs — leave the menu alone then.
125
+ onFocusout(e) {
126
+ const to = e.relatedTarget
127
+ if (!to || this.element.contains(to)) return
128
+ const menu = this.openMenu
129
+ if (!menu) return
130
+ if (this.panel(menu)?.contains(to)) return
131
+ this.close()
132
+ }
133
+
115
134
  // Single keydown listener on the bar (wired via data-action, so no manual
116
135
  // cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
117
136
  onKeydown(e) {
@@ -215,10 +234,12 @@ export default class extends Controller {
215
234
  // state isn't settled until the event finishes dispatching.
216
235
  syncSub(e) {
217
236
  const sub = e.currentTarget
218
- requestAnimationFrame(() => {
237
+ const id = requestAnimationFrame(() => {
238
+ this.subFrames.delete(id)
219
239
  sub.querySelector(":scope > [aria-haspopup]")
220
240
  ?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
221
241
  })
242
+ this.subFrames.add(id)
222
243
  }
223
244
 
224
245
  shift(dir) {
@@ -6,7 +6,7 @@ module PhlexKit
6
6
  class MenubarItem < BaseComponent
7
7
  VARIANTS = { default: nil, destructive: "destructive" }.freeze
8
8
 
9
- def initialize(as: :a, href: "#", shortcut: nil, variant: :default, inset: false, disabled: false, **attrs)
9
+ def initialize(as: :a, href: nil, shortcut: nil, variant: :default, inset: false, disabled: false, **attrs)
10
10
  @as = as.to_sym
11
11
  @href = href
12
12
  @shortcut = shortcut
@@ -24,7 +24,9 @@ module PhlexKit
24
24
  data: @disabled ? { disabled: "true" } : { action: "click->phlex-kit--menubar#close" }
25
25
  }
26
26
  base[:aria] = { disabled: "true" } if @disabled
27
- base[:href] = @href unless @as == :div || @disabled
27
+ # No default href: "#" would make Enter/click navigate (hash change +
28
+ # scroll-to-top). tabindex="-1" keeps the row programmatically focusable.
29
+ base[:href] = @href unless @href.nil? || @as == :div || @disabled
28
30
  send(@as, **mix(base, @attrs)) do
29
31
  block&.call
30
32
  span(class: "pk-menubar-shortcut") { @shortcut } if @shortcut
@@ -1,10 +1,12 @@
1
1
  module PhlexKit
2
2
  # Exclusive-choice group of MenubarRadioItems (share the same `name:`).
3
- # Mirrors shadcn/ui's MenubarRadioGroup. See menubar.rb.
3
+ # Mirrors shadcn/ui's MenubarRadioGroup. role="group" (NOT "radiogroup"):
4
+ # radiogroup may not own menuitemradio children (axe aria-required-children).
5
+ # See menubar.rb.
4
6
  class MenubarRadioGroup < BaseComponent
5
7
  def initialize(**attrs) = (@attrs = attrs)
6
8
  def view_template(&)
7
- div(**mix({ class: "pk-menubar-group", role: "radiogroup" }, @attrs), &)
9
+ div(**mix({ class: "pk-menubar-group", role: "group" }, @attrs), &)
8
10
  end
9
11
  end
10
12
  end
@@ -141,7 +141,8 @@ export default class extends Controller {
141
141
  if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
142
142
  this.programmatic = false;
143
143
  this.element.removeAttribute("data-autoscrolling");
144
- this.viewportTarget?.removeAttribute("data-autoscrolling");
144
+ // Stimulus target getters throw when missing — guard, don't optional-chain.
145
+ if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
145
146
  this.following = false;
146
147
  this.updateButton();
147
148
  }
@@ -184,14 +185,16 @@ export default class extends Controller {
184
185
  // flex row gap each prepended row introduces, or the preserved row
185
186
  // drifts down by one gap per insertion.
186
187
  prependedHeight += (node.offsetHeight || 0) + gap;
187
- } else {
188
- // Inserted at (or after) the end → new turn.
188
+ } else if (record.nextSibling === null) {
189
+ // Inserted at the end → new turn.
189
190
  appended = node;
190
191
  }
192
+ // Both siblings present → inserted between existing rows. Neither
193
+ // history nor a new turn — don't yank a following reader to the end.
191
194
  }
192
195
  }
193
196
 
194
- if (prependedHeight > 0 && this.preserveOnPrependValue) {
197
+ if (prependedHeight > 0 && this.preserveOnPrependValue && this.hasViewportTarget) {
195
198
  // Keep the reader's current row fixed while history loads in above.
196
199
  this.viewportTarget.scrollTop += prependedHeight;
197
200
  }
@@ -307,7 +310,8 @@ export default class extends Controller {
307
310
  finishScroll() {
308
311
  this.programmatic = false;
309
312
  this.element.removeAttribute("data-autoscrolling");
310
- this.viewportTarget?.removeAttribute("data-autoscrolling");
313
+ // Stimulus target getters throw when missing — guard, don't optional-chain.
314
+ if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
311
315
  this.following = this.isAtEnd();
312
316
  this.updateButton();
313
317
  }
@@ -20,8 +20,10 @@
20
20
  color: var(--pk-text);
21
21
  font: inherit;
22
22
  font-size: .875rem;
23
- padding: .25rem 2rem .25rem .625rem;
24
- cursor: pointer;
23
+ padding-block: .25rem;
24
+ padding-inline-start: .625rem;
25
+ padding-inline-end: 2rem; /* chevron space — matches .pk-native-select-icon's inset-inline-end */
26
+ cursor: pointer;
25
27
  transition: color .15s ease, border-color .15s ease, box-shadow .15s ease;
26
28
  }
27
29
  .pk-native-select-field:hover { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
@@ -20,7 +20,10 @@ module PhlexKit
20
20
  # focus returned to the trigger (bare #close as an action received
21
21
  # the event as `opts`, so refocus never happened — focus fell to
22
22
  # <body> when the panel hid).
23
- action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown mouseleave->phlex-kit--menubar#closeSoon"
23
+ # mouseenter->cancelClose: re-entering the nav over list padding /
24
+ # whitespace must cancel the pending mouseleave grace-close (only
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"
24
27
  }
25
28
  }, @attrs), &)
26
29
  end
@@ -2,15 +2,22 @@ module PhlexKit
2
2
  # "Next" control (label + chevron, label hidden on small screens), ported
3
3
  # from shadcn/ui's PaginationNext. Renders its own <li>. See pagination.rb.
4
4
  class PaginationNext < BaseComponent
5
- def initialize(href: "#", label: "Next", **attrs)
5
+ DEFAULT_LABEL = "Next"
6
+
7
+ def initialize(href: "#", label: DEFAULT_LABEL, **attrs)
6
8
  @href = href
7
9
  @label = label
8
10
  @attrs = attrs
9
11
  end
10
12
 
11
13
  def view_template
14
+ # The English aria-label only accompanies the default English label; a
15
+ # custom (possibly localized) label: speaks for itself — hardcoding
16
+ # "Go to next page" over it would make AT announce the wrong language.
17
+ base = { href: @href, class: "pk-button ghost pk-pagination-next" }
18
+ base[:aria] = { label: "Go to next page" } if @label == DEFAULT_LABEL
12
19
  li do
13
- a(**mix({ href: @href, class: "pk-button ghost pk-pagination-next", aria: { label: "Go to next page" } }, @attrs)) do
20
+ a(**mix(base, @attrs)) do
14
21
  span(class: "pk-pagination-label") { @label }
15
22
  render Icon.new(:chevron_right, size: nil)
16
23
  end
@@ -3,15 +3,22 @@ module PhlexKit
3
3
  # ported from shadcn/ui's PaginationPrevious. Renders its own <li>.
4
4
  # See pagination.rb.
5
5
  class PaginationPrevious < BaseComponent
6
- def initialize(href: "#", label: "Previous", **attrs)
6
+ DEFAULT_LABEL = "Previous"
7
+
8
+ def initialize(href: "#", label: DEFAULT_LABEL, **attrs)
7
9
  @href = href
8
10
  @label = label
9
11
  @attrs = attrs
10
12
  end
11
13
 
12
14
  def view_template
15
+ # The English aria-label only accompanies the default English label; a
16
+ # custom (possibly localized) label: speaks for itself — hardcoding
17
+ # "Go to previous page" over it would make AT announce the wrong language.
18
+ base = { href: @href, class: "pk-button ghost pk-pagination-previous" }
19
+ base[:aria] = { label: "Go to previous page" } if @label == DEFAULT_LABEL
13
20
  li do
14
- a(**mix({ href: @href, class: "pk-button ghost pk-pagination-previous", aria: { label: "Go to previous page" } }, @attrs)) do
21
+ a(**mix(base, @attrs)) do
15
22
  render Icon.new(:chevron_left, size: nil)
16
23
  span(class: "pk-pagination-label") { @label }
17
24
  end