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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 832d7d74c2bcda4dcddef6ab391ee7afff8c15a3f011b57392dfb0e5d73b9922
|
|
4
|
+
data.tar.gz: 730dd9af5257a9350c551d9b5573d919cd04dd462a19f38d55558aa282d16822
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 545d9af3c5b4ef67ff2793618825b17fcfff6a42fd7a7e97ca2b294d447d71ded63858094dc32f938dcdabd9c39ac4c8696bdf68da4b74cf1692bf62770d8081
|
|
7
|
+
data.tar.gz: 2a98a887feff3cf7a2144baeb9f380738880f2c100e6ed3ecc3ab2a4a1d94038efe06531b725eede6e1e419c4f9a282b109b6cb2db49328f32962819a17417c3
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
gap: 1rem;
|
|
23
23
|
width: calc(100% - 2rem);
|
|
24
24
|
max-width: 32rem;
|
|
25
|
-
max-height
|
|
25
|
+
/* Breathing room like dialog.css's max-height — 100vh touched the screen
|
|
26
|
+
edges and overflowed behind mobile browser chrome (dvh tracks it). */
|
|
27
|
+
max-height: calc(100dvh - 2rem);
|
|
26
28
|
overflow-y: auto;
|
|
27
29
|
border: 1px solid var(--pk-border);
|
|
28
30
|
border-radius: var(--pk-radius);
|
|
@@ -20,9 +20,20 @@ module PhlexKit
|
|
|
20
20
|
# mousedown on the overlay is prevented so a stray click can't move
|
|
21
21
|
# focus out of the trap. tabindex="-1" on the panel is the focus
|
|
22
22
|
# fallback when the dialog has no focusable children.
|
|
23
|
-
|
|
23
|
+
# data-pk-overlay-clone is a common marker read across clone-based
|
|
24
|
+
# overlay families (see #topmost in alert_dialog_controller.js) so
|
|
25
|
+
# Escape resolves to whichever overlay is actually topmost, even when
|
|
26
|
+
# a different overlay type (e.g. a Sheet) is nested/stacked on top.
|
|
27
|
+
div(data: { controller: "phlex-kit--alert-dialog", pk_overlay_clone: "" }) do
|
|
24
28
|
div(class: "pk-alert-dialog-overlay", "aria-hidden": "true", data: { action: "mousedown->phlex-kit--alert-dialog#overlayMousedown" })
|
|
25
|
-
|
|
29
|
+
panel_attrs = { class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" ") }
|
|
30
|
+
# Defaults only when the caller didn't supply their own — `mix`
|
|
31
|
+
# would fuse role="alertdialog dialog" / aria-modal="true false" /
|
|
32
|
+
# tabindex="-1 0" instead of overriding.
|
|
33
|
+
panel_attrs[:role] = "alertdialog" unless @attrs.key?(:role) || @attrs.key?("role")
|
|
34
|
+
panel_attrs[:"aria-modal"] = "true" unless aria_key_set?(:modal)
|
|
35
|
+
panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
|
|
36
|
+
div(**mix(panel_attrs, @attrs), &block)
|
|
26
37
|
end
|
|
27
38
|
end
|
|
28
39
|
end
|
|
@@ -29,7 +29,17 @@ export default class extends Controller {
|
|
|
29
29
|
this.previousOverflow = document.body.style.overflow;
|
|
30
30
|
document.body.style.overflow = "hidden";
|
|
31
31
|
this.onKeydown = (event) => this.keydown(event);
|
|
32
|
-
|
|
32
|
+
// Turbo snapshots synchronously right after dispatching this event —
|
|
33
|
+
// BEFORE Stimulus disconnect() runs (it fires via MutationObserver, too
|
|
34
|
+
// late). Restore inert + scroll lock here so the snapshot never captures
|
|
35
|
+
// a fully-inerted, scroll-locked page; disconnect()'s later restore
|
|
36
|
+
// becomes a harmless no-op (#restoreInert clears `this.inerted`, and
|
|
37
|
+
// reassigning the same overflow value is idempotent).
|
|
38
|
+
this.beforeCache = () => {
|
|
39
|
+
this.#restoreInert();
|
|
40
|
+
document.body.style.overflow = this.previousOverflow;
|
|
41
|
+
this.element.remove();
|
|
42
|
+
};
|
|
33
43
|
document.addEventListener("keydown", this.onKeydown);
|
|
34
44
|
document.addEventListener("turbo:before-cache", this.beforeCache);
|
|
35
45
|
this.#inertOthers();
|
|
@@ -40,7 +50,16 @@ export default class extends Controller {
|
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
disconnect() {
|
|
43
|
-
if (this.hasContentTarget)
|
|
53
|
+
if (this.hasContentTarget) {
|
|
54
|
+
// The source element (holding the template) can disconnect while a
|
|
55
|
+
// clone it spawned is still live in <body> — e.g. Turbo replacing this
|
|
56
|
+
// region out from under an open dialog. Remove the orphan rather than
|
|
57
|
+
// leaving a modal nothing can dismiss. Idempotent: if the clone
|
|
58
|
+
// already removed itself (Cancel, Escape, its own before-cache),
|
|
59
|
+
// this.clone is disconnected and the isConnected check no-ops.
|
|
60
|
+
if (this.clone?.isConnected) this.clone.remove();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
44
63
|
document.removeEventListener("keydown", this.onKeydown);
|
|
45
64
|
document.removeEventListener("turbo:before-cache", this.beforeCache);
|
|
46
65
|
this.#restoreInert();
|
|
@@ -76,6 +95,12 @@ export default class extends Controller {
|
|
|
76
95
|
// handle the keyboard, or one Escape would close every layer at once.
|
|
77
96
|
if (!this.#topmost()) return;
|
|
78
97
|
if (event.key === "Escape") {
|
|
98
|
+
// A native <dialog> nested inside this panel (e.g. a Dialog opened
|
|
99
|
+
// from within AlertDialogContent) owns its own Escape handling; this
|
|
100
|
+
// document-level listener still sees the keydown since it bubbles all
|
|
101
|
+
// the way up — ignore it so one Escape doesn't also dismiss the alert
|
|
102
|
+
// dialog underneath (mirrors sheet_content_controller's guard).
|
|
103
|
+
if (event.target.closest("dialog[open]")) return;
|
|
79
104
|
event.preventDefault();
|
|
80
105
|
this.dismiss();
|
|
81
106
|
return;
|
|
@@ -104,12 +129,15 @@ export default class extends Controller {
|
|
|
104
129
|
}
|
|
105
130
|
}
|
|
106
131
|
|
|
107
|
-
// Clones are appended as direct <body> children
|
|
108
|
-
//
|
|
109
|
-
//
|
|
132
|
+
// Clones are appended as direct <body> children. Other clone-based overlay
|
|
133
|
+
// families (sheet/drawer content) stamp the same [data-pk-overlay-clone]
|
|
134
|
+
// marker on their own clone root, so this checks z-stacking across overlay
|
|
135
|
+
// TYPES, not just other alert dialogs — a sheet opened from inside an
|
|
136
|
+
// alert dialog (or vice versa) is a later sibling and must absorb Escape
|
|
137
|
+
// first; only the last overlay clone overall may act on it.
|
|
110
138
|
#topmost() {
|
|
111
|
-
const clones = document.body.querySelectorAll(
|
|
112
|
-
return clones.length > 0 && clones[clones.length - 1]
|
|
139
|
+
const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
|
|
140
|
+
return clones.length > 0 && clones[clones.length - 1] === this.element;
|
|
113
141
|
}
|
|
114
142
|
|
|
115
143
|
#panel() {
|
|
@@ -118,6 +118,21 @@
|
|
|
118
118
|
-webkit-text-fill-color: transparent;
|
|
119
119
|
animation: pk-shimmer 2s linear infinite;
|
|
120
120
|
}
|
|
121
|
+
/* Same two fallback arms as _tokens.css's .pk-shimmer (:49-54): never let the
|
|
122
|
+
filename disappear under reduced motion or without relative color syntax
|
|
123
|
+
(Safari <16.4 / Chrome <119). */
|
|
124
|
+
@media (prefers-reduced-motion: reduce) {
|
|
125
|
+
.pk-attachment[data-state="uploading"] .pk-attachment-title,
|
|
126
|
+
.pk-attachment[data-state="processing"] .pk-attachment-title {
|
|
127
|
+
animation: none; background-image: none; -webkit-text-fill-color: currentColor;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
@supports not (color: oklch(from white l c h)) {
|
|
131
|
+
.pk-attachment[data-state="uploading"] .pk-attachment-title,
|
|
132
|
+
.pk-attachment[data-state="processing"] .pk-attachment-title {
|
|
133
|
+
animation: none; background-image: none; -webkit-text-fill-color: currentColor;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
121
136
|
.pk-attachment[data-state="error"] { border-color: color-mix(in oklab, var(--pk-red) 40%, transparent); }
|
|
122
137
|
.pk-attachment[data-state="error"] .pk-attachment-media { color: var(--pk-red); background: color-mix(in oklab, var(--pk-red) 12%, transparent); }
|
|
123
138
|
.pk-attachment[data-state="error"] .pk-attachment-description { color: var(--pk-red); }
|
|
@@ -11,6 +11,9 @@ module PhlexKit
|
|
|
11
11
|
unless AS_TAGS.include?(@as)
|
|
12
12
|
raise ArgumentError, "unknown AttachmentTrigger as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
13
13
|
end
|
|
14
|
+
if @as == :a && href.nil?
|
|
15
|
+
raise ArgumentError, "AttachmentTrigger requires href: when as: :a"
|
|
16
|
+
end
|
|
14
17
|
@href = href
|
|
15
18
|
@attrs = attrs
|
|
16
19
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
display: flex; flex-wrap: wrap; align-items: center;
|
|
4
4
|
gap: .375rem; word-break: break-word;
|
|
5
5
|
font-size: .875rem; color: var(--pk-muted);
|
|
6
|
+
list-style: none; margin: 0; padding: 0;
|
|
6
7
|
}
|
|
7
8
|
.pk-breadcrumb-item { display: inline-flex; align-items: center; gap: .25rem; }
|
|
8
9
|
.pk-breadcrumb-link { color: inherit; text-decoration: none; transition: color .15s ease; }
|
|
@@ -243,7 +243,10 @@ export default class extends Controller {
|
|
|
243
243
|
return this.isoDate(date);
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
|
|
246
|
+
// `focusDay` overrides the day to refocus after re-render: on a keyboard
|
|
247
|
+
// month cross the target day lives in the NEW grid, not the pre-render
|
|
248
|
+
// activeElement, so onKeydown passes it explicitly.
|
|
249
|
+
updateCalendar(focusDay = null) {
|
|
247
250
|
if (this.hasTitleTarget) {
|
|
248
251
|
this.titleTarget.textContent = this.monthAndYear();
|
|
249
252
|
}
|
|
@@ -251,9 +254,11 @@ export default class extends Controller {
|
|
|
251
254
|
this.syncNavButtons();
|
|
252
255
|
// innerHTML replacement drops focus — remember the focused day so
|
|
253
256
|
// keyboard selection/month hops don't strand focus on <body>.
|
|
254
|
-
const focusedDay =
|
|
255
|
-
|
|
256
|
-
|
|
257
|
+
const focusedDay =
|
|
258
|
+
focusDay ||
|
|
259
|
+
(this.calendarTarget.contains(document.activeElement)
|
|
260
|
+
? document.activeElement.dataset?.day
|
|
261
|
+
: null);
|
|
257
262
|
this.calendarTarget.innerHTML = this.calendarHTML();
|
|
258
263
|
this.ensureGridTabStop(focusedDay);
|
|
259
264
|
}
|
|
@@ -282,7 +287,11 @@ export default class extends Controller {
|
|
|
282
287
|
// boundary re-renders the grid on the target month. Enter/Space activate
|
|
283
288
|
// natively (the days are real <button>s).
|
|
284
289
|
onKeydown(e) {
|
|
285
|
-
|
|
290
|
+
// In RTL the day grid mirrors: the physical LEFT arrow advances one day.
|
|
291
|
+
// The week steps (Up/Down) and the PageUp/Down/Home/End jumps are
|
|
292
|
+
// unaffected. Runtime dir check is reliable after a dynamic flip.
|
|
293
|
+
const rtl = getComputedStyle(this.element).direction === "rtl";
|
|
294
|
+
const STEPS = { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1, ArrowUp: -7, ArrowDown: 7 };
|
|
286
295
|
const day = e.target.closest?.("[data-day]");
|
|
287
296
|
if (!day) return;
|
|
288
297
|
|
|
@@ -319,7 +328,15 @@ export default class extends Controller {
|
|
|
319
328
|
}
|
|
320
329
|
|
|
321
330
|
if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
|
|
322
|
-
|
|
331
|
+
// Crossing a month re-renders the grid on the target month. Stimulus
|
|
332
|
+
// value-changed callbacks fire via MutationObserver (async), so the grid
|
|
333
|
+
// is NOT re-rendered synchronously after this assignment — querying it
|
|
334
|
+
// below would hit the OLD month, and the target day (outside it) would be
|
|
335
|
+
// missing → early return, stranding focus on <body>. Drive the re-render
|
|
336
|
+
// ourselves and hand it the target day so ensureGridTabStop focuses it.
|
|
337
|
+
this.viewDateValue = iso;
|
|
338
|
+
this.updateCalendar(iso);
|
|
339
|
+
return;
|
|
323
340
|
}
|
|
324
341
|
const next = this.calendarTarget.querySelector(`[data-day="${iso}"]:not([disabled])`);
|
|
325
342
|
if (!next) return;
|
|
@@ -10,6 +10,11 @@ const DEFAULT_OPTIONS = {
|
|
|
10
10
|
const DRAG_DISTANCE_THRESHOLD = 0.25;
|
|
11
11
|
const DRAG_VELOCITY_THRESHOLD = 0.5;
|
|
12
12
|
|
|
13
|
+
// Subpixel slop when comparing the current offset to the max scrollable
|
|
14
|
+
// offset — layout rounding can leave a fraction of a px short of the true
|
|
15
|
+
// max, which would otherwise read as "can still scroll".
|
|
16
|
+
const OFFSET_EPSILON = 0.5;
|
|
17
|
+
|
|
13
18
|
// Ported from ruby_ui's carousel controller with the embla-carousel dependency
|
|
14
19
|
// removed: a minimal translate-based engine (scrollNext/scrollPrev, loop, x/y
|
|
15
20
|
// axis from the options value) moves the track directly, so only
|
|
@@ -36,6 +41,7 @@ export default class extends Controller {
|
|
|
36
41
|
this._onPointerMove = this._onPointerMove.bind(this);
|
|
37
42
|
this._onPointerUp = this._onPointerUp.bind(this);
|
|
38
43
|
this._onClickCapture = this._onClickCapture.bind(this);
|
|
44
|
+
this._onDragStart = this._onDragStart.bind(this);
|
|
39
45
|
window.addEventListener("resize", this._onResize);
|
|
40
46
|
// window resize misses container-only resizes (flex/grid reflow,
|
|
41
47
|
// sidebar toggles) — observe the viewport itself.
|
|
@@ -45,6 +51,10 @@ export default class extends Controller {
|
|
|
45
51
|
}
|
|
46
52
|
this.viewportTarget.addEventListener("pointerdown", this._onPointerDown);
|
|
47
53
|
this.viewportTarget.addEventListener("click", this._onClickCapture, true);
|
|
54
|
+
// Firefox starts a native image drag on pointerdown-over-<img>, which
|
|
55
|
+
// cancels the pointer sequence mid-swipe; -webkit-user-drag (CSS) is
|
|
56
|
+
// WebKit-only, so suppress it here for every browser.
|
|
57
|
+
this.viewportTarget.addEventListener("dragstart", this._onDragStart);
|
|
48
58
|
this._update();
|
|
49
59
|
}
|
|
50
60
|
|
|
@@ -54,6 +64,7 @@ export default class extends Controller {
|
|
|
54
64
|
this._resizeObserver = null;
|
|
55
65
|
this.viewportTarget.removeEventListener("pointerdown", this._onPointerDown);
|
|
56
66
|
this.viewportTarget.removeEventListener("click", this._onClickCapture, true);
|
|
67
|
+
this.viewportTarget.removeEventListener("dragstart", this._onDragStart);
|
|
57
68
|
// a disconnect mid-drag must drop the move/up listeners and drag state
|
|
58
69
|
this.viewportTarget.removeEventListener("pointermove", this._onPointerMove);
|
|
59
70
|
this.viewportTarget.removeEventListener("pointerup", this._onPointerUp);
|
|
@@ -66,6 +77,14 @@ export default class extends Controller {
|
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
scrollNext() {
|
|
80
|
+
// Non-loop multi-up: the track can hit its max scrollable offset before
|
|
81
|
+
// the index reaches the last slide (_offsetOf clamps). Once there, index
|
|
82
|
+
// still has room to climb but there's nothing left to reveal — bail so
|
|
83
|
+
// neither clicks (button already disables via _update) nor ArrowRight
|
|
84
|
+
// (keyNext, which routes here) produce dead no-op advances.
|
|
85
|
+
if (!this.options.loop && this._offsetOf(this.index) >= this._maxOffset() - OFFSET_EPSILON) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
69
88
|
this._goTo(this.index + 1);
|
|
70
89
|
}
|
|
71
90
|
|
|
@@ -105,8 +124,13 @@ export default class extends Controller {
|
|
|
105
124
|
|
|
106
125
|
_update() {
|
|
107
126
|
this._applyTransform();
|
|
108
|
-
const
|
|
109
|
-
|
|
127
|
+
const canNext = this.options.loop
|
|
128
|
+
? this.slides.length > 1
|
|
129
|
+
// Offset-based, not index-based: with multi-up layouts the track
|
|
130
|
+
// reaches its max scrollable offset (_maxOffset) before this.index
|
|
131
|
+
// reaches the last slide, and _offsetOf clamps to that max — an
|
|
132
|
+
// index-based check left Next enabled for several dead clicks.
|
|
133
|
+
: this._offsetOf(this.index) < this._maxOffset() - OFFSET_EPSILON;
|
|
110
134
|
const canPrev = this.options.loop ? this.slides.length > 1 : this.index > 0;
|
|
111
135
|
this.nextButtonTargets.forEach((button) => (button.disabled = !canNext));
|
|
112
136
|
this.prevButtonTargets.forEach((button) => (button.disabled = !canPrev));
|
|
@@ -189,6 +213,16 @@ export default class extends Controller {
|
|
|
189
213
|
e.stopPropagation();
|
|
190
214
|
}
|
|
191
215
|
|
|
216
|
+
// Firefox-only: a pointer drag that starts on an <img> slide fires a
|
|
217
|
+
// native HTML5 dragstart, which cancels the in-flight pointer sequence
|
|
218
|
+
// (pointercancel) and aborts the swipe. Chrome/Safari never fire it here
|
|
219
|
+
// because carousel.css already sets -webkit-user-drag: none.
|
|
220
|
+
_onDragStart(e) {
|
|
221
|
+
if (e.target.closest("img")) {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
192
226
|
// --- geometry ---
|
|
193
227
|
|
|
194
228
|
_pointerPos(e) {
|
|
@@ -21,15 +21,17 @@ module PhlexKit
|
|
|
21
21
|
def view_template(&)
|
|
22
22
|
base = {
|
|
23
23
|
class: "pk-chart",
|
|
24
|
-
# Canvas content is invisible to AT — announce it as an image with at
|
|
25
|
-
# least a generic name; pass aria: { label: "…" } (or block fallback
|
|
26
|
-
# content) to describe the actual data.
|
|
27
|
-
role: "img",
|
|
28
24
|
data: {
|
|
29
25
|
controller: "phlex-kit--chart",
|
|
30
26
|
phlex_kit__chart_options_value: JSON.generate(@options)
|
|
31
27
|
}
|
|
32
28
|
}
|
|
29
|
+
# Defaults only when the caller didn't supply their own — `mix` would
|
|
30
|
+
# fuse role="img figure" instead of overriding.
|
|
31
|
+
# Canvas content is invisible to AT — announce it as an image with at
|
|
32
|
+
# least a generic name; pass aria: { label: "…" } (or block fallback
|
|
33
|
+
# content) to describe the actual data.
|
|
34
|
+
base[:role] = "img" unless @attrs.key?(:role) || @attrs.key?("role")
|
|
33
35
|
base[:aria] = { label: "Chart" } unless aria_labelled?
|
|
34
36
|
canvas(**mix(base, @attrs), &)
|
|
35
37
|
end
|
|
@@ -26,7 +26,7 @@ export default class extends Controller {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
disconnect() {
|
|
29
|
-
this.
|
|
29
|
+
this.disconnectThemeObservers()
|
|
30
30
|
this.chart?.destroy()
|
|
31
31
|
if (!this.chart) {
|
|
32
32
|
this.dispatch("disconnect", { detail: { canvas: this.element } })
|
|
@@ -44,9 +44,14 @@ export default class extends Controller {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// Kit tokens are plain color values (hex / color-mix results), not the hsl
|
|
47
|
-
// triplets ruby_ui reads — return them as-is.
|
|
47
|
+
// triplets ruby_ui reads — return them as-is. Resolved from this.element,
|
|
48
|
+
// not document.documentElement: a .pk-dark island (_tokens.css:123-129)
|
|
49
|
+
// sets the --pk-* custom properties at the SUBTREE root, and per-element
|
|
50
|
+
// getComputedStyle correctly picks up the nearest ancestor's values —
|
|
51
|
+
// reading from the document root would ignore the island and always
|
|
52
|
+
// return the page-level theme.
|
|
48
53
|
getThemeColor(name) {
|
|
49
|
-
return getComputedStyle(
|
|
54
|
+
return getComputedStyle(this.element).getPropertyValue(`--${name}`).trim()
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
// shadcn-style series colors: dataset N takes --pk-chart-(N%5 + 1). Line/area
|
|
@@ -80,6 +85,13 @@ export default class extends Controller {
|
|
|
80
85
|
|
|
81
86
|
setDefaultColorsForChart() {
|
|
82
87
|
const Chart = this.chartLibrary()
|
|
88
|
+
// Chart.defaults is a library-wide singleton (chart.js has no per-instance
|
|
89
|
+
// defaults object), so it can only ever reflect ONE theme — the
|
|
90
|
+
// documented caveat. It's resolved from the document root, not
|
|
91
|
+
// this.element: a chart inside a .pk-dark island still gets per-dataset
|
|
92
|
+
// colors from the island (getThemeColor/seriesColor, above), just not
|
|
93
|
+
// these library-global defaults (grid/tooltip/legend chrome).
|
|
94
|
+
//
|
|
83
95
|
// One computed-style resolve for all nine tokens (this runs on every
|
|
84
96
|
// connect; assigning identical values to Chart.defaults is free — charts
|
|
85
97
|
// read defaults at construction — so the style resolves are the only cost
|
|
@@ -122,20 +134,37 @@ export default class extends Controller {
|
|
|
122
134
|
// unrelated class flip is the expensive path.
|
|
123
135
|
initThemeObserver() {
|
|
124
136
|
this._themeKey = this.currentThemeKey()
|
|
125
|
-
|
|
137
|
+
const onMutate = () => {
|
|
126
138
|
const key = this.currentThemeKey()
|
|
127
139
|
if (key === this._themeKey) return
|
|
128
140
|
this._themeKey = key
|
|
129
141
|
this.refreshChart()
|
|
130
|
-
}
|
|
142
|
+
}
|
|
143
|
+
this.themeObserver = new MutationObserver(onMutate)
|
|
131
144
|
this.themeObserver.observe(document.documentElement, { attributeFilter: ["data-theme", "class"] })
|
|
145
|
+
|
|
146
|
+
// A .pk-dark island (_tokens.css:123-129) re-themes a subtree without
|
|
147
|
+
// touching the document root, so the root-only observer above misses it.
|
|
148
|
+
// Watch the nearest island ancestor too, if this chart lives inside one.
|
|
149
|
+
const island = this.element.closest(".pk-dark")
|
|
150
|
+
if (island && island !== document.documentElement) {
|
|
151
|
+
this.islandObserver = new MutationObserver(onMutate)
|
|
152
|
+
this.islandObserver.observe(island, { attributeFilter: ["data-theme", "class"] })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
disconnectThemeObservers() {
|
|
157
|
+
this.themeObserver?.disconnect()
|
|
158
|
+
this.islandObserver?.disconnect()
|
|
132
159
|
}
|
|
133
160
|
|
|
134
161
|
// Cheap fingerprint of the resolved theme: two tokens that differ between
|
|
135
|
-
// every light/dark pair.
|
|
136
|
-
//
|
|
162
|
+
// every light/dark pair. Resolved from this.element (island-aware, see
|
|
163
|
+
// getThemeColor) so a mutation on an ancestor .pk-dark island is detected
|
|
164
|
+
// even though the document root never changes. One style resolve per
|
|
165
|
+
// mutation vs a full chart rebuild.
|
|
137
166
|
currentThemeKey() {
|
|
138
|
-
const styles = getComputedStyle(
|
|
167
|
+
const styles = getComputedStyle(this.element)
|
|
139
168
|
return `${styles.getPropertyValue("--pk-bg")}|${styles.getPropertyValue("--pk-text")}`
|
|
140
169
|
}
|
|
141
170
|
|
|
@@ -12,7 +12,11 @@ module PhlexKit
|
|
|
12
12
|
# tabindex=0 + role=region: the block scrolls (overflow:auto), so it must
|
|
13
13
|
# be keyboard-focusable (WCAG 2.1.1 scrollable-region-focusable). Pass
|
|
14
14
|
# `aria: { label: ... }` to name the region for AT.
|
|
15
|
-
|
|
15
|
+
# Defaults only when the caller didn't supply their own — `mix` would
|
|
16
|
+
# fuse tabindex="0 -1" / role="region article" instead of overriding.
|
|
17
|
+
base = { class: "pk-codeblock", data: { syntax: @syntax } }
|
|
18
|
+
base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
|
|
19
|
+
base[:role] = "region" unless @attrs.key?(:role) || @attrs.key?("role")
|
|
16
20
|
# A region landmark without a name is an axe violation — default one
|
|
17
21
|
# from the syntax; a caller aria: { label: } replaces it (never fused).
|
|
18
22
|
unless aria_labelled?
|
|
@@ -50,6 +50,10 @@ export default class extends Controller {
|
|
|
50
50
|
// a restored page doesn't announce an open listbox over a closed combobox.
|
|
51
51
|
this.setExpanded(false)
|
|
52
52
|
this.clearActiveDescendant()
|
|
53
|
+
// Mirrors select_controller.js's connect(): a Turbo snapshot also
|
|
54
|
+
// serializes the keyboard-highlighted item's aria-current even though
|
|
55
|
+
// the popover itself does not survive the restore.
|
|
56
|
+
this.itemTargets.forEach((item) => item.removeAttribute("aria-current"))
|
|
53
57
|
this.generateItemIds()
|
|
54
58
|
this.updateTriggerContent()
|
|
55
59
|
}
|
|
@@ -208,7 +212,19 @@ export default class extends Controller {
|
|
|
208
212
|
remove.textContent = "×"
|
|
209
213
|
remove.addEventListener("click", (e) => {
|
|
210
214
|
e.stopPropagation() // don't reopen the popover via the trigger's click action
|
|
215
|
+
// Removing a focused chip drops focus with it (the remove button is
|
|
216
|
+
// torn out of the DOM by the renderBadges() rebuild below) — hand
|
|
217
|
+
// focus to the badge input instead of stranding it on <body>.
|
|
218
|
+
const wasFocused = document.activeElement === remove
|
|
211
219
|
this.uncheck(input)
|
|
220
|
+
// badgeInput sits inside the trigger wrapper, so focusing it bubbles a
|
|
221
|
+
// focusin the trigger's own focusin->openPopover action would otherwise
|
|
222
|
+
// reopen — same guard closePopover() uses for its own focus restore.
|
|
223
|
+
if (wasFocused && this.hasBadgeInputTarget) {
|
|
224
|
+
this.refocusing = true
|
|
225
|
+
this.badgeInputTarget.focus()
|
|
226
|
+
this.refocusing = false
|
|
227
|
+
}
|
|
212
228
|
})
|
|
213
229
|
|
|
214
230
|
badge.append(remove)
|
|
@@ -235,6 +251,10 @@ export default class extends Controller {
|
|
|
235
251
|
}
|
|
236
252
|
|
|
237
253
|
clearAll(e) {
|
|
254
|
+
// The clear button hides itself once nothing is checked (updateTriggerContent
|
|
255
|
+
// below) — if it held focus, hand focus to the badge input rather than
|
|
256
|
+
// stranding it on a now-hidden element.
|
|
257
|
+
const wasFocused = this.hasClearButtonTarget && document.activeElement === this.clearButtonTarget
|
|
238
258
|
if (e) {
|
|
239
259
|
e.preventDefault()
|
|
240
260
|
e.stopPropagation() // the button sits inside the trigger's click-to-open area
|
|
@@ -246,6 +266,11 @@ export default class extends Controller {
|
|
|
246
266
|
})
|
|
247
267
|
if (this.hasToggleAllTarget) this.toggleAllTarget.checked = false
|
|
248
268
|
this.updateTriggerContent()
|
|
269
|
+
if (wasFocused && this.hasBadgeInputTarget) {
|
|
270
|
+
this.refocusing = true
|
|
271
|
+
this.badgeInputTarget.focus()
|
|
272
|
+
this.refocusing = false
|
|
273
|
+
}
|
|
249
274
|
}
|
|
250
275
|
|
|
251
276
|
togglePopover(event) {
|
|
@@ -303,6 +328,11 @@ export default class extends Controller {
|
|
|
303
328
|
// aria-activedescendant holds an element id; on close it must be removed,
|
|
304
329
|
// not left pointing at a hidden option.
|
|
305
330
|
this.clearActiveDescendant()
|
|
331
|
+
// Also drop the highlight itself — otherwise a closed combobox still
|
|
332
|
+
// exposes an aria-current="true" option that Enter would go on to
|
|
333
|
+
// activate (APG: Enter on a closed combobox must be a no-op).
|
|
334
|
+
this.selectedItemIndex = null
|
|
335
|
+
this.itemTargets.forEach(item => item.ariaCurrent = "false")
|
|
306
336
|
if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
|
|
307
337
|
this.updateTriggerContent() // reflect the selection into an input trigger
|
|
308
338
|
|
|
@@ -349,6 +379,12 @@ export default class extends Controller {
|
|
|
349
379
|
this.applyFilter(field.value.toLowerCase())
|
|
350
380
|
}
|
|
351
381
|
|
|
382
|
+
// Strips combining diacritical marks after an NFD decomposition, so "é"
|
|
383
|
+
// (query or candidate) matches "e" instead of never comparing equal.
|
|
384
|
+
normalizeDiacritics(s) {
|
|
385
|
+
return s.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
|
386
|
+
}
|
|
387
|
+
|
|
352
388
|
applyFilter(filterTerm) {
|
|
353
389
|
if (this.hasToggleAllTarget) {
|
|
354
390
|
if (filterTerm) this.toggleAllTarget.parentElement.classList.add("pk-hidden")
|
|
@@ -363,10 +399,12 @@ export default class extends Controller {
|
|
|
363
399
|
// aria-current="true" stayed the Enter target while invisible.
|
|
364
400
|
this.itemTargets.forEach(item => item.ariaCurrent = "false")
|
|
365
401
|
|
|
402
|
+
const normalizedFilterTerm = this.normalizeDiacritics(filterTerm)
|
|
403
|
+
|
|
366
404
|
this.inputTargets.forEach((input) => {
|
|
367
|
-
const text = this.inputContent(input).toLowerCase()
|
|
405
|
+
const text = this.normalizeDiacritics(this.inputContent(input).toLowerCase())
|
|
368
406
|
|
|
369
|
-
if (text.indexOf(
|
|
407
|
+
if (text.indexOf(normalizedFilterTerm) > -1) {
|
|
370
408
|
input.parentElement.classList.remove("pk-hidden")
|
|
371
409
|
resultCount++
|
|
372
410
|
} else {
|
|
@@ -395,6 +433,12 @@ export default class extends Controller {
|
|
|
395
433
|
keyDownPressed(e) {
|
|
396
434
|
if (e) e.preventDefault()
|
|
397
435
|
|
|
436
|
+
// APG: ArrowDown on a closed combobox opens it and highlights the first
|
|
437
|
+
// option, rather than walking aria-current through invisible options.
|
|
438
|
+
// openPopover() resets selectedItemIndex to null, so the branch below
|
|
439
|
+
// still lands on 0.
|
|
440
|
+
if (!this.isOpen()) this.openPopover()
|
|
441
|
+
|
|
398
442
|
if (this.selectedItemIndex !== null) {
|
|
399
443
|
this.selectedItemIndex++
|
|
400
444
|
} else {
|
|
@@ -407,6 +451,11 @@ export default class extends Controller {
|
|
|
407
451
|
keyUpPressed(e) {
|
|
408
452
|
if (e) e.preventDefault()
|
|
409
453
|
|
|
454
|
+
// Same as keyDownPressed: ArrowUp on a closed combobox opens it and
|
|
455
|
+
// highlights the last option (wrapSelectedInputIndex turns -1 into the
|
|
456
|
+
// final index).
|
|
457
|
+
if (!this.isOpen()) this.openPopover()
|
|
458
|
+
|
|
410
459
|
if (this.selectedItemIndex !== null) {
|
|
411
460
|
this.selectedItemIndex--
|
|
412
461
|
} else {
|
|
@@ -439,6 +488,12 @@ export default class extends Controller {
|
|
|
439
488
|
}
|
|
440
489
|
|
|
441
490
|
keyEnterPressed(event) {
|
|
491
|
+
// APG: Enter on a CLOSED combobox is a no-op — it must not activate
|
|
492
|
+
// whatever option happened to keep aria-current="true" from before the
|
|
493
|
+
// popover closed. Bail before preventDefault so a closed field keeps
|
|
494
|
+
// Enter's ordinary default behaviour.
|
|
495
|
+
if (!this.isOpen()) return
|
|
496
|
+
|
|
442
497
|
event.preventDefault()
|
|
443
498
|
const option = this.itemTargets.find(item => item.ariaCurrent === "true")
|
|
444
499
|
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
|
+
// Strips combining diacritical marks after an NFD decomposition, so "é"
|
|
4
|
+
// (query or candidate) matches "e" — the scorer otherwise treats accented
|
|
5
|
+
// characters as never equal to their bare ASCII counterparts.
|
|
6
|
+
function normalizeDiacritics(s) {
|
|
7
|
+
return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
// Fuzzy subsequence score: every query character must appear in order in the
|
|
4
11
|
// value (else null = no match). Consecutive runs compound, word-starts score
|
|
5
12
|
// extra, and a verbatim substring outranks scattered hits — so "set" beats
|
|
@@ -7,6 +14,8 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
7
14
|
// what fuse.js gave ruby_ui.
|
|
8
15
|
function fuzzyScore(query, value) {
|
|
9
16
|
if (!query) return 0;
|
|
17
|
+
query = normalizeDiacritics(query);
|
|
18
|
+
value = normalizeDiacritics(value);
|
|
10
19
|
let qi = 0;
|
|
11
20
|
let score = 0;
|
|
12
21
|
let streak = 0;
|
|
@@ -50,10 +59,17 @@ export default class extends Controller {
|
|
|
50
59
|
// dismiss() can hand focus back instead of dropping it on <body>.
|
|
51
60
|
this.previouslyFocused = document.activeElement;
|
|
52
61
|
|
|
62
|
+
// A Turbo snapshot serializes stale aria-selected on rows / aria-
|
|
63
|
+
// activedescendant on the input even though the live JS highlight state
|
|
64
|
+
// doesn't survive restore — normalize here so a restored page doesn't
|
|
65
|
+
// announce a stale highlight (mirrors select_controller.js's connect()).
|
|
66
|
+
this.itemTargets.forEach((item) => item.removeAttribute("aria-selected"));
|
|
67
|
+
|
|
53
68
|
if (!this.hasInputTarget) {
|
|
54
69
|
return;
|
|
55
70
|
}
|
|
56
71
|
|
|
72
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
57
73
|
this.generateItemIds();
|
|
58
74
|
// Only the cloned dialog overlay grabs focus on connect — an inline
|
|
59
75
|
// palette connecting at page load must not steal it.
|
|
@@ -174,7 +190,7 @@ export default class extends Controller {
|
|
|
174
190
|
}
|
|
175
191
|
|
|
176
192
|
focusInput() {
|
|
177
|
-
this.inputTarget
|
|
193
|
+
if (this.hasInputTarget) this.inputTarget.focus();
|
|
178
194
|
}
|
|
179
195
|
|
|
180
196
|
filter(e) {
|
|
@@ -11,10 +11,11 @@ module PhlexKit
|
|
|
11
11
|
# `autofocus:` defaults off so an inline palette doesn't steal page focus
|
|
12
12
|
# on load; the dialog clone needs no autofocus either — the command
|
|
13
13
|
# controller's connect() focuses the input when the clone is inserted.
|
|
14
|
-
def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, **attrs)
|
|
14
|
+
def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, value: "", **attrs)
|
|
15
15
|
@placeholder = placeholder
|
|
16
16
|
@list_id = list_id
|
|
17
17
|
@autofocus = autofocus
|
|
18
|
+
@value = value
|
|
18
19
|
@attrs = attrs
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -31,7 +32,7 @@ module PhlexKit
|
|
|
31
32
|
spellcheck: "false",
|
|
32
33
|
autofocus: @autofocus,
|
|
33
34
|
role: "combobox",
|
|
34
|
-
value:
|
|
35
|
+
value: @value,
|
|
35
36
|
aria: { autocomplete: "list", expanded: "true", controls: @list_id },
|
|
36
37
|
data: {
|
|
37
38
|
phlex_kit__command_target: "input",
|
|
@@ -72,6 +72,9 @@
|
|
|
72
72
|
the viewport right edge. */
|
|
73
73
|
.pk-context-menu-sub { anchor-name: --pk-context-menu-sub; anchor-scope: --pk-context-menu-sub; }
|
|
74
74
|
.pk-context-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
75
|
+
/* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
|
|
76
|
+
[dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
|
|
77
|
+
[dir="rtl"] .pk-context-menu-sub-chevron { transform: scaleX(-1); }
|
|
75
78
|
.pk-context-menu-sub-content {
|
|
76
79
|
position: fixed;
|
|
77
80
|
position-anchor: --pk-context-menu-sub;
|