phlex_kit 0.8.0 → 0.9.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 (148) 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_content.rb +11 -2
  5. data/app/components/phlex_kit/accordion/accordion_controller.js +23 -4
  6. data/app/components/phlex_kit/alert/alert.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +11 -4
  9. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +123 -6
  10. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +10 -4
  11. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  12. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  13. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  14. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  15. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  16. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  17. data/app/components/phlex_kit/badge/badge.css +4 -4
  18. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  19. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  20. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  21. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  22. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  23. data/app/components/phlex_kit/button/button.css +6 -6
  24. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  25. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  26. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  27. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  28. data/app/components/phlex_kit/calendar/calendar_body.rb +12 -1
  29. data/app/components/phlex_kit/calendar/calendar_controller.js +151 -12
  30. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  31. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  32. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  34. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  35. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  36. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  37. data/app/components/phlex_kit/chart/chart_controller.js +37 -10
  38. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  39. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  40. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  41. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
  42. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  43. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  44. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  45. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  46. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  47. data/app/components/phlex_kit/combobox/combobox_controller.js +67 -11
  48. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  49. data/app/components/phlex_kit/command/command.css +4 -4
  50. data/app/components/phlex_kit/command/command_controller.js +51 -10
  51. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  52. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  53. data/app/components/phlex_kit/command/command_input.rb +6 -2
  54. data/app/components/phlex_kit/command/command_item.rb +4 -1
  55. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  56. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
  57. data/app/components/phlex_kit/context_menu/context_menu_controller.js +69 -2
  58. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
  59. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
  60. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  61. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  62. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  63. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  64. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  65. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  66. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  67. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  68. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  69. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +6 -5
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  72. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
  73. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
  74. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +99 -45
  75. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  76. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
  77. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
  78. data/app/components/phlex_kit/field/field.css +1 -1
  79. data/app/components/phlex_kit/field/field_error.rb +5 -6
  80. data/app/components/phlex_kit/form_field/form_field_controller.js +44 -12
  81. data/app/components/phlex_kit/form_field/form_field_error.rb +8 -1
  82. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  83. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  84. data/app/components/phlex_kit/input/input.css +1 -1
  85. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  86. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  87. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  88. data/app/components/phlex_kit/input_otp/input_otp_controller.js +43 -4
  89. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  90. data/app/components/phlex_kit/item/item.css +1 -1
  91. data/app/components/phlex_kit/item/item_group.rb +4 -2
  92. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  93. data/app/components/phlex_kit/marker/marker.css +1 -1
  94. data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
  95. data/app/components/phlex_kit/masked_input/masked_input_controller.js +34 -9
  96. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  97. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  98. data/app/components/phlex_kit/menubar/menubar_controller.js +119 -12
  99. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  100. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  101. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +7 -2
  102. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  103. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  104. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  105. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +11 -3
  106. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  107. data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
  108. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  109. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  110. data/app/components/phlex_kit/popover/popover.css +1 -1
  111. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  112. data/app/components/phlex_kit/resizable/resizable_controller.js +78 -3
  113. data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
  114. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  115. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  116. data/app/components/phlex_kit/select/select.css +3 -3
  117. data/app/components/phlex_kit/select/select.rb +11 -7
  118. data/app/components/phlex_kit/select/select_controller.js +35 -4
  119. data/app/components/phlex_kit/select/select_item.rb +5 -2
  120. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  121. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  122. data/app/components/phlex_kit/sidebar/sidebar.css +27 -15
  123. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  124. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  125. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  126. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  127. data/app/components/phlex_kit/slider/slider.css +3 -3
  128. data/app/components/phlex_kit/slider/slider.rb +3 -1
  129. data/app/components/phlex_kit/switch/switch.rb +6 -1
  130. data/app/components/phlex_kit/table/table.css +5 -3
  131. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  132. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  133. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  134. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
  135. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +49 -7
  136. data/app/components/phlex_kit/toast/toast.css +32 -14
  137. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  138. data/app/components/phlex_kit/toast/toast_region.rb +19 -5
  139. data/app/components/phlex_kit/toast/toaster_controller.js +20 -6
  140. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  141. data/app/components/phlex_kit/toggle/toggle.rb +9 -3
  142. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  143. data/app/components/phlex_kit/typography/heading.rb +9 -3
  144. data/app/components/phlex_kit/typography/typography.css +4 -3
  145. data/app/javascript/phlex_kit/controllers/index.js +0 -2
  146. data/lib/phlex_kit/version.rb +1 -1
  147. metadata +1 -2
  148. data/app/components/phlex_kit/select/select_item_controller.js +0 -14
