phlex_kit 0.8.1 → 0.9.1

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +11 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -2
  4. data/app/components/phlex_kit/accordion/accordion_controller.js +8 -2
  5. data/app/components/phlex_kit/alert/alert.css +3 -3
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +12 -8
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
  10. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  11. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  12. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  13. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  14. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  15. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  16. data/app/components/phlex_kit/badge/badge.css +4 -4
  17. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  18. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  19. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  20. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  21. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  22. data/app/components/phlex_kit/button/button.css +6 -6
  23. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  24. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  25. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  26. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  27. data/app/components/phlex_kit/calendar/calendar_body.rb +3 -0
  28. data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
  29. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  30. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  31. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  32. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  34. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  35. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  36. data/app/components/phlex_kit/chart/chart_controller.js +32 -9
  37. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  38. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  39. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  40. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  41. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  42. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  43. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  44. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  45. data/app/components/phlex_kit/combobox/combobox_controller.js +52 -9
  46. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  47. data/app/components/phlex_kit/command/command.css +4 -4
  48. data/app/components/phlex_kit/command/command_controller.js +51 -10
  49. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  50. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  51. data/app/components/phlex_kit/command/command_input.rb +6 -2
  52. data/app/components/phlex_kit/command/command_item.rb +4 -1
  53. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  54. data/app/components/phlex_kit/context_menu/context_menu_controller.js +18 -1
  55. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  56. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  57. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  58. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  59. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  60. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  61. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  62. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  63. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  64. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -3
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  69. data/app/components/phlex_kit/field/field.css +1 -1
  70. data/app/components/phlex_kit/field/field_error.rb +5 -6
  71. data/app/components/phlex_kit/form_field/form_field_controller.js +35 -16
  72. data/app/components/phlex_kit/form_field/form_field_error.rb +5 -1
  73. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  74. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  75. data/app/components/phlex_kit/input/input.css +1 -1
  76. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  77. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  78. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  79. data/app/components/phlex_kit/input_otp/input_otp_controller.js +13 -0
  80. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  81. data/app/components/phlex_kit/item/item.css +1 -1
  82. data/app/components/phlex_kit/item/item_group.rb +4 -2
  83. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  84. data/app/components/phlex_kit/marker/marker.css +1 -1
  85. data/app/components/phlex_kit/masked_input/masked_input_controller.js +10 -4
  86. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  87. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  88. data/app/components/phlex_kit/menubar/menubar_controller.js +70 -7
  89. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  90. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  91. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +4 -1
  92. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  93. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  94. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  95. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +7 -2
  96. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  97. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  98. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  99. data/app/components/phlex_kit/popover/popover.css +1 -1
  100. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  101. data/app/components/phlex_kit/resizable/resizable_controller.js +13 -0
  102. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  103. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  104. data/app/components/phlex_kit/select/select.css +3 -3
  105. data/app/components/phlex_kit/select/select.rb +4 -1
  106. data/app/components/phlex_kit/select/select_controller.js +25 -0
  107. data/app/components/phlex_kit/select/select_item.rb +5 -1
  108. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  109. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  110. data/app/components/phlex_kit/sidebar/sidebar.css +8 -8
  111. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  115. data/app/components/phlex_kit/slider/slider.css +1 -1
  116. data/app/components/phlex_kit/slider/slider.rb +3 -1
  117. data/app/components/phlex_kit/switch/switch.rb +6 -1
  118. data/app/components/phlex_kit/table/table.css +5 -3
  119. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  120. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  121. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  122. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +2 -2
  123. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
  124. data/app/components/phlex_kit/toast/toast.css +32 -14
  125. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  126. data/app/components/phlex_kit/toast/toast_region.rb +13 -3
  127. data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
  128. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  129. data/app/components/phlex_kit/toggle/toggle.rb +6 -2
  130. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  131. data/app/components/phlex_kit/typography/heading.rb +9 -3
  132. data/app/components/phlex_kit/typography/typography.css +4 -3
  133. data/lib/phlex_kit/version.rb +1 -1
  134. metadata +1 -1
@@ -36,7 +36,10 @@ module PhlexKit
36
36
  data: {
37
37
  controller: "phlex-kit--select",
38
38
  phlex_kit__select_open_value: "false",
39
- action: "click@window->phlex-kit--select#clickOutside"
39
+ # keydown.esc rides on the root (not only the items) so Escape closes
40
+ # the [popover=manual] panel with focus on the trigger too; handleEsc
41
+ # no-ops while closed.
42
+ action: "click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc"
40
43
  }
