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
@@ -9,7 +9,9 @@ module PhlexKit
9
9
  # -1: roving focus reaches it via arrows (focus-within opens the sub);
10
10
  # tabindex 0 made it a stray tab stop inside the menu.
11
11
  tabindex: "-1",
12
- aria: { haspopup: "menu" },
12
+ # expanded starts false and is mirrored live by the controller's
13
+ # syncSub (the reveal itself is pure CSS :hover/:focus-within).
14
+ aria: { haspopup: "menu", expanded: "false" },
13
15
  data: { phlex_kit__context_menu_target: "menuItem" }
14
16
  }, @attrs)) do
15
17
  block&.call
@@ -23,16 +23,6 @@
23
23
  /* "Columns" dropdown rows. */
24
24
  .pk-data-table-column-toggle { position: relative; }
25
25
  .pk-data-table-column-toggle-icon { width: 1rem; height: 1rem; margin-inline-start: .25rem; }
26
- .pk-data-table-column-option {
27
- display: flex;
28
- align-items: center;
29
- gap: .5rem;
30
- border-radius: calc(var(--pk-radius) - 4px);
31
- padding: .375rem .5rem;
32
- font-size: .875rem;
33
- cursor: pointer;
34
- }
35
- .pk-data-table-column-option:hover { background: var(--pk-accent); }
36
26
 
37
27
  /* Sortable header link. */
38
28
  .pk-data-table-sort-head { color: var(--pk-text); white-space: nowrap; }
@@ -27,7 +27,10 @@ module PhlexKit
27
27
  # invalid ARIA. The change event bubbles from the hidden checkbox
28
28
  # to this row, where our toggle action picks it up.
29
29
  render DropdownMenuCheckboxItem.new(
30
- checked: true,
30
+ # visible: false starts the menu row unchecked for a column
31
+ # the host server-renders hidden (pk-hidden on its cells) —
32
+ # checked: true hardcoded meant the two could never agree.
33
+ checked: col.fetch(:visible, true),
31
34
  data: {
32
35
  column_key: col[:key].to_s,
33
36
  action: "change->phlex-kit--data-table-column-visibility#toggle"
@@ -20,7 +20,11 @@ export default class extends Controller {
20
20
  toggleAll(event) {
21
21
  const checked = event.target.checked;
22
22
  this.rowCheckboxTargets.forEach((cb) => {
23
+ if (cb.checked === checked) return;
23
24
  cb.checked = checked;
25
+ // Programmatic .checked flips fire no event — hosts (and the
26
+ // form-field wiring on PhlexKit::Checkbox) must still hear select-all.
27
+ cb.dispatchEvent(new Event("change", { bubbles: true }));
24
28
  });
25
29
  this.updateState();
26
30
  }
@@ -31,6 +31,12 @@ module PhlexKit
31
31
  # until the first interaction.
32
32
  @value = value || format_selected_date
33
33
  @placeholder = placeholder
34
+ # An id smuggled through input_attrs would MERGE with the generated one
35
+ # (mix fuses duplicate string attrs), breaking label[for] and the
36
+ # calendar outlet selector — the top-level id: kwarg is the one path.
37
+ if input_attrs.key?(:id) || input_attrs.key?("id")
38
+ raise ArgumentError, "DatePicker: pass id: as the top-level kwarg, not input_attrs[:id]"
39
+ end
34
40
  @input_attrs = input_attrs
35
41
  @calendar_attrs = calendar_attrs
36
42
  @trigger_attrs = trigger_attrs
@@ -11,10 +11,12 @@
11
11
  --pk-dialog-spacing: 1rem;
12
12
  position: fixed; z-index: 50; width: 100%;
13
13
  max-width: min(24rem, calc(100% - 2rem));
14
- max-height: 100vh;
14
+ /* Breathing room like the horizontal inset — 100vh touched the screen
15
+ edges and overflowed behind mobile browser chrome (dvh tracks it). */
16
+ max-height: calc(100dvh - 2rem);
15
17
  overflow-y: auto; gap: 1rem;
16
18
  border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
17
- border-radius: .75rem; background: var(--pk-surface); color: var(--pk-text);
19
+ border-radius: calc(var(--pk-radius) + 2px); background: var(--pk-surface); color: var(--pk-text);
18
20
  padding: var(--pk-dialog-spacing);
19
21
  font-size: .875rem;
20
22
  box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
@@ -35,7 +37,7 @@
35
37
  margin: 0 calc(-1 * var(--pk-dialog-spacing)) calc(-1 * var(--pk-dialog-spacing));
36
38
  padding: var(--pk-dialog-spacing);
37
39
  border-top: 1px solid var(--pk-border);
38
- border-radius: 0 0 .75rem .75rem;
40
+ border-radius: 0 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px);
39
41
  background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent);
40
42
  }
