phlex_kit 0.9.1 → 0.10.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/assets/stylesheets/phlex_kit/_tokens.css +8 -0
- data/app/components/phlex_kit/alert/alert.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +12 -0
- data/app/components/phlex_kit/attachment/attachment.css +6 -3
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
- data/app/components/phlex_kit/avatar/avatar.css +2 -2
- data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
- data/app/components/phlex_kit/bubble/bubble.css +1 -1
- data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
- data/app/components/phlex_kit/button_group/button_group.css +16 -16
- data/app/components/phlex_kit/calendar/calendar.css +10 -2
- data/app/components/phlex_kit/carousel/carousel.css +4 -4
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +18 -2
- data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
- data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
- data/app/components/phlex_kit/combobox/combobox.css +3 -3
- data/app/components/phlex_kit/combobox/combobox.rb +3 -3
- data/app/components/phlex_kit/combobox/combobox_controller.js +17 -9
- data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
- data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +3 -1
- data/app/components/phlex_kit/command/command.css +2 -2
- data/app/components/phlex_kit/command/command_controller.js +57 -34
- data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
- data/app/components/phlex_kit/command/command_group.rb +5 -2
- data/app/components/phlex_kit/dialog/dialog.css +2 -2
- data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_controller.js +9 -0
- data/app/components/phlex_kit/drawer/drawer.css +4 -4
- data/app/components/phlex_kit/empty/empty.css +1 -1
- data/app/components/phlex_kit/field/field.css +2 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +7 -0
- data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
- data/app/components/phlex_kit/marker/marker.rb +6 -0
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +17 -3
- data/app/components/phlex_kit/menubar/menubar.rb +3 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +22 -1
- data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
- data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +9 -5
- data/app/components/phlex_kit/native_select/native_select.css +4 -2
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
- data/app/components/phlex_kit/pagination/pagination_next.rb +9 -2
- data/app/components/phlex_kit/pagination/pagination_previous.rb +9 -2
- data/app/components/phlex_kit/popover/popover_controller.js +9 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +49 -5
- data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +20 -4
- data/app/components/phlex_kit/select/select.css +0 -1
- data/app/components/phlex_kit/select/select_content.rb +9 -3
- data/app/components/phlex_kit/select/select_controller.js +32 -2
- data/app/components/phlex_kit/select/select_item.rb +5 -1
- data/app/components/phlex_kit/separator/separator.rb +7 -1
- data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
- data/app/components/phlex_kit/tabs/tabs.css +2 -2
- data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +4 -1
- data/app/components/phlex_kit/toast/toast.css +1 -1
- data/app/components/phlex_kit/toast/toast_controller.js +6 -1
- data/app/components/phlex_kit/toast/toaster_controller.js +19 -5
- data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +6 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +8 -0
- data/app/components/phlex_kit/tooltip/tooltip.css +2 -2
- data/app/components/phlex_kit/typography/heading.rb +6 -4
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -1
|
@@ -35,8 +35,16 @@ function fuzzyScore(query, value) {
|
|
|
35
35
|
export default class extends Controller {
|
|
36
36
|
static targets = ["input", "group", "item", "empty", "separator", "list", "liveRegion"];
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
initialize() {
|
|
39
|
+
// Stimulus fires [target]Connected before connect(): anything
|
|
40
|
+
// itemTargetConnected uses must be initialized here (mirrors
|
|
41
|
+
// combobox_controller.js).
|
|
42
|
+
this.itemIdCounter = 0;
|
|
43
|
+
this.searchEntries = [];
|
|
39
44
|
this.selectedIndex = -1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
connect() {
|
|
40
48
|
// Remember what had focus before the palette grabbed it (for a cloned
|
|
41
49
|
// dialog this is the element focused when the overlay was inserted), so
|
|
42
50
|
// dismiss() can hand focus back instead of dropping it on <body>.
|
|
@@ -50,7 +58,6 @@ export default class extends Controller {
|
|
|
50
58
|
// Only the cloned dialog overlay grabs focus on connect — an inline
|
|
51
59
|
// palette connecting at page load must not steal it.
|
|
52
60
|
if (this.isDialogClone()) this.inputTarget.focus();
|
|
53
|
-
this.searchIndex = this.buildSearchIndex();
|
|
54
61
|
this.toggleVisibility(this.emptyTargets, false);
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -60,23 +67,46 @@ export default class extends Controller {
|
|
|
60
67
|
return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
// ARIA plumbing:
|
|
64
|
-
//
|
|
65
|
-
// aria-controls is wired to the listbox (mirrors select_controller.js).
|
|
70
|
+
// ARIA plumbing: aria-controls is wired to the listbox (mirrors
|
|
71
|
+
// select_controller.js); the per-result ids come from itemTargetConnected.
|
|
66
72
|
generateItemIds() {
|
|
73
|
+
this.inputTarget.setAttribute("aria-controls", this.listId());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
listId() {
|
|
67
77
|
const list = this.hasListTarget ? this.listTarget : this.element;
|
|
68
78
|
if (!list.id) list.id = `pk-command-list-${Math.random().toString(36).slice(2, 10)}`;
|
|
79
|
+
return list.id;
|
|
80
|
+
}
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
// Every result gets an id derived from the listbox id so the input can
|
|
83
|
+
// point aria-activedescendant at the keyboard highlight, and a search-index
|
|
84
|
+
// entry so filtering can find it. A target callback (not a connect() loop)
|
|
85
|
+
// so items rendered in by the host after load are id'd and findable too.
|
|
86
|
+
// Counter-based, so a removed item's id is never reissued to a later
|
|
87
|
+
// arrival (mirrors combobox_controller.js).
|
|
88
|
+
itemTargetConnected(item) {
|
|
89
|
+
if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
|
|
90
|
+
this.searchEntries.push({
|
|
91
|
+
value: (item.dataset.value || "").toLowerCase(),
|
|
92
|
+
text: (item.dataset.text || "").toLowerCase(),
|
|
93
|
+
element: item,
|
|
72
94
|
});
|
|
95
|
+
}
|
|
73
96
|
|
|
74
|
-
|
|
97
|
+
itemTargetDisconnected(item) {
|
|
98
|
+
this.searchEntries = this.searchEntries.filter(
|
|
99
|
+
(entry) => entry.element !== item,
|
|
100
|
+
);
|
|
75
101
|
}
|
|
76
102
|
|
|
77
103
|
dismiss() {
|
|
78
104
|
// Cloned dialog overlay: tear the clone down and hand focus back.
|
|
79
105
|
if (this.isDialogClone()) {
|
|
106
|
+
// Dispatched BEFORE teardown: the command-dialog controller listens on
|
|
107
|
+
// the overlay and un-inerts the page here — focus() below would be a
|
|
108
|
+
// silent no-op while previouslyFocused sits under an inert ancestor.
|
|
109
|
+
this.dispatch("dismiss");
|
|
80
110
|
// allow scroll on body
|
|
81
111
|
document.body.style.removeProperty("overflow");
|
|
82
112
|
const previous = this.previouslyFocused;
|
|
@@ -157,7 +187,7 @@ export default class extends Controller {
|
|
|
157
187
|
|
|
158
188
|
this.toggleVisibility(this.itemTargets, false);
|
|
159
189
|
|
|
160
|
-
const results = this.
|
|
190
|
+
const results = this.searchItems(query);
|
|
161
191
|
results.forEach((result) =>
|
|
162
192
|
this.toggleVisibility([result.item.element], true),
|
|
163
193
|
);
|
|
@@ -201,31 +231,24 @@ export default class extends Controller {
|
|
|
201
231
|
this.toggleVisibility(this.emptyTargets, false);
|
|
202
232
|
}
|
|
203
233
|
|
|
204
|
-
// Upstream builds a Fuse index here; this keeps the same
|
|
205
|
-
// [{ item }] sorted best-match-first — using the fuzzy scorer above
|
|
206
|
-
//
|
|
207
|
-
// rendered
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
.filter((score) => score !== null);
|
|
223
|
-
return { item, score: scores.length ? Math.max(...scores) : null };
|
|
224
|
-
})
|
|
225
|
-
.filter((result) => result.score !== null)
|
|
226
|
-
.sort((a, b) => b.score - a.score);
|
|
227
|
-
},
|
|
228
|
-
};
|
|
234
|
+
// Upstream builds a Fuse index here; this keeps the same result shape —
|
|
235
|
+
// [{ item }] sorted best-match-first — using the fuzzy scorer above over
|
|
236
|
+
// the live searchEntries (maintained by itemTargetConnected/Disconnected,
|
|
237
|
+
// so host-rendered items are searchable). Both data-value and data-text are
|
|
238
|
+
// searchable (an item's `text:` was rendered but never indexed); the better
|
|
239
|
+
// of the two scores wins.
|
|
240
|
+
searchItems(query) {
|
|
241
|
+
const q = query.toLowerCase();
|
|
242
|
+
return this.searchEntries
|
|
243
|
+
.map((item) => {
|
|
244
|
+
const scores = [item.value, item.text]
|
|
245
|
+
.filter(Boolean)
|
|
246
|
+
.map((field) => fuzzyScore(q, field))
|
|
247
|
+
.filter((score) => score !== null);
|
|
248
|
+
return { item, score: scores.length ? Math.max(...scores) : null };
|
|
249
|
+
})
|
|
250
|
+
.filter((result) => result.score !== null)
|
|
251
|
+
.sort((a, b) => b.score - a.score);
|
|
229
252
|
}
|
|
230
253
|
|
|
231
254
|
handleKeydown(e) {
|
|
@@ -6,14 +6,27 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
6
6
|
// upstream's document-scoped outlet (which let one dialog adopt another's
|
|
7
7
|
// open palette), each instance tracks its own clone — sheet_controller's
|
|
8
8
|
// pattern — and clears it on turbo:before-cache so a cached page never
|
|
9
|
-
// restores a stale overlay + scroll lock.
|
|
9
|
+
// restores a stale overlay + scroll lock. While the overlay is up, the page
|
|
10
|
+
// behind it is inert-ed per element with prior state saved (the same modal
|
|
11
|
+
// contract as alert_dialog/sheet/sidebar — the helper is duplicated per
|
|
12
|
+
// controller by design); restore runs on dismiss (via the overlay's
|
|
13
|
+
// phlex-kit--command:dismiss event), disconnect, AND turbo:before-cache.
|
|
10
14
|
// Connects to data-controller="phlex-kit--command-dialog"
|
|
11
15
|
export default class extends Controller {
|
|
12
16
|
static targets = ["content"];
|
|
13
17
|
|
|
14
18
|
initialize() {
|
|
15
19
|
this.overlay = null;
|
|
20
|
+
this.inerted = null;
|
|
21
|
+
// The command controller's dismiss() dispatches this on the overlay
|
|
22
|
+
// BEFORE it removes the clone / restores focus — un-inert first so the
|
|
23
|
+
// opener is focusable again.
|
|
24
|
+
this.restoreInert = () => {
|
|
25
|
+
for (const [el, wasInert] of this.inerted || []) el.inert = wasInert;
|
|
26
|
+
this.inerted = null;
|
|
27
|
+
};
|
|
16
28
|
this.clearOverlay = () => {
|
|
29
|
+
this.restoreInert();
|
|
17
30
|
if (this.overlay?.isConnected) {
|
|
18
31
|
this.overlay.remove();
|
|
19
32
|
document.body.style.removeProperty("overflow");
|
|
@@ -49,7 +62,23 @@ export default class extends Controller {
|
|
|
49
62
|
|
|
50
63
|
document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
|
|
51
64
|
this.overlay = document.body.lastElementChild;
|
|
65
|
+
this.overlay.addEventListener("phlex-kit--command:dismiss", this.restoreInert);
|
|
52
66
|
// prevent scroll on body
|
|
53
67
|
document.body.style.overflow = "hidden";
|
|
68
|
+
this.#inertOthers();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Make everything behind the overlay inert (the clone is <body>'s last
|
|
72
|
+
// child, so its siblings are the whole page). Prior inert state is saved
|
|
73
|
+
// per element and restored by restoreInert. The command controller's Tab
|
|
74
|
+
// trap on the clone stays as belt and braces.
|
|
75
|
+
#inertOthers() {
|
|
76
|
+
this.inerted = [];
|
|
77
|
+
for (const el of document.body.children) {
|
|
78
|
+
if (el === this.overlay) continue;
|
|
79
|
+
if (["SCRIPT", "STYLE", "LINK", "TEMPLATE"].includes(el.tagName)) continue;
|
|
80
|
+
this.inerted.push([el, el.inert]);
|
|
81
|
+
el.inert = true;
|
|
82
|
+
}
|
|
54
83
|
}
|
|
55
84
|
}
|
|
@@ -5,6 +5,9 @@ module PhlexKit
|
|
|
5
5
|
class CommandGroup < BaseComponent
|
|
6
6
|
def initialize(title: nil, **attrs)
|
|
7
7
|
@title = title
|
|
8
|
+
# Names the role="group" items wrapper via aria-labelledby → heading id
|
|
9
|
+
# (matches cmdk/shadcn; id pattern mirrors CommandList).
|
|
10
|
+
@heading_id = "pk-command-group-heading-#{SecureRandom.hex(4)}" if title
|
|
8
11
|
@attrs = attrs
|
|
9
12
|
end
|
|
10
13
|
|
|
@@ -22,11 +25,11 @@ module PhlexKit
|
|
|
22
25
|
private
|
|
23
26
|
|
|
24
27
|
def render_header
|
|
25
|
-
div(group_heading: @title) { @title }
|
|
28
|
+
div(id: @heading_id, group_heading: @title) { @title }
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def render_items(&)
|
|
29
|
-
div(group_items: "", role: "group", &)
|
|
32
|
+
div(group_items: "", role: "group", aria_labelledby: @heading_id, &)
|
|
30
33
|
end
|
|
31
34
|
end
|
|
32
35
|
end
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
max-height: 100vh;
|
|
15
15
|
overflow-y: auto; gap: 1rem;
|
|
16
16
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
17
|
-
border-radius:
|
|
17
|
+
border-radius: calc(var(--pk-radius) + 2px); background: var(--pk-surface); color: var(--pk-text);
|
|
18
18
|
padding: var(--pk-dialog-spacing);
|
|
19
19
|
font-size: .875rem;
|
|
20
20
|
box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
margin: 0 calc(-1 * var(--pk-dialog-spacing)) calc(-1 * var(--pk-dialog-spacing));
|
|
36
36
|
padding: var(--pk-dialog-spacing);
|
|
37
37
|
border-top: 1px solid var(--pk-border);
|
|
38
|
-
border-radius: 0 0
|
|
38
|
+
border-radius: 0 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px);
|
|
39
39
|
background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent);
|
|
40
40
|
}
|
|
41
41
|
.pk-dialog-title { font-size: 1rem; font-weight: 500; line-height: 1; }
|
|
@@ -7,7 +7,7 @@ module PhlexKit
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def view_template(&)
|
|
10
|
-
span(**mix({ style: "display: contents", data: { action: "click->phlex-kit--dialog#dismiss" } }, @attrs), &)
|
|
10
|
+
span(**mix({ style: "display: contents;", data: { action: "click->phlex-kit--dialog#dismiss" } }, @attrs), &)
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -10,6 +10,9 @@ export default class extends Controller {
|
|
|
10
10
|
|
|
11
11
|
connect() {
|
|
12
12
|
this.dialogTarget.addEventListener("close", this.handleClose)
|
|
13
|
+
// Turbo snapshots at turbo:before-cache, BEFORE disconnect — close now or
|
|
14
|
+
// the cached page restores a non-modal <dialog open> with scroll locked.
|
|
15
|
+
document.addEventListener("turbo:before-cache", this.beforeCache)
|
|
13
16
|
this.#wireAria("aria-labelledby", ".pk-dialog-title")
|
|
14
17
|
this.#wireAria("aria-describedby", ".pk-dialog-description")
|
|
15
18
|
if (this.openValue) this.open()
|
|
@@ -24,8 +27,14 @@ export default class extends Controller {
|
|
|
24
27
|
}
|
|
25
28
|
disconnect() {
|
|
26
29
|
this.dialogTarget.removeEventListener("close", this.handleClose)
|
|
30
|
+
document.removeEventListener("turbo:before-cache", this.beforeCache)
|
|
27
31
|
if (this.dialogTarget.open) this.handleClose()
|
|
28
32
|
}
|
|
33
|
+
beforeCache = () => {
|
|
34
|
+
if (!this.dialogTarget.open) return
|
|
35
|
+
this.dialogTarget.close()
|
|
36
|
+
this.handleClose() // the close event is a queued task — too late for the snapshot
|
|
37
|
+
}
|
|
29
38
|
open(e) {
|
|
30
39
|
e?.preventDefault()
|
|
31
40
|
this.dialogTarget.showModal()
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
bottom: 0;
|
|
28
28
|
max-height: 85vh;
|
|
29
29
|
border-top: 1px solid var(--pk-border);
|
|
30
|
-
border-radius:
|
|
30
|
+
border-radius: calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px) 0 0;
|
|
31
31
|
animation: pk-drawer-up .3s ease;
|
|
32
32
|
}
|
|
33
33
|
.pk-drawer.top {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
top: 0;
|
|
36
36
|
max-height: 85vh;
|
|
37
37
|
border-bottom: 1px solid var(--pk-border);
|
|
38
|
-
border-radius: 0 0
|
|
38
|
+
border-radius: 0 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px);
|
|
39
39
|
animation: pk-drawer-down .3s ease;
|
|
40
40
|
}
|
|
41
41
|
.pk-drawer.left,
|
|
@@ -48,13 +48,13 @@
|
|
|
48
48
|
.pk-drawer.left {
|
|
49
49
|
left: 0;
|
|
50
50
|
border-right: 1px solid var(--pk-border);
|
|
51
|
-
border-radius: 0
|
|
51
|
+
border-radius: 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px) 0;
|
|
52
52
|
animation: pk-drawer-from-left .3s ease;
|
|
53
53
|
}
|
|
54
54
|
.pk-drawer.right {
|
|
55
55
|
right: 0;
|
|
56
56
|
border-left: 1px solid var(--pk-border);
|
|
57
|
-
border-radius:
|
|
57
|
+
border-radius: calc(var(--pk-radius) + 2px) 0 0 calc(var(--pk-radius) + 2px);
|
|
58
58
|
animation: pk-drawer-from-right .3s ease;
|
|
59
59
|
}
|
|
60
60
|
@keyframes pk-drawer-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
.pk-empty {
|
|
6
6
|
display: flex; width: 100%; min-width: 0; flex: 1; flex-direction: column;
|
|
7
7
|
align-items: center; justify-content: center; gap: 1rem;
|
|
8
|
-
border: 0 dashed var(--pk-border); border-radius:
|
|
8
|
+
border: 0 dashed var(--pk-border); border-radius: calc(var(--pk-radius) + 2px);
|
|
9
9
|
padding: 1.5rem; text-align: center; text-wrap: balance;
|
|
10
10
|
}
|
|
11
11
|
.pk-empty.outline { border-width: 1px; }
|
|
@@ -16,6 +16,13 @@ export default class extends Controller {
|
|
|
16
16
|
if (this.hasErrorTarget) {
|
|
17
17
|
if (this.errorTarget.textContent.trim()) {
|
|
18
18
|
this.shouldValidateValue = true;
|
|
19
|
+
// A server-rendered error is already visible — give the control(s)
|
|
20
|
+
// the same aria wiring onInvalid applies, instead of leaving
|
|
21
|
+
// aria-invalid/aria-describedby unset until the first input/change.
|
|
22
|
+
this.inputTargets.forEach((input) => {
|
|
23
|
+
input.setAttribute("aria-invalid", "true");
|
|
24
|
+
this.#describeBy(input, true);
|
|
25
|
+
});
|
|
19
26
|
} else {
|
|
20
27
|
this.errorTarget.classList.add("pk-hidden");
|
|
21
28
|
}
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
}
|
|
19
19
|
.pk-input-otp-slot:not(:first-child) { margin-inline-start: -1px; }
|
|
20
20
|
.pk-input-otp-group .pk-input-otp-slot:first-child {
|
|
21
|
-
border-
|
|
22
|
-
border-
|
|
21
|
+
border-start-start-radius: var(--pk-radius);
|
|
22
|
+
border-end-start-radius: var(--pk-radius);
|
|
23
23
|
}
|
|
24
24
|
.pk-input-otp-group .pk-input-otp-slot:last-child {
|
|
25
|
-
border-
|
|
26
|
-
border-
|
|
25
|
+
border-start-end-radius: var(--pk-radius);
|
|
26
|
+
border-end-end-radius: var(--pk-radius);
|
|
27
27
|
}
|
|
28
28
|
.pk-input-otp-slot:focus {
|
|
29
29
|
position: relative;
|
|
@@ -6,11 +6,17 @@ module PhlexKit
|
|
|
6
6
|
# underline and brighten on hover. `.pk-marker*` (marker.css).
|
|
7
7
|
class Marker < BaseComponent
|
|
8
8
|
VARIANTS = { default: nil, border: "border", separator: "separator" }.freeze
|
|
9
|
+
# The elements this component knows how to render (href: forces <a>);
|
|
10
|
+
# unknown values used to fall through silently to <div> — fail loud instead.
|
|
11
|
+
AS_TAGS = %i[div button].freeze
|
|
9
12
|
|
|
10
13
|
def initialize(variant: :default, href: nil, as: :div, **attrs)
|
|
11
14
|
@variant = variant.to_sym
|
|
12
15
|
@href = href
|
|
13
16
|
@as = as.to_sym
|
|
17
|
+
unless AS_TAGS.include?(@as)
|
|
18
|
+
raise ArgumentError, "unknown Marker as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
19
|
+
end
|
|
14
20
|
@attrs = attrs
|
|
15
21
|
end
|
|
16
22
|
|
|
@@ -11,20 +11,27 @@ export default class extends Controller {
|
|
|
11
11
|
if (!this.mask) return
|
|
12
12
|
// Skip IME composition updates — masking mid-composition mangles the text.
|
|
13
13
|
this.onInput = (e) => { if (!e.isComposing) this.apply() }
|
|
14
|
+
// Some browsers (Safari) fire the final input event with isComposing still
|
|
15
|
+
// true, so IME-committed text would never be masked — catch the commit here.
|
|
16
|
+
this.onCompositionEnd = () => this.apply()
|
|
14
17
|
this.element.addEventListener("input", this.onInput)
|
|
18
|
+
this.element.addEventListener("compositionend", this.onCompositionEnd)
|
|
15
19
|
// A server-prefilled value renders unmasked — format it once up front.
|
|
16
20
|
if (this.element.value) this.apply()
|
|
17
21
|
}
|
|
18
|
-
disconnect() {
|
|
22
|
+
disconnect() {
|
|
23
|
+
if (this.onInput) this.element.removeEventListener("input", this.onInput)
|
|
24
|
+
if (this.onCompositionEnd) this.element.removeEventListener("compositionend", this.onCompositionEnd)
|
|
25
|
+
}
|
|
19
26
|
apply() {
|
|
20
27
|
const el = this.element
|
|
21
28
|
// The whole value is rewritten below, which throws the caret to the end —
|
|
22
29
|
// remember how many maskable chars sit before it and re-seat it after
|
|
23
30
|
// the same count in the masked output (mid-field edits keep their place).
|
|
24
31
|
const caret = el.selectionStart ?? el.value.length
|
|
25
|
-
const rawBefore = el.value.slice(0, caret)
|
|
32
|
+
const rawBefore = this.maskable(el.value.slice(0, caret)).length
|
|
26
33
|
|
|
27
|
-
const raw = el.value
|
|
34
|
+
const raw = this.maskable(el.value)
|
|
28
35
|
let out = "", i = 0
|
|
29
36
|
for (const t of this.mask) {
|
|
30
37
|
if (i >= raw.length) break
|
|
@@ -48,4 +55,11 @@ export default class extends Controller {
|
|
|
48
55
|
el.setSelectionRange(pos, pos)
|
|
49
56
|
}
|
|
50
57
|
}
|
|
58
|
+
// Transliterate full-width digits (0-9, U+FF10–FF19 — common IME numeric
|
|
59
|
+
// output) to ASCII before the charset filter would silently drop them.
|
|
60
|
+
maskable(value) {
|
|
61
|
+
return value
|
|
62
|
+
.replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xFEE0))
|
|
63
|
+
.replace(/[^0-9A-Za-z]/g, "")
|
|
64
|
+
}
|
|
51
65
|
}
|
|
@@ -16,7 +16,9 @@ module PhlexKit
|
|
|
16
16
|
role: "menubar",
|
|
17
17
|
data: {
|
|
18
18
|
controller: "phlex-kit--menubar",
|
|
19
|
-
|
|
19
|
+
# focusout: tabbing out of the bar closes the open [popover=manual]
|
|
20
|
+
# panel (which no focus trap holds open).
|
|
21
|
+
action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
|
|
20
22
|
}
|
|
21
23
|
}, @attrs), &)
|
|
22
24
|
end
|
|
@@ -28,6 +28,9 @@ export default class extends Controller {
|
|
|
28
28
|
// set here. Roving tabindex applies only in menubar mode: a nav's
|
|
29
29
|
// triggers and links stay natural tab stops.
|
|
30
30
|
this.roving = this.element.dataset.hoverOpen === undefined
|
|
31
|
+
// Outstanding syncSub rAF handles — cancelled on disconnect so a queued
|
|
32
|
+
// frame never touches a torn-down menubar.
|
|
33
|
+
this.subFrames = new Set()
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
// Pair each trigger with its panel for AT (aria-controls), and fold the
|
|
@@ -48,6 +51,8 @@ export default class extends Controller {
|
|
|
48
51
|
|
|
49
52
|
disconnect() {
|
|
50
53
|
clearTimeout(this.graceTimer)
|
|
54
|
+
this.subFrames.forEach((id) => cancelAnimationFrame(id))
|
|
55
|
+
this.subFrames.clear()
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
toggle(e) {
|
|
@@ -112,6 +117,20 @@ export default class extends Controller {
|
|
|
112
117
|
this.close()
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
// Tabbing out of the bar: close the open [popover=manual] panel once focus
|
|
121
|
+
// has left both the bar AND the panel (panels are top-layer popovers, but
|
|
122
|
+
// remain DOM children — check both anyway). Focus moving INTO the panel
|
|
123
|
+
// during normal navigation must not close it. relatedTarget is null when
|
|
124
|
+
// the window itself blurs — leave the menu alone then.
|
|
125
|
+
onFocusout(e) {
|
|
126
|
+
const to = e.relatedTarget
|
|
127
|
+
if (!to || this.element.contains(to)) return
|
|
128
|
+
const menu = this.openMenu
|
|
129
|
+
if (!menu) return
|
|
130
|
+
if (this.panel(menu)?.contains(to)) return
|
|
131
|
+
this.close()
|
|
132
|
+
}
|
|
133
|
+
|
|
115
134
|
// Single keydown listener on the bar (wired via data-action, so no manual
|
|
116
135
|
// cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
|
|
117
136
|
onKeydown(e) {
|
|
@@ -215,10 +234,12 @@ export default class extends Controller {
|
|
|
215
234
|
// state isn't settled until the event finishes dispatching.
|
|
216
235
|
syncSub(e) {
|
|
217
236
|
const sub = e.currentTarget
|
|
218
|
-
requestAnimationFrame(() => {
|
|
237
|
+
const id = requestAnimationFrame(() => {
|
|
238
|
+
this.subFrames.delete(id)
|
|
219
239
|
sub.querySelector(":scope > [aria-haspopup]")
|
|
220
240
|
?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
|
|
221
241
|
})
|
|
242
|
+
this.subFrames.add(id)
|
|
222
243
|
}
|
|
223
244
|
|
|
224
245
|
shift(dir) {
|
|
@@ -6,7 +6,7 @@ module PhlexKit
|
|
|
6
6
|
class MenubarItem < BaseComponent
|
|
7
7
|
VARIANTS = { default: nil, destructive: "destructive" }.freeze
|
|
8
8
|
|
|
9
|
-
def initialize(as: :a, href:
|
|
9
|
+
def initialize(as: :a, href: nil, shortcut: nil, variant: :default, inset: false, disabled: false, **attrs)
|
|
10
10
|
@as = as.to_sym
|
|
11
11
|
@href = href
|
|
12
12
|
@shortcut = shortcut
|
|
@@ -24,7 +24,9 @@ module PhlexKit
|
|
|
24
24
|
data: @disabled ? { disabled: "true" } : { action: "click->phlex-kit--menubar#close" }
|
|
25
25
|
}
|
|
26
26
|
base[:aria] = { disabled: "true" } if @disabled
|
|
27
|
-
|
|
27
|
+
# No default href: "#" would make Enter/click navigate (hash change +
|
|
28
|
+
# scroll-to-top). tabindex="-1" keeps the row programmatically focusable.
|
|
29
|
+
base[:href] = @href unless @href.nil? || @as == :div || @disabled
|
|
28
30
|
send(@as, **mix(base, @attrs)) do
|
|
29
31
|
block&.call
|
|
30
32
|
span(class: "pk-menubar-shortcut") { @shortcut } if @shortcut
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Exclusive-choice group of MenubarRadioItems (share the same `name:`).
|
|
3
|
-
# Mirrors shadcn/ui's MenubarRadioGroup.
|
|
3
|
+
# Mirrors shadcn/ui's MenubarRadioGroup. role="group" (NOT "radiogroup"):
|
|
4
|
+
# radiogroup may not own menuitemradio children (axe aria-required-children).
|
|
5
|
+
# See menubar.rb.
|
|
4
6
|
class MenubarRadioGroup < BaseComponent
|
|
5
7
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
8
|
def view_template(&)
|
|
7
|
-
div(**mix({ class: "pk-menubar-group", role: "
|
|
9
|
+
div(**mix({ class: "pk-menubar-group", role: "group" }, @attrs), &)
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
end
|
|
@@ -141,7 +141,8 @@ export default class extends Controller {
|
|
|
141
141
|
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
|
142
142
|
this.programmatic = false;
|
|
143
143
|
this.element.removeAttribute("data-autoscrolling");
|
|
144
|
-
|
|
144
|
+
// Stimulus target getters throw when missing — guard, don't optional-chain.
|
|
145
|
+
if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
|
|
145
146
|
this.following = false;
|
|
146
147
|
this.updateButton();
|
|
147
148
|
}
|
|
@@ -184,14 +185,16 @@ export default class extends Controller {
|
|
|
184
185
|
// flex row gap each prepended row introduces, or the preserved row
|
|
185
186
|
// drifts down by one gap per insertion.
|
|
186
187
|
prependedHeight += (node.offsetHeight || 0) + gap;
|
|
187
|
-
} else {
|
|
188
|
-
// Inserted at
|
|
188
|
+
} else if (record.nextSibling === null) {
|
|
189
|
+
// Inserted at the end → new turn.
|
|
189
190
|
appended = node;
|
|
190
191
|
}
|
|
192
|
+
// Both siblings present → inserted between existing rows. Neither
|
|
193
|
+
// history nor a new turn — don't yank a following reader to the end.
|
|
191
194
|
}
|
|
192
195
|
}
|
|
193
196
|
|
|
194
|
-
if (prependedHeight > 0 && this.preserveOnPrependValue) {
|
|
197
|
+
if (prependedHeight > 0 && this.preserveOnPrependValue && this.hasViewportTarget) {
|
|
195
198
|
// Keep the reader's current row fixed while history loads in above.
|
|
196
199
|
this.viewportTarget.scrollTop += prependedHeight;
|
|
197
200
|
}
|
|
@@ -307,7 +310,8 @@ export default class extends Controller {
|
|
|
307
310
|
finishScroll() {
|
|
308
311
|
this.programmatic = false;
|
|
309
312
|
this.element.removeAttribute("data-autoscrolling");
|
|
310
|
-
|
|
313
|
+
// Stimulus target getters throw when missing — guard, don't optional-chain.
|
|
314
|
+
if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
|
|
311
315
|
this.following = this.isAtEnd();
|
|
312
316
|
this.updateButton();
|
|
313
317
|
}
|
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
color: var(--pk-text);
|
|
21
21
|
font: inherit;
|
|
22
22
|
font-size: .875rem;
|
|
23
|
-
padding: .25rem
|
|
24
|
-
|
|
23
|
+
padding-block: .25rem;
|
|
24
|
+
padding-inline-start: .625rem;
|
|
25
|
+
padding-inline-end: 2rem; /* chevron space — matches .pk-native-select-icon's inset-inline-end */
|
|
26
|
+
cursor: pointer;
|
|
25
27
|
transition: color .15s ease, border-color .15s ease, box-shadow .15s ease;
|
|
26
28
|
}
|
|
27
29
|
.pk-native-select-field:hover { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
|
|
@@ -20,7 +20,10 @@ module PhlexKit
|
|
|
20
20
|
# focus returned to the trigger (bare #close as an action received
|
|
21
21
|
# the event as `opts`, so refocus never happened — focus fell to
|
|
22
22
|
# <body> when the panel hid).
|
|
23
|
-
|
|
23
|
+
# mouseenter->cancelClose: re-entering the nav over list padding /
|
|
24
|
+
# whitespace must cancel the pending mouseleave grace-close (only
|
|
25
|
+
# trigger/panel mouseenter did before).
|
|
26
|
+
action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown mouseleave->phlex-kit--menubar#closeSoon mouseenter->phlex-kit--menubar#cancelClose"
|
|
24
27
|
}
|
|
25
28
|
}, @attrs), &)
|
|
26
29
|
end
|
|
@@ -2,15 +2,22 @@ module PhlexKit
|
|
|
2
2
|
# "Next" control (label + chevron, label hidden on small screens), ported
|
|
3
3
|
# from shadcn/ui's PaginationNext. Renders its own <li>. See pagination.rb.
|
|
4
4
|
class PaginationNext < BaseComponent
|
|
5
|
-
|
|
5
|
+
DEFAULT_LABEL = "Next"
|
|
6
|
+
|
|
7
|
+
def initialize(href: "#", label: DEFAULT_LABEL, **attrs)
|
|
6
8
|
@href = href
|
|
7
9
|
@label = label
|
|
8
10
|
@attrs = attrs
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def view_template
|
|
14
|
+
# The English aria-label only accompanies the default English label; a
|
|
15
|
+
# custom (possibly localized) label: speaks for itself — hardcoding
|
|
16
|
+
# "Go to next page" over it would make AT announce the wrong language.
|
|
17
|
+
base = { href: @href, class: "pk-button ghost pk-pagination-next" }
|
|
18
|
+
base[:aria] = { label: "Go to next page" } if @label == DEFAULT_LABEL
|
|
12
19
|
li do
|
|
13
|
-
a(**mix(
|
|
20
|
+
a(**mix(base, @attrs)) do
|
|
14
21
|
span(class: "pk-pagination-label") { @label }
|
|
15
22
|
render Icon.new(:chevron_right, size: nil)
|
|
16
23
|
end
|
|
@@ -3,15 +3,22 @@ module PhlexKit
|
|
|
3
3
|
# ported from shadcn/ui's PaginationPrevious. Renders its own <li>.
|
|
4
4
|
# See pagination.rb.
|
|
5
5
|
class PaginationPrevious < BaseComponent
|
|
6
|
-
|
|
6
|
+
DEFAULT_LABEL = "Previous"
|
|
7
|
+
|
|
8
|
+
def initialize(href: "#", label: DEFAULT_LABEL, **attrs)
|
|
7
9
|
@href = href
|
|
8
10
|
@label = label
|
|
9
11
|
@attrs = attrs
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def view_template
|
|
15
|
+
# The English aria-label only accompanies the default English label; a
|
|
16
|
+
# custom (possibly localized) label: speaks for itself — hardcoding
|
|
17
|
+
# "Go to previous page" over it would make AT announce the wrong language.
|
|
18
|
+
base = { href: @href, class: "pk-button ghost pk-pagination-previous" }
|
|
19
|
+
base[:aria] = { label: "Go to previous page" } if @label == DEFAULT_LABEL
|
|
13
20
|
li do
|
|
14
|
-
a(**mix(
|
|
21
|
+
a(**mix(base, @attrs)) do
|
|
15
22
|
render Icon.new(:chevron_left, size: nil)
|
|
16
23
|
span(class: "pk-pagination-label") { @label }
|
|
17
24
|
end
|