phlex_kit 0.11.0 → 0.12.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 +23 -6
- 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/codeblock/codeblock.rb +5 -1
- data/app/components/phlex_kit/combobox/combobox.css +1 -1
- data/app/components/phlex_kit/combobox/combobox_controller.js +57 -2
- data/app/components/phlex_kit/command/command_controller.js +17 -1
- 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 +49 -12
- data/app/components/phlex_kit/data_table/data_table.css +1 -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/date_picker/date_picker.rb +13 -2
- data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -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 +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +54 -13
- 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/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 +1 -0
- 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_controller.js +40 -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/scroll_area/scroll_area.rb +1 -14
- 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 +32 -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 +2 -0
- 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 +13 -3
- data/lib/phlex_kit/version.rb +1 -1
- data/lib/phlex_kit.rb +1 -0
- metadata +2 -1
|
@@ -34,7 +34,8 @@ a.pk-item:hover { background: var(--pk-surface-2); }
|
|
|
34
34
|
transform: translateY(.125rem);
|
|
35
35
|
align-self: flex-start;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
/* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
|
|
38
|
+
.pk-item-media.icon svg:not([width]) { width: 1rem; height: 1rem; }
|
|
38
39
|
.pk-item-media.image {
|
|
39
40
|
width: 2.5rem;
|
|
40
41
|
height: 2.5rem;
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
font-weight: 500;
|
|
16
16
|
}
|
|
17
17
|
.pk-kbd-group { display: inline-flex; align-items: center; gap: .25rem; font-family: inherit; }
|
|
18
|
-
|
|
18
|
+
/* Glyphs default to size-3; explicit width attrs (Icon size:) win. */
|
|
19
|
+
.pk-kbd svg:not([width]) { width: .75rem; height: .75rem; }
|
|
19
20
|
/* Inside an (inverted) tooltip the chip tints from the page background. */
|
|
20
21
|
.pk-tooltip-content .pk-kbd {
|
|
21
22
|
background: color-mix(in oklab, var(--pk-bg) 10%, transparent);
|
|
@@ -25,7 +25,8 @@ a.pk-marker, button.pk-marker {
|
|
|
25
25
|
font-size: .875rem;
|
|
26
26
|
}
|
|
27
27
|
a.pk-marker:hover, button.pk-marker:hover { color: var(--pk-text); }
|
|
28
|
-
|
|
28
|
+
/* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
|
|
29
|
+
.pk-marker svg:not([width]) { width: 1rem; height: 1rem; }
|
|
29
30
|
.pk-marker-icon { display: inline-flex; flex: none; width: 1rem; height: 1rem; }
|
|
30
31
|
.pk-marker-content { min-width: 0; overflow-wrap: break-word; }
|
|
31
32
|
.pk-marker-content a { text-decoration: underline; text-underline-offset: 3px; color: inherit; }
|
|
@@ -129,6 +129,9 @@
|
|
|
129
129
|
the viewport right edge. */
|
|
130
130
|
.pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
|
|
131
131
|
.pk-menubar-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
132
|
+
/* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
|
|
133
|
+
[dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
|
|
134
|
+
[dir="rtl"] .pk-menubar-sub-chevron { transform: scaleX(-1); }
|
|
132
135
|
.pk-menubar-sub-content {
|
|
133
136
|
position: fixed;
|
|
134
137
|
position-anchor: --pk-menubar-sub;
|
|
@@ -48,6 +48,12 @@ export default class extends Controller {
|
|
|
48
48
|
// (only on elements that already carry it: plain nav links must not
|
|
49
49
|
// grow an aria-expanded). Covers the trigger and CSS-revealed subs.
|
|
50
50
|
menu.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
|
|
51
|
+
// Same shape for checkbox/radio rows: their `checked` DOM property
|
|
52
|
+
// survives a reconnect but a hand-edited/replayed aria-checked can drift
|
|
53
|
+
// from it — resync every row from its live input.
|
|
54
|
+
menu.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
|
|
55
|
+
i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
|
|
56
|
+
})
|
|
51
57
|
if (this.roving) this.applyRoving()
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -107,8 +113,15 @@ export default class extends Controller {
|
|
|
107
113
|
if (focus) (this.items(menu)[0] ?? this.trigger(menu))?.focus()
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
// Also wired directly as the click handler on plain nav links (see
|
|
117
|
+
// navigation_menu_link.rb) — `opts` is then the click Event rather than an
|
|
118
|
+
// options hash. Guard the default href="#" (dropdown_menu_controller.js's
|
|
119
|
+
// close() does the same) so a link without a real destination doesn't
|
|
120
|
+
// scroll-to-top/append a hash; a link WITH a real href is left to navigate
|
|
121
|
+
// normally, closing whatever panel is open on the way out.
|
|
110
122
|
close(opts = {}) {
|
|
111
123
|
clearTimeout(this.graceTimer)
|
|
124
|
+
if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault?.()
|
|
112
125
|
const menu = this.openMenu
|
|
113
126
|
if (!menu) return
|
|
114
127
|
const panel = this.panel(menu)
|
|
@@ -147,10 +160,14 @@ export default class extends Controller {
|
|
|
147
160
|
e.preventDefault()
|
|
148
161
|
this.show(menu, true)
|
|
149
162
|
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
|
|
150
|
-
// Closed bar: left/right move focus between the triggers (APG).
|
|
163
|
+
// Closed bar: left/right move focus between the triggers (APG). In RTL
|
|
164
|
+
// the bar mirrors, so the physical LEFT arrow moves to the next
|
|
165
|
+
// trigger. Runtime dir check is reliable after a dynamic flip.
|
|
151
166
|
e.preventDefault()
|
|
167
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
168
|
+
const step = (e.key === "ArrowRight" ? 1 : -1) * (rtl ? -1 : 1)
|
|
152
169
|
const menus = this.menuTargets
|
|
153
|
-
const next = menus[(menus.indexOf(menu) +
|
|
170
|
+
const next = menus[(menus.indexOf(menu) + step + menus.length) % menus.length]
|
|
154
171
|
const target = next ? this.trigger(next) : null
|
|
155
172
|
target?.focus()
|
|
156
173
|
if (this.roving && target) this.applyRoving(target)
|
|
@@ -190,23 +207,30 @@ export default class extends Controller {
|
|
|
190
207
|
}
|
|
191
208
|
break
|
|
192
209
|
case "ArrowRight":
|
|
193
|
-
e.preventDefault()
|
|
194
|
-
// On a sub trigger, enter the submenu (focus reveals it via
|
|
195
|
-
// :focus-within) instead of jumping to the next top-level menu.
|
|
196
|
-
if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
|
|
197
|
-
this.enterSub(document.activeElement)
|
|
198
|
-
} else {
|
|
199
|
-
this.shift(1)
|
|
200
|
-
}
|
|
201
|
-
break
|
|
202
210
|
case "ArrowLeft": {
|
|
203
211
|
e.preventDefault()
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
212
|
+
// Both the submenu (opens inline-end = visually LEFT in RTL) and the
|
|
213
|
+
// top-level menu traversal follow visual direction: the "enter/next"
|
|
214
|
+
// key is ArrowLeft in RTL, ArrowRight in LTR. Runtime dir check is
|
|
215
|
+
// reliable after a dynamic flip.
|
|
216
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
217
|
+
const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
|
|
218
|
+
if (e.key === enterKey) {
|
|
219
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
220
|
+
// :focus-within) instead of jumping to the next top-level menu.
|
|
221
|
+
if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
|
|
222
|
+
this.enterSub(document.activeElement)
|
|
223
|
+
} else {
|
|
224
|
+
this.shift(1)
|
|
225
|
+
}
|
|
208
226
|
} else {
|
|
209
|
-
|
|
227
|
+
// Inside a submenu, step back to its trigger instead of switching menus.
|
|
228
|
+
const sub = document.activeElement?.closest(".pk-menubar-sub-content")
|
|
229
|
+
if (sub) {
|
|
230
|
+
sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
|
|
231
|
+
} else {
|
|
232
|
+
this.shift(-1)
|
|
233
|
+
}
|
|
210
234
|
}
|
|
211
235
|
break
|
|
212
236
|
}
|
|
@@ -33,6 +33,20 @@
|
|
|
33
33
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
34
34
|
}
|
|
35
35
|
.pk-native-select-field:disabled { cursor: not-allowed; opacity: .5; }
|
|
36
|
+
/* [size] (a multi-row list box, NativeSelect.new(size: N)) or [multiple]
|
|
37
|
+
renders as a scrollable option list, not a single-line control — the fixed
|
|
38
|
+
2rem row height squashes it and there's no single line for the absolute
|
|
39
|
+
chevron to sit in, so auto-height the field and hide the chevron.
|
|
40
|
+
Note: [size="1"] is a single-line dropdown, not a list box — exclude it. */
|
|
41
|
+
.pk-native-select-field[size]:not([size="1"]),
|
|
42
|
+
.pk-native-select-field[multiple] {
|
|
43
|
+
height: auto;
|
|
44
|
+
padding-inline-end: .625rem;
|
|
45
|
+
}
|
|
46
|
+
.pk-native-select:has(.pk-native-select-field[size]:not([size="1"])) .pk-native-select-icon,
|
|
47
|
+
.pk-native-select:has(.pk-native-select-field[multiple]) .pk-native-select-icon {
|
|
48
|
+
display: none;
|
|
49
|
+
}
|
|
36
50
|
.pk-native-select-field.sm {
|
|
37
51
|
height: 1.75rem;
|
|
38
52
|
padding-top: .125rem;
|
|
@@ -11,7 +11,14 @@ module PhlexKit
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def view_template(&)
|
|
14
|
-
a(**mix({
|
|
14
|
+
a(**mix({
|
|
15
|
+
class: "pk-navigation-menu-link",
|
|
16
|
+
href: @href,
|
|
17
|
+
# click: closes the open panel on real activation (and swallows the
|
|
18
|
+
# default href="#" the way dropdown_menu_controller.js's close() does
|
|
19
|
+
# — see menubar_controller.js#close).
|
|
20
|
+
data: { action: "mouseenter->phlex-kit--menubar#switch click->phlex-kit--menubar#close" }
|
|
21
|
+
}, @attrs), &)
|
|
15
22
|
end
|
|
16
23
|
end
|
|
17
24
|
end
|
|
@@ -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) => {
|
|
@@ -16,21 +16,8 @@ module PhlexKit
|
|
|
16
16
|
# fuse role="region log" / tabindex="0 -1" instead of overriding.
|
|
17
17
|
base = { class: "pk-scroll-area" }
|
|
18
18
|
base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
|
|
19
|
-
base[:role] = "region" if
|
|
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 @attrs.key?(:role) || @attrs.key?("role")
|
|
22
|
+
panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
|
|
23
|
+
panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("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)
|
|
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,7 @@ 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
|
-
this._resume()
|
|
211
|
+
this._resume("hover")
|
|
196
212
|
}
|
|
197
213
|
}
|
|
198
214
|
}
|
|
@@ -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 @attrs.key?(:role) || @attrs.key?("role")
|
|
64
|
+
attrs[:role] = ALERT_VARIANTS.include?(@variant) ? "alert" : "status"
|
|
65
|
+
end
|
|
66
|
+
attrs[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("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
|