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
|
@@ -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
|
|
@@ -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
|
block&.call
|
|
26
26
|
if @show_close_button
|
|
@@ -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))
|
|
@@ -9,7 +9,7 @@ module PhlexKit
|
|
|
9
9
|
# value: is silently clobbered below (the checkbox's real value comes
|
|
10
10
|
# from checked_value:) — fail loud rather than let a caller's value:
|
|
11
11
|
# vanish (mirrors ToggleGroupItem's unsupported-kwargs guard).
|
|
12
|
-
if attrs.key?(:value)
|
|
12
|
+
if attrs.key?(:value) || attrs.key?("value")
|
|
13
13
|
raise ArgumentError, "Switch does not support value: — the checkbox's value comes from checked_value:, pass that instead"
|
|
14
14
|
end
|
|
15
15
|
@include_hidden = include_hidden
|
|
@@ -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) {
|
|
@@ -208,7 +253,12 @@ export default class extends Controller {
|
|
|
208
253
|
this.element.dataset.swipe = "cancel"
|
|
209
254
|
this.element.style.removeProperty("--pk-toast-swipe-x")
|
|
210
255
|
this.element.style.removeProperty("--pk-toast-swipe-y")
|
|
211
|
-
|
|
256
|
+
// pointerleave is suppressed while a swipe is active, so release the
|
|
257
|
+
// hover hold here — but only if the pointer actually left the toast; a
|
|
258
|
+
// cancelled swipe ending under the cursor must stay hover-paused (the
|
|
259
|
+
// eventual pointerleave resumes it, including the touch case where
|
|
260
|
+
// pointerleave fires right after pointerup).
|
|
261
|
+
if (!this.element.matches(":hover")) this._resume("hover")
|
|
212
262
|
}
|
|
213
263
|
}
|
|
214
264
|
}
|
|
@@ -60,10 +60,10 @@ module PhlexKit
|
|
|
60
60
|
# Defaults only when the caller didn't supply their own — `mix` would
|
|
61
61
|
# fuse role="status log" / tabindex="0 -1" / aria-atomic="true false"
|
|
62
62
|
# instead of overriding.
|
|
63
|
-
unless
|
|
63
|
+
unless attr_set?(:role)
|
|
64
64
|
attrs[:role] = ALERT_VARIANTS.include?(@variant) ? "alert" : "status"
|
|
65
65
|
end
|
|
66
|
-
attrs[:tabindex] = "0" unless
|
|
66
|
+
attrs[:tabindex] = "0" unless attr_set?(:tabindex)
|
|
67
67
|
unless aria_key_set?(:atomic)
|
|
68
68
|
attrs[:aria] = { atomic: "true" }
|
|
69
69
|
end
|
|
@@ -63,12 +63,34 @@ export default class extends Controller {
|
|
|
63
63
|
// _setExpanded's state change left auto-dismiss running under the cursor.
|
|
64
64
|
this._onPointerEnter = () => { this._setPaused(true); this._setExpanded(true) }
|
|
65
65
|
this._onPointerLeave = () => { this._setPaused(false); if (!this.expandValue) this._setExpanded(false) }
|
|
66
|
-
this._onWindowToast = (e) =>
|
|
66
|
+
this._onWindowToast = (e) => {
|
|
67
|
+
const detail = e.detail || {}
|
|
68
|
+
// Routing: toast(msg, { region: "<id>" }) targets the region whose
|
|
69
|
+
// wrapper or list carries that id. Without region only the page's
|
|
70
|
+
// FIRST region spawns — the event is a window broadcast, so every
|
|
71
|
+
// region hears it and an unguarded spawn would duplicate the toast
|
|
72
|
+
// once per region.
|
|
73
|
+
if (!this._handlesRegion(detail.region)) return
|
|
74
|
+
this._spawn(detail)
|
|
75
|
+
}
|
|
67
76
|
this._onWindowDismissAll = () => this._dismissById(null)
|
|
77
|
+
// Dismiss-by-id is a broadcast too: _dismissById only acts on a toast in
|
|
78
|
+
// its OWN list, so whichever region owns the id handles it — the api
|
|
79
|
+
// registration (last region wins) no longer has to be the owner.
|
|
80
|
+
this._onWindowDismissId = (e) => this._dismissById(e.detail?.id)
|
|
68
81
|
this._onKey = this._onKey.bind(this)
|
|
69
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
|
+
|
|
70
90
|
window.addEventListener("phlex-kit:toast", this._onWindowToast)
|
|
71
91
|
window.addEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
|
|
92
|
+
window.addEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
|
|
93
|
+
document.addEventListener("turbo:before-render", this._onBeforeRender)
|
|
72
94
|
this._listEl.addEventListener("pointerenter", this._onPointerEnter)
|
|
73
95
|
this._listEl.addEventListener("pointerleave", this._onPointerLeave)
|
|
74
96
|
document.addEventListener("keydown", this._onKey)
|
|
@@ -81,6 +103,8 @@ export default class extends Controller {
|
|
|
81
103
|
disconnect() {
|
|
82
104
|
window.removeEventListener("phlex-kit:toast", this._onWindowToast)
|
|
83
105
|
window.removeEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
|
|
106
|
+
window.removeEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
|
|
107
|
+
document.removeEventListener("turbo:before-render", this._onBeforeRender)
|
|
84
108
|
this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
|
|
85
109
|
this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
|
|
86
110
|
document.removeEventListener("keydown", this._onKey)
|
|
@@ -155,7 +179,10 @@ export default class extends Controller {
|
|
|
155
179
|
node.appendChild(btn)
|
|
156
180
|
}
|
|
157
181
|
|
|
158
|
-
|
|
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"]')) {
|
|
159
186
|
const x = this._cloneSlot(this.closeTplTarget)
|
|
160
187
|
node.classList.add("pk-toast-with-close")
|
|
161
188
|
node.appendChild(x)
|
|
@@ -165,6 +192,31 @@ export default class extends Controller {
|
|
|
165
192
|
return node.id
|
|
166
193
|
}
|
|
167
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
|
+
|
|
211
|
+
// True when this region should act on a toast event carrying `region`
|
|
212
|
+
// (the list's base id or the wrapper's "<id>-region" both match). Without
|
|
213
|
+
// an explicit region, only the page's first region in DOM order handles
|
|
214
|
+
// the broadcast — single-region pages are unaffected.
|
|
215
|
+
_handlesRegion(region) {
|
|
216
|
+
if (region) return region === this.element.id || region === this._listEl?.id
|
|
217
|
+
return this.element === document.querySelector("[data-controller~='phlex-kit--toaster']")
|
|
218
|
+
}
|
|
219
|
+
|
|
168
220
|
_dismissById(id) {
|
|
169
221
|
if (!id) {
|
|
170
222
|
this.toastTargets.forEach((el) =>
|
|
@@ -311,12 +363,14 @@ export default class extends Controller {
|
|
|
311
363
|
api.info = (m, o) => fire("info", m, o)
|
|
312
364
|
api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 })
|
|
313
365
|
api.dismiss = (id) => {
|
|
314
|
-
|
|
366
|
+
// Both broadcasts: whichever region owns the id dismisses it — the
|
|
367
|
+
// api may be registered by a different region than the owner.
|
|
368
|
+
if (id) window.dispatchEvent(new CustomEvent("phlex-kit:toast:dismiss", { detail: { id } }))
|
|
315
369
|
else window.dispatchEvent(new CustomEvent("phlex-kit:toast:dismiss-all"))
|
|
316
370
|
}
|
|
317
371
|
api.promise = (p, msgs = {}) => {
|
|
318
372
|
const id = `toast-${this._uuid()}`
|
|
319
|
-
fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 })
|
|
373
|
+
fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0, region: msgs.region })
|
|
320
374
|
Promise.resolve(p).then(
|
|
321
375
|
(val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success),
|
|
322
376
|
(err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error)
|
|
@@ -330,9 +384,11 @@ export default class extends Controller {
|
|
|
330
384
|
}
|
|
331
385
|
|
|
332
386
|
_mutate(id, variant, text) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
387
|
+
// Document-scoped: promise()'s loading toast spawns in whichever region
|
|
388
|
+
// the broadcast routed it to, which need not be the region that
|
|
389
|
+
// registered the api this closure belongs to.
|
|
390
|
+
const el = document.getElementById(id)
|
|
391
|
+
if (!el || !el.closest(".pk-toast-list")) return
|
|
336
392
|
el.dataset.variant = variant
|
|
337
393
|
el.setAttribute("role", variant === "error" ? "alert" : "status")
|
|
338
394
|
this._swapIcon(el, variant)
|
|
@@ -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; }
|
|
@@ -31,6 +31,15 @@ module PhlexKit
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# True when the caller supplied their own value for a plain attribute in
|
|
35
|
+
# @attrs — symbol or string spelling. A component with a generated default
|
|
36
|
+
# for that attribute must skip it then: `mix` merges duplicate attrs
|
|
37
|
+
# (role="dialog region") instead of overriding. Aria attributes have their
|
|
38
|
+
# own check (aria_key_set?) covering the aria: hash spelling too.
|
|
39
|
+
def attr_set?(key)
|
|
40
|
+
@attrs.key?(key) || @attrs.key?(key.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
34
43
|
# True when the caller supplied an accessible name in @attrs — via the
|
|
35
44
|
# aria: hash or flat aria_label/aria-label(ledby) keys. Components with a
|
|
36
45
|
# generated default label must skip it then: `mix` merges duplicate string
|
data/lib/phlex_kit/version.rb
CHANGED