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
|
@@ -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;
|
|
@@ -40,6 +49,7 @@ export default class extends Controller {
|
|
|
40
49
|
// itemTargetConnected uses must be initialized here (mirrors
|
|
41
50
|
// combobox_controller.js).
|
|
42
51
|
this.itemIdCounter = 0;
|
|
52
|
+
this.orderCounter = 0;
|
|
43
53
|
this.searchEntries = [];
|
|
44
54
|
this.selectedIndex = -1;
|
|
45
55
|
}
|
|
@@ -50,10 +60,17 @@ export default class extends Controller {
|
|
|
50
60
|
// dismiss() can hand focus back instead of dropping it on <body>.
|
|
51
61
|
this.previouslyFocused = document.activeElement;
|
|
52
62
|
|
|
63
|
+
// A Turbo snapshot serializes stale aria-selected on rows / aria-
|
|
64
|
+
// activedescendant on the input even though the live JS highlight state
|
|
65
|
+
// doesn't survive restore — normalize here so a restored page doesn't
|
|
66
|
+
// announce a stale highlight (mirrors select_controller.js's connect()).
|
|
67
|
+
this.itemTargets.forEach((item) => item.removeAttribute("aria-selected"));
|
|
68
|
+
|
|
53
69
|
if (!this.hasInputTarget) {
|
|
54
70
|
return;
|
|
55
71
|
}
|
|
56
72
|
|
|
73
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
57
74
|
this.generateItemIds();
|
|
58
75
|
// Only the cloned dialog overlay grabs focus on connect — an inline
|
|
59
76
|
// palette connecting at page load must not steal it.
|
|
@@ -67,6 +84,13 @@ export default class extends Controller {
|
|
|
67
84
|
return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
|
|
68
85
|
}
|
|
69
86
|
|
|
87
|
+
// Clones are appended as direct <body> children; the last stamped clone is
|
|
88
|
+
// the visually topmost overlay across all clone-based overlay families.
|
|
89
|
+
isTopmostClone() {
|
|
90
|
+
const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
|
|
91
|
+
return clones.length > 0 && clones[clones.length - 1] === this.element;
|
|
92
|
+
}
|
|
93
|
+
|
|
70
94
|
// ARIA plumbing: aria-controls is wired to the listbox (mirrors
|
|
71
95
|
// select_controller.js); the per-result ids come from itemTargetConnected.
|
|
72
96
|
generateItemIds() {
|
|
@@ -87,6 +111,10 @@ export default class extends Controller {
|
|
|
87
111
|
// arrival (mirrors combobox_controller.js).
|
|
88
112
|
itemTargetConnected(item) {
|
|
89
113
|
if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
|
|
114
|
+
// Original position, stamped once — filter() reorders rows by fuzzy
|
|
115
|
+
// score and resetVisibility() restores this order. Survives the
|
|
116
|
+
// disconnect/reconnect a DOM move itself triggers.
|
|
117
|
+
if (!item.dataset.pkOrder) item.dataset.pkOrder = String(this.orderCounter++);
|
|
90
118
|
this.searchEntries.push({
|
|
91
119
|
value: (item.dataset.value || "").toLowerCase(),
|
|
92
120
|
text: (item.dataset.text || "").toLowerCase(),
|
|
@@ -103,7 +131,22 @@ export default class extends Controller {
|
|
|
103
131
|
this.selectedIndex = -1;
|
|
104
132
|
}
|
|
105
133
|
|
|
106
|
-
dismiss() {
|
|
134
|
+
dismiss(e) {
|
|
135
|
+
if (e?.type === "keydown" && this.isDialogClone()) {
|
|
136
|
+
// The backdrop's Escape binding is window-scoped, so it fires even
|
|
137
|
+
// with another overlay (alert dialog, sheet) stacked ON TOP of the
|
|
138
|
+
// palette — those clones are later <body> siblings carrying the same
|
|
139
|
+
// [data-pk-overlay-clone] marker. Only the topmost clone may act on
|
|
140
|
+
// Escape (mirrors alert_dialog_controller.js#topmost).
|
|
141
|
+
if (!this.isTopmostClone()) return;
|
|
142
|
+
// The input's Escape binding is element-scoped and fires BEFORE lower
|
|
143
|
+
// overlays' document/window listeners — stop the keydown here, or by
|
|
144
|
+
// the time they run this clone (and its marker) is already removed and
|
|
145
|
+
// their own topmost check would wrongly elect them (mirrors
|
|
146
|
+
// sheet_content_controller.js's Escape guard).
|
|
147
|
+
e.stopPropagation();
|
|
148
|
+
}
|
|
149
|
+
|
|
107
150
|
// Cloned dialog overlay: tear the clone down and hand focus back.
|
|
108
151
|
if (this.isDialogClone()) {
|
|
109
152
|
// Dispatched BEFORE teardown: the command-dialog controller listens on
|
|
@@ -174,7 +217,7 @@ export default class extends Controller {
|
|
|
174
217
|
}
|
|
175
218
|
|
|
176
219
|
focusInput() {
|
|
177
|
-
this.inputTarget
|
|
220
|
+
if (this.hasInputTarget) this.inputTarget.focus();
|
|
178
221
|
}
|
|
179
222
|
|
|
180
223
|
filter(e) {
|
|
@@ -194,6 +237,9 @@ export default class extends Controller {
|
|
|
194
237
|
results.forEach((result) =>
|
|
195
238
|
this.toggleVisibility([result.item.element], true),
|
|
196
239
|
);
|
|
240
|
+
// cmdk parity: the best match lists first. Within each parent only —
|
|
241
|
+
// groups keep their identity, matches never jump between them.
|
|
242
|
+
this.reorderWithinParents(results.map((r) => r.item.element));
|
|
197
243
|
|
|
198
244
|
this.announceResultCount(results.length);
|
|
199
245
|
this.toggleVisibility(this.emptyTargets, results.length === 0);
|
|
@@ -232,6 +278,35 @@ export default class extends Controller {
|
|
|
232
278
|
this.toggleVisibility(this.groupTargets, true);
|
|
233
279
|
this.toggleVisibility(this.separatorTargets, true);
|
|
234
280
|
this.toggleVisibility(this.emptyTargets, false);
|
|
281
|
+
// Undo any fuzzy-score reordering: back to server-rendered order.
|
|
282
|
+
this.reorderWithinParents(
|
|
283
|
+
[...this.itemTargets].sort((a, b) => Number(a.dataset.pkOrder) - Number(b.dataset.pkOrder)),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Re-inserts `orderedItems` in the given sequence, each within its own
|
|
288
|
+
// parent, at the position of that parent's first item row — headings,
|
|
289
|
+
// separators and empty-state siblings keep their places. (Items are
|
|
290
|
+
// assumed contiguous within a parent; a separator BETWEEN items of one
|
|
291
|
+
// parent would drift below them.) The moves retrigger item target
|
|
292
|
+
// disconnect/connect, which is safe: ids and pkOrder are stamped once,
|
|
293
|
+
// and filter()/dismiss() have already reset the highlight.
|
|
294
|
+
reorderWithinParents(orderedItems) {
|
|
295
|
+
const itemSet = new Set(this.itemTargets);
|
|
296
|
+
const byParent = new Map();
|
|
297
|
+
orderedItems.forEach((el) => {
|
|
298
|
+
const parent = el.parentElement;
|
|
299
|
+
if (!parent) return;
|
|
300
|
+
if (!byParent.has(parent)) byParent.set(parent, []);
|
|
301
|
+
byParent.get(parent).push(el);
|
|
302
|
+
});
|
|
303
|
+
byParent.forEach((els, parent) => {
|
|
304
|
+
const firstItem = [...parent.children].find((c) => itemSet.has(c));
|
|
305
|
+
const placeholder = document.createComment("");
|
|
306
|
+
parent.insertBefore(placeholder, firstItem || null);
|
|
307
|
+
els.forEach((el) => parent.insertBefore(el, placeholder));
|
|
308
|
+
placeholder.remove();
|
|
309
|
+
});
|
|
235
310
|
}
|
|
236
311
|
|
|
237
312
|
// Upstream builds a Fuse index here; this keeps the same result shape —
|
|
@@ -17,9 +17,14 @@ module PhlexKit
|
|
|
17
17
|
template(data: { phlex_kit__command_dialog_target: "content" }) do
|
|
18
18
|
# The keydown action is the focus trap: Tab cycles within the cloned
|
|
19
19
|
# overlay instead of escaping to the page underneath.
|
|
20
|
+
# data-pk-overlay-clone is the common marker stamped by every
|
|
21
|
+
# clone-based overlay family (see alert_dialog_controller.js#topmost):
|
|
22
|
+
# it lets stacked overlays of DIFFERENT types resolve which one is
|
|
23
|
+
# topmost, so one Escape never closes both layers at once.
|
|
20
24
|
div(data: {
|
|
21
25
|
controller: "phlex-kit--command",
|
|
22
26
|
phlex_kit__command_dialog_instance: true,
|
|
27
|
+
pk_overlay_clone: "",
|
|
23
28
|
action: "keydown->phlex-kit--command#trapFocus"
|
|
24
29
|
}) do
|
|
25
30
|
backdrop
|
|
@@ -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;
|
|
@@ -5,7 +5,15 @@ module PhlexKit
|
|
|
5
5
|
class ContextMenu < BaseComponent
|
|
6
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
7
7
|
def view_template(&)
|
|
8
|
-
div(**mix({
|
|
8
|
+
div(**mix({
|
|
9
|
+
class: "pk-context-menu",
|
|
10
|
+
data: {
|
|
11
|
+
controller: "phlex-kit--context-menu",
|
|
12
|
+
# focusout: tabbing (or otherwise moving real focus) out of the
|
|
13
|
+
# menu closes the open [popover=manual] panel (menubar's pattern).
|
|
14
|
+
action: "focusout->phlex-kit--context-menu#onFocusout"
|
|
15
|
+
}
|
|
16
|
+
}, @attrs), &)
|
|
9
17
|
end
|
|
10
18
|
end
|
|
11
19
|
end
|
|
@@ -8,6 +8,18 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
8
8
|
// native [popover=manual] in the top layer (context_menu.css); it is placed
|
|
9
9
|
// in viewport coordinates after showPopover() — a hidden popover has no
|
|
10
10
|
// size — and clamped so it never overflows the viewport (no floating-ui).
|
|
11
|
+
|
|
12
|
+
// One-shot capture listener swallowing the click an outside mousedown is
|
|
13
|
+
// about to produce (see onDocMousedown; same helper in dropdown/menubar —
|
|
14
|
+
// duplicated per controller by design). {once} self-removes; the DOM
|
|
15
|
+
// dedupes re-arming with the same fn. If the gesture's click never fires
|
|
16
|
+
// (e.g. suppressed after a selection drag) the armed swallow eats the next
|
|
17
|
+
// one — rare enough to accept.
|
|
18
|
+
const swallowClick = (ev) => ev.preventDefault()
|
|
19
|
+
function armSwallowClick() {
|
|
20
|
+
window.addEventListener("click", swallowClick, { once: true, capture: true })
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
export default class extends Controller {
|
|
12
24
|
static targets = ["content", "trigger", "menuItem"]
|
|
13
25
|
|
|
@@ -22,6 +34,17 @@ export default class extends Controller {
|
|
|
22
34
|
if (e.type === "contextmenu" && this.element.contains(e.target)) return
|
|
23
35
|
this.close()
|
|
24
36
|
}
|
|
37
|
+
// Kit-wide dismiss contract (Radix modal-menu parity): a plain click
|
|
38
|
+
// outside only dismisses — swallowed, like dropdown/menubar/select.
|
|
39
|
+
// Armed at MOUSEDOWN: the gesture's focusout close() removes onDoc
|
|
40
|
+
// before the click even fires. Right-button mousedowns are ignored so a
|
|
41
|
+
// contextmenu elsewhere stays click-through (that's how a second kit —
|
|
42
|
+
// or the native — context menu opens in the same gesture).
|
|
43
|
+
this.onDocMousedown = (e) => {
|
|
44
|
+
if (this.contentTarget.contains(e.target)) return
|
|
45
|
+
if (e.button !== 0) return
|
|
46
|
+
armSwallowClick()
|
|
47
|
+
}
|
|
25
48
|
this.onKey = (e) => this.keydown(e)
|
|
26
49
|
// Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
|
|
27
50
|
// stale data-state="open" (the panel itself can't resurrect, but host
|
|
@@ -33,9 +56,21 @@ export default class extends Controller {
|
|
|
33
56
|
// Outstanding syncSub rAF handles — cancelled on disconnect (menubar's
|
|
34
57
|
// pattern).
|
|
35
58
|
this.subFrames = new Set()
|
|
59
|
+
// A Turbo snapshot (or a detach/reattach that mimics one) serializes
|
|
60
|
+
// aria-expanded="true" on sub triggers even though no submenu can be
|
|
61
|
+
// open at connect time — sweep every stale marker back to false
|
|
62
|
+
// (menubar's pattern).
|
|
63
|
+
this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
|
|
64
|
+
// Same shape for checkbox/radio rows: their `checked` DOM property
|
|
65
|
+
// survives a reconnect but a hand-edited/replayed aria-checked can drift
|
|
66
|
+
// from it — resync every row from its live input.
|
|
67
|
+
this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
|
|
68
|
+
i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
|
|
69
|
+
})
|
|
36
70
|
}
|
|
37
71
|
|
|
38
72
|
disconnect() {
|
|
73
|
+
document.removeEventListener("mousedown", this.onDocMousedown)
|
|
39
74
|
document.removeEventListener("click", this.onDoc)
|
|
40
75
|
document.removeEventListener("contextmenu", this.onDoc)
|
|
41
76
|
document.removeEventListener("keydown", this.onKey)
|
|
@@ -56,6 +91,18 @@ export default class extends Controller {
|
|
|
56
91
|
this.subFrames.add(id)
|
|
57
92
|
}
|
|
58
93
|
|
|
94
|
+
// Tabbing (or otherwise moving real focus) out of the menu closes it —
|
|
95
|
+
// menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
|
|
96
|
+
// null when the window itself blurs — leave the menu alone then. The
|
|
97
|
+
// trigger + content are both DOM children of this.element even though the
|
|
98
|
+
// content is a top-layer popover, so one containment check covers both.
|
|
99
|
+
onFocusout(e) {
|
|
100
|
+
if (!this.contentTarget.matches(":popover-open")) return
|
|
101
|
+
const to = e.relatedTarget
|
|
102
|
+
if (!to || this.element.contains(to)) return
|
|
103
|
+
this.close()
|
|
104
|
+
}
|
|
105
|
+
|
|
59
106
|
open(e) {
|
|
60
107
|
e.preventDefault()
|
|
61
108
|
let x = e.clientX
|
|
@@ -72,6 +119,7 @@ export default class extends Controller {
|
|
|
72
119
|
c.style.left = `${x}px`
|
|
73
120
|
c.style.top = `${y}px`
|
|
74
121
|
c.dataset.state = "open"
|
|
122
|
+
document.addEventListener("mousedown", this.onDocMousedown)
|
|
75
123
|
document.addEventListener("click", this.onDoc)
|
|
76
124
|
document.addEventListener("contextmenu", this.onDoc)
|
|
77
125
|
document.addEventListener("keydown", this.onKey)
|
|
@@ -86,6 +134,7 @@ export default class extends Controller {
|
|
|
86
134
|
if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault()
|
|
87
135
|
if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
|
|
88
136
|
this.contentTarget.dataset.state = "closed"
|
|
137
|
+
document.removeEventListener("mousedown", this.onDocMousedown)
|
|
89
138
|
document.removeEventListener("click", this.onDoc)
|
|
90
139
|
document.removeEventListener("contextmenu", this.onDoc)
|
|
91
140
|
document.removeEventListener("keydown", this.onKey)
|
|
@@ -93,6 +142,13 @@ export default class extends Controller {
|
|
|
93
142
|
}
|
|
94
143
|
|
|
95
144
|
keydown(e) {
|
|
145
|
+
// This listener stays attached document-wide while the menu is open (see
|
|
146
|
+
// open()/close()) so it keeps receiving events even once real focus has
|
|
147
|
+
// left the menu — scope the roving-nav keys to when focus is actually
|
|
148
|
+
// within the widget (this.element, not just contentTarget, so a focused
|
|
149
|
+
// trigger still counts). Escape stays global while the menu is open (APG).
|
|
150
|
+
if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return
|
|
151
|
+
|
|
96
152
|
const items = this.items()
|
|
97
153
|
const index = items.indexOf(document.activeElement)
|
|
98
154
|
switch (e.key) {
|
|
@@ -126,19 +182,26 @@ export default class extends Controller {
|
|
|
126
182
|
}
|
|
127
183
|
break
|
|
128
184
|
case "ArrowRight":
|
|
129
|
-
// On a sub trigger, enter the submenu (focus reveals it via
|
|
130
|
-
// :focus-within, making its rows visible to the roving nav).
|
|
131
|
-
if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
|
|
132
|
-
e.preventDefault()
|
|
133
|
-
this.enterSub(document.activeElement)
|
|
134
|
-
}
|
|
135
|
-
break
|
|
136
185
|
case "ArrowLeft": {
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
186
|
+
// Submenus open inline-end — visually LEFT in RTL — so the enter key
|
|
187
|
+
// follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
|
|
188
|
+
// (and the reverse in LTR). Runtime dir check is reliable after a flip.
|
|
189
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
190
|
+
const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
|
|
191
|
+
if (e.key === enterKey) {
|
|
192
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
193
|
+
// :focus-within, making its rows visible to the roving nav).
|
|
194
|
+
if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
|
|
195
|
+
e.preventDefault()
|
|
196
|
+
this.enterSub(document.activeElement)
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// Inside a submenu, step back to its trigger (closes it on focus-out).
|
|
200
|
+
const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
|
|
201
|
+
if (sub) {
|
|
202
|
+
e.preventDefault()
|
|
203
|
+
sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
|
|
204
|
+
}
|
|
142
205
|
}
|
|
143
206
|
break
|
|
144
207
|
}
|
|
@@ -66,7 +66,13 @@ export default class extends Controller {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
if (this.hasSelectionSummaryTarget) {
|
|
69
|
-
|
|
69
|
+
// The component stamps its (possibly localized) %{selected}/%{total}
|
|
70
|
+
// template as data-format; live updates interpolate the same string
|
|
71
|
+
// the server rendered.
|
|
72
|
+
const format = this.selectionSummaryTarget.dataset.format || "%{selected} of %{total} row(s) selected.";
|
|
73
|
+
this.selectionSummaryTarget.textContent = format
|
|
74
|
+
.replace("%{selected}", String(selected))
|
|
75
|
+
.replace("%{total}", String(total));
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
if (this.hasBulkActionsTarget) {
|
|
@@ -70,7 +70,10 @@ module PhlexKit
|
|
|
70
70
|
def edge_item(disabled:, href:, label:, name:)
|
|
71
71
|
if disabled
|
|
72
72
|
li do
|
|
73
|
-
|
|
73
|
+
# role-less span + aria-label is invalid ARIA (aria-label on a
|
|
74
|
+
# role-less element is not perceivable) — role + aria-disabled
|
|
75
|
+
# mirrors BreadcrumbPage's disabled-control pattern.
|
|
76
|
+
span(class: "pk-data-table-page-disabled", role: "link", aria: { disabled: "true", label: name }) { label }
|
|
74
77
|
end
|
|
75
78
|
else
|
|
76
79
|
render PaginationItem.new(href: href, aria: { label: name }) { label }
|
|
@@ -4,21 +4,26 @@ module PhlexKit
|
|
|
4
4
|
# (search/sort/direction/...) survive via `preserved_params:` hidden inputs,
|
|
5
5
|
# mirroring DataTableSearch. See data_table.rb.
|
|
6
6
|
class DataTablePerPageSelect < BaseComponent
|
|
7
|
-
|
|
7
|
+
# method:/action: are kit-owned (a GET form targeting `path:`) — named
|
|
8
|
+
# kwargs so a caller override lands in @method/@action directly instead
|
|
9
|
+
# of fusing with the generated defaults via `mix` ("get post").
|
|
10
|
+
def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, method: "get", action: nil, **attrs)
|
|
8
11
|
@path = path
|
|
9
12
|
@name = name
|
|
10
13
|
@value = value
|
|
11
14
|
@frame_id = frame_id
|
|
12
15
|
@options = options
|
|
13
16
|
@preserved_params = preserved_params
|
|
17
|
+
@method = method
|
|
18
|
+
@action = action || @path
|
|
14
19
|
@attrs = attrs
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def view_template
|
|
18
23
|
form_attrs = {
|
|
19
24
|
class: "pk-data-table-per-page",
|
|
20
|
-
action: @
|
|
21
|
-
method:
|
|
25
|
+
action: @action,
|
|
26
|
+
method: @method,
|
|
22
27
|
# Reuse the search controller for its CSP-safe #submitNow action.
|
|
23
28
|
data: { controller: "phlex-kit--data-table-search" }
|
|
24
29
|
}
|
|
@@ -3,7 +3,10 @@ module PhlexKit
|
|
|
3
3
|
# params survive via `preserved_params:` hidden inputs; focus/caret survive the
|
|
4
4
|
# frame swap (see the search controller). See data_table.rb.
|
|
5
5
|
class DataTableSearch < BaseComponent
|
|
6
|
-
|
|
6
|
+
# method:/action: are kit-owned (a GET form targeting `path:`) — named
|
|
7
|
+
# kwargs so a caller override lands in @method/@action directly instead
|
|
8
|
+
# of fusing with the generated defaults via `mix` ("get post").
|
|
9
|
+
def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, method: "get", action: nil, **attrs)
|
|
7
10
|
@path = path
|
|
8
11
|
@name = name
|
|
9
12
|
@value = value
|
|
@@ -11,11 +14,13 @@ module PhlexKit
|
|
|
11
14
|
@placeholder = placeholder
|
|
12
15
|
@debounce = debounce
|
|
13
16
|
@preserved_params = preserved_params
|
|
17
|
+
@method = method
|
|
18
|
+
@action = action || @path
|
|
14
19
|
@attrs = attrs
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def view_template
|
|
18
|
-
form(**mix({ class: "pk-data-table-search", method:
|
|
23
|
+
form(**mix({ class: "pk-data-table-search", method: @method, action: @action, data: form_data }, @attrs)) do
|
|
19
24
|
render Input.new(
|
|
20
25
|
type: :search,
|
|
21
26
|
name: @name,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# "N of M row(s) selected." line, kept current by the controller.
|
|
3
|
+
# `format:` localizes it — a template with %{selected} and %{total}
|
|
4
|
+
# placeholders, used for the server render AND stamped on the element so
|
|
5
|
+
# the controller's live updates interpolate the same string.
|
|
3
6
|
# See data_table.rb.
|
|
4
7
|
class DataTableSelectionSummary < BaseComponent
|
|
5
|
-
|
|
8
|
+
DEFAULT_FORMAT = "%{selected} of %{total} row(s) selected."
|
|
9
|
+
|
|
10
|
+
def initialize(total_on_page: 0, format: DEFAULT_FORMAT, **attrs)
|
|
6
11
|
@total_on_page = total_on_page
|
|
12
|
+
@format = format
|
|
7
13
|
@attrs = attrs
|
|
8
14
|
end
|
|
9
15
|
|
|
@@ -11,9 +17,12 @@ module PhlexKit
|
|
|
11
17
|
div(**mix({
|
|
12
18
|
class: "pk-data-table-selection-summary",
|
|
13
19
|
role: "status", # live region — selection changes are announced to AT
|
|
14
|
-
data: {
|
|
20
|
+
data: {
|
|
21
|
+
phlex_kit__data_table_target: "selectionSummary",
|
|
22
|
+
format: @format
|
|
23
|
+
}
|
|
15
24
|
}, @attrs)) do
|
|
16
|
-
plain
|
|
25
|
+
plain Kernel.format(@format, selected: 0, total: @total_on_page)
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
end
|
|
@@ -5,7 +5,12 @@ module PhlexKit
|
|
|
5
5
|
# controller and the calendar's outlet points at its id, so picking a day
|
|
6
6
|
# writes the formatted date into the field. Upstream's popover_options
|
|
7
7
|
# (hover trigger via @floating-ui) are gone — the kit popover is click-only,
|
|
8
|
-
# CSS-positioned.
|
|
8
|
+
# CSS-positioned. The phlex-kit--date-picker controller on the root div
|
|
9
|
+
# listens for the calendar's change event and closes the popover once a
|
|
10
|
+
# selection is COMPLETE (single: any pick; range: the second end only;
|
|
11
|
+
# multiple: never), returning focus to the input — see
|
|
12
|
+
# date_picker_controller.js and popover_controller.js#close.
|
|
13
|
+
# Tailwind → vanilla `.pk-date-picker*` (date_picker.css).
|
|
9
14
|
class DatePicker < BaseComponent
|
|
10
15
|
def initialize(
|
|
11
16
|
id: nil,
|
|
@@ -45,7 +50,13 @@ module PhlexKit
|
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
def view_template
|
|
48
|
-
div(**mix({
|
|
53
|
+
div(**mix({
|
|
54
|
+
class: "pk-date-picker",
|
|
55
|
+
data: {
|
|
56
|
+
controller: "phlex-kit--date-picker",
|
|
57
|
+
action: "phlex-kit--calendar:change->phlex-kit--date-picker#onCalendarChange"
|
|
58
|
+
}
|
|
59
|
+
}, @attrs)) do
|
|
49
60
|
render Popover.new do
|
|
50
61
|
render PopoverTrigger.new(**trigger_attrs) do
|
|
51
62
|
div(class: "pk-date-picker-field") do
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Bridges the nested Calendar's change event to the wrapping Popover's
|
|
4
|
+
// close(), so picking a date collapses the panel instead of leaving the
|
|
5
|
+
// user to click outside/Escape after every pick (shadcn's DatePicker
|
|
6
|
+
// behavior). Only closes on a COMPLETE selection: single mode closes on
|
|
7
|
+
// any pick, range mode only once the second end (rangeEnd) is set — closing
|
|
8
|
+
// on the first end would strand the user before they can pick the second —
|
|
9
|
+
// and multiple mode never auto-closes since picking more than one date is
|
|
10
|
+
// the entire point. See date_picker.rb for the data-action wiring and
|
|
11
|
+
// popover_controller.js for close()/focusTrigger().
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
onCalendarChange(e) {
|
|
14
|
+
const { mode, rangeEnd } = e.detail
|
|
15
|
+
if (mode === "multiple") return
|
|
16
|
+
// calendar_controller.js clears rangeEnd by assigning undefined (Stimulus
|
|
17
|
+
// removes the attribute), so an unset rangeEnd reads back as null here.
|
|
18
|
+
if (mode === "range" && !rangeEnd) return
|
|
19
|
+
this.popoverController()?.close()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
popoverController() {
|
|
23
|
+
const popoverEl = this.element.querySelector(".pk-popover")
|
|
24
|
+
return popoverEl && this.application.getControllerForElementAndIdentifier(popoverEl, "phlex-kit--popover")
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -37,6 +37,9 @@ export default class extends Controller {
|
|
|
37
37
|
}
|
|
38
38
|
open(e) {
|
|
39
39
|
e?.preventDefault()
|
|
40
|
+
// open: is one-shot — the reflected value sits in the Turbo snapshot, so
|
|
41
|
+
// a cache-restored reconnect would re-open a dismissed dialog.
|
|
42
|
+
this.openValue = false
|
|
40
43
|
this.dialogTarget.showModal()
|
|
41
44
|
// Save/restore rather than removeProperty: a host page (or another
|
|
42
45
|
// component) may have set body overflow itself.
|
|
@@ -12,7 +12,7 @@ module PhlexKit
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def view_template(&)
|
|
15
|
-
div(**mix({ data: { controller: "phlex-kit--sheet", phlex_kit__sheet_open_value: @open } }, @attrs), &)
|
|
15
|
+
div(**mix({ data: { controller: "phlex-kit--sheet", phlex_kit__sheet_open_value: @open.to_s } }, @attrs), &)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -12,9 +12,16 @@ module PhlexKit
|
|
|
12
12
|
|
|
13
13
|
def view_template(&block)
|
|
14
14
|
template(data: { phlex_kit__sheet_target: "content" }) do
|
|
15
|
-
div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown" }) do
|
|
16
|
-
div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close" })
|
|
17
|
-
|
|
15
|
+
div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown", pk_overlay_clone: "" }) do
|
|
16
|
+
div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close mousedown->phlex-kit--sheet-content#overlayMousedown" })
|
|
17
|
+
panel_attrs = { class: "pk-drawer #{fetch_option(SIDES, @side, :side)}", 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
|
|
18
25
|
div(class: "pk-drawer-handle", aria: { hidden: "true" })
|
|
19
26
|
yield if block
|
|
20
27
|
end
|
|
@@ -116,6 +116,10 @@
|
|
|
116
116
|
the viewport right edge. */
|
|
117
117
|
.pk-dropdown-menu-sub { anchor-name: --pk-dropdown-menu-sub; anchor-scope: --pk-dropdown-menu-sub; }
|
|
118
118
|
.pk-dropdown-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
119
|
+
/* The chevron points toward where the submenu opens (inline-end). In RTL that
|
|
120
|
+
is visually LEFT — mirror it. Ancestor [dir="rtl"] (not :dir()) so Chrome
|
|
121
|
+
re-styles it on a dynamic dir flip too. */
|
|
122
|
+
[dir="rtl"] .pk-dropdown-menu-sub-chevron { transform: scaleX(-1); }
|
|
119
123
|
.pk-dropdown-menu-sub-content {
|
|
120
124
|
position: fixed;
|
|
121
125
|
position-anchor: --pk-dropdown-menu-sub;
|
|
@@ -18,7 +18,11 @@ module PhlexKit
|
|
|
18
18
|
data: {
|
|
19
19
|
controller: "phlex-kit--dropdown-menu",
|
|
20
20
|
phlex_kit__dropdown_menu_open_value: @open,
|
|
21
|
-
|
|
21
|
+
# focusout: tabbing (or otherwise moving real focus) out of the
|
|
22
|
+
# menu closes the open [popover=manual] panel (menubar's pattern).
|
|
23
|
+
# mousedown@window arms the modal-menu click swallow before the
|
|
24
|
+
# gesture's focusout closes the panel (see the controller).
|
|
25
|
+
action: "mousedown@window->phlex-kit--dropdown-menu#onMousedownOutside click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
|
|
22
26
|
}
|
|
23
27
|
}, @attrs), &block)
|
|
24
28
|
end
|