phlex_kit 0.10.0 → 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 (94) 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_controller.js +3 -0
  5. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  6. data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
  7. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  8. data/app/components/phlex_kit/badge/badge.css +1 -1
  9. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  10. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  11. data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
  12. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  13. data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
  14. data/app/components/phlex_kit/chart/chart.rb +8 -2
  15. data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
  16. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  17. data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
  18. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  19. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  20. data/app/components/phlex_kit/combobox/combobox_controller.js +15 -1
  21. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  22. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  23. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
  24. data/app/components/phlex_kit/command/command_controller.js +6 -1
  25. data/app/components/phlex_kit/command/command_item.rb +4 -0
  26. data/app/components/phlex_kit/command/command_list.rb +6 -2
  27. data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
  28. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  29. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  30. data/app/components/phlex_kit/data_table/data_table.css +0 -10
  31. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  32. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  33. data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
  34. data/app/components/phlex_kit/dialog/dialog.css +3 -1
  35. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  36. data/app/components/phlex_kit/dialog/dialog_controller.js +13 -2
  37. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
  38. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
  39. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  40. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  41. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  42. data/app/components/phlex_kit/form_field/form_field_controller.js +22 -1
  43. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  44. data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
  45. data/app/components/phlex_kit/icon/icon.css +9 -0
  46. data/app/components/phlex_kit/icon/icon.rb +7 -1
  47. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  48. data/app/components/phlex_kit/marker/marker.rb +9 -2
  49. data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
  50. data/app/components/phlex_kit/menubar/menubar_controller.js +6 -0
  51. data/app/components/phlex_kit/message/message.rb +5 -0
  52. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
  53. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  54. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  55. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  56. data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
  57. data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
  58. data/app/components/phlex_kit/popover/popover_controller.js +23 -1
  59. data/app/components/phlex_kit/progress/progress.css +8 -0
  60. data/app/components/phlex_kit/progress/progress.rb +14 -5
  61. data/app/components/phlex_kit/resizable/resizable_controller.js +3 -0
  62. data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -2
  63. data/app/components/phlex_kit/select/select.css +5 -2
  64. data/app/components/phlex_kit/select/select_controller.js +27 -4
  65. data/app/components/phlex_kit/separator/separator.rb +5 -1
  66. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  67. data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
  68. data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
  69. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  70. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  71. data/app/components/phlex_kit/slider/slider.css +15 -0
  72. data/app/components/phlex_kit/switch/switch.css +5 -0
  73. data/app/components/phlex_kit/table/table.css +2 -1
  74. data/app/components/phlex_kit/table/table.rb +5 -2
  75. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  76. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  77. data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
  78. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  79. data/app/components/phlex_kit/toast/toast.css +2 -1
  80. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  81. data/app/components/phlex_kit/toast/toast_controller.js +7 -4
  82. data/app/components/phlex_kit/toast/toast_region.rb +19 -9
  83. data/app/components/phlex_kit/toast/toaster_controller.js +15 -7
  84. data/app/components/phlex_kit/toggle_group/toggle_group.rb +1 -1
  85. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +5 -1
  86. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
  87. data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
  88. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  89. data/app/components/phlex_kit/typography/heading.rb +7 -1
  90. data/app/components/phlex_kit/typography/list.rb +22 -0
  91. data/app/components/phlex_kit/typography/text.rb +7 -1
  92. data/lib/phlex_kit/base_component.rb +13 -0
  93. data/lib/phlex_kit/version.rb +1 -1
  94. metadata +3 -1
@@ -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,7 +11,9 @@
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
19
  border-radius: calc(var(--pk-radius) + 2px); background: var(--pk-surface); color: var(--pk-text);
@@ -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
@@ -44,15 +44,26 @@ export default class extends Controller {
44
44
  document.body.style.overflow = "hidden"
45
45
  }
46
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
+ }
47
54
  // Only a click on the ::backdrop dismisses. e.target is the <dialog> for
48
55
  // backdrop clicks, but ALSO for clicks on the dialog's own padding/flex
49
56
  // gaps — disambiguate by testing the click point against the dialog's box.
50
57
  backdropClick(e) {
51
58
  if (e.target !== this.dialogTarget) return
52
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) {
53
65
  const r = this.dialogTarget.getBoundingClientRect()
54
- const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
55
- if (!inside) this.dismiss()
66
+ return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
56
67
  }
57
68
  handleClose = () => { document.body.style.overflow = this.previousOverflow ?? "" }
58
69
  }
@@ -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
@@ -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,10 +9,21 @@ 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;
@@ -37,6 +48,16 @@ export default class extends Controller {
37
48
  if (!this.hasErrorTarget) return;
38
49
  error.preventDefault();
39
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
+
40
61
  this.shouldValidateValue = true;
41
62
  this.#setErrorMessage(error);
42
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);
@@ -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.
@@ -42,6 +42,12 @@ 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"))
45
51
  if (this.roving) this.applyRoving()
46
52
  }
47
53
 
@@ -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
  }
