phlex_kit 0.5.1 → 0.6.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/README.md +21 -8
- data/app/components/phlex_kit/accordion/accordion.css +2 -0
- data/app/components/phlex_kit/accordion/accordion.rb +1 -1
- data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
- data/app/components/phlex_kit/accordion/accordion_controller.js +26 -2
- data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +20 -4
- data/app/components/phlex_kit/accordion/accordion_trigger.rb +18 -2
- data/app/components/phlex_kit/alert/alert.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_cancel.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +1 -1
- data/app/components/phlex_kit/attachment/attachment.rb +2 -2
- data/app/components/phlex_kit/attachment/attachment_media.rb +1 -1
- data/app/components/phlex_kit/avatar/avatar.rb +1 -1
- data/app/components/phlex_kit/badge/badge.rb +1 -1
- data/app/components/phlex_kit/button/button.rb +1 -1
- data/app/components/phlex_kit/button_group/button_group.rb +1 -1
- data/app/components/phlex_kit/calendar/calendar_controller.js +12 -8
- data/app/components/phlex_kit/card/card.rb +1 -1
- data/app/components/phlex_kit/carousel/carousel.rb +1 -1
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +1 -1
- data/app/components/phlex_kit/combobox/combobox.css +21 -12
- data/app/components/phlex_kit/combobox/combobox.rb +19 -3
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +7 -2
- data/app/components/phlex_kit/combobox/combobox_checkbox.rb +3 -0
- data/app/components/phlex_kit/combobox/combobox_controller.js +85 -20
- data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +11 -2
- data/app/components/phlex_kit/combobox/combobox_item.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox_list.rb +9 -2
- data/app/components/phlex_kit/combobox/combobox_popover.rb +8 -7
- data/app/components/phlex_kit/combobox/combobox_radio.rb +3 -0
- data/app/components/phlex_kit/combobox/combobox_search_input.rb +8 -3
- data/app/components/phlex_kit/command/command.rb +17 -2
- data/app/components/phlex_kit/command/command_controller.js +93 -3
- data/app/components/phlex_kit/command/command_dialog_content.rb +16 -4
- data/app/components/phlex_kit/command/command_input.rb +7 -2
- data/app/components/phlex_kit/command/command_item.rb +12 -3
- data/app/components/phlex_kit/command/command_list.rb +9 -2
- data/app/components/phlex_kit/context_menu/context_menu.css +25 -13
- data/app/components/phlex_kit/context_menu/context_menu_content.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +71 -12
- data/app/components/phlex_kit/context_menu/context_menu_item.rb +7 -3
- data/app/components/phlex_kit/context_menu/context_menu_label.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_trigger.rb +3 -1
- data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +1 -1
- data/app/components/phlex_kit/data_table/data_table_controller.js +12 -1
- data/app/components/phlex_kit/data_table/data_table_pagination.rb +12 -10
- data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +1 -0
- data/app/components/phlex_kit/data_table/data_table_sort_head.rb +5 -1
- data/app/components/phlex_kit/dialog/dialog_content.rb +12 -4
- data/app/components/phlex_kit/dialog/dialog_controller.js +13 -0
- data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +36 -15
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +8 -11
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +1 -1
- data/app/components/phlex_kit/empty/empty_media.rb +1 -1
- data/app/components/phlex_kit/field/field.rb +1 -1
- data/app/components/phlex_kit/field/field_legend.rb +1 -1
- data/app/components/phlex_kit/hover_card/hover_card.css +27 -8
- data/app/components/phlex_kit/hover_card/hover_card.rb +2 -1
- data/app/components/phlex_kit/hover_card/hover_card_content.rb +2 -2
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +19 -5
- data/app/components/phlex_kit/input_group/input_group_addon.rb +1 -1
- data/app/components/phlex_kit/input_otp/input_otp.rb +4 -1
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +12 -1
- data/app/components/phlex_kit/item/item.rb +1 -1
- data/app/components/phlex_kit/item/item_media.rb +1 -1
- data/app/components/phlex_kit/link/link.rb +1 -1
- data/app/components/phlex_kit/marker/marker.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar.css +36 -14
- data/app/components/phlex_kit/menubar/menubar.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +5 -2
- data/app/components/phlex_kit/menubar/menubar_content.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +100 -12
- data/app/components/phlex_kit/menubar/menubar_item.rb +8 -5
- data/app/components/phlex_kit/menubar/menubar_radio_item.rb +5 -2
- data/app/components/phlex_kit/menubar/menubar_trigger.rb +1 -0
- data/app/components/phlex_kit/native_select/native_select.rb +1 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu.css +24 -7
- data/app/components/phlex_kit/navigation_menu/navigation_menu_content.rb +3 -1
- data/app/components/phlex_kit/popover/popover.css +24 -9
- data/app/components/phlex_kit/popover/popover.rb +2 -1
- data/app/components/phlex_kit/popover/popover_content.rb +6 -4
- data/app/components/phlex_kit/popover/popover_controller.js +12 -8
- data/app/components/phlex_kit/popover/popover_trigger.rb +1 -1
- data/app/components/phlex_kit/resizable/resizable_panel.rb +4 -2
- data/app/components/phlex_kit/select/select.css +24 -12
- data/app/components/phlex_kit/select/select_content.rb +6 -4
- data/app/components/phlex_kit/select/select_controller.js +21 -17
- data/app/components/phlex_kit/select/select_trigger.rb +1 -1
- data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +68 -2
- data/app/components/phlex_kit/sheet/sheet_controller.js +28 -3
- data/app/components/phlex_kit/sidebar/sidebar.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +1 -1
- data/app/components/phlex_kit/spinner/spinner.rb +1 -1
- data/app/components/phlex_kit/switch/switch.rb +7 -4
- data/app/components/phlex_kit/tabs/tabs.rb +1 -1
- data/app/components/phlex_kit/tabs/tabs_content.rb +4 -1
- data/app/components/phlex_kit/tabs/tabs_controller.js +60 -7
- data/app/components/phlex_kit/tabs/tabs_list.rb +2 -2
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +14 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -3
- data/app/components/phlex_kit/toast/toast_controller.js +17 -0
- data/app/components/phlex_kit/toast/toast_icon.rb +1 -1
- data/app/components/phlex_kit/toast/toast_item.rb +1 -1
- data/app/components/phlex_kit/toggle/toggle.rb +2 -2
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +2 -2
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +1 -1
- data/app/components/phlex_kit/tooltip/tooltip.css +40 -22
- data/app/components/phlex_kit/tooltip/tooltip.rb +3 -1
- data/app/components/phlex_kit/tooltip/tooltip_content.rb +1 -1
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +32 -0
- data/app/components/phlex_kit/tooltip/tooltip_trigger.rb +8 -3
- data/app/components/phlex_kit/typography/text.rb +1 -1
- data/app/javascript/phlex_kit/controllers/index.js +2 -0
- data/lib/generators/phlex_kit/component/component_generator.rb +28 -0
- data/lib/generators/phlex_kit/install/templates/phlex_kit.rb +3 -0
- data/lib/phlex_kit/base_component.rb +12 -0
- data/lib/phlex_kit/engine.rb +11 -1
- data/lib/phlex_kit/icons/lucide.rb +1 -1
- data/lib/phlex_kit/icons/phosphor.rb +1 -1
- data/lib/phlex_kit/icons/remix.rb +1 -1
- data/lib/phlex_kit/icons/tabler.rb +1 -1
- data/lib/phlex_kit/version.rb +1 -1
- data/lib/phlex_kit.rb +5 -0
- metadata +2 -1
|
@@ -2,7 +2,7 @@ module PhlexKit
|
|
|
2
2
|
class PopoverTrigger < BaseComponent
|
|
3
3
|
def initialize(**attrs) = (@attrs = attrs)
|
|
4
4
|
def view_template(&)
|
|
5
|
-
div(**mix({ class: "pk-popover-trigger", data: { phlex_kit__popover_target: "trigger", action: "click->phlex-kit--popover#toggle" } }, @attrs), &)
|
|
5
|
+
div(**mix({ class: "pk-popover-trigger", data: { phlex_kit__popover_target: "trigger", action: "pointerdown->phlex-kit--popover#armToggle click->phlex-kit--popover#toggle" } }, @attrs), &)
|
|
6
6
|
end
|
|
7
7
|
end
|
|
8
8
|
end
|
|
@@ -3,12 +3,14 @@ module PhlexKit
|
|
|
3
3
|
# See resizable_panel_group.rb.
|
|
4
4
|
class ResizablePanel < BaseComponent
|
|
5
5
|
def initialize(default_size: nil, **attrs)
|
|
6
|
-
|
|
6
|
+
# Numeric coercion: the value is interpolated into a style attribute, and
|
|
7
|
+
# hosts plausibly round-trip saved panel sizes from client state.
|
|
8
|
+
@default_size = default_size.nil? ? nil : Float(default_size)
|
|
7
9
|
@attrs = attrs
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def view_template(&)
|
|
11
|
-
style = @default_size ? "flex-grow: #{@default_size}" : nil
|
|
13
|
+
style = @default_size ? "flex-grow: #{Kernel.format("%g", @default_size)}" : nil
|
|
12
14
|
div(**mix({
|
|
13
15
|
class: "pk-resizable-panel",
|
|
14
16
|
style: style,
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
is the native one). ruby_ui's structure + Stimulus kept; Tailwind replaced with
|
|
3
3
|
our vanilla CSS on the palette tokens. Geometry from ruby_ui (h-9 trigger,
|
|
4
4
|
rounded-md, p-1 viewport, px-2 py-1.5 items, accent on hover/selected). The
|
|
5
|
-
panel is
|
|
6
|
-
|
|
7
|
-
.
|
|
5
|
+
panel is a native [popover=manual] in the top layer, anchor-positioned to
|
|
6
|
+
the trigger (Baseline 2026, no @floating-ui); position-try-fallbacks flips
|
|
7
|
+
it above at the viewport bottom. Theme tokens come from the global
|
|
8
|
+
stylesheet. */
|
|
9
|
+
.pk-select { width: 100%; anchor-scope: --pk-select; }
|
|
8
10
|
|
|
9
11
|
/* Hidden input that carries the form value. */
|
|
10
12
|
.pk-select-input { display: none; }
|
|
11
13
|
|
|
12
14
|
/* Closed-state trigger button (mirrors .pk-input geometry). */
|
|
13
15
|
.pk-select-trigger {
|
|
16
|
+
anchor-name: --pk-select;
|
|
14
17
|
display: flex;
|
|
15
18
|
align-items: center;
|
|
16
19
|
justify-content: space-between;
|
|
@@ -44,20 +47,26 @@
|
|
|
44
47
|
.pk-select-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-left: .5rem; opacity: .5; }
|
|
45
48
|
.pk-select-value { overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
|
46
49
|
|
|
47
|
-
/* Dropdown panel —
|
|
50
|
+
/* Dropdown panel — anchored directly below the trigger, flips above at the
|
|
51
|
+
viewport bottom. */
|
|
48
52
|
.pk-select-content {
|
|
49
|
-
position:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
position: fixed;
|
|
54
|
+
position-anchor: --pk-select;
|
|
55
|
+
inset: auto;
|
|
56
|
+
/* the bordered box is the inner viewport — strip the [popover] UA chrome
|
|
57
|
+
(border: solid, padding, Canvas background) from this wrapper */
|
|
58
|
+
border: 0; padding: 0; background: transparent; color: inherit; overflow: visible;
|
|
59
|
+
position-area: block-end span-inline-end;
|
|
60
|
+
margin: .25rem 0 0;
|
|
61
|
+
position-try-fallbacks: flip-block;
|
|
53
62
|
/* Content-hugging with the trigger width as the floor — items never wrap,
|
|
54
63
|
so a narrow trigger (e.g. the currency select in a button group) still
|
|
55
|
-
gets a readable panel; for form selects
|
|
64
|
+
gets a readable panel; for form selects the anchor-size floor keeps
|
|
65
|
+
panel = trigger (percentages resolve against the position-area cell,
|
|
66
|
+
not the trigger, once anchored). */
|
|
56
67
|
width: max-content;
|
|
57
|
-
min-width:
|
|
58
|
-
margin-top: .25rem;
|
|
68
|
+
min-width: anchor-size(width);
|
|
59
69
|
}
|
|
60
|
-
.pk-select-content.hidden { display: none; }
|
|
61
70
|
.pk-select-viewport {
|
|
62
71
|
max-height: 24rem;
|
|
63
72
|
overflow: auto;
|
|
@@ -118,3 +127,6 @@
|
|
|
118
127
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
|
|
119
128
|
}
|
|
120
129
|
}
|
|
130
|
+
/* Hiding must survive author display values (a host class like
|
|
131
|
+
display:grid would beat the UA [popover] { display: none }). */
|
|
132
|
+
.pk-select-content:not(:popover-open) { display: none; }
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# The dropdown panel for PhlexKit::Select —
|
|
3
|
-
# controller toggles
|
|
4
|
-
#
|
|
2
|
+
# The dropdown panel for PhlexKit::Select — a native [popover=manual] the
|
|
3
|
+
# controller toggles, anchor-positioned to the trigger with viewport-edge
|
|
4
|
+
# flipping (select.css). Outer div is the positioned/targeted layer; the
|
|
5
|
+
# inner `.pk-select-viewport` is the bordered, scrollable box.
|
|
5
6
|
# Holds SelectGroup / SelectLabel / SelectItem children. See select.rb.
|
|
6
7
|
class SelectContent < BaseComponent
|
|
7
8
|
def initialize(**attrs)
|
|
@@ -14,7 +15,8 @@ module PhlexKit
|
|
|
14
15
|
id: @id,
|
|
15
16
|
role: "listbox",
|
|
16
17
|
tabindex: "-1",
|
|
17
|
-
class: "pk-select-content
|
|
18
|
+
class: "pk-select-content",
|
|
19
|
+
popover: "manual",
|
|
18
20
|
data: { phlex_kit__select_target: "content" }
|
|
19
21
|
}, @attrs)) do
|
|
20
22
|
div(class: "pk-select-viewport", &block)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
|
-
// Ported from ruby_ui's phlex-kit--select controller
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Ported from ruby_ui's phlex-kit--select controller, minus the
|
|
4
|
+
// @floating-ui/dom dependency: the panel is a native [popover=manual] in the
|
|
5
|
+
// top layer, anchor-positioned with viewport-edge flipping by select.css
|
|
6
|
+
// (manual so this controller keeps owning open/close, item selection,
|
|
7
|
+
// keyboard nav, and click-outside — all unchanged from upstream).
|
|
8
8
|
export default class extends Controller {
|
|
9
9
|
static targets = ["trigger", "content", "input", "value", "item"];
|
|
10
10
|
static values = { open: Boolean };
|
|
@@ -47,7 +47,8 @@ export default class extends Controller {
|
|
|
47
47
|
);
|
|
48
48
|
|
|
49
49
|
if (currentIndex + 1 < this.itemTargets.length) {
|
|
50
|
-
|
|
50
|
+
// No aria-current yet (fresh open via keyboard) → start at the first item.
|
|
51
|
+
if (currentIndex >= 0) this.itemTargets[currentIndex].removeAttribute("aria-current");
|
|
51
52
|
this.setAriaCurrentAndActiveDescendant(currentIndex + 1);
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -75,15 +76,12 @@ export default class extends Controller {
|
|
|
75
76
|
(item) => item.getAttribute("aria-selected") === "true",
|
|
76
77
|
);
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this.itemTarget.setAttribute("aria-current", "true");
|
|
85
|
-
this.triggerTarget.setAttribute("aria-activedescendant", this.itemTarget.getAttribute("id"));
|
|
86
|
-
}
|
|
79
|
+
const focusItem = selectedItem || (this.hasItemTarget ? this.itemTarget : null);
|
|
80
|
+
if (!focusItem) return; // empty select — nothing to focus
|
|
81
|
+
|
|
82
|
+
focusItem.focus({ preventScroll: true });
|
|
83
|
+
focusItem.setAttribute("aria-current", "true");
|
|
84
|
+
this.triggerTarget.setAttribute("aria-activedescendant", focusItem.getAttribute("id"));
|
|
87
85
|
}
|
|
88
86
|
|
|
89
87
|
resetCurrent() {
|
|
@@ -100,7 +98,11 @@ export default class extends Controller {
|
|
|
100
98
|
|
|
101
99
|
toogleContent() {
|
|
102
100
|
this.openValue = !this.openValue;
|
|
103
|
-
this.
|
|
101
|
+
if (this.openValue) {
|
|
102
|
+
this.contentTarget.showPopover();
|
|
103
|
+
} else if (this.contentTarget.matches(":popover-open")) {
|
|
104
|
+
this.contentTarget.hidePopover();
|
|
105
|
+
}
|
|
104
106
|
this.triggerTarget.setAttribute("aria-expanded", this.openValue);
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -124,7 +126,9 @@ export default class extends Controller {
|
|
|
124
126
|
this.toogleContent();
|
|
125
127
|
this.resetCurrent();
|
|
126
128
|
|
|
127
|
-
|
|
129
|
+
// aria-activedescendant holds an element id; on close it must be removed,
|
|
130
|
+
// not set to the literal string "true".
|
|
131
|
+
this.triggerTarget.removeAttribute("aria-activedescendant");
|
|
128
132
|
this.triggerTarget.focus({ preventScroll: true });
|
|
129
133
|
}
|
|
130
134
|
|
|
@@ -14,7 +14,7 @@ module PhlexKit
|
|
|
14
14
|
button(**mix({
|
|
15
15
|
type: :button,
|
|
16
16
|
role: "combobox",
|
|
17
|
-
class: [ "pk-select-trigger", SIZES
|
|
17
|
+
class: [ "pk-select-trigger", fetch_option(SIZES, @size, :size) ].compact.join(" "),
|
|
18
18
|
aria: { expanded: "false", haspopup: "listbox", autocomplete: "none" },
|
|
19
19
|
data: { action: "phlex-kit--select#onClick", phlex_kit__select_target: "trigger" }
|
|
20
20
|
}, @attrs)) do
|
|
@@ -9,9 +9,9 @@ module PhlexKit
|
|
|
9
9
|
end
|
|
10
10
|
def view_template(&block)
|
|
11
11
|
template(data: { phlex_kit__sheet_target: "content" }) do
|
|
12
|
-
div(data: { controller: "phlex-kit--sheet-content" }) do
|
|
12
|
+
div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown" }) do
|
|
13
13
|
div(class: "pk-sheet-backdrop", data: { action: "click->phlex-kit--sheet-content#close" })
|
|
14
|
-
div(**mix({ class: ["pk-sheet-content", SIDES
|
|
14
|
+
div(**mix({ class: [ "pk-sheet-content", fetch_option(SIDES, @side, :side) ].join(" "), role: "dialog", aria: { modal: "true" }, tabindex: "-1", data: { phlex_kit__sheet_content_target: "panel" } }, @attrs)) do
|
|
15
15
|
block&.call
|
|
16
16
|
if @show_close_button
|
|
17
17
|
button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--sheet-content#close" }) do
|
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
// Connects to data-controller="phlex-kit--sheet-content" (on the cloned node
|
|
3
|
+
// Connects to data-controller="phlex-kit--sheet-content" (on the cloned node —
|
|
4
|
+
// drawers ride the same machinery). Owns the modal contract for the open panel:
|
|
5
|
+
// scroll lock, initial focus, Tab focus trap, Escape-to-close, focus restore to
|
|
6
|
+
// the opener, and aria-labelledby wiring to the SheetTitle/DrawerTitle. connect
|
|
7
|
+
// and disconnect exactly bracket the clone's lifetime, so removal by any path
|
|
8
|
+
// (close button, backdrop, Escape, turbo:before-cache) restores state.
|
|
9
|
+
const FOCUSABLE =
|
|
10
|
+
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
11
|
+
|
|
4
12
|
export default class extends Controller {
|
|
5
|
-
|
|
13
|
+
static targets = ["panel"]
|
|
14
|
+
|
|
15
|
+
connect() {
|
|
16
|
+
this.opener = document.activeElement
|
|
17
|
+
this.previousOverflow = document.body.style.overflow
|
|
18
|
+
document.body.style.overflow = "hidden"
|
|
19
|
+
this.#wireAriaLabelledby()
|
|
20
|
+
const focusables = this.#focusables()
|
|
21
|
+
;(focusables[0] || this.panel).focus()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnect() {
|
|
25
|
+
document.body.style.overflow = this.previousOverflow
|
|
26
|
+
if (this.opener?.isConnected) this.opener.focus()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
close() {
|
|
30
|
+
this.element.remove()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
keydown(event) {
|
|
34
|
+
if (event.key === "Escape") {
|
|
35
|
+
event.preventDefault()
|
|
36
|
+
this.close()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (event.key !== "Tab") return
|
|
40
|
+
const focusables = this.#focusables()
|
|
41
|
+
if (focusables.length === 0) {
|
|
42
|
+
event.preventDefault()
|
|
43
|
+
this.panel.focus()
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const first = focusables[0]
|
|
47
|
+
const last = focusables[focusables.length - 1]
|
|
48
|
+
if (event.shiftKey && (document.activeElement === first || document.activeElement === this.panel)) {
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
last.focus()
|
|
51
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
52
|
+
event.preventDefault()
|
|
53
|
+
first.focus()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get panel() {
|
|
58
|
+
return this.hasPanelTarget ? this.panelTarget : this.element
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#focusables() {
|
|
62
|
+
return [...this.panel.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#wireAriaLabelledby() {
|
|
66
|
+
if (this.panel.getAttribute("aria-labelledby")) return
|
|
67
|
+
const title = this.element.querySelector(".pk-sheet-title, .pk-drawer-title")
|
|
68
|
+
if (!title) return
|
|
69
|
+
if (!title.id) title.id = `pk-modal-title-${Math.random().toString(36).slice(2, 10)}`
|
|
70
|
+
this.panel.setAttribute("aria-labelledby", title.id)
|
|
71
|
+
}
|
|
6
72
|
}
|
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="phlex-kit--sheet". Clones the content <template>
|
|
4
|
-
// into <body> on open. Ported from ruby_ui.
|
|
4
|
+
// into <body> on open. Ported from ruby_ui. Guards against double-opening (one
|
|
5
|
+
// live clone at a time) and clears the clone on turbo:before-cache so a cached
|
|
6
|
+
// page never restores a stale overlay. The clone's own phlex-kit--sheet-content
|
|
7
|
+
// controller owns the modal behavior (focus trap, Escape, scroll lock).
|
|
5
8
|
export default class extends Controller {
|
|
6
9
|
static targets = ["content"]
|
|
7
10
|
static values = { open: { type: Boolean, default: false } }
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
|
|
12
|
+
initialize() {
|
|
13
|
+
this.overlay = null
|
|
14
|
+
this.clearOverlay = () => {
|
|
15
|
+
if (this.overlay?.isConnected) this.overlay.remove()
|
|
16
|
+
this.overlay = null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
connect() {
|
|
21
|
+
document.addEventListener("turbo:before-cache", this.clearOverlay)
|
|
22
|
+
if (this.openValue) this.open()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
document.removeEventListener("turbo:before-cache", this.clearOverlay)
|
|
27
|
+
this.clearOverlay()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
open() {
|
|
31
|
+
if (this.overlay?.isConnected) return
|
|
32
|
+
document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
|
|
33
|
+
this.overlay = document.body.lastElementChild
|
|
34
|
+
}
|
|
10
35
|
}
|
|
@@ -59,7 +59,7 @@ module PhlexKit
|
|
|
59
59
|
def collapsible? = @collapsible != :none
|
|
60
60
|
|
|
61
61
|
def classes
|
|
62
|
-
[ "pk-sidebar-wrapper", COLLAPSIBLE
|
|
62
|
+
[ "pk-sidebar-wrapper", fetch_option(COLLAPSIBLE, @collapsible, :collapsible) ].compact.join(" ")
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
end
|
|
@@ -12,7 +12,7 @@ module PhlexKit
|
|
|
12
12
|
def view_template
|
|
13
13
|
# A visible loader, not decoration: role=status + label, aria-hidden off.
|
|
14
14
|
render Icon.new(:loader, size: nil, **mix({
|
|
15
|
-
class: [ "pk-spinner", SIZES
|
|
15
|
+
class: [ "pk-spinner", fetch_option(SIZES, @size, :size) ].compact.join(" "),
|
|
16
16
|
role: "status",
|
|
17
17
|
aria: { label: "Loading" },
|
|
18
18
|
"aria-hidden": false
|
|
@@ -5,18 +5,21 @@ module PhlexKit
|
|
|
5
5
|
class Switch < BaseComponent
|
|
6
6
|
SIZES = { md: nil, sm: "sm" }.freeze
|
|
7
7
|
|
|
8
|
-
def initialize(include_hidden: true, checked_value: "1", unchecked_value: "0", size: :md, **attrs)
|
|
8
|
+
def initialize(include_hidden: true, checked_value: "1", unchecked_value: "0", size: :md, wrapper: {}, **attrs)
|
|
9
9
|
@include_hidden = include_hidden
|
|
10
10
|
@checked_value = checked_value
|
|
11
11
|
@unchecked_value = unchecked_value
|
|
12
12
|
@size = size.to_sym
|
|
13
|
+
@wrapper = wrapper
|
|
13
14
|
@attrs = attrs
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def view_template
|
|
17
|
-
label(**mix({
|
|
18
|
-
input(type: "hidden", name: @attrs[:name], value: @unchecked_value) if @include_hidden
|
|
19
|
-
|
|
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]
|
|
20
|
+
# role="switch" belongs on the focusable control: native checkedness then
|
|
21
|
+
# maps to aria-checked for AT, and the label stays a plain label.
|
|
22
|
+
input(**mix({ class: "pk-switch-input", role: "switch" }, @attrs).merge(type: "checkbox", value: @checked_value))
|
|
20
23
|
span(class: "pk-switch-thumb")
|
|
21
24
|
end
|
|
22
25
|
end
|
|
@@ -11,7 +11,7 @@ module PhlexKit
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def view_template(&)
|
|
14
|
-
classes = [ "pk-tabs", ORIENTATIONS
|
|
14
|
+
classes = [ "pk-tabs", fetch_option(ORIENTATIONS, @orientation, :orientation) ].compact.join(" ")
|
|
15
15
|
div(**mix({ class: classes, data: { controller: "phlex-kit--tabs", phlex_kit__tabs_active_value: @default } }, @attrs), &)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -5,7 +5,10 @@ module PhlexKit
|
|
|
5
5
|
@attrs = attrs
|
|
6
6
|
end
|
|
7
7
|
def view_template(&)
|
|
8
|
-
|
|
8
|
+
# id / aria-labelledby mirror TabsTrigger's deterministic ids (re-scoped
|
|
9
|
+
# 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), &)
|
|
9
12
|
end
|
|
10
13
|
end
|
|
11
14
|
end
|
|
@@ -1,26 +1,79 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
+
let uid = 0
|
|
4
|
+
|
|
3
5
|
// Connects to data-controller="phlex-kit--tabs"
|
|
4
6
|
export default class extends Controller {
|
|
5
|
-
static targets = ["trigger", "content"]
|
|
7
|
+
static targets = ["list", "trigger", "content"]
|
|
6
8
|
static values = { active: String }
|
|
7
9
|
|
|
8
10
|
connect() {
|
|
11
|
+
this.scopeIds()
|
|
12
|
+
if (this.hasListTarget && this.element.classList.contains("vertical")) {
|
|
13
|
+
this.listTarget.setAttribute("aria-orientation", "vertical")
|
|
14
|
+
}
|
|
9
15
|
if (!this.hasActiveValue && this.triggerTargets.length > 0) {
|
|
10
16
|
this.activeValue = this.triggerTargets[0].dataset.value
|
|
17
|
+
} else {
|
|
18
|
+
this.sync()
|
|
11
19
|
}
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
show(e) { this.activeValue = e.currentTarget.dataset.value }
|
|
15
23
|
|
|
24
|
+
// APG tabs keyboard model on the tablist: arrows move focus AND activate
|
|
25
|
+
// (shadcn/Radix activation-on-focus), Home/End jump to the ends. Vertical
|
|
26
|
+
// tab sets also answer to ArrowUp/ArrowDown.
|
|
27
|
+
keydown(event) {
|
|
28
|
+
const vertical = this.element.classList.contains("vertical")
|
|
29
|
+
const nextKeys = vertical ? ["ArrowRight", "ArrowDown"] : ["ArrowRight"]
|
|
30
|
+
const prevKeys = vertical ? ["ArrowLeft", "ArrowUp"] : ["ArrowLeft"]
|
|
31
|
+
const triggers = this.triggerTargets.filter((el) => !el.disabled)
|
|
32
|
+
if (triggers.length === 0) return
|
|
33
|
+
const index = triggers.indexOf(document.activeElement)
|
|
34
|
+
let target
|
|
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]
|
|
37
|
+
else if (event.key === "Home") target = triggers[0]
|
|
38
|
+
else if (event.key === "End") target = triggers[triggers.length - 1]
|
|
39
|
+
if (!target) return
|
|
40
|
+
event.preventDefault()
|
|
41
|
+
target.focus()
|
|
42
|
+
this.activeValue = target.dataset.value
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
activeValueChanged(current, previous) {
|
|
17
46
|
if (current === "" || current === previous) return
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
47
|
+
this.sync()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reflect the active value onto every trigger/panel: visibility,
|
|
51
|
+
// data-state, aria-selected, and the roving tabindex.
|
|
52
|
+
sync() {
|
|
53
|
+
this.contentTargets.forEach((el) => el.classList.toggle("pk-hidden", el.dataset.value !== this.activeValue))
|
|
54
|
+
this.triggerTargets.forEach((el) => {
|
|
55
|
+
const active = el.dataset.value === this.activeValue
|
|
56
|
+
el.dataset.state = active ? "active" : "inactive"
|
|
57
|
+
el.setAttribute("aria-selected", active ? "true" : "false")
|
|
58
|
+
el.setAttribute("tabindex", active ? "0" : "-1")
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The parts render deterministic ids derived from their value, which would
|
|
63
|
+
// collide across multiple tab sets on one page — re-scope them with a
|
|
64
|
+
// per-instance prefix and rewire aria-controls/aria-labelledby. Caller-
|
|
65
|
+
// supplied (non pk-tabs-*) ids are left alone.
|
|
66
|
+
scopeIds() {
|
|
67
|
+
const prefix = `pk-tabs-${++uid}`
|
|
68
|
+
this.triggerTargets.forEach((trigger) => {
|
|
69
|
+
const value = trigger.dataset.value
|
|
70
|
+
const panel = this.contentTargets.find((el) => el.dataset.value === value)
|
|
71
|
+
if (!trigger.id || trigger.id.startsWith("pk-tabs-trigger-")) trigger.id = `${prefix}-trigger-${value}`
|
|
72
|
+
if (!panel) return
|
|
73
|
+
if (!panel.id || panel.id.startsWith("pk-tabs-panel-")) panel.id = `${prefix}-panel-${value}`
|
|
74
|
+
trigger.setAttribute("aria-controls", panel.id)
|
|
75
|
+
panel.setAttribute("aria-labelledby", trigger.id)
|
|
76
|
+
})
|
|
24
77
|
}
|
|
25
78
|
|
|
26
79
|
activeTriggerTarget() { return this.triggerTargets.find((el) => el.dataset.value === this.activeValue) }
|
|
@@ -8,8 +8,8 @@ module PhlexKit
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def view_template(&)
|
|
11
|
-
classes = [ "pk-tabs-list", VARIANTS
|
|
12
|
-
div(**mix({ class: classes, role: "tablist" }, @attrs), &)
|
|
11
|
+
classes = [ "pk-tabs-list", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
|
|
12
|
+
div(**mix({ class: classes, role: "tablist", data: { phlex_kit__tabs_target: "list", action: "keydown->phlex-kit--tabs#keydown" } }, @attrs), &)
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
class TabsTrigger < BaseComponent
|
|
3
|
-
def initialize(value:, as: :button, **attrs)
|
|
3
|
+
def initialize(value:, as: :button, active: false, **attrs)
|
|
4
4
|
@value = value
|
|
5
5
|
@as = as.to_sym
|
|
6
|
+
@active = active
|
|
6
7
|
@attrs = attrs
|
|
7
8
|
end
|
|
8
9
|
def view_template(&)
|
|
9
|
-
|
|
10
|
+
# Deterministic ids pair each trigger with its panel (aria-controls /
|
|
11
|
+
# aria-labelledby); the controller re-scopes them per instance in
|
|
12
|
+
# connect() so multiple tab sets sharing a value can't collide.
|
|
13
|
+
# aria-selected and the roving tabindex are kept in sync by the
|
|
14
|
+
# controller's activeValueChanged.
|
|
15
|
+
base = {
|
|
16
|
+
class: "pk-tabs-trigger", role: "tab", id: "pk-tabs-trigger-#{@value}",
|
|
17
|
+
aria_selected: @active ? "true" : "false",
|
|
18
|
+
aria_controls: "pk-tabs-panel-#{@value}",
|
|
19
|
+
tabindex: @active ? "0" : "-1",
|
|
20
|
+
data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value }
|
|
21
|
+
}
|
|
10
22
|
if @as == :a
|
|
11
23
|
a(**mix(base, @attrs), &)
|
|
12
24
|
else
|
|
@@ -3,11 +3,19 @@ module PhlexKit
|
|
|
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
5
|
class ThemeToggle < BaseComponent
|
|
6
|
-
def initialize(
|
|
6
|
+
def initialize(wrapper: {}, aria: {}, **attrs)
|
|
7
|
+
@wrapper = wrapper
|
|
8
|
+
@aria = aria
|
|
9
|
+
@attrs = attrs
|
|
10
|
+
end
|
|
11
|
+
|
|
7
12
|
def view_template(&block)
|
|
13
|
+
# Merge caller wrapper/aria instead of splatting @attrs over the defaults —
|
|
14
|
+
# a caller `wrapper:` would otherwise replace the hash wholesale and
|
|
15
|
+
# silently delete the theme-toggle controller wiring.
|
|
8
16
|
render PhlexKit::Toggle.new(
|
|
9
|
-
aria: { label: "Toggle theme" },
|
|
10
|
-
wrapper: { data: { controller: "phlex-kit--theme-toggle", action: "phlex-kit--toggle:change->phlex-kit--theme-toggle#apply" } },
|
|
17
|
+
aria: { label: "Toggle theme" }.merge(@aria),
|
|
18
|
+
wrapper: mix({ data: { controller: "phlex-kit--theme-toggle", action: "phlex-kit--toggle:change->phlex-kit--theme-toggle#apply" } }, @wrapper),
|
|
11
19
|
**@attrs, &block
|
|
12
20
|
)
|
|
13
21
|
end
|
|
@@ -27,6 +27,10 @@ export default class extends Controller {
|
|
|
27
27
|
this._onPointerUp = this._onPointerUp.bind(this)
|
|
28
28
|
this._onPointerEnter = () => this._pause()
|
|
29
29
|
this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }
|
|
30
|
+
// Keyboard parity with hover: tabbing into the toast (e.g. to reach its
|
|
31
|
+
// action button) must pause auto-dismiss just like pointerenter does.
|
|
32
|
+
this._onFocusIn = () => this._pause()
|
|
33
|
+
this._onFocusOut = (e) => { if (!this.element.contains(e.relatedTarget)) this._resume() }
|
|
30
34
|
this._onKeyDown = this._onKeyDown.bind(this)
|
|
31
35
|
this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
|
|
32
36
|
this._onRestart = () => this._restart()
|
|
@@ -36,6 +40,8 @@ export default class extends Controller {
|
|
|
36
40
|
this.element.addEventListener("pointerdown", this._onPointerDown)
|
|
37
41
|
this.element.addEventListener("pointerenter", this._onPointerEnter)
|
|
38
42
|
this.element.addEventListener("pointerleave", this._onPointerLeave)
|
|
43
|
+
this.element.addEventListener("focusin", this._onFocusIn)
|
|
44
|
+
this.element.addEventListener("focusout", this._onFocusOut)
|
|
39
45
|
this.element.addEventListener("keydown", this._onKeyDown)
|
|
40
46
|
this.element.addEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
|
|
41
47
|
this.element.addEventListener("phlex-kit:toast:restart", this._onRestart)
|
|
@@ -53,6 +59,8 @@ export default class extends Controller {
|
|
|
53
59
|
this.element.removeEventListener("pointerdown", this._onPointerDown)
|
|
54
60
|
this.element.removeEventListener("pointerenter", this._onPointerEnter)
|
|
55
61
|
this.element.removeEventListener("pointerleave", this._onPointerLeave)
|
|
62
|
+
this.element.removeEventListener("focusin", this._onFocusIn)
|
|
63
|
+
this.element.removeEventListener("focusout", this._onFocusOut)
|
|
56
64
|
this.element.removeEventListener("keydown", this._onKeyDown)
|
|
57
65
|
this.element.removeEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
|
|
58
66
|
this.element.removeEventListener("phlex-kit:toast:restart", this._onRestart)
|
|
@@ -70,9 +78,18 @@ export default class extends Controller {
|
|
|
70
78
|
if (this.element.dataset.state === "closing") return
|
|
71
79
|
this.element.dataset.state = "closing"
|
|
72
80
|
this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "phlex-kit:toast:auto-close" : "phlex-kit:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
|
|
81
|
+
this._invokeCallback(reason === "auto" ? this.onAutoCloseValue : this.onDismissValue)
|
|
73
82
|
setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
|
|
74
83
|
}
|
|
75
84
|
|
|
85
|
+
// on_dismiss:/on_auto_close: name a global function (Sonner-style callback
|
|
86
|
+
// for server-rendered toasts).
|
|
87
|
+
_invokeCallback(name) {
|
|
88
|
+
if (!name) return
|
|
89
|
+
const fn = window[name]
|
|
90
|
+
if (typeof fn === "function") fn({ id: this.element.id })
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
_start() {
|
|
77
94
|
if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return
|
|
78
95
|
this._startedAt = performance.now()
|
|
@@ -22,7 +22,7 @@ module PhlexKit
|
|
|
22
22
|
def view_template
|
|
23
23
|
return unless renderable?
|
|
24
24
|
span(**mix({ class: "pk-toast-icon", data: { slot: "icon" } }, @attrs)) do
|
|
25
|
-
render Icon.new(GLYPHS
|
|
25
|
+
render Icon.new(fetch_option(GLYPHS, @variant, :glyph), size: 16, class: svg_classes)
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
@@ -7,7 +7,7 @@ module PhlexKit
|
|
|
7
7
|
SIZES = { sm: "sm", default: nil, lg: "lg" }.freeze
|
|
8
8
|
|
|
9
9
|
def self.modifier_classes(variant:, size:)
|
|
10
|
-
[VARIANTS.fetch(variant, nil), SIZES.fetch(size, nil)].compact
|
|
10
|
+
[ VARIANTS.fetch(variant, nil), SIZES.fetch(size, nil) ].compact
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def initialize(pressed: false, name: nil, value: "1", unpressed_value: nil,
|
|
@@ -33,7 +33,7 @@ module PhlexKit
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
35
|
def classes
|
|
36
|
-
(["pk-toggle"] + self.class.modifier_classes(variant: @variant, size: @size)).join(" ")
|
|
36
|
+
([ "pk-toggle" ] + self.class.modifier_classes(variant: @variant, size: @size)).join(" ")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def button_default_attrs
|