phlex_kit 0.11.0 → 0.13.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/alert_dialog/alert_dialog.css +3 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +35 -7
- data/app/components/phlex_kit/attachment/attachment.css +15 -0
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -0
- data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
- data/app/components/phlex_kit/calendar/calendar_controller.js +37 -9
- data/app/components/phlex_kit/carousel/carousel_controller.js +36 -2
- data/app/components/phlex_kit/chart/chart.rb +6 -4
- data/app/components/phlex_kit/chart/chart_controller.js +37 -8
- data/app/components/phlex_kit/checkbox/checkbox.rb +15 -1
- data/app/components/phlex_kit/codeblock/codeblock.rb +5 -1
- data/app/components/phlex_kit/combobox/combobox.css +1 -1
- data/app/components/phlex_kit/combobox/combobox_controller.js +61 -2
- data/app/components/phlex_kit/command/command_controller.js +77 -2
- data/app/components/phlex_kit/command/command_dialog_content.rb +5 -0
- data/app/components/phlex_kit/command/command_input.rb +3 -2
- data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
- data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +75 -12
- data/app/components/phlex_kit/data_table/data_table.css +1 -1
- data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
- data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
- data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
- data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
- data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
- data/app/components/phlex_kit/date_picker/date_picker.rb +13 -2
- data/app/components/phlex_kit/date_picker/date_picker_controller.js +26 -0
- data/app/components/phlex_kit/dialog/dialog_controller.js +3 -0
- data/app/components/phlex_kit/drawer/drawer.rb +1 -1
- data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +4 -0
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +78 -18
- data/app/components/phlex_kit/form_field/form_field_controller.js +32 -24
- data/app/components/phlex_kit/hover_card/hover_card.css +6 -0
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
- data/app/components/phlex_kit/icon/icon.rb +1 -1
- data/app/components/phlex_kit/input/input.css +0 -1
- data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
- data/app/components/phlex_kit/item/item.css +2 -1
- data/app/components/phlex_kit/kbd/kbd.css +2 -1
- data/app/components/phlex_kit/label/label.css +1 -1
- data/app/components/phlex_kit/link/link.rb +11 -1
- data/app/components/phlex_kit/marker/marker.css +2 -1
- data/app/components/phlex_kit/menubar/menubar.css +3 -0
- data/app/components/phlex_kit/menubar/menubar.rb +3 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +66 -16
- data/app/components/phlex_kit/native_select/native_select.css +14 -0
- data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
- data/app/components/phlex_kit/popover/popover_controller.js +19 -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/scroll_area/scroll_area.rb +2 -15
- data/app/components/phlex_kit/select/select_controller.js +22 -0
- data/app/components/phlex_kit/select/select_trigger.rb +4 -1
- data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -0
- data/app/components/phlex_kit/sheet/sheet_controller.js +17 -2
- data/app/components/phlex_kit/spinner/spinner.rb +6 -3
- data/app/components/phlex_kit/switch/switch.rb +6 -0
- data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
- data/app/components/phlex_kit/toast/toast_action.rb +1 -1
- data/app/components/phlex_kit/toast/toast_close.rb +6 -3
- data/app/components/phlex_kit/toast/toast_controller.js +37 -16
- data/app/components/phlex_kit/toast/toast_item.rb +11 -7
- data/app/components/phlex_kit/toast/toast_region.rb +12 -3
- data/app/components/phlex_kit/toast/toaster_controller.js +35 -5
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +16 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +11 -8
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +15 -5
- data/app/javascript/phlex_kit/controllers/index.js +2 -0
- data/lib/phlex_kit/base_component.rb +22 -3
- data/lib/phlex_kit/version.rb +1 -1
- data/lib/phlex_kit.rb +1 -0
- metadata +2 -1
|
@@ -48,6 +48,25 @@ export default class extends Controller {
|
|
|
48
48
|
this.contentTarget.togglePopover()
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Public close, for consumers that need to dismiss the panel from outside
|
|
52
|
+
// a trigger interaction (e.g. the date picker closing on a complete
|
|
53
|
+
// calendar selection). Returns focus to the trigger's focusable child —
|
|
54
|
+
// dismissing without it would strand focus on the day button that's
|
|
55
|
+
// about to disappear from the DOM.
|
|
56
|
+
close() {
|
|
57
|
+
if (this.contentTarget.matches(":popover-open")) {
|
|
58
|
+
this.contentTarget.hidePopover()
|
|
59
|
+
}
|
|
60
|
+
this.focusTrigger()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
focusTrigger() {
|
|
64
|
+
const focusable = this.fallbackInvoker
|
|
65
|
+
|| this.triggerTarget.querySelector("button, input, a[href], select, textarea, [tabindex]")
|
|
66
|
+
|| this.triggerTarget
|
|
67
|
+
focusable.focus()
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
// One stable handler added/removed symmetrically — an anonymous listener
|
|
52
71
|
// here would pile up a duplicate on every target reconnect.
|
|
53
72
|
syncState = (e) => {
|
|
@@ -23,3 +23,22 @@
|
|
|
23
23
|
.pk-progress:dir(rtl) .pk-progress-indicator {
|
|
24
24
|
transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/* Indeterminate (value: nil): a 40%-wide segment sweeps the track.
|
|
28
|
+
translateX % is relative to the segment's own width: -100% parks it just
|
|
29
|
+
off the track's start edge, +250% (track = 250% of segment) just past the
|
|
30
|
+
end. Direction-agnostic — the sweep reads fine mirrored, no RTL arm. */
|
|
31
|
+
.pk-progress.indeterminate .pk-progress-indicator {
|
|
32
|
+
width: 40%;
|
|
33
|
+
transition: none;
|
|
34
|
+
animation: pk-progress-indeterminate 1.5s ease-in-out infinite;
|
|
35
|
+
}
|
|
36
|
+
@keyframes pk-progress-indeterminate {
|
|
37
|
+
from { transform: translateX(-100%); }
|
|
38
|
+
to { transform: translateX(250%); }
|
|
39
|
+
}
|
|
40
|
+
@media (prefers-reduced-motion: reduce) {
|
|
41
|
+
.pk-progress.indeterminate .pk-progress-indicator {
|
|
42
|
+
animation-duration: 3s;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -6,29 +6,45 @@ module PhlexKit
|
|
|
6
6
|
class Progress < BaseComponent
|
|
7
7
|
# value_text: is a named kwarg (not read from **attrs) because `mix`
|
|
8
8
|
# would *fuse* a caller "aria-valuetext" with the generated default.
|
|
9
|
+
# `value: nil` renders the INDETERMINATE state (Radix parity): a sliding
|
|
10
|
+
# animation, data-state="indeterminate", and no aria-valuenow — which is
|
|
11
|
+
# exactly how ARIA marks a progressbar of unknown value.
|
|
9
12
|
def initialize(value: 0, value_text: nil, **attrs)
|
|
10
|
-
@value = value.to_f.clamp(0.0, 100.0)
|
|
13
|
+
@value = value.nil? ? nil : value.to_f.clamp(0.0, 100.0)
|
|
11
14
|
@value_text = value_text
|
|
12
15
|
@attrs = attrs
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def view_template
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
div(**mix(root_attrs, @attrs)) do
|
|
20
|
+
div(class: "pk-progress-indicator")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def root_attrs
|
|
27
|
+
base = {
|
|
22
28
|
role: "progressbar",
|
|
23
|
-
"aria-valuenow": v,
|
|
24
29
|
"aria-valuemin": 0,
|
|
25
30
|
"aria-valuemax": 100,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
class: "pk-progress"
|
|
32
|
+
}
|
|
33
|
+
if @value.nil?
|
|
34
|
+
base[:class] = "pk-progress indeterminate"
|
|
35
|
+
base[:data] = { state: "indeterminate" }
|
|
36
|
+
base[:"aria-valuetext"] = @value_text if @value_text
|
|
37
|
+
else
|
|
38
|
+
# %g drops float artifacts ("40.0" would be announced as "forty point
|
|
39
|
+
# zero percent"). The value travels as a custom property so the fill
|
|
40
|
+
# transform lives in progress.css, where a :dir(rtl) arm can flip it —
|
|
41
|
+
# an inline translateX always filled from the physical left.
|
|
42
|
+
v = Kernel.format("%g", @value)
|
|
43
|
+
base[:"aria-valuenow"] = v
|
|
44
|
+
base[:"aria-valuetext"] = @value_text || "#{v}%"
|
|
45
|
+
base[:style] = "--pk-progress-value: #{v};"
|
|
31
46
|
end
|
|
47
|
+
base
|
|
32
48
|
end
|
|
33
49
|
end
|
|
34
50
|
end
|
|
@@ -15,22 +15,9 @@ module PhlexKit
|
|
|
15
15
|
# Defaults only when the caller didn't supply their own — `mix` would
|
|
16
16
|
# fuse role="region log" / tabindex="0 -1" instead of overriding.
|
|
17
17
|
base = { class: "pk-scroll-area" }
|
|
18
|
-
base[:tabindex] = "0" unless
|
|
19
|
-
base[:role] = "region" if
|
|
18
|
+
base[:tabindex] = "0" unless attr_set?(:tabindex)
|
|
19
|
+
base[:role] = "region" if aria_labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
|
|
20
20
|
div(**mix(base, @attrs), &)
|
|
21
21
|
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
# True when the caller supplies an accessible name, via the aria: hash
|
|
26
|
-
# (aria: { label:/labelledby: }) or flat aria_label/aria-labelledby attrs.
|
|
27
|
-
def labelled?
|
|
28
|
-
aria = @attrs[:aria] || @attrs["aria"]
|
|
29
|
-
if aria.is_a?(Hash)
|
|
30
|
-
return true if %w[label labelledby].any? { |k| aria[k.to_sym] || aria[k] }
|
|
31
|
-
end
|
|
32
|
-
[ :aria_label, "aria_label", "aria-label",
|
|
33
|
-
:aria_labelledby, "aria_labelledby", "aria-labelledby" ].any? { |k| @attrs[k] }
|
|
34
|
-
end
|
|
35
22
|
end
|
|
36
23
|
end
|
|
@@ -59,6 +59,28 @@ export default class extends Controller {
|
|
|
59
59
|
this.closeContent();
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// APG select pattern: ArrowDown/ArrowUp on the CLOSED trigger opens the
|
|
63
|
+
// listbox and highlights the first/last option, instead of only working
|
|
64
|
+
// once already open (combobox_controller.js's keyDownPressed/keyUpPressed
|
|
65
|
+
// does the equivalent for its own open/closed model).
|
|
66
|
+
handleTriggerArrowDown(event) {
|
|
67
|
+
if (this.openValue) return
|
|
68
|
+
event.preventDefault()
|
|
69
|
+
this.#show()
|
|
70
|
+
if (this.itemTargets.length === 0) return
|
|
71
|
+
this.resetCurrent()
|
|
72
|
+
this.setAriaCurrentAndActiveDescendant(0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
handleTriggerArrowUp(event) {
|
|
76
|
+
if (this.openValue) return
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
this.#show()
|
|
79
|
+
if (this.itemTargets.length === 0) return
|
|
80
|
+
this.resetCurrent()
|
|
81
|
+
this.setAriaCurrentAndActiveDescendant(this.itemTargets.length - 1)
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
onClick() {
|
|
63
85
|
this.toggleContent();
|
|
64
86
|
|
|
@@ -16,7 +16,10 @@ module PhlexKit
|
|
|
16
16
|
role: "combobox",
|
|
17
17
|
class: [ "pk-select-trigger", fetch_option(SIZES, @size, :size) ].compact.join(" "),
|
|
18
18
|
aria: { expanded: "false", haspopup: "listbox", autocomplete: "none" },
|
|
19
|
-
data: { action: "phlex-kit--select#onClick
|
|
19
|
+
data: { action: "phlex-kit--select#onClick " \
|
|
20
|
+
"keydown.down->phlex-kit--select#handleTriggerArrowDown " \
|
|
21
|
+
"keydown.up->phlex-kit--select#handleTriggerArrowUp",
|
|
22
|
+
phlex_kit__select_target: "trigger" }
|
|
20
23
|
}, @attrs)) do
|
|
21
24
|
block&.call
|
|
22
25
|
icon
|
|
@@ -9,9 +9,19 @@ module PhlexKit
|
|
|
9
9
|
end
|
|
10
10
|
def view_template(&block)
|
|
11
11
|
template(data: { phlex_kit__sheet_target: "content" }) do
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
# data-pk-overlay-clone is a common marker read by other clone-based
|
|
13
|
+
# overlays (see alert_dialog_controller.js#topmost) to determine
|
|
14
|
+
# z-stacking across overlay families when they nest.
|
|
15
|
+
div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown", pk_overlay_clone: "" }) do
|
|
16
|
+
div(class: "pk-sheet-backdrop", data: { action: "click->phlex-kit--sheet-content#close mousedown->phlex-kit--sheet-content#overlayMousedown" })
|
|
17
|
+
panel_attrs = { class: [ "pk-sheet-content", fetch_option(SIDES, @side, :side) ].join(" "), data: { phlex_kit__sheet_content_target: "panel" } }
|
|
18
|
+
# Defaults only when the caller didn't supply their own — `mix`
|
|
19
|
+
# would fuse role="dialog region" / aria-modal="true false" /
|
|
20
|
+
# tabindex="-1 0" instead of overriding.
|
|
21
|
+
panel_attrs[:role] = "dialog" unless attr_set?(:role)
|
|
22
|
+
panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
|
|
23
|
+
panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
24
|
+
div(**mix(panel_attrs, @attrs)) do
|
|
15
25
|
block&.call
|
|
16
26
|
if @show_close_button
|
|
17
27
|
button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--sheet-content#close" }) do
|
|
@@ -15,6 +15,7 @@ export default class extends Controller {
|
|
|
15
15
|
static targets = ["panel"]
|
|
16
16
|
|
|
17
17
|
connect() {
|
|
18
|
+
this.restored = false
|
|
18
19
|
this.opener = document.activeElement
|
|
19
20
|
this.previousOverflow = document.body.style.overflow
|
|
20
21
|
document.body.style.overflow = "hidden"
|
|
@@ -26,6 +27,20 @@ export default class extends Controller {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
disconnect() {
|
|
30
|
+
this.restoreImmediate()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Synchronously undoes everything connect() did: background inert,
|
|
34
|
+
// scroll lock, and the closed-notification to the source sheet
|
|
35
|
+
// controller. Called from disconnect() (the normal removal path) AND
|
|
36
|
+
// from the source phlex-kit--sheet controller's turbo:before-cache
|
|
37
|
+
// handler, which must restore state before Turbo's synchronous snapshot
|
|
38
|
+
// — disconnect() fires via MutationObserver, too late for that snapshot.
|
|
39
|
+
// Idempotent (guarded by `this.restored`) so whichever path runs second
|
|
40
|
+
// is a harmless no-op.
|
|
41
|
+
restoreImmediate() {
|
|
42
|
+
if (this.restored) return
|
|
43
|
+
this.restored = true
|
|
29
44
|
this.#restoreInert()
|
|
30
45
|
document.body.style.overflow = this.previousOverflow
|
|
31
46
|
if (this.opener?.isConnected) this.opener.focus()
|
|
@@ -39,9 +54,30 @@ export default class extends Controller {
|
|
|
39
54
|
this.element.remove()
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
// Backdrop mousedown would move focus to <body>, killing this
|
|
58
|
+
// element-scoped keydown listener (Escape after a drag-off-backdrop would
|
|
59
|
+
// stop working) — prevented here so focus stays inside the panel. The
|
|
60
|
+
// backdrop's click->close action still fires normally afterward.
|
|
61
|
+
overlayMousedown(event) {
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
keydown(event) {
|
|
66
|
+
// A native <dialog> nested inside this panel (e.g. a Dialog opened from
|
|
67
|
+
// within SheetContent) owns its own Escape handling; the keydown still
|
|
68
|
+
// bubbles through this element-scoped listener since dialog is a DOM
|
|
69
|
+
// descendant — ignore it so one Escape doesn't also close the sheet
|
|
70
|
+
// underneath.
|
|
71
|
+
if (event.key === "Escape" && event.target.closest("dialog[open]")) return
|
|
43
72
|
if (event.key === "Escape") {
|
|
44
73
|
event.preventDefault()
|
|
74
|
+
// Stop the keydown from reaching a lower/outer overlay's own
|
|
75
|
+
// document-level Escape listener (e.g. an alert_dialog this sheet is
|
|
76
|
+
// stacked on top of) — this element-scoped listener runs first during
|
|
77
|
+
// bubbling, and by the time a document-level listener would see it,
|
|
78
|
+
// this.close() has already removed the [data-pk-overlay-clone] marker
|
|
79
|
+
// the other overlay's #topmost() check relies on.
|
|
80
|
+
event.stopPropagation()
|
|
45
81
|
this.close()
|
|
46
82
|
return
|
|
47
83
|
}
|
|
@@ -11,8 +11,19 @@ export default class extends Controller {
|
|
|
11
11
|
|
|
12
12
|
initialize() {
|
|
13
13
|
this.overlay = null
|
|
14
|
+
// Turbo snapshots synchronously right after dispatching this event —
|
|
15
|
+
// BEFORE Stimulus disconnect() runs (it fires via MutationObserver, too
|
|
16
|
+
// late to keep the snapshot from capturing an inerted, scroll-locked
|
|
17
|
+
// page). Reach into the clone's own controller and restore its state
|
|
18
|
+
// synchronously before removing it; that method is idempotent, so the
|
|
19
|
+
// content controller's own disconnect() (fired later, once the removal
|
|
20
|
+
// is observed) is a harmless no-op.
|
|
14
21
|
this.clearOverlay = () => {
|
|
15
|
-
if (this.overlay?.isConnected)
|
|
22
|
+
if (this.overlay?.isConnected) {
|
|
23
|
+
const content = this.application.getControllerForElementAndIdentifier(this.overlay, "phlex-kit--sheet-content")
|
|
24
|
+
content?.restoreImmediate()
|
|
25
|
+
this.overlay.remove()
|
|
26
|
+
}
|
|
16
27
|
this.overlay = null
|
|
17
28
|
}
|
|
18
29
|
}
|
|
@@ -22,7 +33,11 @@ export default class extends Controller {
|
|
|
22
33
|
// Popup semantics for AT: the trigger's real control announces that it
|
|
23
34
|
// opens a dialog, and expanded state tracks the clone's lifetime (the
|
|
24
35
|
// content controller signals removal via phlex-kit:sheet-content:closed).
|
|
25
|
-
this
|
|
36
|
+
// Drawer rides this same controller (see drawer.rb) — its trigger is
|
|
37
|
+
// marked .pk-drawer-trigger rather than .pk-sheet-trigger.
|
|
38
|
+
this.invoker = this.element.querySelector(
|
|
39
|
+
".pk-sheet-trigger button, .pk-sheet-trigger a, .pk-sheet-trigger [tabindex], .pk-drawer-trigger button, .pk-drawer-trigger a, .pk-drawer-trigger [tabindex]"
|
|
40
|
+
)
|
|
26
41
|
this.invoker?.setAttribute("aria-haspopup", "dialog")
|
|
27
42
|
this.invoker?.setAttribute("aria-expanded", "false")
|
|
28
43
|
this.onOverlayClosed = () => {
|
|
@@ -11,12 +11,15 @@ module PhlexKit
|
|
|
11
11
|
|
|
12
12
|
def view_template
|
|
13
13
|
# A visible loader, not decoration: role=status + label, aria-hidden off.
|
|
14
|
-
|
|
14
|
+
base = {
|
|
15
15
|
class: [ "pk-spinner", fetch_option(SIZES, @size, :size) ].compact.join(" "),
|
|
16
16
|
role: "status",
|
|
17
|
-
aria: { label: "Loading" },
|
|
18
17
|
"aria-hidden": false
|
|
19
|
-
}
|
|
18
|
+
}
|
|
19
|
+
# Default label only when the caller didn't supply their own — `mix`
|
|
20
|
+
# would fuse aria-label="Loading Saving" instead of overriding.
|
|
21
|
+
base[:aria] = { label: "Loading" } unless aria_labelled?
|
|
22
|
+
render Icon.new(:loader, size: nil, **mix(base, @attrs))
|
|
20
23
|
end
|
|
21
24
|
end
|
|
22
25
|
end
|
|
@@ -6,6 +6,12 @@ module PhlexKit
|
|
|
6
6
|
SIZES = { md: nil, sm: "sm" }.freeze
|
|
7
7
|
|
|
8
8
|
def initialize(include_hidden: true, checked_value: "1", unchecked_value: "0", size: :md, wrapper: {}, **attrs)
|
|
9
|
+
# value: is silently clobbered below (the checkbox's real value comes
|
|
10
|
+
# from checked_value:) — fail loud rather than let a caller's value:
|
|
11
|
+
# vanish (mirrors ToggleGroupItem's unsupported-kwargs guard).
|
|
12
|
+
if attrs.key?(:value) || attrs.key?("value")
|
|
13
|
+
raise ArgumentError, "Switch does not support value: — the checkbox's value comes from checked_value:, pass that instead"
|
|
14
|
+
end
|
|
9
15
|
@include_hidden = include_hidden
|
|
10
16
|
@checked_value = checked_value
|
|
11
17
|
@unchecked_value = unchecked_value
|
|
@@ -29,8 +29,14 @@ export default class extends Controller {
|
|
|
29
29
|
// tab sets also answer to ArrowUp/ArrowDown.
|
|
30
30
|
keydown(event) {
|
|
31
31
|
const vertical = this.element.classList.contains("vertical")
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// In RTL the tablist mirrors: the physical LEFT arrow moves to the NEXT
|
|
33
|
+
// trigger. The vertical Up/Down pair is unaffected (runtime dir check is
|
|
34
|
+
// reliable even after a dynamic flip — carousel's idiom).
|
|
35
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
36
|
+
const fwd = rtl ? "ArrowLeft" : "ArrowRight"
|
|
37
|
+
const back = rtl ? "ArrowRight" : "ArrowLeft"
|
|
38
|
+
const nextKeys = vertical ? [fwd, "ArrowDown"] : [fwd]
|
|
39
|
+
const prevKeys = vertical ? [back, "ArrowUp"] : [back]
|
|
34
40
|
const triggers = this.triggerTargets.filter((el) => !el.disabled)
|
|
35
41
|
if (triggers.length === 0) return
|
|
36
42
|
const index = triggers.indexOf(document.activeElement)
|
|
@@ -15,7 +15,7 @@ module PhlexKit
|
|
|
15
15
|
# Caller action first, then dismiss — client-spawned actions dismiss
|
|
16
16
|
# their toast after onClick (Sonner parity), so server-rendered ones
|
|
17
17
|
# must too.
|
|
18
|
-
data[:action] = [ @on, "click->phlex-kit--toast#dismiss" ].compact.join(" ")
|
|
18
|
+
data[:action] = [ @on, "click->phlex-kit--toast#dismiss" ].compact.join(" ")
|
|
19
19
|
button(**mix({ type: :button, class: "pk-toast-action", data: data }, @attrs)) { @label }
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -7,12 +7,15 @@ module PhlexKit
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def view_template
|
|
10
|
-
|
|
10
|
+
base = {
|
|
11
11
|
type: :button,
|
|
12
12
|
class: "pk-toast-close",
|
|
13
|
-
aria: { label: "Close toast" },
|
|
14
13
|
data: { slot: "close", action: "click->phlex-kit--toast#dismiss" }
|
|
15
|
-
}
|
|
14
|
+
}
|
|
15
|
+
# Default label only when the caller didn't supply their own — `mix`
|
|
16
|
+
# would fuse aria-label into a two-token value instead of overriding.
|
|
17
|
+
base[:aria] = { label: "Close toast" } unless aria_labelled?
|
|
18
|
+
button(**mix(base, @attrs)) do
|
|
16
19
|
render Icon.new(:x, size: 14)
|
|
17
20
|
span(class: "pk-sr-only") { "Close" }
|
|
18
21
|
end
|
|
@@ -20,23 +20,28 @@ export default class extends Controller {
|
|
|
20
20
|
this._unmountTimer = null
|
|
21
21
|
this._startedAt = 0
|
|
22
22
|
this._remaining = this.durationValue
|
|
23
|
-
|
|
23
|
+
// Independent pause sources (hover / focus / region) — resume only once
|
|
24
|
+
// ALL are clear. A single shared boolean let any one source's resume
|
|
25
|
+
// restart the timer while another source still held it paused (e.g.
|
|
26
|
+
// pointer leaving a toast into the gap while the region is still
|
|
27
|
+
// hover-paused).
|
|
28
|
+
this._pauseSources = new Set()
|
|
24
29
|
this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }
|
|
25
30
|
|
|
26
31
|
this._onPointerDown = this._onPointerDown.bind(this)
|
|
27
32
|
this._onPointerMove = this._onPointerMove.bind(this)
|
|
28
33
|
this._onPointerUp = this._onPointerUp.bind(this)
|
|
29
|
-
this._onPointerEnter = () => this._pause()
|
|
30
|
-
this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }
|
|
34
|
+
this._onPointerEnter = () => this._pause("hover")
|
|
35
|
+
this._onPointerLeave = () => { if (!this._swipe.active) this._resume("hover") }
|
|
31
36
|
// Keyboard parity with hover: tabbing into the toast (e.g. to reach its
|
|
32
37
|
// action button) must pause auto-dismiss just like pointerenter does.
|
|
33
|
-
this._onFocusIn = () => this._pause()
|
|
34
|
-
this._onFocusOut = (e) => { if (!this.element.contains(e.relatedTarget)) this._resume() }
|
|
38
|
+
this._onFocusIn = () => this._pause("focus")
|
|
39
|
+
this._onFocusOut = (e) => { if (!this.element.contains(e.relatedTarget)) this._resume("focus") }
|
|
35
40
|
this._onKeyDown = this._onKeyDown.bind(this)
|
|
36
41
|
this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
|
|
37
42
|
this._onRestart = () => this._restart()
|
|
38
|
-
this._onRegionPause = () => this._pause()
|
|
39
|
-
this._onRegionResume = () => this._resume()
|
|
43
|
+
this._onRegionPause = () => this._pause("region")
|
|
44
|
+
this._onRegionResume = () => this._resume("region")
|
|
40
45
|
|
|
41
46
|
this.element.addEventListener("pointerdown", this._onPointerDown)
|
|
42
47
|
this.element.addEventListener("pointerenter", this._onPointerEnter)
|
|
@@ -52,7 +57,8 @@ export default class extends Controller {
|
|
|
52
57
|
this._regionList.addEventListener("phlex-kit:toast:pause", this._onRegionPause)
|
|
53
58
|
this._regionList.addEventListener("phlex-kit:toast:resume", this._onRegionResume)
|
|
54
59
|
|
|
55
|
-
requestAnimationFrame(() => {
|
|
60
|
+
this._connectFrame = requestAnimationFrame(() => {
|
|
61
|
+
this._connectFrame = null
|
|
56
62
|
this.element.dataset.state = "open"
|
|
57
63
|
// Spawned under an already-hovered stack (the toaster stamps
|
|
58
64
|
// data-pk-toasts-paused while dispatching pause): arm as paused so the
|
|
@@ -60,7 +66,7 @@ export default class extends Controller {
|
|
|
60
66
|
// under the user's cursor.
|
|
61
67
|
if (Number.isFinite(this.durationValue) && this.durationValue > 0 &&
|
|
62
68
|
this.element.closest("[data-pk-toasts-paused]")) {
|
|
63
|
-
this.
|
|
69
|
+
this._pauseSources.add("region")
|
|
64
70
|
this._remaining = this.durationValue
|
|
65
71
|
} else {
|
|
66
72
|
this._start()
|
|
@@ -70,6 +76,8 @@ export default class extends Controller {
|
|
|
70
76
|
|
|
71
77
|
disconnect() {
|
|
72
78
|
this._clearTimer()
|
|
79
|
+
if (this._connectFrame) cancelAnimationFrame(this._connectFrame)
|
|
80
|
+
this._connectFrame = null
|
|
73
81
|
// Don't let a pending 200ms unmount fire after teardown (e.g. into a
|
|
74
82
|
// Turbo-cached copy of the page).
|
|
75
83
|
if (this._unmountTimer) clearTimeout(this._unmountTimer)
|
|
@@ -115,22 +123,30 @@ export default class extends Controller {
|
|
|
115
123
|
this._timer = setTimeout(() => this._close("auto"), this._remaining)
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
// Resets the timer duration but must respect any pause source still
|
|
127
|
+
// active (e.g. a promise resolving into a fresh duration while the toast
|
|
128
|
+
// is hovered) — arm paused instead of ticking away under the cursor, and
|
|
129
|
+
// leave the source set intact so the matching resume can still fire.
|
|
118
130
|
_restart() {
|
|
119
131
|
this._clearTimer()
|
|
132
|
+
this._remaining = this.durationValue
|
|
133
|
+
if (this._pauseSources.size > 0) return
|
|
120
134
|
this._start()
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
_pause() {
|
|
124
|
-
|
|
125
|
-
this.
|
|
137
|
+
_pause(source) {
|
|
138
|
+
this._pauseSources.add(source)
|
|
139
|
+
if (!this._timer) return
|
|
126
140
|
clearTimeout(this._timer)
|
|
127
141
|
this._timer = null
|
|
128
142
|
this._remaining -= performance.now() - this._startedAt
|
|
129
143
|
}
|
|
130
144
|
|
|
131
|
-
_resume() {
|
|
132
|
-
|
|
133
|
-
this.
|
|
145
|
+
_resume(source) {
|
|
146
|
+
this._pauseSources.delete(source)
|
|
147
|
+
if (this._pauseSources.size > 0) return
|
|
148
|
+
if (this._timer) return
|
|
149
|
+
if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return
|
|
134
150
|
if (this._remaining <= 0) return this._close("auto")
|
|
135
151
|
this._startedAt = performance.now()
|
|
136
152
|
this._timer = setTimeout(() => this._close("auto"), this._remaining)
|
|
@@ -192,7 +208,12 @@ export default class extends Controller {
|
|
|
192
208
|
this.element.dataset.swipe = "cancel"
|
|
193
209
|
this.element.style.removeProperty("--pk-toast-swipe-x")
|
|
194
210
|
this.element.style.removeProperty("--pk-toast-swipe-y")
|
|
195
|
-
|
|
211
|
+
// pointerleave is suppressed while a swipe is active, so release the
|
|
212
|
+
// hover hold here — but only if the pointer actually left the toast; a
|
|
213
|
+
// cancelled swipe ending under the cursor must stay hover-paused (the
|
|
214
|
+
// eventual pointerleave resumes it, including the touch case where
|
|
215
|
+
// pointerleave fires right after pointerup).
|
|
216
|
+
if (!this.element.matches(":hover")) this._resume("hover")
|
|
196
217
|
}
|
|
197
218
|
}
|
|
198
219
|
}
|
|
@@ -56,13 +56,17 @@ module PhlexKit
|
|
|
56
56
|
data[:phlex_kit__toast_on_dismiss_value] = @on_dismiss if @on_dismiss
|
|
57
57
|
data[:phlex_kit__toast_on_auto_close_value] = @on_auto_close if @on_auto_close
|
|
58
58
|
|
|
59
|
-
attrs = {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
attrs = { class: "pk-toast", data: data }
|
|
60
|
+
# Defaults only when the caller didn't supply their own — `mix` would
|
|
61
|
+
# fuse role="status log" / tabindex="0 -1" / aria-atomic="true false"
|
|
62
|
+
# instead of overriding.
|
|
63
|
+
unless attr_set?(:role)
|
|
64
|
+
attrs[:role] = ALERT_VARIANTS.include?(@variant) ? "alert" : "status"
|
|
65
|
+
end
|
|
66
|
+
attrs[:tabindex] = "0" unless attr_set?(:tabindex)
|
|
67
|
+
unless aria_key_set?(:atomic)
|
|
68
|
+
attrs[:aria] = { atomic: "true" }
|
|
69
|
+
end
|
|
66
70
|
attrs[:id] = @id if @id
|
|
67
71
|
attrs
|
|
68
72
|
end
|
|
@@ -10,6 +10,12 @@ module PhlexKit
|
|
|
10
10
|
class ToastRegion < BaseComponent
|
|
11
11
|
SKELETON_VARIANTS = %i[default success error warning info loading].freeze
|
|
12
12
|
|
|
13
|
+
# Sentinel default for dir: — distinguishes "caller didn't pass dir:" (omit
|
|
14
|
+
# the attribute, inherit from the page) from an explicit dir: :ltr (must
|
|
15
|
+
# render dir="ltr"), which a plain `:ltr` default can't tell apart.
|
|
16
|
+
DIR_UNSET = Object.new.freeze
|
|
17
|
+
private_constant :DIR_UNSET
|
|
18
|
+
|
|
13
19
|
POSITIONS = {
|
|
14
20
|
top_left: "top-left",
|
|
15
21
|
top_center: "top-center",
|
|
@@ -30,7 +36,7 @@ module PhlexKit
|
|
|
30
36
|
rich_colors: false,
|
|
31
37
|
close_button: false,
|
|
32
38
|
hotkey: %w[alt t],
|
|
33
|
-
dir:
|
|
39
|
+
dir: DIR_UNSET,
|
|
34
40
|
flash: nil,
|
|
35
41
|
id: "pk-toaster",
|
|
36
42
|
**attrs
|
|
@@ -53,7 +59,8 @@ module PhlexKit
|
|
|
53
59
|
end
|
|
54
60
|
@close_button = close_button
|
|
55
61
|
@hotkey = hotkey
|
|
56
|
-
@
|
|
62
|
+
@dir_explicit = !dir.equal?(DIR_UNSET)
|
|
63
|
+
@dir = (@dir_explicit ? dir : :ltr).to_sym
|
|
57
64
|
unless %i[ltr rtl auto].include?(@dir)
|
|
58
65
|
raise ArgumentError, "ToastRegion dir: must be :ltr, :rtl or :auto"
|
|
59
66
|
end
|
|
@@ -138,7 +145,9 @@ module PhlexKit
|
|
|
138
145
|
phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+")
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
|
-
|
|
148
|
+
# Only an explicit dir: renders the attribute — the true default (no
|
|
149
|
+
# dir: passed) omits it so the toast inherits the page's direction.
|
|
150
|
+
base[:dir] = @dir.to_s if @dir_explicit
|
|
142
151
|
base
|
|
143
152
|
end
|
|
144
153
|
end
|