41
43
  .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
@@ -21,7 +21,7 @@ module PhlexKit
21
21
  aria = {}
22
22
  aria[:labelledby] = @labelledby if @labelledby
23
23
  aria[:describedby] = @describedby if @describedby
24
- dialog(**mix({ class: cls, aria: aria, data: { phlex_kit__dialog_target: "dialog", action: "click->phlex-kit--dialog#backdropClick" } }, @attrs)) do
24
+ dialog(**mix({ class: cls, aria: aria, data: { phlex_kit__dialog_target: "dialog", action: "pointerdown->phlex-kit--dialog#backdropPointerdown click->phlex-kit--dialog#backdropClick" } }, @attrs)) do
25
25
  yield
26
26
  if @show_close_button
27
27
  button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--dialog#dismiss" }) do
@@ -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()
@@ -35,15 +44,26 @@ export default class extends Controller {
35
44
  document.body.style.overflow = "hidden"
36
45
  }
37
46
  dismiss() { this.dialogTarget.close() }
47
+ // Only a press that STARTED on the backdrop may dismiss: a drag-select
48
+ // beginning inside the panel and released over the backdrop fires the
49
+ // click on <dialog> with outside coordinates — that must not close it
50
+ // (Radix guards on pointerdown origin the same way).
51
+ backdropPointerdown(e) {
52
+ this.pressStartedOutside = e.target === this.dialogTarget && !this.#insideBox(e)
53
+ }
38
54
  // Only a click on the ::backdrop dismisses. e.target is the <dialog> for
39
55
  // backdrop clicks, but ALSO for clicks on the dialog's own padding/flex
40
56
  // gaps — disambiguate by testing the click point against the dialog's box.
41
57
  backdropClick(e) {
42
58
  if (e.target !== this.dialogTarget) return
43
59
  if (e.detail === 0) return // synthetic/keyboard click — no coordinates
60
+ if (!this.pressStartedOutside) return
61
+ this.pressStartedOutside = false
62
+ if (!this.#insideBox(e)) this.dismiss()
63
+ }
64
+ #insideBox(e) {
44
65
  const r = this.dialogTarget.getBoundingClientRect()
45
- const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
46
- if (!inside) this.dismiss()
66
+ return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
47
67
  }
48
68
  handleClose = () => { document.body.style.overflow = this.previousOverflow ?? "" }
49
69
  }
@@ -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); } }
@@ -20,14 +20,17 @@
20
20
  position-try-fallbacks: flip-block;
21
21
  }
22
22
  .pk-dropdown-menu-content-up { position-area: block-start span-inline-end; margin: 0 0 4px; }
23
+ /* Gap margins are logical to match the logical position-area — physical
24
+ margins landed on the wrong side in RTL (and flip-inline flips logical
25
+ margins along with the placement). */
23
26
  .pk-dropdown-menu-content-right {
24
27
  position-area: inline-end span-block-end;
25
- margin: 0 0 0 4px;
28
+ margin: 0; margin-inline-start: 4px;
26
29
  position-try-fallbacks: flip-inline;
27
30
  }
28
31
  .pk-dropdown-menu-content-left {
29
32
  position-area: inline-start span-block-end;
30
- margin: 0 4px 0 0;
33
+ margin: 0; margin-inline-end: 4px;
31
34
  position-try-fallbacks: flip-inline;
32
35
  }