41
44
  }, @attrs), &block)
42
45
  end
@@ -72,7 +72,25 @@ export default class extends Controller {
72
72
  }
73
73
  }
74
74
 
75
+ handleHome(event) {
76
+ event.preventDefault();
77
+ if (this.itemTargets.length === 0) return;
78
+ this.resetCurrent();
79
+ this.setAriaCurrentAndActiveDescendant(0);
80
+ }
81
+
82
+ handleEnd(event) {
83
+ event.preventDefault();
84
+ if (this.itemTargets.length === 0) return;
85
+ this.resetCurrent();
86
+ this.setAriaCurrentAndActiveDescendant(this.itemTargets.length - 1);
87
+ }
88
+
89
+ // Bound on the root as well as the items, so Escape also closes with focus
90
+ // on the trigger; no-op while closed (a closed-state Escape must not steal
91
+ // focus back to the trigger).
75
92
  handleEsc(event) {
93
+ if (!this.contentTarget.matches(":popover-open")) return;
76
94
  event.preventDefault();
77
95
  this.closeContent();
78
96
  }
@@ -85,7 +103,11 @@ export default class extends Controller {
85
103
  const focusItem = selectedItem || (this.hasItemTarget ? this.itemTarget : null);
86
104
  if (!focusItem) return; // empty select — nothing to focus
87
105
 
106
+ // preventScroll stops the page jumping when the top-layer panel opens;
107
+ // scrollIntoView(nearest) then scrolls only the panel's own viewport so
108
+ // the focused option is actually visible (max-height panel).
88
109
  focusItem.focus({ preventScroll: true });
110
+ focusItem.scrollIntoView({ block: "nearest" });
89
111
  focusItem.setAttribute("aria-current", "true");
90
112
  this.triggerTarget.setAttribute("aria-activedescendant", focusItem.getAttribute("id"));
91
113
  }