@@ -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
@@ -25,8 +25,8 @@
25
25
  margin-top: -0.25rem;
26
26
  border-radius: 9999px;
27
27
  border: 1px solid var(--pk-ring);
28
- background: #fff;
29
- box-shadow: 0 1px 2px rgb(0 0 0 / .25);
28
+ background: var(--pk-bg);
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,
@@ -51,7 +51,7 @@
51
51
  height: .75rem;
52
52
  border-radius: 9999px;
53
53
  border: 1px solid var(--pk-ring);
54
- background: #fff;
54
+ background: var(--pk-bg);
55
55
  }
56
56
  .pk-slider:hover::-moz-range-thumb,
57
57
  .pk-slider:active::-moz-range-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
@@ -2,6 +2,17 @@ module PhlexKit
2
2
  # Light/dark theme switch built on Toggle. Ported from ruby_ui's RubyUI::
3
3
  # ThemeToggle. The sibling phlex-kit--theme-toggle controller listens for the
4
4
  # toggle's change event and flips :root[data-theme] (matching the token system).
5
+ # Only an actual user toggle is persisted to localStorage — with no stored
6
+ # choice the OS preference is followed live on every load.
7
+ #
8
+ # The controller applies the stored theme at Stimulus connect, which is late
9
+ # enough to flash light-mode for returning dark-theme users. To avoid the
10
+ # FOUC, inline this in <head> before any stylesheets:
11
+ #
12
+ # <script>
13
+ # let t; try { t = localStorage.theme } catch {}
14
+ # document.documentElement.dataset.theme = (t === "dark" || t === "light") ? t : "system";
15
+ # </script>
5
16
  class ThemeToggle < BaseComponent
6
17
  def initialize(wrapper: {}, aria: {}, **attrs)
7
18
  @wrapper = wrapper
@@ -3,20 +3,62 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="phlex-kit--theme-toggle". Sits on the same wrapper
4
4
  // as phlex-kit--toggle; pressed = dark. Flips :root[data-theme] to match the
5
5
  // PhlexKit token system (not .dark/.light classes). Adapted from ruby_ui.
