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.
- 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_controller.js +8 -2
- 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 +12 -8
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
- 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 +3 -0
- data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
- 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 +32 -9
- 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/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 +52 -9
- 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_controller.js +18 -1
- 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 +3 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
- 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 +35 -16
- data/app/components/phlex_kit/form_field/form_field_error.rb +5 -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 +13 -0
- 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_controller.js +10 -4
- 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 +70 -7
- 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 +4 -1
- 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 +7 -2
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -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 +13 -0
- 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 +4 -1
- data/app/components/phlex_kit/select/select_controller.js +25 -0
- data/app/components/phlex_kit/select/select_item.rb +5 -1
- 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 +8 -8
- 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 +1 -1
- 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 +2 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
- 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 +13 -3
- data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
- data/app/components/phlex_kit/toggle/toggle.css +4 -4
- data/app/components/phlex_kit/toggle/toggle.rb +6 -2
- 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/lib/phlex_kit/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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,
|
|
6
|
-
// the opener, and aria-labelledby wiring to the
|
|
7
|
-
// and disconnect exactly bracket the
|
|
8
|
-
// (close button, backdrop, Escape,
|
|
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.#
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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:
|
|
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
|
|
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:
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
@@ -10,8 +10,8 @@ module PhlexKit
|
|
|
10
10
|
# FOUC, inline this in <head> before any stylesheets:
|
|
11
11
|
#
|
|
12
12
|
# <script>
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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)
|