@@ -133,6 +155,9 @@ export default class extends Controller {
133
155
  setAriaCurrentAndActiveDescendant(currentIndex) {
134
156
  const currentItem = this.itemTargets[currentIndex];
135
157
  currentItem.focus({ preventScroll: true });
158
+ // Keep the highlighted option visible inside the scrollable viewport —
159
+ // preventScroll (page-jump guard) otherwise lets it scroll out of view.
160
+ currentItem.scrollIntoView({ block: "nearest" });
136
161
  currentItem.setAttribute("aria-current", "true");
137
162
  this.triggerTarget.setAttribute("aria-activedescendant", currentItem.getAttribute("id"));
138
163
  }
@@ -16,13 +16,17 @@ module PhlexKit
16
16
  def view_template(&block)
17
17
  div(**mix({
18
18
  role: "option",
19
- tabindex: "0",
19
+ # Roving listbox pattern: options are focusable programmatically only
20
+ # (focus() works on tabindex=-1), so Tab exits the widget instead of
21
+ # walking every option.
22
+ tabindex: "-1",
20
23
  class: "pk-select-item",
21
24
  "aria-selected": (@selected ? "true" : "false"),
22
25
  data: {
23
26
  value: @value,
24
27
  action: "click->phlex-kit--select#selectItem keydown.enter->phlex-kit--select#selectItem " \
25
28
  "keydown.down->phlex-kit--select#handleKeyDown keydown.up->phlex-kit--select#handleKeyUp " \
29
+ "keydown.home->phlex-kit--select#handleHome keydown.end->phlex-kit--select#handleEnd " \
26
30
  "keydown.esc->phlex-kit--select#handleEsc",
27
31
  phlex_kit__select_target: "item"
28
32
  }
@@ -4,15 +4,15 @@
4
4
  75%-wide capped at 24rem. Overlay at the kit-unified black/10 + blur-xs
5
5
  (see dialog.css). Theme tokens from the global stylesheet. */
6
6
  .pk-sheet-trigger { display: inline-block; }
7
- .pk-sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: rgb(0 0 0 / .1); backdrop-filter: blur(4px); }
7
+ .pk-sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: color-mix(in srgb, var(--pk-overlay) 10%, transparent); backdrop-filter: blur(4px); }
8
8
  @supports not (backdrop-filter: blur(1px)) {
9
- .pk-sheet-backdrop { background: rgb(0 0 0 / .5); }
9
+ .pk-sheet-backdrop { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
10
10
  }
11
11
  .pk-sheet-content {
12
12
  position: fixed; z-index: 50; display: flex; flex-direction: column; gap: 1rem;
13
13
  background: var(--pk-surface); color: var(--pk-text); padding: 0;
14
14
  font-size: .875rem;
15
- box-shadow: 0 10px 30px rgb(0 0 0 / .35); overflow: auto;
15
+ box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent); overflow: auto;
16
16
  }
17
17
  .pk-sheet-content.right { inset-block: 0; right: 0; height: 100%; width: 75%; max-width: 24rem; border-left: 1px solid var(--pk-border); }
18
18
  .pk-sheet-content.left { inset-block: 0; left: 0; height: 100%; width: 75%; max-width: 24rem; border-right: 1px solid var(--pk-border); }
@@ -2,12 +2,14 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--sheet-content" (on the cloned node —
4
4
  // drawers ride the same machinery). Owns the modal contract for the open panel:
5
- // scroll lock, initial focus, Tab focus trap, Escape-to-close, focus restore to
6
- // the opener, and aria-labelledby wiring to the SheetTitle/DrawerTitle. connect
7
- // and disconnect exactly bracket the clone's lifetime, so removal by any path
8
- // (close button, backdrop, Escape, turbo:before-cache) restores state.
5
+ // scroll lock, background inert, initial focus, Tab focus trap, Escape-to-close,
6
+ // focus restore to the opener, and aria-labelledby/-describedby wiring to the
7
+ // Sheet/Drawer Title + Description. connect and disconnect exactly bracket the
8
+ // clone's lifetime, so removal by any path (close button, backdrop, Escape,
9
+ // turbo:before-cache — the phlex-kit--sheet source controller removes the clone
10
+ // there) restores state.
9
11
  const FOCUSABLE =
10
- 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
12
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
11
13
 
12
14
  export default class extends Controller {
13
15
  static targets = ["panel"]
@@ -16,12 +18,15 @@ export default class extends Controller {
16
18
  this.opener = document.activeElement
17
19
  this.previousOverflow = document.body.style.overflow
18
20
  document.body.style.overflow = "hidden"
19
- this.#wireAriaLabelledby()
21
+ this.#inertOthers()
22
+ this.#wireAria("aria-labelledby", ".pk-sheet-title, .pk-drawer-title")
23
+ this.#wireAria("aria-describedby", ".pk-sheet-description, .pk-drawer-description")
20
24
  const focusables = this.#focusables()
21
25
  ;(focusables[0] || this.panel).focus()
22
26
  }
23
27
 
24
28
  disconnect() {
29
+ this.#restoreInert()
25
30
  document.body.style.overflow = this.previousOverflow
26
31
  if (this.opener?.isConnected) this.opener.focus()
27
32
  }
@@ -62,11 +67,30 @@ export default class extends Controller {
62
67
  return [...this.panel.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0)
63
68
  }
64
69
 
65
- #wireAriaLabelledby() {
66
- if (this.panel.getAttribute("aria-labelledby")) return
67
- const title = this.element.querySelector(".pk-sheet-title, .pk-drawer-title")
68
- if (!title) return
69
- if (!title.id) title.id = `pk-modal-title-${Math.random().toString(36).slice(2, 10)}`
70
- this.panel.setAttribute("aria-labelledby", title.id)
70
+ // Make everything behind the panel inert (the clone is <body>'s last child,
71
+ // so its siblings are the whole page). Prior inert state is saved per
72
+ // element and restored on disconnect — which fires on every removal path,
73
+ // including the source controller's turbo:before-cache cleanup.
74
+ #inertOthers() {
75
+ this.inerted = []
76
+ for (const el of document.body.children) {
77
+ if (el === this.element) continue
78
+ if (["SCRIPT", "STYLE", "LINK", "TEMPLATE"].includes(el.tagName)) continue
79
+ this.inerted.push([el, el.inert])
80
+ el.inert = true
81
+ }
82
+ }
83
+
84
+ #restoreInert() {
85
+ for (const [el, wasInert] of this.inerted || []) el.inert = wasInert
86
+ this.inerted = null
87
+ }
88
+
89
+ #wireAria(attr, selector) {
90
+ if (this.panel.getAttribute(attr)) return
91
+ const el = this.element.querySelector(selector)
92
+ if (!el) return
93
+ if (!el.id) el.id = `pk-modal-${attr.slice(5)}-${Math.random().toString(36).slice(2, 10)}`
94
+ this.panel.setAttribute(attr, el.id)
71
95
  }
