phlex_kit 0.12.0 → 0.14.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/components/phlex_kit/accordion/accordion_content.rb +3 -1
- data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +1 -1
- data/app/components/phlex_kit/accordion/accordion_trigger.rb +1 -1
- data/app/components/phlex_kit/alert/alert.rb +5 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +2 -2
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
- data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
- data/app/components/phlex_kit/attachment/attachment.css +7 -1
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
- data/app/components/phlex_kit/avatar/avatar.css +4 -0
- data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
- data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
- data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
- data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
- data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
- data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
- data/app/components/phlex_kit/calendar/calendar.css +5 -0
- data/app/components/phlex_kit/calendar/calendar_controller.js +38 -9
- data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
- data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
- data/app/components/phlex_kit/carousel/carousel.css +7 -0
- data/app/components/phlex_kit/carousel/carousel.rb +8 -4
- data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
- data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
- data/app/components/phlex_kit/chart/chart.rb +1 -1
- data/app/components/phlex_kit/chart/chart_controller.js +5 -1
- data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
- data/app/components/phlex_kit/checkbox/checkbox.rb +19 -1
- data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
- data/app/components/phlex_kit/codeblock/codeblock.rb +2 -2
- data/app/components/phlex_kit/combobox/combobox.rb +1 -1
- data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
- data/app/components/phlex_kit/combobox/combobox_controller.js +60 -5
- data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
- data/app/components/phlex_kit/command/command_controller.js +69 -2
- data/app/components/phlex_kit/command/command_dialog_content.rb +15 -6
- data/app/components/phlex_kit/command/command_empty.rb +5 -3
- data/app/components/phlex_kit/command/command_separator.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
- data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +42 -1
- data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
- data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
- data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
- data/app/components/phlex_kit/data_table/data_table.css +4 -0
- data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
- data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
- data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
- data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
- data/app/components/phlex_kit/date_picker/date_picker_controller.js +3 -5
- data/app/components/phlex_kit/drawer/drawer.css +4 -0
- data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
- data/app/components/phlex_kit/empty/empty.rb +1 -0
- data/app/components/phlex_kit/field/field.rb +4 -1
- data/app/components/phlex_kit/field/field_error.rb +4 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
- data/app/components/phlex_kit/icon/icon.rb +6 -2
- data/app/components/phlex_kit/input_group/input_group.css +6 -3
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
- data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
- data/app/components/phlex_kit/item/item.css +9 -1
- data/app/components/phlex_kit/item/item_separator.rb +1 -1
- data/app/components/phlex_kit/label/label.css +6 -1
- data/app/components/phlex_kit/link/link.rb +10 -1
- data/app/components/phlex_kit/menubar/menubar.rb +8 -4
- data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
- data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +36 -0
- data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
- data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
- data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
- data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
- data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
- data/app/components/phlex_kit/pagination/pagination.css +4 -0
- data/app/components/phlex_kit/progress/progress.css +19 -0
- data/app/components/phlex_kit/progress/progress.rb +29 -13
- data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
- data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -1
- data/app/components/phlex_kit/select/select.rb +1 -1
- data/app/components/phlex_kit/select/select_content.rb +7 -5
- data/app/components/phlex_kit/select/select_controller.js +10 -0
- data/app/components/phlex_kit/select/select_separator.rb +1 -1
- data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
- data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
- data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
- data/app/components/phlex_kit/switch/switch.rb +5 -2
- data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
- data/app/components/phlex_kit/toast/toast_controller.js +52 -2
- data/app/components/phlex_kit/toast/toast_item.rb +2 -2
- data/app/components/phlex_kit/toast/toaster_controller.js +63 -7
- data/app/components/phlex_kit/toggle/toggle.rb +1 -1
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
- data/app/components/phlex_kit/typography/typography.css +5 -2
- data/lib/phlex_kit/base_component.rb +9 -0
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -1
|
@@ -18,11 +18,14 @@ module PhlexKit
|
|
|
18
18
|
|
|
19
19
|
def view_template(&block)
|
|
20
20
|
classes = [ "pk-context-menu-item", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
|
|
21
|
-
base = { href: @href,
|
|
21
|
+
base = { href: @href, class: classes,
|
|
22
22
|
data: { action: ("click->phlex-kit--context-menu#close" unless @disabled),
|
|
23
23
|
phlex_kit__context_menu_target: "menuItem",
|
|
24
24
|
disabled: (@disabled ? "true" : nil) }.compact }
|
|
25
|
-
|
|
25
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
26
|
+
base[:role] = "menuitem" unless attr_set?(:role)
|
|
27
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
28
|
+
base[:"aria-disabled"] = "true" if @disabled && !aria_key_set?(:disabled)
|
|
26
29
|
a(**mix(base, @attrs)) do
|
|
27
30
|
if @checked
|
|
28
31
|
span(class: "pk-context-menu-check") do
|
|
@@ -4,7 +4,10 @@ module PhlexKit
|
|
|
4
4
|
class ContextMenuRadioGroup < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template(&)
|
|
7
|
-
|
|
7
|
+
base = { class: "pk-context-menu-group" }
|
|
8
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
9
|
+
base[:role] = "radiogroup" unless attr_set?(:role)
|
|
10
|
+
div(**mix(base, @attrs), &)
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -11,13 +11,15 @@ module PhlexKit
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def view_template(&block)
|
|
14
|
-
|
|
14
|
+
base = {
|
|
15
15
|
class: "pk-context-menu-item pk-context-menu-radio-item",
|
|
16
|
-
role: "menuitemradio",
|
|
17
|
-
tabindex: "-1",
|
|
18
|
-
aria: { checked: @checked ? "true" : "false" },
|
|
19
16
|
data: { phlex_kit__context_menu_target: "menuItem" }
|
|
20
|
-
}
|
|
17
|
+
}
|
|
18
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
19
|
+
base[:role] = "menuitemradio" unless attr_set?(:role)
|
|
20
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
21
|
+
base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
|
|
22
|
+
label(**mix(base, @attrs)) do
|
|
21
23
|
input(type: :radio, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
22
24
|
tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
|
|
23
25
|
span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
|
|
@@ -2,7 +2,12 @@ module PhlexKit
|
|
|
2
2
|
class ContextMenuSeparator < BaseComponent
|
|
3
3
|
def initialize(**attrs) = (@attrs = attrs)
|
|
4
4
|
def view_template
|
|
5
|
-
|
|
5
|
+
base = { class: "pk-context-menu-separator" }
|
|
6
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses,
|
|
7
|
+
# and aria_key_set? covers the aria: hash spelling the flat key missed.
|
|
8
|
+
base[:role] = "separator" unless attr_set?(:role)
|
|
9
|
+
base[:"aria-orientation"] = "horizontal" unless aria_key_set?(:orientation)
|
|
10
|
+
div(**mix(base, @attrs))
|
|
6
11
|
end
|
|
7
12
|
end
|
|
8
13
|
end
|
|
@@ -4,7 +4,10 @@ module PhlexKit
|
|
|
4
4
|
class ContextMenuSubContent < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template(&)
|
|
7
|
-
|
|
7
|
+
base = { class: "pk-context-menu-sub-content" }
|
|
8
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
9
|
+
base[:role] = "menu" unless attr_set?(:role)
|
|
10
|
+
div(**mix(base, @attrs)) do
|
|
8
11
|
div(class: "pk-context-menu-sub-viewport", &)
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -3,17 +3,20 @@ module PhlexKit
|
|
|
3
3
|
class ContextMenuSubTrigger < BaseComponent
|
|
4
4
|
def initialize(**attrs) = (@attrs = attrs)
|
|
5
5
|
def view_template(&block)
|
|
6
|
-
|
|
6
|
+
base = {
|
|
7
7
|
class: "pk-context-menu-item pk-context-menu-sub-trigger",
|
|
8
|
-
role: "menuitem",
|
|
9
|
-
# -1: roving focus reaches it via arrows (focus-within opens the sub);
|
|
10
|
-
# tabindex 0 made it a stray tab stop inside the menu.
|
|
11
|
-
tabindex: "-1",
|
|
12
|
-
# expanded starts false and is mirrored live by the controller's
|
|
13
|
-
# syncSub (the reveal itself is pure CSS :hover/:focus-within).
|
|
14
|
-
aria: { haspopup: "menu", expanded: "false" },
|
|
15
8
|
data: { phlex_kit__context_menu_target: "menuItem" }
|
|
16
|
-
}
|
|
9
|
+
}
|
|
10
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
11
|
+
base[:role] = "menuitem" unless attr_set?(:role)
|
|
12
|
+
# -1: roving focus reaches it via arrows (focus-within opens the sub);
|
|
13
|
+
# tabindex 0 made it a stray tab stop inside the menu.
|
|
14
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
15
|
+
base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
|
|
16
|
+
# expanded starts false and is mirrored live by the controller's
|
|
17
|
+
# syncSub (the reveal itself is pure CSS :hover/:focus-within).
|
|
18
|
+
base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
|
|
19
|
+
div(**mix(base, @attrs)) do
|
|
17
20
|
block&.call
|
|
18
21
|
render Icon.new(:chevron_right, size: nil, class: "pk-context-menu-sub-chevron")
|
|
19
22
|
end
|
|
@@ -98,3 +98,7 @@
|
|
|
98
98
|
opacity: .5;
|
|
99
99
|
pointer-events: none;
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
/* RTL: the collapsed expand-toggle chevron points along the reading
|
|
103
|
+
direction; the expanded state's rotate(90deg) points down either way. */
|
|
104
|
+
[dir="rtl"] .pk-data-table-expand-toggle:not([aria-expanded="true"]) svg { transform: scaleX(-1); }
|
|
@@ -66,7 +66,13 @@ export default class extends Controller {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
if (this.hasSelectionSummaryTarget) {
|
|
69
|
-
|
|
69
|
+
// The component stamps its (possibly localized) %{selected}/%{total}
|
|
70
|
+
// template as data-format; live updates interpolate the same string
|
|
71
|
+
// the server rendered.
|
|
72
|
+
const format = this.selectionSummaryTarget.dataset.format || "%{selected} of %{total} row(s) selected.";
|
|
73
|
+
this.selectionSummaryTarget.textContent = format
|
|
74
|
+
.replace("%{selected}", String(selected))
|
|
75
|
+
.replace("%{total}", String(total));
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
if (this.hasBulkActionsTarget) {
|
|
@@ -39,7 +39,13 @@ export default class extends Controller {
|
|
|
39
39
|
this.element.requestSubmit();
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
captureBeforeRender() {
|
|
42
|
+
captureBeforeRender(event) {
|
|
43
|
+
// Only capture for a render of the frame THIS form drives — the listener
|
|
44
|
+
// is document-wide, so an unrelated frame rendering elsewhere while the
|
|
45
|
+
// search input is focused would otherwise write a PENDING_FOCUS entry no
|
|
46
|
+
// matching swap ever consumes, stealing focus on a later restore.
|
|
47
|
+
const frame = event?.target;
|
|
48
|
+
if (!frame || !this.rendersInto(frame)) return;
|
|
43
49
|
const input = this.input();
|
|
44
50
|
if (!input || document.activeElement !== input) return;
|
|
45
51
|
PENDING_FOCUS.set(this.key(), {
|
|
@@ -48,6 +54,14 @@ export default class extends Controller {
|
|
|
48
54
|
});
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
// True when `frame` is the <turbo-frame> this form's submission renders: the
|
|
58
|
+
// frame named by the form's data-turbo-frame, else the frame the form lives
|
|
59
|
+
// inside (Turbo's default target).
|
|
60
|
+
rendersInto(frame) {
|
|
61
|
+
const targeted = this.element.dataset.turboFrame;
|
|
62
|
+
return targeted ? frame.id === targeted : frame.contains(this.element);
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
restoreIfPending() {
|
|
52
66
|
const state = PENDING_FOCUS.get(this.key());
|
|
53
67
|
if (!state) return;
|
|
@@ -2,18 +2,22 @@ module PhlexKit
|
|
|
2
2
|
# Header checkbox selecting every DataTableRowCheckbox on the page (the
|
|
3
3
|
# controller sets indeterminate for partial selections). See data_table.rb.
|
|
4
4
|
class DataTableSelectAllCheckbox < BaseComponent
|
|
5
|
-
def initialize(**attrs)
|
|
5
|
+
def initialize(label: "Select all", **attrs)
|
|
6
|
+
@label = label
|
|
6
7
|
@attrs = attrs
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
def view_template
|
|
10
|
-
|
|
11
|
-
aria: { label: "Select all" },
|
|
11
|
+
base = {
|
|
12
12
|
data: {
|
|
13
13
|
phlex_kit__data_table_target: "selectAll",
|
|
14
14
|
action: "change->phlex-kit--data-table#toggleAll"
|
|
15
15
|
}
|
|
16
|
-
}
|
|
16
|
+
}
|
|
17
|
+
# label: kwarg (mirrors DataTableRowCheckbox); default only when the
|
|
18
|
+
# caller didn't supply their own accessible name — `mix` fuses.
|
|
19
|
+
base[:aria] = { label: @label } unless aria_labelled?
|
|
20
|
+
render Checkbox.new(**mix(base, @attrs))
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
end
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# "N of M row(s) selected." line, kept current by the controller.
|
|
3
|
+
# `format:` localizes it — a template with %{selected} and %{total}
|
|
4
|
+
# placeholders, used for the server render AND stamped on the element so
|
|
5
|
+
# the controller's live updates interpolate the same string.
|
|
3
6
|
# See data_table.rb.
|
|
4
7
|
class DataTableSelectionSummary < BaseComponent
|
|
5
|
-
|
|
8
|
+
DEFAULT_FORMAT = "%{selected} of %{total} row(s) selected."
|
|
9
|
+
|
|
10
|
+
def initialize(total_on_page: 0, format: DEFAULT_FORMAT, **attrs)
|
|
6
11
|
@total_on_page = total_on_page
|
|
12
|
+
@format = format
|
|
7
13
|
@attrs = attrs
|
|
8
14
|
end
|
|
9
15
|
|
|
@@ -11,9 +17,12 @@ module PhlexKit
|
|
|
11
17
|
div(**mix({
|
|
12
18
|
class: "pk-data-table-selection-summary",
|
|
13
19
|
role: "status", # live region — selection changes are announced to AT
|
|
14
|
-
data: {
|
|
20
|
+
data: {
|
|
21
|
+
phlex_kit__data_table_target: "selectionSummary",
|
|
22
|
+
format: @format
|
|
23
|
+
}
|
|
15
24
|
}, @attrs)) do
|
|
16
|
-
plain
|
|
25
|
+
plain Kernel.format(@format, selected: 0, total: @total_on_page)
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
end
|
|
@@ -13,11 +13,9 @@ export default class extends Controller {
|
|
|
13
13
|
onCalendarChange(e) {
|
|
14
14
|
const { mode, rangeEnd } = e.detail
|
|
15
15
|
if (mode === "multiple") return
|
|
16
|
-
// calendar_controller.js clears rangeEnd
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
// back as that string, not JS null/undefined/"".
|
|
20
|
-
if (mode === "range" && (!rangeEnd || rangeEnd === "null")) return
|
|
16
|
+
// calendar_controller.js clears rangeEnd by assigning undefined (Stimulus
|
|
17
|
+
// removes the attribute), so an unset rangeEnd reads back as null here.
|
|
18
|
+
if (mode === "range" && !rangeEnd) return
|
|
21
19
|
this.popoverController()?.close()
|
|
22
20
|
}
|
|
23
21
|
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
background: var(--pk-surface);
|
|
21
21
|
color: var(--pk-text);
|
|
22
22
|
font-size: .875rem;
|
|
23
|
+
/* Content taller than the panel's max-height must scroll, not squash/spill
|
|
24
|
+
(sheet.css does the same on .pk-sheet-content); overflow also keeps the
|
|
25
|
+
rounded corners clipping. */
|
|
26
|
+
overflow: auto;
|
|
23
27
|
}
|
|
24
28
|
/* bottom (default) */
|
|
25
29
|
.pk-drawer.bottom {
|
|
@@ -18,9 +18,9 @@ module PhlexKit
|
|
|
18
18
|
# Defaults only when the caller didn't supply their own — `mix`
|
|
19
19
|
# would fuse role="dialog region" / aria-modal="true false" /
|
|
20
20
|
# tabindex="-1 0" instead of overriding.
|
|
21
|
-
panel_attrs[:role] = "dialog" unless
|
|
21
|
+
panel_attrs[:role] = "dialog" unless attr_set?(:role)
|
|
22
22
|
panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
|
|
23
|
-
panel_attrs[:tabindex] = "-1" unless
|
|
23
|
+
panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
24
24
|
div(**mix(panel_attrs, @attrs)) do
|
|
25
25
|
div(class: "pk-drawer-handle", aria: { hidden: "true" })
|
|
26
26
|
yield if block
|
|
@@ -20,7 +20,9 @@ module PhlexKit
|
|
|
20
20
|
phlex_kit__dropdown_menu_open_value: @open,
|
|
21
21
|
# focusout: tabbing (or otherwise moving real focus) out of the
|
|
22
22
|
# menu closes the open [popover=manual] panel (menubar's pattern).
|
|
23
|
-
|
|
23
|
+
# mousedown@window arms the modal-menu click swallow before the
|
|
24
|
+
# gesture's focusout closes the panel (see the controller).
|
|
25
|
+
action: "mousedown@window->phlex-kit--dropdown-menu#onMousedownOutside click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
|
|
24
26
|
}
|
|
25
27
|
}, @attrs), &block)
|
|
26
28
|
end
|
|
@@ -12,13 +12,15 @@ module PhlexKit
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def view_template(&block)
|
|
15
|
-
|
|
15
|
+
base = {
|
|
16
16
|
class: "pk-dropdown-menu-item pk-dropdown-menu-checkbox-item",
|
|
17
|
-
role: "menuitemcheckbox",
|
|
18
|
-
tabindex: "-1",
|
|
19
|
-
aria: { checked: @checked ? "true" : "false" },
|
|
20
17
|
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
21
|
-
}
|
|
18
|
+
}
|
|
19
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
20
|
+
base[:role] = "menuitemcheckbox" unless attr_set?(:role)
|
|
21
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
22
|
+
base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
|
|
23
|
+
label(**mix(base, @attrs)) do
|
|
22
24
|
input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
23
25
|
tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
|
|
24
26
|
span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) { check_icon }
|
|
@@ -14,9 +14,12 @@ 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
|
+
base = { class: "pk-dropdown-menu-viewport" }
|
|
17
18
|
# role="menu" on the items' direct parent — the rows render
|
|
18
19
|
# role="menuitem", which is invalid ARIA without a menu ancestor.
|
|
19
|
-
|
|
20
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
21
|
+
base[:role] = "menu" unless attr_set?(:role)
|
|
22
|
+
div(**mix(base, @attrs), &block)
|
|
20
23
|
end
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -10,6 +10,27 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
10
10
|
// sub-rows are skipped via a visibility filter. Escape returns focus to the
|
|
11
11
|
// trigger; Enter/Space activate the focused row (links, checkbox/radio
|
|
12
12
|
// labels, and sub triggers alike).
|
|
13
|
+
|
|
14
|
+
// One-shot capture listener swallowing the click an outside mousedown is
|
|
15
|
+
// about to produce (see onMousedownOutside; same helper in menubar/context
|
|
16
|
+
// menu — duplicated per controller by design). {once} self-removes; the DOM
|
|
17
|
+
// dedupes re-arming with the same fn. If the gesture's click never fires
|
|
18
|
+
// (e.g. suppressed after a selection drag) the armed swallow eats the next
|
|
19
|
+
// one — rare enough to accept.
|
|
20
|
+
const swallowClick = (ev) => {
|
|
21
|
+
// preventDefault alone suppresses only native default actions; the
|
|
22
|
+
// dismissing outside click would still fire addEventListener handlers on
|
|
23
|
+
// whatever sits under the pointer. stopPropagation (this listener is
|
|
24
|
+
// capture-phase on window, the outermost node, so it runs before any deeper
|
|
25
|
+
// handler) starves those too — the modal-menu contract is that an outside
|
|
26
|
+
// click ONLY dismisses.
|
|
27
|
+
ev.preventDefault();
|
|
28
|
+
ev.stopPropagation();
|
|
29
|
+
};
|
|
30
|
+
function armSwallowClick() {
|
|
31
|
+
window.addEventListener("click", swallowClick, { once: true, capture: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
export default class extends Controller {
|
|
14
35
|
static targets = ["trigger", "content", "menuItem"];
|
|
15
36
|
static values = { open: { type: Boolean, default: false } };
|
|
@@ -69,16 +90,23 @@ export default class extends Controller {
|
|
|
69
90
|
this.subFrames.add(id);
|
|
70
91
|
}
|
|
71
92
|
|
|
93
|
+
// Deliberate (matches Radix's modal dismiss): the dismissing outside
|
|
94
|
+
// click ONLY dismisses the menu — swallowed rather than also acting on
|
|
95
|
+
// whatever was under the pointer (e.g. navigating a link). Armed at
|
|
96
|
+
// MOUSEDOWN: for a focusable click target the same gesture's focusout
|
|
97
|
+
// closes the panel before the click fires, so a click-time swallow
|
|
98
|
+
// silently skipped those targets (audit round 8).
|
|
99
|
+
onMousedownOutside(event) {
|
|
100
|
+
if (!this.contentTarget.matches(":popover-open")) return;
|
|
101
|
+
if (this.element.contains(event.target)) return;
|
|
102
|
+
armSwallowClick();
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
onClickOutside(event) {
|
|
73
106
|
// Gate on the live :popover-open state, not the stored flag — a stale
|
|
74
107
|
// flag is how a close on an already-closed panel becomes an open.
|
|
75
108
|
if (!this.contentTarget.matches(":popover-open")) return;
|
|
76
109
|
if (this.element.contains(event.target)) return;
|
|
77
|
-
|
|
78
|
-
// Deliberate (matches Radix's modal dismiss): the outside click ONLY
|
|
79
|
-
// dismisses the menu — it is swallowed rather than also acting on
|
|
80
|
-
// whatever was under the pointer (e.g. navigating a link).
|
|
81
|
-
event.preventDefault();
|
|
82
110
|
this.close();
|
|
83
111
|
}
|
|
84
112
|
|
|
@@ -5,7 +5,10 @@ module PhlexKit
|
|
|
5
5
|
class DropdownMenuGroup < BaseComponent
|
|
6
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
7
7
|
def view_template(&)
|
|
8
|
-
|
|
8
|
+
base = { class: "pk-dropdown-menu-group" }
|
|
9
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
10
|
+
base[:role] = "group" unless attr_set?(:role)
|
|
11
|
+
div(**mix(base, @attrs), &)
|
|
9
12
|
end
|
|
10
13
|
end
|
|
11
14
|
end
|
|
@@ -13,11 +13,12 @@ module PhlexKit
|
|
|
13
13
|
|
|
14
14
|
def view_template(&block)
|
|
15
15
|
base = {
|
|
16
|
-
role: "menuitem",
|
|
17
|
-
tabindex: "-1",
|
|
18
16
|
class: [ "pk-dropdown-menu-item", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" "),
|
|
19
17
|
data: { phlex_kit__dropdown_menu_target: "menuItem", action: "click->phlex-kit--dropdown-menu#close" }
|
|
20
18
|
}
|
|
19
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
20
|
+
base[:role] = "menuitem" unless attr_set?(:role)
|
|
21
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
21
22
|
base[:href] = @href unless @as == :div
|
|
22
23
|
send(@as, **mix(base, @attrs), &block)
|
|
23
24
|
end
|
|
@@ -4,7 +4,10 @@ module PhlexKit
|
|
|
4
4
|
class DropdownMenuRadioGroup < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template(&)
|
|
7
|
-
|
|
7
|
+
base = { class: "pk-dropdown-menu-group" }
|
|
8
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
9
|
+
base[:role] = "radiogroup" unless attr_set?(:role)
|
|
10
|
+
div(**mix(base, @attrs), &)
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -11,13 +11,15 @@ module PhlexKit
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def view_template(&block)
|
|
14
|
-
|
|
14
|
+
base = {
|
|
15
15
|
class: "pk-dropdown-menu-item pk-dropdown-menu-radio-item",
|
|
16
|
-
role: "menuitemradio",
|
|
17
|
-
tabindex: "-1",
|
|
18
|
-
aria: { checked: @checked ? "true" : "false" },
|
|
19
16
|
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
20
|
-
}
|
|
17
|
+
}
|
|
18
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
19
|
+
base[:role] = "menuitemradio" unless attr_set?(:role)
|
|
20
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
21
|
+
base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
|
|
22
|
+
label(**mix(base, @attrs)) do
|
|
21
23
|
input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
22
24
|
tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
|
|
23
25
|
span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) do
|
|
@@ -6,7 +6,12 @@ module PhlexKit
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def view_template
|
|
9
|
-
|
|
9
|
+
base = { class: "pk-dropdown-menu-separator" }
|
|
10
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses,
|
|
11
|
+
# and aria_key_set? covers the aria: hash spelling the flat key missed.
|
|
12
|
+
base[:role] = "separator" unless attr_set?(:role)
|
|
13
|
+
base[:"aria-orientation"] = "horizontal" unless aria_key_set?(:orientation)
|
|
14
|
+
div(**mix(base, @attrs))
|
|
10
15
|
end
|
|
11
16
|
end
|
|
12
17
|
end
|
|
@@ -4,7 +4,10 @@ module PhlexKit
|
|
|
4
4
|
class DropdownMenuSubContent < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template(&)
|
|
7
|
-
|
|
7
|
+
base = { class: "pk-dropdown-menu-sub-content" }
|
|
8
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
9
|
+
base[:role] = "menu" unless attr_set?(:role)
|
|
10
|
+
div(**mix(base, @attrs)) do
|
|
8
11
|
div(class: "pk-dropdown-menu-viewport", &)
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -4,17 +4,20 @@ module PhlexKit
|
|
|
4
4
|
class DropdownMenuSubTrigger < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template(&block)
|
|
7
|
-
|
|
7
|
+
base = {
|
|
8
8
|
class: "pk-dropdown-menu-item pk-dropdown-menu-sub-trigger",
|
|
9
|
-
role: "menuitem",
|
|
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
|
-
# expanded starts false and is mirrored live by the controller's
|
|
14
|
-
# syncSub (the reveal itself is pure CSS :hover/:focus-within).
|
|
15
|
-
aria: { haspopup: "menu", expanded: "false" },
|
|
16
9
|
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
17
|
-
}
|
|
10
|
+
}
|
|
11
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
12
|
+
base[:role] = "menuitem" unless attr_set?(:role)
|
|
13
|
+
# -1: roving focus reaches it via arrows (focus-within opens the sub);
|
|
14
|
+
# tabindex 0 made it a stray tab stop inside the menu.
|
|
15
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
16
|
+
base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
|
|
17
|
+
# expanded starts false and is mirrored live by the controller's
|
|
18
|
+
# syncSub (the reveal itself is pure CSS :hover/:focus-within).
|
|
19
|
+
base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
|
|
20
|
+
div(**mix(base, @attrs)) do
|
|
18
21
|
block&.call
|
|
19
22
|
render Icon.new(:chevron_right, size: nil, class: "pk-dropdown-menu-sub-chevron")
|
|
20
23
|
end
|
|
@@ -29,7 +29,10 @@ module PhlexKit
|
|
|
29
29
|
data = { slot: "field", orientation: fetch_option(ORIENTATIONS, @orientation, :orientation) }
|
|
30
30
|
data[:invalid] = "true" if @invalid
|
|
31
31
|
data[:disabled] = "true" if @disabled
|
|
32
|
-
|
|
32
|
+
base = { class: "pk-field", data: data }
|
|
33
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
34
|
+
base[:role] = "group" unless attr_set?(:role)
|
|
35
|
+
div(**mix(base, @attrs), &)
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
38
|
end
|
|
@@ -13,7 +13,10 @@ module PhlexKit
|
|
|
13
13
|
def view_template(&block)
|
|
14
14
|
return if @errors.empty? && !block
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
base = { class: "pk-field-error", data: { slot: "field-error" } }
|
|
17
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
18
|
+
base[:role] = "alert" unless attr_set?(:role)
|
|
19
|
+
div(**mix(base, @attrs)) do
|
|
17
20
|
if @errors.length == 1
|
|
18
21
|
plain @errors.first
|
|
19
22
|
elsif @errors.length > 1
|
|
@@ -49,11 +49,28 @@ export default class extends Controller {
|
|
|
49
49
|
error.preventDefault();
|
|
50
50
|
|
|
51
51
|
// preventDefault also cancels the browser's focus-first-invalid — restore
|
|
52
|
-
// it for the
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
// it for the FIRST invalid control of the submit pass. Coordinate across
|
|
53
|
+
// sibling FormField controllers via a flag on the shared form: invalid
|
|
54
|
+
// events fire in document order, so the first control to claim wins. A
|
|
55
|
+
// per-controller flag alone let each FormField focus its own control, so
|
|
56
|
+
// the LAST invalid field ended up focused, inverting native behavior. No
|
|
57
|
+
// shared JS util (kit rule) — the form element itself is the coordination
|
|
58
|
+
// point. The reset runs on a macrotask, NOT queueMicrotask: a microtask
|
|
59
|
+
// checkpoint can drain between two invalid events (the JS stack empties
|
|
60
|
+
// after each listener), clearing the flag mid-pass so the next field
|
|
61
|
+
// re-claims. setTimeout survives the whole synchronous validation pass and
|
|
62
|
+
// resets before the next submit. A control with no form (rare) falls back
|
|
63
|
+
// to the per-controller flag.
|
|
64
|
+
const form = error.target.form;
|
|
65
|
+
const alreadyClaimed = form ? form.dataset.pkInvalidFocused : this._focusedInvalid;
|
|
66
|
+
if (!alreadyClaimed) {
|
|
67
|
+
if (form) {
|
|
68
|
+
form.dataset.pkInvalidFocused = "1";
|
|
69
|
+
setTimeout(() => { delete form.dataset.pkInvalidFocused; }, 0);
|
|
70
|
+
} else {
|
|
71
|
+
this._focusedInvalid = true;
|
|
72
|
+
setTimeout(() => { this._focusedInvalid = false; }, 0);
|
|
73
|
+
}
|
|
57
74
|
error.target.focus({ preventScroll: true });
|
|
58
75
|
error.target.scrollIntoView({ block: "nearest" });
|
|
59
76
|
}
|
|
@@ -23,9 +23,13 @@ module PhlexKit
|
|
|
23
23
|
base = base_attrs(icon)
|
|
24
24
|
# `mix` merges duplicate string attrs ("16 24") — drop a generated attr
|
|
25
25
|
# whenever the caller supplies their own copy, so theirs wins.
|
|
26
|
-
%i[width height viewbox
|
|
27
|
-
base.delete(key) if
|
|
26
|
+
%i[width height viewbox].each do |key|
|
|
27
|
+
base.delete(key) if attr_set?(key)
|
|
28
28
|
end
|
|
29
|
+
# aria-hidden needs the aria-aware guard: the aria: hash and underscore
|
|
30
|
+
# spellings must also suppress the default (a caller-supplied
|
|
31
|
+
# accessible name must not be hidden from AT by our default).
|
|
32
|
+
base.delete(:"aria-hidden") if aria_key_set?(:hidden) || aria_labelled?
|
|
29
33
|
svg(**mix(base, @attrs)) do |s|
|
|
30
34
|
icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
|
|
31
35
|
end
|
|
@@ -34,7 +34,10 @@
|
|
|
34
34
|
border-color: color-mix(in oklab, var(--pk-red) 50%, transparent);
|
|
35
35
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
/* Group-disabled presentation follows the CONTROL only (the direct-child
|
|
38
|
+
input/textarea/select) — a disabled addon button must not dim a group whose
|
|
39
|
+
input is still editable. */
|
|
40
|
+
.pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) {
|
|
38
41
|
opacity: .5;
|
|
39
42
|
background: color-mix(in oklab, var(--pk-input) 80%, transparent);
|
|
40
43
|
}
|
|
@@ -97,14 +100,14 @@
|
|
|
97
100
|
.pk-input-group-text svg { pointer-events: none; }
|
|
98
101
|
|
|
99
102
|
:root[data-theme="light"] .pk-input-group { background: transparent; }
|
|
100
|
-
:root[data-theme="light"] .pk-input-group:has(:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
|
|
103
|
+
:root[data-theme="light"] .pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
|
|
101
104
|
:root[data-theme="light"] .pk-input-group:has([aria-invalid="true"]) {
|
|
102
105
|
border-color: var(--pk-red);
|
|
103
106
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
|
|
104
107
|
}
|
|
105
108
|
@media (prefers-color-scheme: light) {
|
|
106
109
|
:root[data-theme="system"] .pk-input-group { background: transparent; }
|
|
107
|
-
:root[data-theme="system"] .pk-input-group:has(:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
|
|
110
|
+
:root[data-theme="system"] .pk-input-group:has(> input:disabled, > textarea:disabled, > select:disabled) { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
|
|
108
111
|
:root[data-theme="system"] .pk-input-group:has([aria-invalid="true"]) {
|
|
109
112
|
border-color: var(--pk-red);
|
|
110
113
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 20%, transparent);
|