33
36
  .pk-dropdown-menu-viewport {
@@ -16,20 +16,47 @@ export default class extends Controller {
16
16
 
17
17
  connect() {
18
18
  this.boundHandleKeydown = this.#handleKeydown.bind(this);
19
+ // Outstanding syncSub rAF handles — cancelled on disconnect so a queued
20
+ // frame never touches a torn-down menu (menubar's pattern).
21
+ this.subFrames = new Set();
19
22
  // The visible control is the caller's button/link inside the trigger
20
23
  // wrapper — that's what AT reads, so the popup wiring belongs on it.
21
24
  this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
22
25
  this.invoker.setAttribute("aria-haspopup", "menu");
23
26
  this.invoker.setAttribute("aria-expanded", "false");
27
+ // Associate trigger and menu for AT (the dialog controller wires its
28
+ // ids the same way).
29
+ if (!this.contentTarget.id) this.contentTarget.id = `pk-dropdown-menu-${Math.random().toString(36).slice(2, 8)}`;
30
+ this.invoker.setAttribute("aria-controls", this.contentTarget.id);
31
+ // Turbo snapshots BEFORE disconnect — an open menu (reflected in the
32
+ // open value) would be resurrected from the page cache on restore.
33
+ this.boundBeforeCache = () => this.close();
34
+ document.addEventListener("turbo:before-cache", this.boundBeforeCache);
24
35
  // `open:` on the Ruby side renders the value — start open (same
25
36
  // kwarg -> value -> connect flow as Collapsible).
26
37
  if (this.openValue) this.#open();
27
38
  }
28
39
 
29
40
  disconnect() {
41
+ document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
42
+ this.subFrames.forEach((id) => cancelAnimationFrame(id));
43
+ this.subFrames.clear();
30
44
  this.#removeEventListeners();
31
45
  }
32
46
 
47
+ // Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
48
+ // sub trigger's aria-expanded. rAF: on mouseleave/focusout the pseudo-class
49
+ // state isn't settled until the event finishes dispatching.
50
+ syncSub(e) {
51
+ const sub = e.currentTarget;
52
+ const id = requestAnimationFrame(() => {
53
+ this.subFrames.delete(id);
54
+ sub.querySelector(":scope > [aria-haspopup]")
55
+ ?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"));
56
+ });
57
+ this.subFrames.add(id);
58
+ }
59
+
33
60
  onClickOutside(event) {
34
61
  // Gate on the live :popover-open state, not the stored flag — a stale
35
62
  // flag is how a close on an already-closed panel becomes an open.
@@ -5,7 +5,9 @@ module PhlexKit
5
5
  class DropdownMenuSub < BaseComponent
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
  def view_template(&)
8
- div(**mix({ class: "pk-dropdown-menu-sub" }, @attrs), &)
8
+ # syncSub mirrors the CSS-driven reveal state onto the sub trigger's
9
+ # aria-expanded (same wiring as MenubarSub).
10
+ div(**mix({ class: "pk-dropdown-menu-sub", data: { action: "mouseenter->phlex-kit--dropdown-menu#syncSub mouseleave->phlex-kit--dropdown-menu#syncSub focusin->phlex-kit--dropdown-menu#syncSub focusout->phlex-kit--dropdown-menu#syncSub" } }, @attrs), &)
9
11
  end
10
12
  end
11
13
  end
@@ -10,7 +10,9 @@ module PhlexKit
10
10
  # -1: roving focus reaches it via arrows (focus-within opens the sub);
11
11
  # tabindex 0 made it a stray tab stop inside the menu.
12
12
  tabindex: "-1",
13
- aria: { haspopup: "menu" },
13
+ # expanded starts false and is mirrored live by the controller's
14
+ # syncSub (the reveal itself is pure CSS :hover/:focus-within).
15
+ aria: { haspopup: "menu", expanded: "false" },
14
16
  data: { phlex_kit__dropdown_menu_target: "menuItem" }
15
17
  }, @attrs)) do
16
18
  block&.call
@@ -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
  }
@@ -6,7 +6,7 @@ module PhlexKit
6
6
  def initialize(**attrs) = (@attrs = attrs)
7
7
 
8
8
  def view_template(&block)
9
- div(**mix({ class: "pk-field-separator", data: { content: block ? "true" : "false" } }, @attrs)) do
9
+ div(**mix({ class: "pk-field-separator" }, @attrs)) do
10
10
  render Separator.new(class: "pk-field-separator-line")
11
11
  span(class: "pk-field-separator-content", &block) if block
12
12
  end
@@ -9,13 +9,31 @@ import { Controller } from "@hotwired/stimulus";
9
9
  // back to the browser's validationMessage.
10
10
  // Connects to data-controller="phlex-kit--form-field"
