phlex_kit 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/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_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 +26 -8
- 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_controller.js +5 -1
- data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
- data/app/components/phlex_kit/checkbox/checkbox.rb +5 -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/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 +56 -5
- data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
- data/app/components/phlex_kit/command/command_controller.js +9 -1
- data/app/components/phlex_kit/command/command_dialog_content.rb +10 -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 +32 -3
- 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_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/drawer/drawer.css +4 -0
- 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 +25 -2
- 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 +5 -1
- 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/menubar/menubar.rb +5 -3
- 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 +28 -2
- 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/popover/popover_content.rb +8 -1
- data/app/components/phlex_kit/popover/popover_controller.js +4 -0
- data/app/components/phlex_kit/radio_button/radio_button.css +16 -0
- 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/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 +42 -0
- data/app/components/phlex_kit/select/select_separator.rb +1 -1
- 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 +4 -1
- data/app/components/phlex_kit/table/table_head.rb +6 -1
- 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 +46 -1
- data/app/components/phlex_kit/toast/toaster_controller.js +29 -1
- data/app/components/phlex_kit/toggle/toggle.rb +1 -1
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +19 -4
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +6 -1
- 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/version.rb +1 -1
- metadata +1 -1
|
@@ -3,17 +3,18 @@ module PhlexKit
|
|
|
3
3
|
class MenubarSubTrigger < BaseComponent
|
|
4
4
|
def initialize(**attrs) = (@attrs = attrs)
|
|
5
5
|
def view_template(&block)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
base = { class: "pk-menubar-item pk-menubar-sub-trigger" }
|
|
7
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
8
|
+
base[:role] = "menuitem" unless attr_set?(:role)
|
|
9
|
+
# -1: the roving focus reaches it via arrows (focus-within opens the
|
|
10
|
+
# sub); tabindex 0 made it a stray tab stop inside the open panel.
|
|
11
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
12
|
+
base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
|
|
13
|
+
# expanded starts false; the sub is revealed by CSS (:hover /
|
|
14
|
+
# :focus-within), so MenubarSub's syncSub actions mirror that state
|
|
15
|
+
# onto this attribute.
|
|
16
|
+
base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
|
|
17
|
+
div(**mix(base, @attrs)) do
|
|
17
18
|
block&.call
|
|
18
19
|
render Icon.new(:chevron_right, size: nil, class: "pk-menubar-sub-chevron")
|
|
19
20
|
end
|
|
@@ -3,13 +3,16 @@ module PhlexKit
|
|
|
3
3
|
class MenubarTrigger < BaseComponent
|
|
4
4
|
def initialize(**attrs) = (@attrs = attrs)
|
|
5
5
|
def view_template(&)
|
|
6
|
-
|
|
6
|
+
base = {
|
|
7
7
|
type: :button,
|
|
8
8
|
class: "pk-menubar-trigger",
|
|
9
|
-
role: "menuitem",
|
|
10
|
-
aria: { haspopup: "menu", expanded: "false" },
|
|
11
9
|
data: { action: "click->phlex-kit--menubar#toggle mouseenter->phlex-kit--menubar#switch" }
|
|
12
|
-
}
|
|
10
|
+
}
|
|
11
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
12
|
+
base[:role] = "menuitem" unless attr_set?(:role)
|
|
13
|
+
base[:"aria-haspopup"] = "menu" unless aria_key_set?(:haspopup)
|
|
14
|
+
base[:"aria-expanded"] = "false" unless aria_key_set?(:expanded)
|
|
15
|
+
button(**mix(base, @attrs), &)
|
|
13
16
|
end
|
|
14
17
|
end
|
|
15
18
|
end
|
|
@@ -30,6 +30,11 @@ export default class extends Controller {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
connect() {
|
|
33
|
+
// connect() re-derives reflected state: a Turbo snapshot serializes a
|
|
34
|
+
// mid-flight data-autoscrolling marker; no programmatic scroll is in
|
|
35
|
+
// flight on a fresh connect.
|
|
36
|
+
this.element.removeAttribute("data-autoscrolling");
|
|
37
|
+
if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
|
|
33
38
|
// Reader is considered "following" the live edge until they move away.
|
|
34
39
|
this.following = true;
|
|
35
40
|
// True only while a programmatic scroll is in flight, so reader-intent
|
|
@@ -10,3 +10,7 @@
|
|
|
10
10
|
.pk-button.pk-pagination-next { padding-inline-end: .375rem; }
|
|
11
11
|
.pk-pagination-label { display: none; }
|
|
12
12
|
@media (min-width: 640px) { .pk-pagination-label { display: block; } }
|
|
13
|
+
|
|
14
|
+
/* RTL: Previous/Next chevrons follow the reading direction. */
|
|
15
|
+
[dir="rtl"] .pk-pagination-previous svg,
|
|
16
|
+
[dir="rtl"] .pk-pagination-next svg { transform: scaleX(-1); }
|
|
@@ -13,7 +13,14 @@ module PhlexKit
|
|
|
13
13
|
|
|
14
14
|
def view_template(&)
|
|
15
15
|
classes = [ "pk-popover-content", fetch_option(ALIGNS, @align, :align) ].compact.join(" ")
|
|
16
|
-
|
|
16
|
+
# `popover:` is a named default, not a merged attr: `mix` joins duplicate
|
|
17
|
+
# attrs (a caller's `popover: "manual"` would fuse into "auto manual",
|
|
18
|
+
# which the browser normalizes to manual — killing the native light
|
|
19
|
+
# dismiss/Escape this controller relies on). Skip the default when the
|
|
20
|
+
# caller sets it (mirrors MenubarContent).
|
|
21
|
+
base = { class: classes, data: { phlex_kit__popover_target: "content", state: "closed" } }
|
|
22
|
+
base[:popover] = "auto" unless attr_set?(:popover)
|
|
23
|
+
div(**mix(base, @attrs), &)
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
end
|
|
@@ -16,6 +16,10 @@ export default class extends Controller {
|
|
|
16
16
|
const invoker = this.triggerTarget.querySelector("button")
|
|
17
17
|
if (invoker) {
|
|
18
18
|
invoker.popoverTargetElement = this.contentTarget
|
|
19
|
+
// popoverTargetElement gives native aria-expanded but not haspopup —
|
|
20
|
+
// shadcn/Radix Popover.Trigger ships aria-haspopup="dialog", so add it
|
|
21
|
+
// (leave a caller-set value alone) to match the button-less path below.
|
|
22
|
+
if (!invoker.hasAttribute("aria-haspopup")) invoker.setAttribute("aria-haspopup", "dialog")
|
|
19
23
|
} else {
|
|
20
24
|
// Button-less fallback: keyboard toggling and popup semantics don't
|
|
21
25
|
// come for free like they do with popoverTargetElement — wire Enter
|
|
@@ -38,6 +38,22 @@
|
|
|
38
38
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
|
|
39
39
|
}
|
|
40
40
|
.pk-radio[aria-invalid="true"]:checked { border-color: var(--pk-brand); }
|
|
41
|
+
/* Invalid + keyboard focus: the always-on invalid ring above would otherwise
|
|
42
|
+
swallow the equal-specificity :focus-visible rule, leaving no visible focus
|
|
43
|
+
change (and the theme-scoped invalid arms below beat it outright in light).
|
|
44
|
+
Keep the red border (invalid stays legible) but switch the ring to the
|
|
45
|
+
standard focus color — mirrors checkbox.css. */
|
|
46
|
+
.pk-radio[aria-invalid="true"]:focus-visible,
|
|
47
|
+
:root[data-theme="light"] .pk-radio[aria-invalid="true"]:focus-visible {
|
|
48
|
+
border-color: var(--pk-red);
|
|
49
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
50
|
+
}
|
|
51
|
+
@media (prefers-color-scheme: light) {
|
|
52
|
+
:root[data-theme="system"] .pk-radio[aria-invalid="true"]:focus-visible {
|
|
53
|
+
border-color: var(--pk-red);
|
|
54
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
41
57
|
.pk-radio:disabled { cursor: not-allowed; opacity: .5; }
|
|
42
58
|
:root[data-theme="light"] .pk-radio:not(:checked) { background: transparent; }
|
|
43
59
|
:root[data-theme="light"] .pk-radio[aria-invalid="true"] {
|
|
@@ -57,6 +57,9 @@ export default class extends Controller {
|
|
|
57
57
|
try { handle.setPointerCapture(e.pointerId) } catch {}
|
|
58
58
|
|
|
59
59
|
const onMove = (ev) => {
|
|
60
|
+
// Only the initiating pointer drives the drag — a second touch point
|
|
61
|
+
// over the handle must not hijack it.
|
|
62
|
+
if (ev.pointerId !== e.pointerId) return
|
|
60
63
|
// LTR assumption: clientX grows toward the trailing panel; RTL drag
|
|
61
64
|
// inversion is a documented limitation.
|
|
62
65
|
const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
|
|
@@ -65,9 +68,12 @@ export default class extends Controller {
|
|
|
65
68
|
const prevGrow = this.clampPrevGrow(prev, next, (prevSize / total) * drag.pairGrow, drag.pairGrow, drag.groupGrow)
|
|
66
69
|
prev.style.flexGrow = prevGrow
|
|
67
70
|
next.style.flexGrow = drag.pairGrow - prevGrow
|
|
68
|
-
this
|
|
71
|
+
// Resizing this pair moves every share: 3+ panel groups have sibling
|
|
72
|
+
// handles whose aria-valuenow would otherwise go stale.
|
|
73
|
+
this.handleTargets.forEach((h) => this.syncValuenow(h))
|
|
69
74
|
}
|
|
70
|
-
const onUp = () => {
|
|
75
|
+
const onUp = (ev) => {
|
|
76
|
+
if (ev.pointerId !== e.pointerId) return
|
|
71
77
|
handle.removeEventListener("pointermove", onMove)
|
|
72
78
|
handle.removeEventListener("pointerup", onUp)
|
|
73
79
|
handle.removeEventListener("pointercancel", onUp)
|
|
@@ -89,8 +95,14 @@ export default class extends Controller {
|
|
|
89
95
|
if (!prev || !next) return
|
|
90
96
|
|
|
91
97
|
const horizontal = this.directionValue === "horizontal"
|
|
98
|
+
// Horizontal arrows follow visual direction (round-7 kit convention, same
|
|
99
|
+
// as tabs/toggle_group): in RTL the DOM-first panel is on the RIGHT, so
|
|
100
|
+
// ArrowLeft must GROW it (move the divider visually left). Only the
|
|
101
|
+
// pointer-drag clientX math keeps its documented LTR exemption; Up/Down
|
|
102
|
+
// never flip. Runtime dir check — reliable after a dynamic flip.
|
|
103
|
+
const rtl = horizontal && getComputedStyle(this.element).direction === "rtl"
|
|
92
104
|
const steps = horizontal
|
|
93
|
-
? { ArrowLeft: -0.05, ArrowRight: 0.05 }
|
|
105
|
+
? (rtl ? { ArrowLeft: 0.05, ArrowRight: -0.05 } : { ArrowLeft: -0.05, ArrowRight: 0.05 })
|
|
94
106
|
: { ArrowUp: -0.05, ArrowDown: 0.05 }
|
|
95
107
|
|
|
96
108
|
const pairGrow = this.growOf(prev) + this.growOf(next) || 2
|
|
@@ -109,7 +121,8 @@ export default class extends Controller {
|
|
|
109
121
|
const prevGrow = this.clampPrevGrow(prev, next, share * pairGrow, pairGrow, this.groupGrow())
|
|
110
122
|
prev.style.flexGrow = prevGrow
|
|
111
123
|
next.style.flexGrow = pairGrow - prevGrow
|
|
112
|
-
|
|
124
|
+
// All handles: sibling dividers share panels in 3+ panel groups.
|
|
125
|
+
this.handleTargets.forEach((h) => this.syncValuenow(h))
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
growOf(el) {
|
|
@@ -8,18 +8,20 @@ module PhlexKit
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def view_template
|
|
11
|
-
|
|
11
|
+
base = {
|
|
12
12
|
class: "pk-resizable-handle",
|
|
13
|
-
role: "separator",
|
|
14
|
-
tabindex: "0",
|
|
15
|
-
# Correct for the default horizontal group; the controller re-stamps
|
|
16
|
-
# orientation (and aria-valuenow/min/max) per group direction on connect.
|
|
17
|
-
aria: { orientation: "vertical" },
|
|
18
13
|
data: {
|
|
19
14
|
phlex_kit__resizable_target: "handle",
|
|
20
15
|
action: "pointerdown->phlex-kit--resizable#start keydown->phlex-kit--resizable#keydown"
|
|
21
16
|
}
|
|
22
|
-
}
|
|
17
|
+
}
|
|
18
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
19
|
+
base[:role] = "separator" unless attr_set?(:role)
|
|
20
|
+
base[:tabindex] = "0" unless attr_set?(:tabindex)
|
|
21
|
+
# Correct for the default horizontal group; the controller re-stamps
|
|
22
|
+
# orientation (and aria-valuenow/min/max) per group direction on connect.
|
|
23
|
+
base[:aria] = { orientation: "vertical" } unless aria_key_set?(:orientation)
|
|
24
|
+
div(**mix(base, @attrs)) do
|
|
23
25
|
span(class: "pk-resizable-handle-grip", aria: { hidden: "true" }) if @with_handle
|
|
24
26
|
end
|
|
25
27
|
end
|
|
@@ -39,7 +39,7 @@ module PhlexKit
|
|
|
39
39
|
# keydown.esc rides on the root (not only the items) so Escape closes
|
|
40
40
|
# the [popover=manual] panel with focus on the trigger too; handleEsc
|
|
41
41
|
# no-ops while closed.
|
|
42
|
-
action: "click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc"
|
|
42
|
+
action: "mousedown@window->phlex-kit--select#onMousedownOutside click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc focusout->phlex-kit--select#onFocusout"
|
|
43
43
|
}
|
|
44
44
|
}, @attrs), &block)
|
|
45
45
|
end
|
|
@@ -14,17 +14,19 @@ module PhlexKit
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def view_template(&block)
|
|
17
|
-
|
|
17
|
+
base = {
|
|
18
18
|
id: @id,
|
|
19
|
-
role: "listbox",
|
|
20
|
-
tabindex: "-1",
|
|
21
19
|
class: "pk-select-content",
|
|
22
|
-
popover: "manual",
|
|
23
20
|
data: {
|
|
24
21
|
phlex_kit__select_target: "content",
|
|
25
22
|
action: "keydown->phlex-kit--select#typeahead"
|
|
26
23
|
}
|
|
27
|
-
}
|
|
24
|
+
}
|
|
25
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
26
|
+
base[:role] = "listbox" unless attr_set?(:role)
|
|
27
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
28
|
+
base[:popover] = "manual" unless attr_set?(:popover)
|
|
29
|
+
div(**mix(base, @attrs)) do
|
|
28
30
|
div(class: "pk-select-viewport", &block)
|
|
29
31
|
end
|
|
30
32
|
end
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
|
+
// One-shot capture listener swallowing the click an outside mousedown is about
|
|
4
|
+
// to produce (see onMousedownOutside; same helper in dropdown/context/menubar
|
|
5
|
+
// — duplicated per controller by design). Select is a MODAL menu: the
|
|
6
|
+
// dismissing outside click ONLY dismisses, never also acts on what sits under
|
|
7
|
+
// the pointer. Armed at MOUSEDOWN because for a focusable click target the same
|
|
8
|
+
// gesture's focusout closes the panel before the click fires (a click-time
|
|
9
|
+
// check would already see it closed and skip the swallow — audit round 8).
|
|
10
|
+
const swallowClick = (ev) => {
|
|
11
|
+
ev.preventDefault();
|
|
12
|
+
ev.stopPropagation();
|
|
13
|
+
};
|
|
14
|
+
function armSwallowClick() {
|
|
15
|
+
window.addEventListener("click", swallowClick, { once: true, capture: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
// Ported from ruby_ui's phlex-kit--select controller, minus the
|
|
4
19
|
// @floating-ui/dom dependency: the panel is a native [popover=manual] in the
|
|
5
20
|
// top layer, anchor-positioned with viewport-edge flipping by select.css
|
|
@@ -53,6 +68,12 @@ export default class extends Controller {
|
|
|
53
68
|
const newValue = item.dataset.value;
|
|
54
69
|
|
|
55
70
|
this.inputTarget.value = newValue;
|
|
71
|
+
// Also set the value ATTRIBUTE, not just the .value property: a Turbo
|
|
72
|
+
// snapshot restore clones the DOM (dropping the dirty property) and would
|
|
73
|
+
// otherwise revert the hidden input to its server value while the label
|
|
74
|
+
// and aria-selected still show the chosen item — submitting the wrong
|
|
75
|
+
// value behind a correct-looking UI. (re-derive-from-live-truth rule.)
|
|
76
|
+
this.inputTarget.setAttribute("value", newValue);
|
|
56
77
|
this.valueTarget.innerText = item.innerText;
|
|
57
78
|
|
|
58
79
|
this.dispatchOnChange(oldValue, newValue);
|
|
@@ -162,6 +183,17 @@ export default class extends Controller {
|
|
|
162
183
|
this.itemTargets.forEach((item) => item.removeAttribute("aria-current"));
|
|
163
184
|
}
|
|
164
185
|
|
|
186
|
+
// Modal dismiss: arm the swallow at mousedown so the outside click that
|
|
187
|
+
// dismisses an open select doesn't ALSO activate a focusable control under
|
|
188
|
+
// the pointer (the same gesture's focusout closes the panel before the
|
|
189
|
+
// click, so clickOutside below would already see it closed). Mirrors
|
|
190
|
+
// dropdown/context/menubar.
|
|
191
|
+
onMousedownOutside(event) {
|
|
192
|
+
if (!this.contentTarget.matches(":popover-open")) return;
|
|
193
|
+
if (this.element.contains(event.target)) return;
|
|
194
|
+
armSwallowClick();
|
|
195
|
+
}
|
|
196
|
+
|
|
165
197
|
clickOutside(event) {
|
|
166
198
|
if (!this.contentTarget.matches(":popover-open")) return;
|
|
167
199
|
if (this.element.contains(event.target)) return;
|
|
@@ -170,6 +202,16 @@ export default class extends Controller {
|
|
|
170
202
|
this.#hide();
|
|
171
203
|
}
|
|
172
204
|
|
|
205
|
+
// APG (select-only combobox): Tab out of an open listbox closes it —
|
|
206
|
+
// dropdown_menu's onFocusout pattern. relatedTarget is null when the
|
|
207
|
+
// window itself blurs; leave the panel alone then.
|
|
208
|
+
onFocusout(event) {
|
|
209
|
+
if (!this.contentTarget.matches(":popover-open")) return;
|
|
210
|
+
const to = event.relatedTarget;
|
|
211
|
+
if (!to || this.element.contains(to)) return;
|
|
212
|
+
this.#hide();
|
|
213
|
+
}
|
|
214
|
+
|
|
173
215
|
// Open/close derive from the live :popover-open state, never a stored
|
|
174
216
|
// flag — a stale flag is how a close on an already-closed panel becomes
|
|
175
217
|
// an open (bit the popover's keyboard toggle).
|
|
@@ -4,7 +4,7 @@ module PhlexKit
|
|
|
4
4
|
class SelectSeparator < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template
|
|
7
|
-
div(**mix({ class: "pk-select-separator", role: "separator", aria: { hidden: true } }, @attrs))
|
|
7
|
+
div(**mix({ class: "pk-select-separator", role: "separator", aria: { hidden: "true" } }, @attrs))
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
10
|
end
|
|
@@ -63,12 +63,19 @@ export default class extends Controller {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
keydown(event) {
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
66
|
+
// Yield to any inner overlay that owns this Escape so one keypress doesn't
|
|
67
|
+
// also close the sheet/drawer underneath: a native <dialog> (its own
|
|
68
|
+
// Escape handling), or an open [popover] menu — Select / DropdownMenu /
|
|
69
|
+
// Combobox, all popover=manual — rendered inside the panel. Two signals
|
|
70
|
+
// make the outcome independent of ordering: event.defaultPrevented catches
|
|
71
|
+
// an inner handler that already consumed the key (Select's Escape is
|
|
72
|
+
// element-scoped and fires deeper in the bubble, so it runs first); the
|
|
73
|
+
// live :popover-open check catches a menu whose Escape handler is
|
|
74
|
+
// document-level (DropdownMenu) and thus still open when this
|
|
75
|
+
// element-scoped listener runs.
|
|
76
|
+
if (event.key === "Escape" && event.defaultPrevented) return
|
|
71
77
|
if (event.key === "Escape" && event.target.closest("dialog[open]")) return
|
|
78
|
+
if (event.key === "Escape" && this.panel.querySelector("[popover]:popover-open")) return
|
|
72
79
|
if (event.key === "Escape") {
|
|
73
80
|
event.preventDefault()
|
|
74
81
|
// Stop the keydown from reaching a lower/outer overlay's own
|
|
@@ -17,12 +17,13 @@ module PhlexKit
|
|
|
17
17
|
base = {
|
|
18
18
|
type: :button,
|
|
19
19
|
class: "pk-sidebar-rail",
|
|
20
|
-
aria_label: "Toggle sidebar",
|
|
21
20
|
"aria-expanded": @expanded ? "true" : "false",
|
|
22
21
|
tabindex: "-1",
|
|
23
|
-
title: "Toggle sidebar",
|
|
24
22
|
data: { action: "click->phlex-kit--sidebar#toggle" }
|
|
25
23
|
}
|
|
24
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
25
|
+
base[:aria_label] = "Toggle sidebar" unless aria_labelled?
|
|
26
|
+
base[:title] = "Toggle sidebar" unless attr_set?(:title)
|
|
26
27
|
base["aria-controls"] = @controls if @controls
|
|
27
28
|
button(**mix(base, @attrs))
|
|
28
29
|
end
|
|
@@ -20,10 +20,11 @@ module PhlexKit
|
|
|
20
20
|
base = {
|
|
21
21
|
type: :button,
|
|
22
22
|
class: "pk-sidebar-trigger",
|
|
23
|
-
aria_label: "Toggle sidebar",
|
|
24
23
|
"aria-expanded": @expanded ? "true" : "false",
|
|
25
24
|
data: { action: "click->phlex-kit--sidebar#toggle" }
|
|
26
25
|
}
|
|
26
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
27
|
+
base[:aria_label] = "Toggle sidebar" unless aria_labelled?
|
|
27
28
|
base["aria-controls"] = @controls if @controls
|
|
28
29
|
button(**mix(base, @attrs)) do
|
|
29
30
|
block ? yield : render(Icon.new(:menu, size: nil))
|
|
@@ -30,7 +30,10 @@ module PhlexKit
|
|
|
30
30
|
end
|
|
31
31
|
# role="switch" belongs on the focusable control: native checkedness then
|
|
32
32
|
# maps to aria-checked for AT, and the label stays a plain label.
|
|
33
|
-
|
|
33
|
+
# Default role only when the caller didn't supply their own — `mix` fuses.
|
|
34
|
+
input_base = { class: "pk-switch-input" }
|
|
35
|
+
input_base[:role] = "switch" unless attr_set?(:role)
|
|
36
|
+
input(**mix(input_base, @attrs).merge(type: "checkbox", value: @checked_value))
|
|
34
37
|
span(class: "pk-switch-thumb")
|
|
35
38
|
end
|
|
36
39
|
end
|
|
@@ -6,7 +6,12 @@ module PhlexKit
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def view_template(&block)
|
|
9
|
-
|
|
9
|
+
# scope="col" is the common case (header cells sit in a TableHeader row);
|
|
10
|
+
# a generated default, not a merged attr — `mix` would fuse a caller's
|
|
11
|
+
# `scope: "row"` into "col row" — so skip it when the caller sets scope.
|
|
12
|
+
base = { class: "pk-table-head" }
|
|
13
|
+
base[:scope] = "col" unless attr_set?(:scope)
|
|
14
|
+
th(**mix(base, @attrs), &block)
|
|
10
15
|
end
|
|
11
16
|
end
|
|
12
17
|
end
|
|
@@ -13,7 +13,12 @@ module PhlexKit
|
|
|
13
13
|
# focusable, per the APG tabs pattern. `active:` (mirroring TabsTrigger's)
|
|
14
14
|
# renders the panel visible pre-JS/no-JS; the controller's sync()
|
|
15
15
|
# reconciles visibility from the active value after connect.
|
|
16
|
-
|
|
16
|
+
base = { class: @active ? "pk-tabs-content" : "pk-tabs-content pk-hidden", role: "tabpanel", id: @id, data: { phlex_kit__tabs_target: "content", value: @value } }
|
|
17
|
+
# Generated defaults only when the caller didn't supply their own —
|
|
18
|
+
# `mix` fuses (custom-id trigger pairing / a non-focusable panel).
|
|
19
|
+
base[:aria_labelledby] = "pk-tabs-trigger-#{@value}" unless aria_key_set?(:labelledby)
|
|
20
|
+
base[:tabindex] = "0" unless attr_set?(:tabindex)
|
|
21
|
+
div(**mix(base, @attrs), &)
|
|
17
22
|
end
|
|
18
23
|
end
|
|
19
24
|
end
|
|
@@ -24,13 +24,15 @@ module PhlexKit
|
|
|
24
24
|
base = {
|
|
25
25
|
class: "pk-tabs-trigger", role: "tab", id: @id,
|
|
26
26
|
aria_selected: @active ? "true" : "false",
|
|
27
|
-
aria_controls: "pk-tabs-panel-#{@value}",
|
|
28
27
|
tabindex: @active ? "0" : "-1",
|
|
29
28
|
# data-state is the only hook tabs.css styles the active trigger by —
|
|
30
29
|
# it must be server-rendered or the active tab looks inactive pre-JS.
|
|
31
30
|
data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value,
|
|
32
31
|
state: @active ? "active" : "inactive" }
|
|
33
32
|
}
|
|
33
|
+
# Generated idref default only when the caller didn't supply their own
|
|
34
|
+
# (pairing with a custom-id TabsContent) — `mix` fuses.
|
|
35
|
+
base[:aria_controls] = "pk-tabs-panel-#{@value}" unless aria_key_set?(:controls)
|
|
34
36
|
if @as == :a
|
|
35
37
|
a(**mix(base, @attrs), &)
|
|
36
38
|
else
|
|
@@ -25,12 +25,23 @@ export default class extends Controller {
|
|
|
25
25
|
// echo guard in apply().
|
|
26
26
|
this._rootObserver = new MutationObserver(() => this.syncPressed())
|
|
27
27
|
this._rootObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] })
|
|
28
|
+
// Cross-tab sync: `storage` fires only in OTHER tabs when localStorage.theme
|
|
29
|
+
// changes, so another tab's toggle re-applies here. Without this, this tab's
|
|
30
|
+
// DOM went stale while storedTheme() (read fresh from localStorage) already
|
|
31
|
+
// returned the new value — the echo guard in apply() then swallowed a
|
|
32
|
+
// genuine click because resolvedTheme() already matched.
|
|
33
|
+
this._onStorage = (e) => {
|
|
34
|
+
if (e.key !== "theme") return
|
|
35
|
+
this.applyTheme(this.storedTheme() || "system")
|
|
36
|
+
}
|
|
37
|
+
window.addEventListener("storage", this._onStorage)
|
|
28
38
|
this.applyTheme(this.storedTheme() || "system")
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
disconnect() {
|
|
32
42
|
this._media?.removeEventListener("change", this._onMediaChange)
|
|
33
43
|
this._rootObserver?.disconnect()
|
|
44
|
+
window.removeEventListener("storage", this._onStorage)
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
syncPressed() {
|
|
@@ -41,12 +52,19 @@ export default class extends Controller {
|
|
|
41
52
|
|
|
42
53
|
apply(e) {
|
|
43
54
|
const theme = e.detail?.pressed ? "dark" : "light"
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// is
|
|
48
|
-
//
|
|
49
|
-
|
|
55
|
+
// Skip when this isn't a genuine user toggle — two echo shapes:
|
|
56
|
+
// - the incoming theme already matches the RESOLVED one: connect()'s own
|
|
57
|
+
// pressed-state sync re-enters here via the toggle's change event
|
|
58
|
+
// (Stimulus value observation is async, so a flag can't gate it), and a
|
|
59
|
+
// "system" resolution lands here too — persisting either would silently
|
|
60
|
+
// pin the visitor's OS preference in localStorage on first visit.
|
|
61
|
+
// - the root's data-theme ALREADY equals it: host code (or a cross-tab
|
|
62
|
+
// storage sync) wrote the theme directly. The contract is that only a
|
|
63
|
+
// user toggle persists, and a real user toggle reaches here BEFORE
|
|
64
|
+
// applyTheme updates the root, so the root still holds the OLD value —
|
|
65
|
+
// a match therefore means "not a user action, don't persist".
|
|
66
|
+
const rootTheme = document.documentElement.getAttribute("data-theme")
|
|
67
|
+
if (theme === this.resolvedTheme() || theme === rootTheme) return
|
|
50
68
|
this.storeTheme(theme)
|
|
51
69
|
this.applyTheme(theme)
|
|
52
70
|
}
|
|
@@ -16,6 +16,11 @@ export default class extends Controller {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
connect() {
|
|
19
|
+
// Inherit the region's duration when this item didn't set its own: a
|
|
20
|
+
// block-rendered ToastItem carries no duration-value, so without this the
|
|
21
|
+
// Stimulus 4000ms default would win and ToastRegion(duration:) would be
|
|
22
|
+
// dead config for block items (skeleton/flash items already stamp it).
|
|
23
|
+
this._inheritRegionDuration()
|
|
19
24
|
this._timer = null
|
|
20
25
|
this._unmountTimer = null
|
|
21
26
|
this._startedAt = 0
|
|
@@ -35,7 +40,16 @@ export default class extends Controller {
|
|
|
35
40
|
this._onPointerLeave = () => { if (!this._swipe.active) this._resume("hover") }
|
|
36
41
|
// Keyboard parity with hover: tabbing into the toast (e.g. to reach its
|
|
37
42
|
// action button) must pause auto-dismiss just like pointerenter does.
|
|
38
|
-
this._onFocusIn = () =>
|
|
43
|
+
this._onFocusIn = (e) => {
|
|
44
|
+
this._pause("focus")
|
|
45
|
+
// Remember where focus came from the FIRST time it enters from outside
|
|
46
|
+
// the toaster, so dismissing the last focused toast can hand focus back
|
|
47
|
+
// instead of dropping it on <body>.
|
|
48
|
+
const list = this.element.closest(".pk-toast-list") || this.element
|
|
49
|
+
if (this._focusReturn == null && e.relatedTarget && !list.contains(e.relatedTarget)) {
|
|
50
|
+
this._focusReturn = e.relatedTarget
|
|
51
|
+
}
|
|
52
|
+
}
|
|
39
53
|
this._onFocusOut = (e) => { if (!this.element.contains(e.relatedTarget)) this._resume("focus") }
|
|
40
54
|
this._onKeyDown = this._onKeyDown.bind(this)
|
|
41
55
|
this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
|
|
@@ -103,11 +117,42 @@ export default class extends Controller {
|
|
|
103
117
|
_close(reason) {
|
|
104
118
|
if (this.element.dataset.state === "closing") return
|
|
105
119
|
this.element.dataset.state = "closing"
|
|
120
|
+
// If focus is inside this toast, move it BEFORE removal so it doesn't drop
|
|
121
|
+
// to <body> when the element is gone (Escape, close button, or action).
|
|
122
|
+
const hadFocus = this.element.contains(document.activeElement)
|
|
106
123
|
this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "phlex-kit:toast:auto-close" : "phlex-kit:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
|
|
107
124
|
this._invokeCallback(reason === "auto" ? this.onAutoCloseValue : this.onDismissValue)
|
|
125
|
+
if (hadFocus) this._restoreFocusOnClose()
|
|
108
126
|
this._unmountTimer = setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
|
|
109
127
|
}
|
|
110
128
|
|
|
129
|
+
// Keep keyboard focus inside the toaster when a focused toast is dismissed:
|
|
130
|
+
// hand it to an adjacent remaining toast, else back to wherever focus came
|
|
131
|
+
// from before it entered the toaster (tracked on first focusin).
|
|
132
|
+
_restoreFocusOnClose() {
|
|
133
|
+
const list = this.element.closest(".pk-toast-list")
|
|
134
|
+
const others = list
|
|
135
|
+
? [...list.querySelectorAll("[data-controller~='phlex-kit--toast']")]
|
|
136
|
+
.filter((t) => t !== this.element && t.dataset.state !== "closing")
|
|
137
|
+
: []
|
|
138
|
+
if (others.length) {
|
|
139
|
+
const children = [...list.children]
|
|
140
|
+
const myIdx = children.indexOf(this.element)
|
|
141
|
+
const next = others.find((t) => children.indexOf(t) > myIdx) || others[others.length - 1]
|
|
142
|
+
next.focus()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (this._focusReturn?.isConnected) this._focusReturn.focus()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_inheritRegionDuration() {
|
|
149
|
+
if (this.element.hasAttribute("data-phlex-kit--toast-duration-value")) return
|
|
150
|
+
const region = this.element.closest("[data-phlex-kit--toaster-duration-value]")
|
|
151
|
+
if (!region) return
|
|
152
|
+
const inherited = Number(region.getAttribute("data-phlex-kit--toaster-duration-value"))
|
|
153
|
+
if (Number.isFinite(inherited)) this.durationValue = inherited
|
|
154
|
+
}
|
|
155
|
+
|
|
111
156
|
// on_dismiss:/on_auto_close: name a global function (Sonner-style callback
|
|
112
157
|
// for server-rendered toasts).
|
|
113
158
|
_invokeCallback(name) {
|