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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/phlex_kit/_tokens.css +11 -0
- data/app/components/phlex_kit/accordion/accordion.css +2 -2
- data/app/components/phlex_kit/accordion/accordion_content.rb +11 -2
- data/app/components/phlex_kit/accordion/accordion_controller.js +23 -4
- data/app/components/phlex_kit/alert/alert.css +3 -3
- data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +11 -4
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +123 -6
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +10 -4
- data/app/components/phlex_kit/attachment/attachment.css +1 -1
- data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
- data/app/components/phlex_kit/avatar/avatar.css +16 -5
- data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
- data/app/components/phlex_kit/badge/badge.css +4 -4
- data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
- data/app/components/phlex_kit/bubble/bubble.css +3 -3
- data/app/components/phlex_kit/bubble/bubble.rb +2 -0
- data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
- data/app/components/phlex_kit/button/button.css +6 -6
- data/app/components/phlex_kit/button_group/button_group.css +2 -2
- data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
- data/app/components/phlex_kit/calendar/calendar.css +2 -2
- data/app/components/phlex_kit/calendar/calendar.rb +10 -3
- data/app/components/phlex_kit/calendar/calendar_body.rb +12 -1
- data/app/components/phlex_kit/calendar/calendar_controller.js +151 -12
- data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
- data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
- data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
- data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
- data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
- data/app/components/phlex_kit/carousel/carousel.rb +11 -1
- data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
- data/app/components/phlex_kit/chart/chart_controller.js +37 -10
- data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
- data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
- data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
- data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
- data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
- data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
- data/app/components/phlex_kit/combobox/combobox.css +10 -8
- data/app/components/phlex_kit/combobox/combobox_controller.js +67 -11
- data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
- data/app/components/phlex_kit/command/command.css +4 -4
- data/app/components/phlex_kit/command/command_controller.js +51 -10
- data/app/components/phlex_kit/command/command_dialog.rb +5 -6
- data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
- data/app/components/phlex_kit/command/command_input.rb +6 -2
- data/app/components/phlex_kit/command/command_item.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
- data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +69 -2
- data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
- data/app/components/phlex_kit/data_table/data_table.css +1 -1
- data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
- data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
- data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
- data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
- data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
- data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
- data/app/components/phlex_kit/dialog/dialog.css +4 -4
- data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
- data/app/components/phlex_kit/drawer/drawer.css +3 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +6 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +99 -45
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
- data/app/components/phlex_kit/field/field.css +1 -1
- data/app/components/phlex_kit/field/field_error.rb +5 -6
- data/app/components/phlex_kit/form_field/form_field_controller.js +44 -12
- data/app/components/phlex_kit/form_field/form_field_error.rb +8 -1
- data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
- data/app/components/phlex_kit/input/input.css +1 -1
- data/app/components/phlex_kit/input_group/input_group.css +9 -7
- data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
- data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +43 -4
- data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
- data/app/components/phlex_kit/item/item.css +1 -1
- data/app/components/phlex_kit/item/item_group.rb +4 -2
- data/app/components/phlex_kit/item/item_separator.rb +3 -1
- data/app/components/phlex_kit/marker/marker.css +1 -1
- data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +34 -9
- data/app/components/phlex_kit/menubar/menubar.css +10 -9
- data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
- data/app/components/phlex_kit/menubar/menubar_controller.js +119 -12
- data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
- data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +7 -2
- data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
- data/app/components/phlex_kit/native_select/native_select.css +1 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu.css +11 -3
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
- data/app/components/phlex_kit/pagination/pagination.css +2 -2
- data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
- data/app/components/phlex_kit/popover/popover.css +1 -1
- data/app/components/phlex_kit/resizable/resizable.css +3 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +78 -3
- data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
- data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
- data/app/components/phlex_kit/select/select.css +3 -3
- data/app/components/phlex_kit/select/select.rb +11 -7
- data/app/components/phlex_kit/select/select_controller.js +35 -4
- data/app/components/phlex_kit/select/select_item.rb +5 -2
- data/app/components/phlex_kit/sheet/sheet.css +3 -3
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
- data/app/components/phlex_kit/sidebar/sidebar.css +27 -15
- data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
- data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
- data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
- data/app/components/phlex_kit/slider/slider.css +3 -3
- data/app/components/phlex_kit/slider/slider.rb +3 -1
- data/app/components/phlex_kit/switch/switch.rb +6 -1
- data/app/components/phlex_kit/table/table.css +5 -3
- data/app/components/phlex_kit/tabs/tabs.css +4 -4
- data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
- data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
- data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +49 -7
- data/app/components/phlex_kit/toast/toast.css +32 -14
- data/app/components/phlex_kit/toast/toast_controller.js +24 -4
- data/app/components/phlex_kit/toast/toast_region.rb +19 -5
- data/app/components/phlex_kit/toast/toaster_controller.js +20 -6
- data/app/components/phlex_kit/toggle/toggle.css +4 -4
- data/app/components/phlex_kit/toggle/toggle.rb +9 -3
- data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
- data/app/components/phlex_kit/typography/heading.rb +9 -3
- data/app/components/phlex_kit/typography/typography.css +4 -3
- data/app/javascript/phlex_kit/controllers/index.js +0 -2
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -2
- 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.
|
|
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
|
|
6
|
-
# icon-collapsed rail hides its text
|
|
7
|
-
#
|
|
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["
|
|
19
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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:
|
|
29
|
-
box-shadow: 0 1px 2px
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
26
|
-
|
|
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-
|
|
26
|
-
.pk-tabs-trigger:has(> [data-icon="inline-end"]) { padding-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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-
|
|
69
|
-
margin-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|