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
|
@@ -5,7 +5,10 @@ module PhlexKit
|
|
|
5
5
|
# Trigger + Content (+ Item / Label / Separator). Tailwind → vanilla
|
|
6
6
|
# `.pk-dropdown-menu*` (dropdown_menu.css).
|
|
7
7
|
class DropdownMenu < BaseComponent
|
|
8
|
-
|
|
8
|
+
# `open: true` renders the menu open on connect (matching Collapsible's
|
|
9
|
+
# kwarg -> Stimulus value -> connect flow).
|
|
10
|
+
def initialize(open: false, **attrs)
|
|
11
|
+
@open = open
|
|
9
12
|
@attrs = attrs
|
|
10
13
|
end
|
|
11
14
|
|
|
@@ -14,6 +17,7 @@ module PhlexKit
|
|
|
14
17
|
class: "pk-dropdown-menu",
|
|
15
18
|
data: {
|
|
16
19
|
controller: "phlex-kit--dropdown-menu",
|
|
20
|
+
phlex_kit__dropdown_menu_open_value: @open,
|
|
17
21
|
action: "click@window->phlex-kit--dropdown-menu#onClickOutside"
|
|
18
22
|
}
|
|
19
23
|
}, @attrs), &block)
|
|
@@ -14,9 +14,13 @@ module PhlexKit
|
|
|
14
14
|
def view_template(&block)
|
|
15
15
|
label(**mix({
|
|
16
16
|
class: "pk-dropdown-menu-item pk-dropdown-menu-checkbox-item",
|
|
17
|
-
role: "menuitemcheckbox"
|
|
17
|
+
role: "menuitemcheckbox",
|
|
18
|
+
tabindex: "-1",
|
|
19
|
+
aria: { checked: @checked ? "true" : "false" },
|
|
20
|
+
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
18
21
|
}, @attrs)) do
|
|
19
|
-
input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked
|
|
22
|
+
input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
23
|
+
tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
|
|
20
24
|
span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) { check_icon }
|
|
21
25
|
yield if block
|
|
22
26
|
end
|
|
@@ -14,7 +14,9 @@ module PhlexKit
|
|
|
14
14
|
|
|
15
15
|
def view_template(&block)
|
|
16
16
|
div(class: wrapper_classes, popover: "manual", data: { phlex_kit__dropdown_menu_target: "content" }) do
|
|
17
|
-
|
|
17
|
+
# role="menu" on the items' direct parent — the rows render
|
|
18
|
+
# role="menuitem", which is invalid ARIA without a menu ancestor.
|
|
19
|
+
div(**mix({ class: "pk-dropdown-menu-viewport", role: "menu" }, @attrs), &block)
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
|
|
@@ -4,14 +4,26 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
4
4
|
// @floating-ui/dom dependency: the panel is a native [popover=manual] in the
|
|
5
5
|
// top layer, anchor-positioned with viewport-edge flipping by
|
|
6
6
|
// dropdown_menu.css (manual so this controller keeps owning click-outside,
|
|
7
|
-
// Escape, and
|
|
7
|
+
// Escape, and keyboard nav). Keyboard nav moves REAL focus over the rows
|
|
8
|
+
// (upstream stamped aria-selected on menuitems, which AT ignores): :focus
|
|
9
|
+
// styles the highlight and :focus-within opens/closes submenus, so hidden
|
|
10
|
+
// sub-rows are skipped via a visibility filter. Escape returns focus to the
|
|
11
|
+
// trigger; Enter/Space activate the focused row (links, checkbox/radio
|
|
12
|
+
// labels, and sub triggers alike).
|
|
8
13
|
export default class extends Controller {
|
|
9
14
|
static targets = ["trigger", "content", "menuItem"];
|
|
10
15
|
static values = { open: { type: Boolean, default: false } };
|
|
11
16
|
|
|
12
17
|
connect() {
|
|
13
18
|
this.boundHandleKeydown = this.#handleKeydown.bind(this);
|
|
14
|
-
|
|
19
|
+
// The visible control is the caller's button/link inside the trigger
|
|
20
|
+
// wrapper — that's what AT reads, so the popup wiring belongs on it.
|
|
21
|
+
this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
|
|
22
|
+
this.invoker.setAttribute("aria-haspopup", "menu");
|
|
23
|
+
this.invoker.setAttribute("aria-expanded", "false");
|
|
24
|
+
// `open:` on the Ruby side renders the value — start open (same
|
|
25
|
+
// kwarg -> value -> connect flow as Collapsible).
|
|
26
|
+
if (this.openValue) this.#open();
|
|
15
27
|
}
|
|
16
28
|
|
|
17
29
|
disconnect() {
|
|
@@ -24,6 +36,9 @@ export default class extends Controller {
|
|
|
24
36
|
if (!this.contentTarget.matches(":popover-open")) return;
|
|
25
37
|
if (this.element.contains(event.target)) return;
|
|
26
38
|
|
|
39
|
+
// Deliberate (matches Radix's modal dismiss): the outside click ONLY
|
|
40
|
+
// dismisses the menu — it is swallowed rather than also acting on
|
|
41
|
+
// whatever was under the pointer (e.g. navigating a link).
|
|
27
42
|
event.preventDefault();
|
|
28
43
|
this.close();
|
|
29
44
|
}
|
|
@@ -34,65 +49,104 @@ export default class extends Controller {
|
|
|
34
49
|
|
|
35
50
|
#open() {
|
|
36
51
|
this.openValue = true;
|
|
37
|
-
this.#deselectAll();
|
|
38
52
|
this.#addEventListeners();
|
|
39
53
|
this.contentTarget.showPopover();
|
|
54
|
+
this.invoker.setAttribute("aria-expanded", "true");
|
|
40
55
|
}
|
|
41
56
|
|
|
42
|
-
close() {
|
|
57
|
+
close(event) {
|
|
58
|
+
// Menu rows default to href="#"; without this an Enter/click on a
|
|
59
|
+
// non-link row scrolls to top and appends # to the URL.
|
|
60
|
+
if (event?.target?.closest?.('a[href="#"]')) event.preventDefault();
|
|
43
61
|
this.openValue = false;
|
|
44
62
|
this.#removeEventListeners();
|
|
45
63
|
if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover();
|
|
64
|
+
this.invoker.setAttribute("aria-expanded", "false");
|
|
46
65
|
}
|
|
47
66
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.#updateSelectedItem(-1);
|
|
57
|
-
} else if (e.key === "Enter" && this.selectedIndex !== -1) {
|
|
58
|
-
e.preventDefault();
|
|
59
|
-
this.menuItemTargets[this.selectedIndex].click();
|
|
60
|
-
} else if (e.key === "Escape") {
|
|
61
|
-
this.close();
|
|
62
|
-
}
|
|
67
|
+
// Mirrors a checkbox/radio row's native input state onto the row's
|
|
68
|
+
// aria-checked (radios also reset their group's siblings).
|
|
69
|
+
syncChecked(event) {
|
|
70
|
+
const input = event.target;
|
|
71
|
+
const group = input.name
|
|
72
|
+
? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
|
|
73
|
+
: [input];
|
|
74
|
+
group.forEach((i) => i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked));
|
|
63
75
|
}
|
|
64
76
|
|
|
65
|
-
#
|
|
66
|
-
this
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
#handleKeydown(e) {
|
|
78
|
+
const items = this.#items();
|
|
79
|
+
if (items.length === 0) return;
|
|
80
|
+
const index = items.indexOf(document.activeElement);
|
|
81
|
+
|
|
82
|
+
switch (e.key) {
|
|
83
|
+
case "ArrowDown":
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
items[(index + 1) % items.length]?.focus();
|
|
86
|
+
break;
|
|
87
|
+
case "ArrowUp":
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
items[index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length]?.focus();
|
|
90
|
+
break;
|
|
91
|
+
case "Home":
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
items[0]?.focus();
|
|
94
|
+
break;
|
|
95
|
+
case "End":
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
items[items.length - 1]?.focus();
|
|
98
|
+
break;
|
|
99
|
+
case "Enter":
|
|
100
|
+
case " ":
|
|
101
|
+
// Explicit click so label rows (checkbox/radio) activate too —
|
|
102
|
+
// labels have no native keyboard activation.
|
|
103
|
+
if (index >= 0) {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
items[index].click();
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
case "Escape":
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
this.close();
|
|
111
|
+
this.invoker.focus();
|
|
112
|
+
break;
|
|
113
|
+
case "ArrowRight":
|
|
114
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
115
|
+
// :focus-within, making its rows visible to the roving nav).
|
|
116
|
+
// Same keyboard grammar as context_menu.
|
|
117
|
+
if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
this.#enterSub(document.activeElement);
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
case "ArrowLeft": {
|
|
123
|
+
// Inside a submenu, step back to its trigger (closes it on focus-out).
|
|
124
|
+
const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
|
|
125
|
+
if (sub) {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
80
131
|
}
|
|
81
|
-
|
|
82
|
-
this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);
|
|
83
132
|
}
|
|
84
133
|
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
134
|
+
#enterSub(trigger) {
|
|
135
|
+
trigger.focus();
|
|
136
|
+
const panel = trigger.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-content");
|
|
137
|
+
if (!panel) return;
|
|
138
|
+
const first = [...panel.querySelectorAll('[role^="menuitem"]')]
|
|
139
|
+
.find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0);
|
|
140
|
+
first?.focus();
|
|
91
141
|
}
|
|
92
142
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
143
|
+
// Rows inside a closed submenu are display:none (revealed by CSS on
|
|
144
|
+
// hover/focus-within) — focus() on them silently fails and the roving
|
|
145
|
+
// navigation would jam at the submenu boundary.
|
|
146
|
+
#items() {
|
|
147
|
+
return this.menuItemTargets.filter(
|
|
148
|
+
(el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0,
|
|
149
|
+
);
|
|
96
150
|
}
|
|
97
151
|
|
|
98
152
|
#addEventListeners() {
|
|
@@ -6,7 +6,9 @@ module PhlexKit
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def view_template(&block)
|
|
9
|
-
|
|
9
|
+
# div, not a heading: role="menu" only allows menuitem/group/separator
|
|
10
|
+
# children, so an <h3> is invalid ARIA there (shadcn uses a div too).
|
|
11
|
+
div(**mix({ class: "pk-dropdown-menu-label" }, @attrs), &block)
|
|
10
12
|
end
|
|
11
13
|
end
|
|
12
14
|
end
|
|
@@ -13,9 +13,13 @@ module PhlexKit
|
|
|
13
13
|
def view_template(&block)
|
|
14
14
|
label(**mix({
|
|
15
15
|
class: "pk-dropdown-menu-item pk-dropdown-menu-radio-item",
|
|
16
|
-
role: "menuitemradio"
|
|
16
|
+
role: "menuitemradio",
|
|
17
|
+
tabindex: "-1",
|
|
18
|
+
aria: { checked: @checked ? "true" : "false" },
|
|
19
|
+
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
17
20
|
}, @attrs)) do
|
|
18
|
-
input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked
|
|
21
|
+
input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
22
|
+
tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
|
|
19
23
|
span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) do
|
|
20
24
|
# Deliberately NOT a PhlexKit::Icon: a filled selection dot is
|
|
21
25
|
# geometry, not icon-library vocabulary — it stays identical across
|
|
@@ -7,8 +7,11 @@ module PhlexKit
|
|
|
7
7
|
div(**mix({
|
|
8
8
|
class: "pk-dropdown-menu-item pk-dropdown-menu-sub-trigger",
|
|
9
9
|
role: "menuitem",
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
# -1: roving focus reaches it via arrows (focus-within opens the sub);
|
|
11
|
+
# tabindex 0 made it a stray tab stop inside the menu.
|
|
12
|
+
tabindex: "-1",
|
|
13
|
+
aria: { haspopup: "menu" },
|
|
14
|
+
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
12
15
|
}, @attrs)) do
|
|
13
16
|
block&.call
|
|
14
17
|
render Icon.new(:chevron_right, size: nil, class: "pk-dropdown-menu-sub-chevron")
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
font-size: .875rem;
|
|
111
111
|
font-weight: 400;
|
|
112
112
|
line-height: 1.5;
|
|
113
|
-
text-align:
|
|
113
|
+
text-align: start;
|
|
114
114
|
}
|
|
115
115
|
.pk-field-description a { text-decoration: underline; text-underline-offset: 4px; color: inherit; }
|
|
116
116
|
.pk-field-description a:hover { color: var(--pk-brand); }
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Validation message(s) for a Field, ported from shadcn/ui's FieldError.
|
|
3
3
|
# Pass `errors:` an array of message strings (deduped; one renders plain,
|
|
4
|
-
# several render a list) or give a block
|
|
5
|
-
# matching theirs. Distinct from FormFieldError, which is the Stimulus
|
|
4
|
+
# several render a list) or give a block — both render (block after the
|
|
5
|
+
# list) when both are given. Renders nothing when empty — matching theirs. Distinct from FormFieldError, which is the Stimulus
|
|
6
6
|
# live-validation slot; server-rendered errors belong here. See field.rb.
|
|
7
7
|
class FieldError < BaseComponent
|
|
8
8
|
def initialize(errors: nil, **attrs)
|
|
@@ -14,15 +14,14 @@ module PhlexKit
|
|
|
14
14
|
return if @errors.empty? && !block
|
|
15
15
|
|
|
16
16
|
div(**mix({ class: "pk-field-error", role: "alert", data: { slot: "field-error" } }, @attrs)) do
|
|
17
|
-
if
|
|
18
|
-
yield
|
|
19
|
-
elsif @errors.length == 1
|
|
17
|
+
if @errors.length == 1
|
|
20
18
|
plain @errors.first
|
|
21
|
-
|
|
19
|
+
elsif @errors.length > 1
|
|
22
20
|
ul(class: "pk-field-error-list") do
|
|
23
21
|
@errors.each { |message| li { message } }
|
|
24
22
|
end
|
|
25
23
|
end
|
|
24
|
+
yield if block
|
|
26
25
|
end
|
|
27
26
|
end
|
|
28
27
|
end
|
|
@@ -14,7 +14,7 @@ export default class extends Controller {
|
|
|
14
14
|
|
|
15
15
|
connect() {
|
|
16
16
|
if (this.hasErrorTarget) {
|
|
17
|
-
if (this.errorTarget.textContent) {
|
|
17
|
+
if (this.errorTarget.textContent.trim()) {
|
|
18
18
|
this.shouldValidateValue = true;
|
|
19
19
|
} else {
|
|
20
20
|
this.errorTarget.classList.add("pk-hidden");
|
|
@@ -23,36 +23,68 @@ export default class extends Controller {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
onInvalid(error) {
|
|
26
|
+
// Only suppress the native validation bubble when we can show our own
|
|
27
|
+
// message — a FormField without a FormFieldError previously ate the
|
|
28
|
+
// bubble AND crashed on the missing target, so a required-but-empty
|
|
29
|
+
// form gave zero feedback and never submitted.
|
|
30
|
+
if (!this.hasErrorTarget) return;
|
|
26
31
|
error.preventDefault();
|
|
27
32
|
|
|
28
33
|
this.shouldValidateValue = true;
|
|
29
|
-
this.#setErrorMessage();
|
|
34
|
+
this.#setErrorMessage(error);
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
onInput() {
|
|
33
|
-
this.#setErrorMessage();
|
|
37
|
+
onInput(event) {
|
|
38
|
+
this.#setErrorMessage(event);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
onChange() {
|
|
37
|
-
this.#setErrorMessage();
|
|
41
|
+
onChange(event) {
|
|
42
|
+
this.#setErrorMessage(event);
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
// The event's own control, when it's one of ours — radio/checkbox groups
|
|
46
|
+
// register several input targets and validity/aria-invalid must land on
|
|
47
|
+
// the control that fired, not always the first.
|
|
48
|
+
#inputFor(event) {
|
|
49
|
+
if (event && this.inputTargets.includes(event.target)) return event.target;
|
|
50
|
+
return this.inputTarget;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#setErrorMessage(event) {
|
|
54
|
+
if (!this.shouldValidateValue || !this.hasErrorTarget) return;
|
|
42
55
|
|
|
43
|
-
|
|
56
|
+
const input = this.#inputFor(event);
|
|
57
|
+
// aria-invalid drives the red ring for LIVE validation too (input.css
|
|
58
|
+
// only matched server-rendered attrs); aria-describedby ties the message
|
|
59
|
+
// to the control for AT.
|
|
60
|
+
if (input.validity.valid) {
|
|
61
|
+
input.removeAttribute("aria-invalid");
|
|
62
|
+
this.#describeBy(input, false);
|
|
44
63
|
this.errorTarget.textContent = "";
|
|
45
64
|
this.errorTarget.classList.add("pk-hidden");
|
|
46
65
|
} else {
|
|
47
|
-
|
|
66
|
+
input.setAttribute("aria-invalid", "true");
|
|
67
|
+
this.#describeBy(input, true);
|
|
68
|
+
this.errorTarget.textContent = this.#getValidationMessage(input);
|
|
48
69
|
this.errorTarget.classList.remove("pk-hidden");
|
|
49
70
|
}
|
|
50
71
|
}
|
|
51
72
|
|
|
52
|
-
|
|
73
|
+
// Append/remove the error's id on the control's aria-describedby without
|
|
74
|
+
// clobbering pre-existing tokens (e.g. a hint's id).
|
|
75
|
+
#describeBy(input, present) {
|
|
76
|
+
const id = this.errorTarget.id;
|
|
77
|
+
if (!id) return;
|
|
78
|
+
const tokens = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter((t) => t && t !== id);
|
|
79
|
+
if (present) tokens.push(id);
|
|
80
|
+
if (tokens.length) input.setAttribute("aria-describedby", tokens.join(" "));
|
|
81
|
+
else input.removeAttribute("aria-describedby");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#getValidationMessage(input) {
|
|
53
85
|
let errorMessage;
|
|
54
86
|
|
|
55
|
-
const { validity, dataset, validationMessage } =
|
|
87
|
+
const { validity, dataset, validationMessage } = input;
|
|
56
88
|
|
|
57
89
|
if (validity.tooLong) errorMessage = dataset.tooLong;
|
|
58
90
|
if (validity.tooShort) errorMessage = dataset.tooShort;
|
|
@@ -5,13 +5,20 @@ module PhlexKit
|
|
|
5
5
|
# with server-rendered model errors — non-empty content arms validation on
|
|
6
6
|
# connect. See form_field.rb.
|
|
7
7
|
class FormFieldError < BaseComponent
|
|
8
|
-
def initialize(**attrs)
|
|
8
|
+
def initialize(id: nil, **attrs)
|
|
9
|
+
# A default id (like SelectContent / CommandList) so the controller can
|
|
10
|
+
# always wire aria-describedby from the control to this message.
|
|
11
|
+
@id = id || "pk-form-field-error-#{SecureRandom.hex(4)}"
|
|
9
12
|
@attrs = attrs
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def view_template(&block)
|
|
16
|
+
# role="alert": the controller swaps this text live on invalid/input —
|
|
17
|
+
# without a live region the announcement never reaches screen readers.
|
|
13
18
|
p(**mix({
|
|
19
|
+
id: @id,
|
|
14
20
|
class: "pk-form-field-error",
|
|
21
|
+
role: "alert",
|
|
15
22
|
data: { phlex_kit__form_field_target: "error" }
|
|
16
23
|
}, @attrs), &block)
|
|
17
24
|
end
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
20
20
|
border-radius: var(--pk-radius); background: var(--pk-surface);
|
|
21
21
|
color: var(--pk-text); padding: .625rem; font-size: .875rem;
|
|
22
|
-
box-shadow: 0 8px 24px
|
|
22
|
+
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
|
|
23
23
|
}
|
|
24
24
|
.pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
|
|
25
25
|
.pk-hover-card-content.left {
|
|
@@ -4,6 +4,9 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
4
4
|
// with a small open/close delay. The panel is a native [popover=manual]
|
|
5
5
|
// (top layer; manual because the controller owns open/close — hover
|
|
6
6
|
// semantics want no light dismiss), anchor-positioned by hover_card.css.
|
|
7
|
+
// Manual popovers get no built-in Escape dismiss, so a window keydown
|
|
8
|
+
// listener (bound only while open, cf. tooltip_controller.js) closes the
|
|
9
|
+
// card immediately — the keyboard/touch escape hatch for a hover surface.
|
|
7
10
|
export default class extends Controller {
|
|
8
11
|
static targets = ["trigger", "content"]
|
|
9
12
|
static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
|
|
@@ -12,14 +15,35 @@ export default class extends Controller {
|
|
|
12
15
|
this.t = setTimeout(() => {
|
|
13
16
|
if (!this.contentTarget.matches(":popover-open")) this.contentTarget.showPopover()
|
|
14
17
|
this.contentTarget.dataset.state = "open"
|
|
18
|
+
this.#bindEscape()
|
|
15
19
|
}, this.openDelayValue)
|
|
16
20
|
}
|
|
17
21
|
hide() {
|
|
18
22
|
clearTimeout(this.t)
|
|
19
|
-
this.t = setTimeout(() =>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
this.t = setTimeout(() => this.#close(), this.closeDelayValue)
|
|
24
|
+
}
|
|
25
|
+
disconnect() {
|
|
26
|
+
clearTimeout(this.t)
|
|
27
|
+
this.#unbindEscape()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#close() {
|
|
31
|
+
if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
|
|
32
|
+
this.contentTarget.dataset.state = "closed"
|
|
33
|
+
this.#unbindEscape()
|
|
34
|
+
}
|
|
35
|
+
#bindEscape() {
|
|
36
|
+
if (this.onKeydown) return
|
|
37
|
+
this.onKeydown = (e) => {
|
|
38
|
+
if (e.key !== "Escape") return
|
|
39
|
+
clearTimeout(this.t)
|
|
40
|
+
this.#close()
|
|
41
|
+
}
|
|
42
|
+
window.addEventListener("keydown", this.onKeydown)
|
|
43
|
+
}
|
|
44
|
+
#unbindEscape() {
|
|
45
|
+
if (!this.onKeydown) return
|
|
46
|
+
window.removeEventListener("keydown", this.onKeydown)
|
|
47
|
+
this.onKeydown = null
|
|
23
48
|
}
|
|
24
|
-
disconnect() { clearTimeout(this.t) }
|
|
25
49
|
}
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
flex-direction: column;
|
|
25
25
|
align-items: stretch;
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
/* Ring only when the CONTROL is focused (shadcn gates on the control's
|
|
28
|
+
focus-visible) — :focus-within would also light it for addon buttons. */
|
|
29
|
+
.pk-input-group:has(.pk-input:focus-visible, .pk-textarea:focus-visible) {
|
|
28
30
|
border-color: var(--pk-ring);
|
|
29
31
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
30
32
|
}
|
|
@@ -48,8 +50,8 @@
|
|
|
48
50
|
.pk-input-group .pk-input:disabled,
|
|
49
51
|
.pk-input-group .pk-textarea:disabled { background: transparent; }
|
|
50
52
|
/* Tighten the control's near-side padding beside an inline addon. */
|
|
51
|
-
.pk-input-group:has(> .pk-input-group-addon.start) > .pk-input { padding-
|
|
52
|
-
.pk-input-group:has(> .pk-input-group-addon.end) > .pk-input { padding-
|
|
53
|
+
.pk-input-group:has(> .pk-input-group-addon.start) > .pk-input { padding-inline-start: .375rem; }
|
|
54
|
+
.pk-input-group:has(> .pk-input-group-addon.end) > .pk-input { padding-inline-end: .375rem; }
|
|
53
55
|
|
|
54
56
|
.pk-input-group-addon {
|
|
55
57
|
display: flex;
|
|
@@ -64,8 +66,8 @@
|
|
|
64
66
|
font-size: .875rem;
|
|
65
67
|
font-weight: 500;
|
|
66
68
|
}
|
|
67
|
-
.pk-input-group-addon.start { order: -1; padding-
|
|
68
|
-
.pk-input-group-addon.end { order: 99; padding-
|
|
69
|
+
.pk-input-group-addon.start { order: -1; padding-inline-start: .5rem; }
|
|
70
|
+
.pk-input-group-addon.end { order: 99; padding-inline-end: .5rem; }
|
|
69
71
|
.pk-input-group-addon.block-start,
|
|
70
72
|
.pk-input-group-addon.block-end {
|
|
71
73
|
width: 100%;
|
|
@@ -78,8 +80,8 @@
|
|
|
78
80
|
.pk-input-group-addon.block-end.bordered { border-top: 1px solid var(--pk-border); padding-top: .5rem; }
|
|
79
81
|
.pk-input-group-addon svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
|
|
80
82
|
.pk-input-group-addon > kbd { border-radius: calc(var(--pk-radius) - 5px); }
|
|
81
|
-
.pk-input-group-addon.start:has(> .pk-button) { margin-
|
|
82
|
-
.pk-input-group-addon.end:has(> .pk-button) { margin-
|
|
83
|
+
.pk-input-group-addon.start:has(> .pk-button) { margin-inline-start: -0.3rem; }
|
|
84
|
+
.pk-input-group-addon.end:has(> .pk-button) { margin-inline-end: -0.3rem; }
|
|
83
85
|
|
|
84
86
|
.pk-button.pk-input-group-button {
|
|
85
87
|
gap: .25rem;
|
|
@@ -17,7 +17,11 @@ module PhlexKit
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def view_template(&)
|
|
20
|
-
|
|
20
|
+
# No role: shadcn puts role="group" on its addon, but an unnamed group
|
|
21
|
+
# nested inside the InputGroup's own group is noise for AT — a text or
|
|
22
|
+
# icon addon needs no role, and interactive addon children (buttons)
|
|
23
|
+
# carry their own semantics.
|
|
24
|
+
div(**mix({ class: "pk-input-group-addon #{fetch_option(ALIGNS, @align, :align)}" }, @attrs), &)
|
|
21
25
|
end
|
|
22
26
|
end
|
|
23
27
|
end
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
outline: none;
|
|
17
17
|
transition: border-color .15s ease, box-shadow .15s ease;
|
|
18
18
|
}
|
|
19
|
-
.pk-input-otp-slot:not(:first-child) { margin-
|
|
19
|
+
.pk-input-otp-slot:not(:first-child) { margin-inline-start: -1px; }
|
|
20
20
|
.pk-input-otp-group .pk-input-otp-slot:first-child {
|
|
21
21
|
border-top-left-radius: var(--pk-radius);
|
|
22
22
|
border-bottom-left-radius: var(--pk-radius);
|
|
@@ -10,6 +10,12 @@ export default class extends Controller {
|
|
|
10
10
|
static values = { length: Number }
|
|
11
11
|
|
|
12
12
|
connect() {
|
|
13
|
+
// Slots compose without knowing their position, so `length:` can drift
|
|
14
|
+
// from the actual slot count — flag the mismatch instead of silently
|
|
15
|
+
// truncating/underfilling codes.
|
|
16
|
+
if (this.hasLengthValue && this.lengthValue !== this.slotTargets.length) {
|
|
17
|
+
console.warn(`phlex-kit--input-otp: length is ${this.lengthValue} but ${this.slotTargets.length} slots rendered`, this.element)
|
|
18
|
+
}
|
|
13
19
|
// Slots compose without knowing their position — label them here so AT
|
|
14
20
|
// announces "Digit N of M" instead of anonymous edit fields.
|
|
15
21
|
this.slotTargets.forEach((slot, i) => {
|
|
@@ -17,12 +23,39 @@ export default class extends Controller {
|
|
|
17
23
|
slot.setAttribute("aria-label", `Digit ${i + 1} of ${this.slotTargets.length}`)
|
|
18
24
|
}
|
|
19
25
|
})
|
|
26
|
+
// A server-provided value renders on the hidden input only — distribute
|
|
27
|
+
// it into the empty slots BEFORE syncing, or syncValue would join the
|
|
28
|
+
// empty slots and wipe the value with a spurious change event.
|
|
29
|
+
if (this.hasValueTarget && this.valueTarget.value && this.slotTargets.every((slot) => !slot.value)) {
|
|
30
|
+
const chars = this.valueTarget.value.split("")
|
|
31
|
+
this.slotTargets.forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
|
|
32
|
+
}
|
|
20
33
|
this.syncValue()
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
onInput(e) {
|
|
24
37
|
const slot = e.target
|
|
25
|
-
|
|
38
|
+
let text = slot.value.replace(/\s/g, "")
|
|
39
|
+
// Typing rejects the same characters paste strips — a letter keyed into
|
|
40
|
+
// an inputmode=numeric slot must not land.
|
|
41
|
+
if (slot.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
|
|
42
|
+
|
|
43
|
+
if (text.length > 1) {
|
|
44
|
+
if (e.data && e.data.length === 1) {
|
|
45
|
+
// one keystroke into an already-filled slot: keep the newest char
|
|
46
|
+
slot.value = e.data
|
|
47
|
+
this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
48
|
+
this.syncValue()
|
|
49
|
+
} else {
|
|
50
|
+
// OTP autofill (autocomplete="one-time-code") delivers the whole
|
|
51
|
+
// code into the focused slot — distribute it like a paste instead
|
|
52
|
+
// of discarding all but one digit.
|
|
53
|
+
this.fillFrom(slot, text)
|
|
54
|
+
}
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
slot.value = text
|
|
26
59
|
if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
27
60
|
this.syncValue()
|
|
28
61
|
}
|
|
@@ -50,10 +83,16 @@ export default class extends Controller {
|
|
|
50
83
|
// Numeric slots reject non-digits on typing; pasted text like
|
|
51
84
|
// "code: 123456" should distribute only the digits.
|
|
52
85
|
if (e.target.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
|
|
86
|
+
if (!text.length) return
|
|
87
|
+
this.fillFrom(e.target, text)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Distribute a multi-character string across the slots starting at `slot`
|
|
91
|
+
// (shared by paste and autofill), then park focus after the last filled one.
|
|
92
|
+
fillFrom(slot, text) {
|
|
53
93
|
const chars = text.split("")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.slotTargets.slice(start).forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
|
|
94
|
+
const start = this.slotTargets.indexOf(slot)
|
|
95
|
+
this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
|
|
57
96
|
this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
|
|
58
97
|
this.syncValue()
|
|
59
98
|
}
|