72
96
  }
@@ -42,7 +42,7 @@
42
42
  .pk-sidebar-group-action {
43
43
  position: absolute;
44
44
  top: .375rem;
45
- right: .25rem;
45
+ inset-inline-end: .25rem;
46
46
  display: flex;
47
47
  align-items: center;
48
48
  justify-content: center;
@@ -71,7 +71,7 @@
71
71
  color: var(--pk-sidebar-text, var(--pk-text));
72
72
  font: inherit;
73
73
  font-size: .9rem;
74
- text-align: left;
74
+ text-align: start;
75
75
  text-decoration: none;
76
76
  cursor: pointer;
77
77
  }
@@ -86,7 +86,7 @@
86
86
  .pk-sidebar-menu-action {
87
87
  position: absolute;
88
88
  top: .5rem;
89
- right: .25rem;
89
+ inset-inline-end: .25rem;
90
90
  display: flex;
91
91
  align-items: center;
92
92
  justify-content: center;
@@ -105,7 +105,7 @@
105
105
  user-select: none;
106
106
  position: absolute;
107
107
  top: .5rem;
108
- right: .25rem;
108
+ inset-inline-end: .25rem;
109
109
  display: flex;
110
110
  align-items: center;
111
111
  justify-content: center;
@@ -130,7 +130,7 @@
130
130
  min-width: 0;
131
131
  margin: 0 .875rem;
132
132
  padding: .125rem .625rem;
133
- border-left: 1px solid var(--pk-sidebar-border, var(--pk-border));
133
+ border-inline-start: 1px solid var(--pk-sidebar-border, var(--pk-border));
134
134
  }
135
135
  .pk-sidebar-menu-sub-item { position: relative; }
136
136
  .pk-sidebar-menu-sub-button {
@@ -148,7 +148,7 @@
148
148
  color: var(--pk-sidebar-text, var(--pk-text));
149
149
  font: inherit;
150
150
  font-size: .875rem;
151
- text-align: left;
151
+ text-align: start;
152
152
  text-decoration: none;
153
153
  cursor: pointer;
154
154
  }
@@ -276,7 +276,7 @@
276
276
  transform: translateX(-100%);
277
277
  /* closed drawer: invisible AND out of the tab order */
278
278
  visibility: hidden;
279
- box-shadow: 0 8px 30px rgb(0 0 0 / .35);
279
+ box-shadow: 0 8px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
280
280
  }
281
281
  .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon)[data-open] .pk-sidebar { transform: none; visibility: visible; }
