phlex_kit 0.13.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_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 +17 -2
- 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 +10 -1
- 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 +11 -1
- 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/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 +10 -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/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 +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/version.rb +1 -1
- metadata +1 -1
|
@@ -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: "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
|
|
@@ -170,6 +170,16 @@ export default class extends Controller {
|
|
|
170
170
|
this.#hide();
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// APG (select-only combobox): Tab out of an open listbox closes it —
|
|
174
|
+
// dropdown_menu's onFocusout pattern. relatedTarget is null when the
|
|
175
|
+
// window itself blurs; leave the panel alone then.
|
|
176
|
+
onFocusout(event) {
|
|
177
|
+
if (!this.contentTarget.matches(":popover-open")) return;
|
|
178
|
+
const to = event.relatedTarget;
|
|
179
|
+
if (!to || this.element.contains(to)) return;
|
|
180
|
+
this.#hide();
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
// Open/close derive from the live :popover-open state, never a stored
|
|
174
184
|
// flag — a stale flag is how a close on an already-closed panel becomes
|
|
175
185
|
// 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
|
|
@@ -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) {
|
|
@@ -80,9 +80,17 @@ export default class extends Controller {
|
|
|
80
80
|
this._onWindowDismissId = (e) => this._dismissById(e.detail?.id)
|
|
81
81
|
this._onKey = this._onKey.bind(this)
|
|
82
82
|
|
|
83
|
+
// The region is data-turbo-permanent (in-flight toasts survive a Turbo
|
|
84
|
+
// Drive visit), but that means Turbo KEEPS this old region and DISCARDS
|
|
85
|
+
// the incoming page's — throwing away any server-rendered flash toasts it
|
|
86
|
+
// holds. Adopt them into this permanent list before the transplant so
|
|
87
|
+
// flash: works on Turbo Drive visits, not only full page loads.
|
|
88
|
+
this._onBeforeRender = (e) => this._adoptIncomingToasts(e)
|
|
89
|
+
|
|
83
90
|
window.addEventListener("phlex-kit:toast", this._onWindowToast)
|
|
84
91
|
window.addEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
|
|
85
92
|
window.addEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
|
|
93
|
+
document.addEventListener("turbo:before-render", this._onBeforeRender)
|
|
86
94
|
this._listEl.addEventListener("pointerenter", this._onPointerEnter)
|
|
87
95
|
this._listEl.addEventListener("pointerleave", this._onPointerLeave)
|
|
88
96
|
document.addEventListener("keydown", this._onKey)
|
|
@@ -96,6 +104,7 @@ export default class extends Controller {
|
|
|
96
104
|
window.removeEventListener("phlex-kit:toast", this._onWindowToast)
|
|
97
105
|
window.removeEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
|
|
98
106
|
window.removeEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
|
|
107
|
+
document.removeEventListener("turbo:before-render", this._onBeforeRender)
|
|
99
108
|
this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
|
|
100
109
|
this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
|
|
101
110
|
document.removeEventListener("keydown", this._onKey)
|
|
@@ -170,7 +179,10 @@ export default class extends Controller {
|
|
|
170
179
|
node.appendChild(btn)
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
|
|
182
|
+
// Don't append a second close button when the skeleton already baked one
|
|
183
|
+
// in (region rendered with close_button: true) — spawning with
|
|
184
|
+
// closeButton: true would otherwise stack two ×'s.
|
|
185
|
+
if (detail.closeButton && this.hasCloseTplTarget && !node.querySelector('[data-slot="close"]')) {
|
|
174
186
|
const x = this._cloneSlot(this.closeTplTarget)
|
|
175
187
|
node.classList.add("pk-toast-with-close")
|
|
176
188
|
node.appendChild(x)
|
|
@@ -180,6 +192,22 @@ export default class extends Controller {
|
|
|
180
192
|
return node.id
|
|
181
193
|
}
|
|
182
194
|
|
|
195
|
+
// Move server-rendered flash toasts from the incoming (about-to-be-discarded)
|
|
196
|
+
// page's matching region into this permanent list, so a Turbo Drive visit
|
|
197
|
+
// doesn't silently drop them. Runs on turbo:before-render, before Turbo
|
|
198
|
+
// transplants this permanent element into the new body (children ride along).
|
|
199
|
+
_adoptIncomingToasts(e) {
|
|
200
|
+
const newBody = e.detail?.newBody
|
|
201
|
+
if (!newBody || !this._listEl?.id) return
|
|
202
|
+
const incomingList = newBody.querySelector(`#${CSS.escape(this._listEl.id)}`)
|
|
203
|
+
if (!incomingList) return
|
|
204
|
+
incomingList.querySelectorAll(":scope > [data-phlex-kit--toaster-target='toast']").forEach((el) => {
|
|
205
|
+
// Skip a flash id already showing here, so a re-render doesn't dupe it.
|
|
206
|
+
if (el.id && this._listEl.querySelector(`#${CSS.escape(el.id)}`)) return
|
|
207
|
+
this._listEl.appendChild(el.cloneNode(true))
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
// True when this region should act on a toast event carrying `region`
|
|
184
212
|
// (the list's base id or the wrapper's "<id>-region" both match). Without
|
|
185
213
|
// an explicit region, only the page's first region in DOM order handles
|
|
@@ -39,7 +39,7 @@ module PhlexKit
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def button_default_attrs
|
|
42
|
-
a = { type:
|
|
42
|
+
a = { type: :button, class: classes, aria: { pressed: @pressed.to_s },
|
|
43
43
|
data: { state: @pressed ? "on" : "off", phlex_kit__toggle_target: "button" } }
|
|
44
44
|
a[:disabled] = true if @disabled
|
|
45
45
|
a
|
|
@@ -42,9 +42,16 @@ module PhlexKit
|
|
|
42
42
|
# (`pressed || enabledItems()[0]`). Items call this once each, in render
|
|
43
43
|
# order, as they're constructed — a single-threaded sequential claim, not
|
|
44
44
|
# a lookahead over siblings not yet rendered.
|
|
45
|
-
def claim_tab_stop(item_disabled)
|
|
46
|
-
|
|
45
|
+
def claim_tab_stop(item_disabled, pressed: false)
|
|
46
|
+
# A pressed-but-disabled item can't take focus (reconcile() mirrors
|
|
47
|
+
# this with `pressed && !el.disabled`): unlock the fallback claim so a
|
|
48
|
+
# LATER enabled item takes the stop instead of the whole group
|
|
49
|
+
# rendering Tab-unreachable. (If every enabled item precedes the
|
|
50
|
+
# pressed-disabled one, the pre-JS render still has no stop — the
|
|
51
|
+
# sequential claim can't look ahead; reconcile() fixes it at connect.)
|
|
52
|
+
@selection_unfocusable = true if pressed && item_disabled
|
|
47
53
|
return false if item_disabled
|
|
54
|
+
return false unless selected_values.empty? || @selection_unfocusable
|
|
48
55
|
return false if @tab_stop_claimed
|
|
49
56
|
@tab_stop_claimed = true
|
|
50
57
|
true
|
|
@@ -61,10 +68,17 @@ module PhlexKit
|
|
|
61
68
|
|
|
62
69
|
def render_hidden_inputs
|
|
63
70
|
return unless @name
|
|
71
|
+
# Disabled in lockstep with the group — a disabled toggle group must
|
|
72
|
+
# not submit its value (matches toggle.rb and native form semantics).
|
|
73
|
+
disabled = @disabled ? true : nil
|
|
64
74
|
if @type == :single
|
|
65
|
-
input(type: "hidden", name: @name, value: selected_values.first.to_s,
|
|
75
|
+
input(type: "hidden", name: @name, value: selected_values.first.to_s, disabled: disabled,
|
|
76
|
+
data: { phlex_kit__toggle_group_target: "input" })
|
|
66
77
|
else
|
|
67
|
-
selected_values.each
|
|
78
|
+
selected_values.each do |v|
|
|
79
|
+
input(type: "hidden", name: "#{@name}[]", value: v, disabled: disabled,
|
|
80
|
+
data: { phlex_kit__toggle_group_target: "input" })
|
|
81
|
+
end
|
|
68
82
|
end
|
|
69
83
|
end
|
|
70
84
|
|
|
@@ -17,7 +17,7 @@ module PhlexKit
|
|
|
17
17
|
# group_context[:group] is nil for a hand-built context (bypassing
|
|
18
18
|
# ToggleGroup#item_context) — treat that as "no tab-stop claim" rather
|
|
19
19
|
# than raising, since standalone tests construct contexts by hand.
|
|
20
|
-
@is_first_tab_stop = group_context[:group]&.claim_tab_stop(item_disabled) || false
|
|
20
|
+
@is_first_tab_stop = group_context[:group]&.claim_tab_stop(item_disabled, pressed: pressed) || false
|
|
21
21
|
super(pressed: pressed, name: nil, value: @item_value,
|
|
22
22
|
variant: variant || group_context[:variant],
|
|
23
23
|
size: size || group_context[:size],
|
|
@@ -31,7 +31,7 @@ module PhlexKit
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
33
|
def item_default_attrs
|
|
34
|
-
a = { type:
|
|
34
|
+
a = { type: :button,
|
|
35
35
|
class: ([ "pk-toggle" ] + self.class.modifier_classes(variant: @variant, size: @size) + [ "pk-toggle-group-item" ]).join(" "),
|
|
36
36
|
data: { state: @pressed ? "on" : "off", value: @item_value,
|
|
37
37
|
phlex_kit__toggle_group_target: "item",
|
|
@@ -20,6 +20,11 @@ export default class extends Controller {
|
|
|
20
20
|
})
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// connect() re-derives reflected state from live truth: a Turbo snapshot
|
|
24
|
+
// serializes an Escape-dismissed suppression, and a cache restore must
|
|
25
|
+
// not resurrect it (the pointer/focus that justified it is gone).
|
|
26
|
+
delete this.element.dataset.pkDismissed
|
|
27
|
+
|
|
23
28
|
this._onKeydown = (e) => {
|
|
24
29
|
if (e.key !== "Escape") return
|
|
25
30
|
if (this.element.matches(":hover, :focus-within")) this.element.dataset.pkDismissed = ""
|
|
@@ -50,6 +50,9 @@
|
|
|
50
50
|
/* Blockquote */
|
|
51
51
|
.pk-blockquote { margin: 1.5rem 0; border-inline-start: 2px solid var(--pk-border); padding-inline-start: 1.5rem; font-style: italic; }
|
|
52
52
|
|
|
53
|
-
/* Prose list (shadcn typography's list style).
|
|
54
|
-
|
|
53
|
+
/* Prose list (shadcn typography's list style). Ordered variant (as: :ol)
|
|
54
|
+
keeps its numbers — the disc is scoped to unordered lists. */
|
|
55
|
+
.pk-list { margin: 1.5rem 0; padding-inline-start: 1.5rem; }
|
|
56
|
+
ul.pk-list { list-style: disc; }
|
|
57
|
+
ol.pk-list { list-style: decimal; }
|
|
55
58
|
.pk-list > li { margin-top: .5rem; }
|
data/lib/phlex_kit/version.rb
CHANGED