6
+ //
7
+ // With no stored choice the root is stamped data-theme="system" — _tokens.css
8
+ // resolves that via prefers-color-scheme, and the matchMedia listener below
9
+ // keeps the toggle's pressed state live when the OS preference flips. Only an
10
+ // explicit user toggle persists "dark"/"light". localStorage access is
11
+ // try/catch-wrapped (storage-blocked contexts): the toggle then still works,
12
+ // just non-persistently, via the in-memory fallback.
6
13
  export default class extends Controller {
7
- connect() { this.applyTheme(this.currentTheme()) }
14
+ connect() {
15
+ this._media = window.matchMedia("(prefers-color-scheme: dark)")
16
+ this._onMediaChange = () => {
17
+ // Only re-apply while the user has no explicit choice.
18
+ if (!this.storedTheme()) this.applyTheme("system")
19
+ }
20
+ this._media.addEventListener("change", this._onMediaChange)
21
+ this.applyTheme(this.storedTheme() || "system")
22
+ }
23
+
24
+ disconnect() {
25
+ this._media?.removeEventListener("change", this._onMediaChange)
26
+ }
27
+
8
28
  apply(e) {
9
29
  const theme = e.detail?.pressed ? "dark" : "light"
10
- localStorage.theme = theme
30
+ // connect()'s own pressed-state sync re-enters here via the toggle's
31
+ // change event (Stimulus value observation is async, so a flag can't
32
+ // gate it). If the incoming theme already matches the resolved one this
33
+ // is that echo, not a user toggle — persisting it would silently pin
34
+ // the visitor's OS preference in localStorage on first visit.
35
+ if (theme === this.resolvedTheme()) return
36
+ this.storeTheme(theme)
11
37
  this.applyTheme(theme)
12
38
  }
13
- currentTheme() {
14
- if (localStorage.theme === "dark") return "dark"
15
- if (localStorage.theme === "light") return "light"
16
- return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
39
+
40
+ // The user's explicit choice, if any ("dark"/"light"), else null.
41
+ storedTheme() {
42
+ try {
43
+ const t = localStorage.theme
44
+ if (t === "dark" || t === "light") return t
45
+ } catch {}
46
+ return this._fallbackTheme || null
47
+ }
48
+
49
+ storeTheme(theme) {
50
+ this._fallbackTheme = theme
51
+ try { localStorage.theme = theme } catch {}
17
52
  }
53
+
54
+ // The theme actually in effect right now: explicit choice, else OS.
55
+ resolvedTheme() {
56
+ return this.storedTheme() || (this._media.matches ? "dark" : "light")
57
+ }
58
+
18
59
  applyTheme(theme) {
19
60
  document.documentElement.setAttribute("data-theme", theme)
20
- this.element.setAttribute("data-phlex-kit--toggle-pressed-value", theme === "dark" ? "true" : "false")
61
+ const dark = theme === "system" ? this._media.matches : theme === "dark"
62
+ this.element.setAttribute("data-phlex-kit--toggle-pressed-value", dark ? "true" : "false")
21
63
  }
22
64
  }
@@ -45,7 +45,7 @@
45
45
  color: var(--pk-text);
46
46
  padding: 1rem;
47
47
  font-size: 13px;
48
- box-shadow: 0 4px 12px rgba(0, 0, 0, .1);
48
+ box-shadow: 0 4px 12px color-mix(in srgb, var(--pk-shadow-color) 10%, transparent);
49
49
  transition: transform .3s ease-out, opacity .3s ease-out;
50
50
  will-change: transform;
51
51
  opacity: var(--opacity, 1);
@@ -53,9 +53,27 @@
53
53
  .pk-toast[data-state="pending"], .pk-toast[data-state="closing"] { opacity: 0; }
54
54
  .pk-toast[data-swipe="move"] { transition: none; }
55
55
 
56
+ /* Swipe-out: fly along the direction the controller stamps into
57
+ --swipe-end-x/y (implicit `from` = the inline transform, which still
58
+ carries the drag offset via --pk-toast-swipe-x/y). forwards keeps the
59
+ end pose until the 200ms unmount removes the node. */
60
+ .pk-toast[data-swipe="end"] {
61
+ animation: pk-toast-swipe-out .2s ease-out forwards;
62
+ }
63
+ @keyframes pk-toast-swipe-out {
64
+ to {
65
+ transform: translate3d(var(--swipe-end-x, 0px), calc(var(--y-offset, 0px) + var(--swipe-end-y, 0px)), 0);
66
+ opacity: 0;
67
+ }
68
+ }
69
+ @media (prefers-reduced-motion: reduce) {
70
+ /* No fly-out; [data-state="closing"] still fades it. */
71
+ .pk-toast[data-swipe="end"] { animation: none; }
72
+ }
73
+
56
74
  /* Room for the corner close button (region-wide, or per-spawned-toast). */
57
75
  .pk-toast-region[data-close-button="true"] .pk-toast,
58
- .pk-toast.pk-toast-with-close { padding-right: 2.5rem; }
76
+ .pk-toast.pk-toast-with-close { padding-inline-end: 2.5rem; }
59
77
 
60
78
  .pk-toast-icon {
61
79
  position: relative;
@@ -65,11 +83,11 @@
65
83
  justify-content: flex-start;
66
84
  width: 1rem;
67
85
  height: 1rem;
68
- margin-left: -3px;
69
- margin-right: .25rem;
86
+ margin-inline-start: -3px;
87
+ margin-inline-end: .25rem;
70
88
  color: var(--pk-text);
71
89
  }
72
- .pk-toast-icon-svg { width: 1rem; height: 1rem; margin-left: -1px; }
90
+ .pk-toast-icon-svg { width: 1rem; height: 1rem; margin-inline-start: -1px; }
73
91
  .pk-toast-icon-svg.spin { animation: pk-toast-spin 1s linear infinite; }
74
92
  @keyframes pk-toast-spin { to { transform: rotate(360deg); } }
75
93
 
@@ -93,7 +111,7 @@
93
111
  justify-content: center;
94
112
  flex: none;
95
113
  height: 1.5rem;
96
- margin-left: auto;
114
+ margin-inline-start: auto;
97
115
  border: 0;
98
116
  border-radius: calc(var(--pk-radius) - 4px);
99
117
  padding: 0 .5rem;
@@ -106,9 +124,9 @@
106
124
  transition: opacity .15s ease;
107
125
  }
108
126
  .pk-toast-action:hover { opacity: .9; }