282
282
  .pk-sidebar-wrapper:is(.collapsible-offcanvas, .collapsible-icon)[data-open] .pk-sidebar-scrim {
@@ -284,7 +284,7 @@
284
284
  position: fixed;
285
285
  inset: 0;
286
286
  z-index: 49;
287
- background: rgb(0 0 0 / .5);
287
+ background: color-mix(in srgb, var(--pk-overlay) 50%, transparent);
288
288
  border: 0;
289
289
  padding: 0;
290
290
  cursor: default;
@@ -9,23 +9,54 @@ import { Controller } from "@hotwired/stimulus"
9
9
  // media query. The desktop state persists in a `pk_sidebar_state` cookie so a
10
10
  // host layout can render the collapsed rail server-side (no flash) via
11
11
  // SidebarWrapper's `default_collapsed:`.
12
+ //
13
+ // A11y: connect() wires aria-controls on every trigger/rail that didn't get one
14
+ // server-side (generating an id on the .pk-sidebar if needed) and keeps their
15
+ // aria-expanded in sync on every toggle. The mobile overlay drawer is a real
16
+ // modal: opening moves focus into it and inerts the rest of the wrapper + page
17
+ // (the scrim stays live — it IS the close control), closing (scrim, Escape,
18
+ // trigger, turbo:before-cache) restores inert state and returns focus to the
19
+ // opener.
20
+ const FOCUSABLE =
21
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
22
+
12
23
  export default class extends Controller {
13
24
  static MOBILE = "(max-width: 767px)"
14
25
  static COOKIE = "pk_sidebar_state"
15
26
 
27
+ connect() {
28
+ this.#wireAriaControls()
29
+ this.#syncExpanded()
30
+ }
31
+
32
+ disconnect() {
33
+ this.#restoreInert()
34
+ }
35
+
16
36
  toggle(event) {
17
37
  if (event?.type === "keydown" && !(event.metaKey || event.ctrlKey)) return
18
38
 
19
39
  if (this.mobile) {
20
- this.element.toggleAttribute("data-open")
40
+ if (this.element.hasAttribute("data-open")) {
41
+ this.closeMobile()
42
+ } else {
43
+ this.element.setAttribute("data-open", "")
44
+ this.#trapMobile(event)
45
+ }
21
46
  } else {
22
47
  this.element.toggleAttribute("data-collapsed")
23
48
  this.persist()
24
49
  }
50
+ this.#syncExpanded()
25
51
  }
26
52
 
27
53
  closeMobile() {
54
+ if (!this.element.hasAttribute("data-open")) return
28
55
  this.element.removeAttribute("data-open")
56
+ this.#restoreInert()
57
+ if (this.opener?.isConnected) this.opener.focus()
58
+ this.opener = null
59
+ this.#syncExpanded()
29
60
  }
30
61
 
31
62
  persist() {
@@ -36,4 +67,70 @@ export default class extends Controller {
36
67
  get mobile() {
37
68
  return window.matchMedia(this.constructor.MOBILE).matches
38
69
  }
70
+
71
+ get sidebar() {
72
+ return this.element.querySelector(".pk-sidebar")
73
+ }
74
+
75
+ get togglers() {
76
+ return this.element.querySelectorAll(".pk-sidebar-trigger, .pk-sidebar-rail")
77
+ }
78
+
79
+ // Open mobile drawer: remember the opener, inert everything that isn't the
80
+ // sidebar or its scrim, and move focus into the drawer.
81
+ #trapMobile(event) {
82
+ this.opener = event?.currentTarget instanceof HTMLElement ? event.currentTarget : document.activeElement
83
+ this.#inertOthers()
84
+ const sidebar = this.sidebar
85
+ if (!sidebar) return
86
+ const focusable = sidebar.querySelector(FOCUSABLE)
87
+ ;(focusable || sidebar).focus()
88
+ }
89
+
90
+ // Inert the page behind the scrim: the wrapper's siblings in <body>, plus
91
+ // wrapper children other than the sidebar and the scrim (typically the
92
+ // SidebarInset). Prior inert state is saved per element and restored on
93
+ // close/disconnect (turbo:before-cache routes through closeMobile).
94
+ #inertOthers() {
95
+ this.inerted = []
96
+ const skip = ["SCRIPT", "STYLE", "LINK", "TEMPLATE"]
97
+ const mark = (el) => {
98
+ if (skip.includes(el.tagName)) return
99
+ this.inerted.push([el, el.inert])
100
+ el.inert = true
101
+ }
102
+ for (const el of document.body.children) {
103
+ if (el === this.element || el.contains(this.element)) continue
104
+ mark(el)
105
+ }
106
+ for (const el of this.element.children) {
107
+ if (el.matches(".pk-sidebar, .pk-sidebar-scrim")) continue
108
+ mark(el)
109
+ }
110
+ }
111
+
112
+ #restoreInert() {
113
+ for (const [el, wasInert] of this.inerted || []) el.inert = wasInert
114
+ this.inerted = null
115
+ }
116
+
117
+ // aria-controls: point every trigger/rail at the sidebar's id (server-side
118
+ // `controls:` wins; an id is generated when the sidebar has none).
119
+ #wireAriaControls() {
120
+ const sidebar = this.sidebar
121
+ if (!sidebar) return
122
+ if (!sidebar.id) sidebar.id = `pk-sidebar-${Math.random().toString(36).slice(2, 10)}`
123
+ for (const el of this.togglers) {
124
+ if (!el.getAttribute("aria-controls")) el.setAttribute("aria-controls", sidebar.id)
125
+ }
126
+ }
127
+
128
+ // aria-expanded mirrors the surface the viewport is actually showing:
129
+ // the mobile drawer's data-open below the cutoff, data-collapsed above it.
130
+ #syncExpanded() {
131
+ const expanded = this.mobile
132
+ ? this.element.hasAttribute("data-open")
133
+ : !this.element.hasAttribute("data-collapsed")
134
+ for (const el of this.togglers) el.setAttribute("aria-expanded", String(expanded))
135
+ }
39
136
  }
@@ -2,9 +2,14 @@ module PhlexKit
2
2
  # The interactive element of a SidebarMenuItem. `as:` is :button (default) or :a
3
3
  # (pass `href:`); `active: true` marks the current page (drives the highlight via