@@ -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
@@ -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)
@@ -17,14 +17,23 @@ export default class extends Controller {
17
17
  if (invoker) {
18
18
  invoker.popoverTargetElement = this.contentTarget
19
19
  } else {
20
+ // Button-less fallback: keyboard toggling and popup semantics don't
21
+ // come for free like they do with popoverTargetElement — wire Enter
22
+ // (and Space, off text-entry controls) plus aria-haspopup/-expanded
23
+ // onto the focusable child so AT users can discover and open it.
24
+ this.fallbackInvoker = this.triggerTarget.querySelector("input, a[href], select, textarea, [tabindex]") || this.triggerTarget
25
+ this.fallbackInvoker.setAttribute("aria-haspopup", "dialog")
26
+ this.fallbackInvoker.setAttribute("aria-expanded", this.contentTarget.matches(":popover-open") ? "true" : "false")
20
27
  this.triggerTarget.addEventListener("pointerdown", this.armToggle)
21
28
  this.triggerTarget.addEventListener("click", this.toggle)
29
+ this.triggerTarget.addEventListener("keydown", this.keyToggle)
22
30
  }
23
31
  }
24
32
 
25
33
  disconnect() {
26
34
  this.triggerTarget.removeEventListener("pointerdown", this.armToggle)
27
35
  this.triggerTarget.removeEventListener("click", this.toggle)
36
+ this.triggerTarget.removeEventListener("keydown", this.keyToggle)
28
37
  }
29
38
 
30
39
  armToggle = () => { this.wasOpen = this.contentTarget.matches(":popover-open") }
@@ -32,12 +41,25 @@ export default class extends Controller {
32
41
  if (this.wasOpen) { this.wasOpen = false; return }
33
42
  this.contentTarget.togglePopover()
34
43
  }
44
+ keyToggle = (e) => {
45
+ const textEntry = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)
46
+ if (e.key !== "Enter" && !(e.key === " " && !textEntry)) return
47
+ e.preventDefault()
48
+ this.contentTarget.togglePopover()
49
+ }
35
50
 
36
51
  // One stable handler added/removed symmetrically — an anonymous listener
37
52
  // here would pile up a duplicate on every target reconnect.
38
- syncState = (e) => { e.target.dataset.state = e.newState === "open" ? "open" : "closed" }
53
+ syncState = (e) => {
54
+ e.target.dataset.state = e.newState === "open" ? "open" : "closed"
55
+ this.fallbackInvoker?.setAttribute("aria-expanded", e.newState === "open" ? "true" : "false")
56
+ }
39
57
 
40
58
  contentTargetConnected(el) {
59
+ // A Turbo snapshot serializes data-state="open" but :popover-open does
60
+ // not survive the restore — resync before listening so host CSS keyed
61
+ // on the state hook doesn't misfire.
62
+ el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
41
63
  el.addEventListener("toggle", this.syncState)
42
64
  }
43
65
 
@@ -14,4 +14,12 @@
14
14
  width: 100%;
15
15
  background: var(--pk-brand);
16
16
  transition: transform .3s ease;
17
+ /* value% − 100%: slides the full-width indicator into view from the
18
+ inline-start edge. --pk-progress-value is a unitless 0–100 set inline
19
+ by progress.rb. */
20
+ transform: translateX(calc((var(--pk-progress-value, 0) - 100) * 1%));
21
+ }
22
+ /* RTL: the bar fills from the right, so travel flips. */
23
+ .pk-progress:dir(rtl) .pk-progress-indicator {
24
+ transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
17
25
  }
@@ -4,21 +4,30 @@ module PhlexKit
4
4
  # JS; width comes from a caller `class:`/`style:`. Tailwind → vanilla
5
5
  # `.pk-progress*` (progress.css).
6
6
  class Progress < BaseComponent
7
- def initialize(value: 0, **attrs)
7
+ # value_text: is a named kwarg (not read from **attrs) because `mix`
8
+ # would *fuse* a caller "aria-valuetext" with the generated default.
9
+ def initialize(value: 0, value_text: nil, **attrs)
8
10
  @value = value.to_f.clamp(0.0, 100.0)
11
+ @value_text = value_text
9
12
  @attrs = attrs
10
13
  end
11
14
 
12
15
  def view_template
16
+ # %g drops float artifacts ("40.0" would be announced as "forty point
17
+ # zero percent"). The value travels as a custom property so the fill
18
+ # transform lives in progress.css, where a :dir(rtl) arm can flip it —
19
+ # an inline translateX always filled from the physical left.
20
+ v = Kernel.format("%g", @value)
13
21
  div(**mix({
14
22
  role: "progressbar",
15
- "aria-valuenow": @value,
23
+ "aria-valuenow": v,
16
24
  "aria-valuemin": 0,
17
25
  "aria-valuemax": 100,
18
- "aria-valuetext": "#{@value}%",
19
- class: "pk-progress"
26
+ "aria-valuetext": @value_text || "#{v}%",
27
+ class: "pk-progress",
28
+ style: "--pk-progress-value: #{v};"
20
29
  }, @attrs)) do
21
- div(class: "pk-progress-indicator", style: "transform: translateX(-#{100 - @value}%)")
30
+ div(class: "pk-progress-indicator")
22
31
  end
23
32
  end
24
33
  end