11
11
  export default class extends Controller {
12
- static targets = ["input", "error"];
12
+ static targets = ["input", "error", "hint"];
13
13
  static values = { shouldValidate: false };
14
14
 
15
15
  connect() {
16
+ // Hints are static description text — wire them onto every control's
17
+ // aria-describedby up front (errors join/leave dynamically via
18
+ // #describeBy, which preserves foreign tokens like these).
19
+ this.hintTargets.forEach((hint) => {
20
+ if (!hint.id) return;
21
+ this.inputTargets.forEach((input) => {
22
+ const tokens = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean);
23
+ if (!tokens.includes(hint.id)) tokens.push(hint.id);
24
+ input.setAttribute("aria-describedby", tokens.join(" "));
25
+ });
26
+ });
16
27
  if (this.hasErrorTarget) {
17
28
  if (this.errorTarget.textContent.trim()) {
18
29
  this.shouldValidateValue = true;
30
+ // A server-rendered error is already visible — give the control(s)
31
+ // the same aria wiring onInvalid applies, instead of leaving
32
+ // aria-invalid/aria-describedby unset until the first input/change.
33
+ this.inputTargets.forEach((input) => {
34
+ input.setAttribute("aria-invalid", "true");
35
+ this.#describeBy(input, true);
36
+ });
19
37
  } else {
20
38
  this.errorTarget.classList.add("pk-hidden");
21
39
  }
@@ -30,6 +48,16 @@ export default class extends Controller {
30
48
  if (!this.hasErrorTarget) return;
31
49
  error.preventDefault();
32
50
 
51
+ // preventDefault also cancels the browser's focus-first-invalid — restore
52
+ // it for the first invalid control of the submit pass (all invalid events
53
+ // fire in the same task, so the microtask reset scopes "first" correctly).
54
+ if (!this._focusedInvalid) {
55
+ this._focusedInvalid = true;
56
+ queueMicrotask(() => { this._focusedInvalid = false; });
57
+ error.target.focus({ preventScroll: true });
58
+ error.target.scrollIntoView({ block: "nearest" });
59
+ }
60
+
33
61
  this.shouldValidateValue = true;
34
62
  this.#setErrorMessage(error);
35
63
  }
@@ -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
@@ -22,12 +22,14 @@
22
22
  box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
23
23
  }
24
24
  .pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
25
+ /* Gap margins are logical to match the logical position-area (and tooltip's
26
+ pattern) — physical margins landed on the wrong side in RTL. */
25
27
  .pk-hover-card-content.left {
26
- position-area: inline-start; margin: 0 .25rem 0 0;
28
+ position-area: inline-start; margin: 0; margin-inline-end: .25rem;
27
29
  position-try-fallbacks: flip-inline;
28
30
  }
29
31
  .pk-hover-card-content.right {
30
- position-area: inline-end; margin: 0 0 0 .25rem;
32
+ position-area: inline-end; margin: 0; margin-inline-start: .25rem;
31
33
  position-try-fallbacks: flip-inline;
32
34
  }
33
35
  /* Hiding must survive author display values (a host class like
@@ -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
@@ -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);
@@ -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,24 @@ 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
- 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)
11
17
  @variant = variant.to_sym
12
18
  @href = href
13
19
  @as = as.to_sym
20
+ unless AS_TAGS.include?(@as)
21
+ raise ArgumentError, "unknown Marker as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
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
14
27
  @attrs = attrs
15
28
  end
16
29
 
@@ -19,7 +32,7 @@ module PhlexKit
19
32
  if @href
20
33
  a(**mix({ class: classes, href: @href }, @attrs), &)
21
34
  elsif @as == :button
22
- button(**mix({ class: classes, type: "button" }, @attrs), &)
35
+ button(**mix({ class: classes, type: @type }, @attrs), &)
23
36
  else
24
37
  div(**mix({ class: classes }, @attrs), &)
25
38
  end
@@ -11,21 +11,42 @@ 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
- // remember how many maskable chars sit before it and re-seat it after
23
- // 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.
24
33
  const caret = el.selectionStart ?? el.value.length
25
- const rawBefore = el.value.slice(0, caret).replace(/[^0-9A-Za-z]/g, "").length
34
+ const filledBefore = this.maskFill(this.maskable(el.value.slice(0, caret))).tokenPos.length
35
+
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
+ }
26
44
 
27
- const raw = el.value.replace(/[^0-9A-Za-z]/g, "")
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) {
28
48
  let out = "", i = 0
49
+ const tokenPos = []
29
50
  for (const t of this.mask) {
30
51
  if (i >= raw.length) break
31
52
  if (t === "#" || t === "A" || t === "*") {
@@ -34,18 +55,17 @@ export default class extends Controller {
34
55
  const re = t === "#" ? /\d/ : t === "A" ? /[A-Za-z]/ : /[0-9A-Za-z]/
35
56
  while (i < raw.length && !re.test(raw[i])) i++
36
57
  if (i >= raw.length) break
58
+ tokenPos.push(out.length)
37
59
  out += raw[i++]
38
60
  } else { out += t; if (raw[i] === t) i++ }
39
61
  }
40
- el.value = out
41
-
42
- if (document.activeElement === el) {
43
- let pos = 0, seen = 0
44
- while (pos < out.length && seen < rawBefore) {
45
- if (/[0-9A-Za-z]/.test(out[pos])) seen++
46
- pos++
47
- }
48
- el.setSelectionRange(pos, pos)
49
- }
62
+ return { out, tokenPos }
63
+ }
64
+ // Transliterate full-width digits (0-9, U+FF10–FF19 common IME numeric
65
+ // output) to ASCII before the charset filter would silently drop them.
66
+ maskable(value) {
67
+ return value
68
+ .replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xFEE0))
69
+ .replace(/[^0-9A-Za-z]/g, "")
50
70
  }
51
71
  }
@@ -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