4
4
  # data-active — a named kwarg, not an attr, since `mix` would merge a repeated
5
- # attribute rather than override). `tooltip:` labels the button while an
6
- # icon-collapsed rail hides its text (pure-CSS hover bubble). Attrs pass
7
- # through via mix. See sidebar.rb.
5
+ # attribute rather than override; an active link also gets aria-current="page").
6
+ # `tooltip:` labels the button while an icon-collapsed rail hides its text
7
+ # (pure-CSS hover bubble) and doubles as the aria-label so the button keeps an
8
+ # accessible name once the text is hidden. Bare block text is wrapped in a
9
+ # <span> automatically so the collapsed rail's `> :not(svg)` rule can hide it;
10
+ # blocks that emit their own markup (icon + <span>Label</span>) pass through
11
+ # untouched — keep the label in a real element there. Attrs pass through via
12
+ # mix. See sidebar.rb.
8
13
  class SidebarMenuButton < BaseComponent
9
14
  def initialize(as: :button, active: false, tooltip: nil, **attrs)
10
15
  @as = as
@@ -15,8 +20,22 @@ module PhlexKit
15
20
 
16
21
  def view_template(&block)
17
22
  base = { class: "pk-sidebar-menu-button", "data-active": (@active ? "true" : "false") }
18
- base["data-tooltip"] = @tooltip if @tooltip
19
- send(@as, **mix(base, @attrs), &block)
23
+ base["aria-current"] = "page" if @active && @as == :a
24
+ if @tooltip
25
+ base["data-tooltip"] = @tooltip
26
+ base["aria-label"] = @tooltip
27
+ end
28
+ send(@as, **mix(base, @attrs)) do
29
+ # capture renders the block once; pure text (no markup) gets the span
30
+ # wrapper the icon-collapsed CSS needs. The captured output is
31
+ # already-escaped HTML, so re-emit it via raw(safe(...)).
32
+ content = capture(&block)
33
+ if content.include?("<") || content.empty?
34
+ raw safe(content)
35
+ else
36
+ span { raw safe(content) }
37
+ end
38
+ end
20
39
  end
21
40
  end
22
41
  end
@@ -1,21 +1,30 @@
1
1
  module PhlexKit
2
2
  # Invisible grab strip along the sidebar's trailing edge — clicking it
3
3
  # toggles the collapse, like shadcn/ui's SidebarRail. Place it as the last
4
- # child of a Sidebar inside a collapsible wrapper. See sidebar.rb.
4
+ # child of a Sidebar inside a collapsible wrapper. `expanded:` renders the
5
+ # initial aria-expanded (pass false when the wrapper starts
6
+ # default_collapsed); the phlex-kit--sidebar controller keeps it in sync on
7
+ # toggle. `controls:` renders aria-controls pointing at the Sidebar's id —
8
+ # when omitted, the controller wires it on connect. See sidebar.rb.
5
9
  class SidebarRail < BaseComponent
6
- def initialize(**attrs)
10
+ def initialize(expanded: true, controls: nil, **attrs)
11
+ @expanded = expanded
12
+ @controls = controls
7
13
  @attrs = attrs
8
14
  end
9
15
 
10
16
  def view_template
11
- button(**mix({
17
+ base = {
12
18
  type: :button,
13
19
  class: "pk-sidebar-rail",
14
20
  aria_label: "Toggle sidebar",
21
+ "aria-expanded": @expanded ? "true" : "false",
15
22
  tabindex: "-1",
16
23
  title: "Toggle sidebar",
17
24
  data: { action: "click->phlex-kit--sidebar#toggle" }
18
- }, @attrs))
25
+ }
26
+ base["aria-controls"] = @controls if @controls
27
+ button(**mix(base, @attrs))
19
28
  end
20
29
  end
21
30
  end
@@ -3,19 +3,29 @@ module PhlexKit
3
3
  # SidebarTrigger. Place it anywhere inside a
4
4
  # SidebarWrapper(collapsible: :offcanvas) — typically at the top of the
5
5
  # SidebarInset. Renders the :menu icon unless a block supplies its own
6
- # content. Hidden by CSS unless the wrapper is offcanvas. See sidebar.rb.
6
+ # content. Hidden by CSS unless the wrapper is offcanvas. `expanded:` renders
7
+ # the initial aria-expanded (pass false when the wrapper starts
8
+ # default_collapsed); the phlex-kit--sidebar controller keeps it in sync on
9
+ # toggle. `controls:` renders aria-controls pointing at the Sidebar's id —
10
+ # when omitted, the controller wires it on connect (generating an id on the
11
+ # sidebar if needed). See sidebar.rb.
7
12
  class SidebarTrigger < BaseComponent