109
- .pk-toast-action:focus {
127
+ .pk-toast-action:focus-visible {
110
128
  outline: none;
111
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
129
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
112
130
  }
113
131
  .pk-toast-action:disabled { pointer-events: none; opacity: .5; }
114
132
 
@@ -119,7 +137,7 @@
119
137
  justify-content: center;
120
138
  flex: none;
121
139
  height: 1.5rem;
122
- margin-left: auto;
140
+ margin-inline-start: auto;
123
141
  border: 0;
124
142
  border-radius: calc(var(--pk-radius) - 4px);
125
143
  padding: 0 .5rem;
@@ -132,15 +150,15 @@
132
150
  transition: background-color .15s ease;
133
151
  }
134
152
  .pk-toast-cancel:hover { background: color-mix(in oklab, var(--pk-text) 15%, transparent); }
135
- .pk-toast-cancel:focus {
153
+ .pk-toast-cancel:focus-visible {
136
154
  outline: none;
137
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
155
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
138
156
  }
139
157
 
140
158
  /* Corner × close. */
141
159
  .pk-toast-close {
142
160
  position: absolute;
143
- right: .5rem;
161
+ inset-inline-end: .5rem;
144
162
  top: .5rem;
145
163
  display: flex;
146
164
  align-items: center;
@@ -156,8 +174,8 @@
156
174
  transition: background-color .15s ease, color .15s ease;
157
175
  }
158
176
  .pk-toast-close:hover { background: var(--pk-accent); color: var(--pk-text); }
159
- .pk-toast-close:focus {
177
+ .pk-toast-close:focus-visible {
160
178
  outline: none;
161
- box-shadow: 0 0 0 1px color-mix(in oklab, var(--pk-ring) 50%, transparent);
179
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
162
180
  }
163
181
  .pk-toast-close svg { width: .875rem; height: .875rem; }
@@ -50,7 +50,17 @@ export default class extends Controller {
50
50
 
51
51
  requestAnimationFrame(() => {
52
52
  this.element.dataset.state = "open"
53
- this._start()
53
+ // Spawned under an already-hovered stack (the toaster stamps
54
+ // data-pk-toasts-paused while dispatching pause): arm as paused so the
55
+ // region's resume event starts the timer, instead of ticking away
56
+ // under the user's cursor.
57
+ if (Number.isFinite(this.durationValue) && this.durationValue > 0 &&
58
+ this.element.closest("[data-pk-toasts-paused]")) {
59
+ this._paused = true
60
+ this._remaining = this.durationValue
61
+ } else {
62
+ this._start()
63
+ }
54
64
  })
55
65
  }
56
66
 
@@ -142,7 +152,11 @@ export default class extends Controller {
142
152
  const dx = e.clientX - this._swipe.x
143
153
  const dy = e.clientY - this._swipe.y
144
154
  this.element.dataset.swipe = "move"
145
- this.element.style.transform = `translate(${dx}px, ${dy}px)`
155
+ // Never write style.transform here: the toaster owns it (stacking
156
+ // translate3d + scale, written to compose these vars). Writing it
157
+ // directly snapped non-front toasts out of position.
158
+ this.element.style.setProperty("--pk-toast-swipe-x", `${dx}px`)
159
+ this.element.style.setProperty("--pk-toast-swipe-y", `${dy}px`)
146
160
  }
147
161
 
148
162
  _onPointerUp(e) {
@@ -156,14 +170,20 @@ export default class extends Controller {
156
170
  this.element.removeEventListener("pointercancel", this._onPointerUp)
157
171
  this._swipe.active = false
158
172
  if (dist > SWIPE_THRESHOLD || velocity > 0.5) {
173
+ // Keep the swipe vars in place: they are the animation's start point.
174
+ // toast.css's [data-swipe="end"] keyframes fly the toast out along
175
+ // --swipe-end-x/y while _close fades + removes it.
159
176
  this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`)
160
177
  this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`)
161
178
  this.element.dataset.swipe = "end"
162
- this.element.style.transform = ""
163
179
  this._close("dismiss")
164
180
  } else {
181
+ // Clearing the vars composes the toast back into the toaster's stacking
182
+ // transform; the transition (re-enabled once data-swipe leaves "move")
183
+ // animates it home.
165
184
  this.element.dataset.swipe = "cancel"
166
- this.element.style.transform = ""
185
+ this.element.style.removeProperty("--pk-toast-swipe-x")
186
+ this.element.style.removeProperty("--pk-toast-swipe-y")
167
187
  this._resume()
168
188
  }
169
189
  }