8
- def initialize(**attrs)
13
+ def initialize(expanded: true, controls: nil, **attrs)
14
+ @expanded = expanded
15
+ @controls = controls
9
16
  @attrs = attrs
10
17
  end
11
18
 
12
19
  def view_template(&block)
13
- button(**mix({
20
+ base = {
14
21
  type: :button,
15
22
  class: "pk-sidebar-trigger",
16
23
  aria_label: "Toggle sidebar",
24
+ "aria-expanded": @expanded ? "true" : "false",
17
25
  data: { action: "click->phlex-kit--sidebar#toggle" }
18
- }, @attrs)) do
26
+ }
27
+ base["aria-controls"] = @controls if @controls
28
+ button(**mix(base, @attrs)) do
19
29
  block ? yield : render(Icon.new(:menu, size: nil))
20
30
  end
21
31
  end
@@ -26,7 +26,7 @@
26
26
  border-radius: 9999px;
27
27
  border: 1px solid var(--pk-ring);
28
28
  background: var(--pk-bg);
29
- box-shadow: 0 1px 2px rgb(0 0 0 / .25);
29
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--pk-shadow-color) 25%, transparent);
30
30
  transition: box-shadow .15s ease;
31
31
  }
32
32
  .pk-slider:hover::-webkit-slider-thumb,
@@ -20,7 +20,9 @@ module PhlexKit
20
20
  step: @step,
21
21
  value: @value,
22
22
  class: "pk-slider",
23
- style: "--pk-slider-progress: #{progress}%",
23
+ # Trailing ";" — mix joins a caller style: with a space; without it
24
+ # both declarations fuse into one invalid declaration.
25
+ style: "--pk-slider-progress: #{progress}%;",
24
26
  data: {
25
27
  controller: "phlex-kit--slider",
26
28
  action: "input->phlex-kit--slider#update"
@@ -16,7 +16,12 @@ module PhlexKit
16
16
 
17
17
  def view_template
18
18
  label(**mix({ class: [ "pk-switch", fetch_option(SIZES, @size, :size) ].compact.join(" ") }, @wrapper)) do
19
- input(type: "hidden", name: @attrs[:name], value: @unchecked_value) if @include_hidden && @attrs[:name]
19
+ if @include_hidden && @attrs[:name]
20
+ # Disabled in lockstep with the checkbox (Rails' check_box idiom) —
21
+ # a disabled switch must not still post its unchecked value.
22
+ input(type: "hidden", name: @attrs[:name], value: @unchecked_value,
23
+ disabled: @attrs[:disabled] ? true : nil)
24
+ end
20
25
  # role="switch" belongs on the focusable control: native checkedness then
21
26
  # maps to aria-checked for AT, and the label stays a plain label.
22
27
  input(**mix({ class: "pk-switch-input", role: "switch" }, @attrs).merge(type: "checkbox", value: @checked_value))
@@ -16,13 +16,15 @@
16
16
  .pk-table-head {
17
17
  height: 2.5rem;
18
18
  padding: 0 .5rem;
19
- text-align: left;
19
+ text-align: start;
20
20
  vertical-align: middle;
21
21
  font-weight: 500;
22
22
  white-space: nowrap;
23
23
  color: var(--pk-text);
24
24
  }
25
- .pk-table-head:has([role="checkbox"]),
26
- .pk-table-cell:has([role="checkbox"]) { padding-right: 0; }
25
+ /* Native inputs (the kit Checkbox) have no role="checkbox" — match both;
26
+ the [role] arm covers custom/ARIA checkboxes. */
27
+ .pk-table-head:has(input[type="checkbox"], [role="checkbox"]),
28
+ .pk-table-cell:has(input[type="checkbox"], [role="checkbox"]) { padding-inline-end: 0; }
27
29
  .pk-table-cell { padding: .5rem; vertical-align: middle; white-space: nowrap; }
28
30
  .pk-table-caption { margin-top: 1rem; font-size: .875rem; color: var(--pk-muted); }
@@ -22,8 +22,8 @@
22
22
  .pk-tabs-trigger:hover { color: var(--pk-text); }
23
23
  .pk-tabs-trigger svg { pointer-events: none; flex-shrink: 0; }
24
24
  .pk-tabs-trigger svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
25
- .pk-tabs-trigger:has(> [data-icon="inline-start"]) { padding-left: .25rem; }
26
- .pk-tabs-trigger:has(> [data-icon="inline-end"]) { padding-right: .25rem; }
25
+ .pk-tabs-trigger:has(> [data-icon="inline-start"]) { padding-inline-start: .25rem; }
26
+ .pk-tabs-trigger:has(> [data-icon="inline-end"]) { padding-inline-end: .25rem; }
27
27
  .pk-tabs-trigger:focus-visible {
28
28
  outline: none;
29
29
  border-color: var(--pk-ring);
@@ -33,7 +33,7 @@
33
33
  background: color-mix(in oklab, var(--pk-input) 30%, transparent);
34
34
  border-color: var(--pk-input);
35
35
  color: var(--pk-text);
36
- box-shadow: 0 1px 2px rgb(0 0 0 / .1);
36
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--pk-shadow-color) 10%, transparent);
37
37
  }
38
38
  .pk-tabs-trigger:disabled { pointer-events: none; opacity: .5; }
39
39
 
@@ -57,7 +57,7 @@
57
57
  .pk-tabs.vertical .pk-tabs-trigger::after {
58
58
  inset-inline: auto;
59
59
  inset-block: 0;
60
- right: -.25rem;
60
+ inset-inline-end: -.25rem;
61
61
  width: 2px;
62
62
  height: auto;
63
63
  }
@@ -1,14 +1,17 @@
1
1
  module PhlexKit
2
2
  class TabsContent < BaseComponent
3
- def initialize(value:, **attrs)
3
+ def initialize(value:, active: false, **attrs)
4
4
  @value = value
5
+ @active = active
5
6
  @attrs = attrs
6
7
  end
7
8
  def view_template(&)
8
9
  # id / aria-labelledby mirror TabsTrigger's deterministic ids (re-scoped
9
10
  # per instance by the controller); tabindex="0" makes the panel itself
10
- # focusable, per the APG tabs pattern.
11
- div(**mix({ class: "pk-tabs-content pk-hidden", role: "tabpanel", id: "pk-tabs-panel-#{@value}", aria_labelledby: "pk-tabs-trigger-#{@value}", tabindex: "0", data: { phlex_kit__tabs_target: "content", value: @value } }, @attrs), &)
11
+ # focusable, per the APG tabs pattern. `active:` (mirroring TabsTrigger's)
12
+ # renders the panel visible pre-JS/no-JS; the controller's sync()
13
+ # reconciles visibility from the active value after connect.
14
+ div(**mix({ class: @active ? "pk-tabs-content" : "pk-tabs-content pk-hidden", role: "tabpanel", id: "pk-tabs-panel-#{@value}", aria_labelledby: "pk-tabs-trigger-#{@value}", tabindex: "0", data: { phlex_kit__tabs_target: "content", value: @value } }, @attrs), &)
12
15
  end
13
16
  end
14
17
  end
@@ -33,7 +33,9 @@ export default class extends Controller {
33
33
  const index = triggers.indexOf(document.activeElement)
34
34
  let target
35
35
  if (nextKeys.includes(event.key)) target = triggers[(index + 1) % triggers.length]
36
- else if (prevKeys.includes(event.key)) target = triggers[(index - 1 + triggers.length) % triggers.length]
36
+ // index is -1 when focus isn't on a trigger treat prev as "last"
37
+ // (the modulo would land on n-2 otherwise).
38
+ else if (prevKeys.includes(event.key)) target = triggers[index === -1 ? triggers.length - 1 : (index - 1 + triggers.length) % triggers.length]
37
39
  else if (event.key === "Home") target = triggers[0]
38
40
  else if (event.key === "End") target = triggers[triggers.length - 1]
39
41
  if (!target) return
@@ -10,8 +10,8 @@ module PhlexKit
10
10
  # FOUC, inline this in <head> before any stylesheets:
11
11
  #
12
12
  # <script>
13
- # const t = localStorage.theme;
14
- # if (t === "dark" || t === "light") document.documentElement.dataset.theme = t;
13
+ # let t; try { t = localStorage.theme } catch {}
14
+ # document.documentElement.dataset.theme = (t === "dark" || t === "light") ? t : "system";
15
15
  # </script>
16
16
  class ThemeToggle < BaseComponent
17
17
  def initialize(wrapper: {}, aria: {}, **attrs)