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
|
@@ -10,6 +10,18 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
10
10
|
// sub-rows are skipped via a visibility filter. Escape returns focus to the
|
|
11
11
|
// trigger; Enter/Space activate the focused row (links, checkbox/radio
|
|
12
12
|
// labels, and sub triggers alike).
|
|
13
|
+
|
|
14
|
+
// One-shot capture listener swallowing the click an outside mousedown is
|
|
15
|
+
// about to produce (see onMousedownOutside; same helper in menubar/context
|
|
16
|
+
// menu — duplicated per controller by design). {once} self-removes; the DOM
|
|
17
|
+
// dedupes re-arming with the same fn. If the gesture's click never fires
|
|
18
|
+
// (e.g. suppressed after a selection drag) the armed swallow eats the next
|
|
19
|
+
// one — rare enough to accept.
|
|
20
|
+
const swallowClick = (ev) => ev.preventDefault();
|
|
21
|
+
function armSwallowClick() {
|
|
22
|
+
window.addEventListener("click", swallowClick, { once: true, capture: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
export default class extends Controller {
|
|
14
26
|
static targets = ["trigger", "content", "menuItem"];
|
|
15
27
|
static values = { open: { type: Boolean, default: false } };
|
|
@@ -22,6 +34,18 @@ export default class extends Controller {
|
|
|
22
34
|
// The visible control is the caller's button/link inside the trigger
|
|
23
35
|
// wrapper — that's what AT reads, so the popup wiring belongs on it.
|
|
24
36
|
this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
|
|
37
|
+
// A Turbo snapshot (or a detach/reattach that mimics one) serializes
|
|
38
|
+
// aria-expanded="true" on sub triggers even though no submenu can be
|
|
39
|
+
// open at connect time — sweep every stale marker back to false
|
|
40
|
+
// (menubar's pattern). Runs before the invoker's own reset below so
|
|
41
|
+
// both are covered by one mental model.
|
|
42
|
+
this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"));
|
|
43
|
+
// Same shape for checkbox/radio rows: their `checked` DOM property
|
|
44
|
+
// survives a reconnect but a hand-edited/replayed aria-checked can drift
|
|
45
|
+
// from it — resync every row from its live input.
|
|
46
|
+
this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
|
|
47
|
+
i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked);
|
|
48
|
+
});
|
|
25
49
|
this.invoker.setAttribute("aria-haspopup", "menu");
|
|
26
50
|
this.invoker.setAttribute("aria-expanded", "false");
|
|
27
51
|
// Associate trigger and menu for AT (the dialog controller wires its
|
|
@@ -57,16 +81,23 @@ export default class extends Controller {
|
|
|
57
81
|
this.subFrames.add(id);
|
|
58
82
|
}
|
|
59
83
|
|
|
84
|
+
// Deliberate (matches Radix's modal dismiss): the dismissing outside
|
|
85
|
+
// click ONLY dismisses the menu — swallowed rather than also acting on
|
|
86
|
+
// whatever was under the pointer (e.g. navigating a link). Armed at
|
|
87
|
+
// MOUSEDOWN: for a focusable click target the same gesture's focusout
|
|
88
|
+
// closes the panel before the click fires, so a click-time swallow
|
|
89
|
+
// silently skipped those targets (audit round 8).
|
|
90
|
+
onMousedownOutside(event) {
|
|
91
|
+
if (!this.contentTarget.matches(":popover-open")) return;
|
|
92
|
+
if (this.element.contains(event.target)) return;
|
|
93
|
+
armSwallowClick();
|
|
94
|
+
}
|
|
95
|
+
|
|
60
96
|
onClickOutside(event) {
|
|
61
97
|
// Gate on the live :popover-open state, not the stored flag — a stale
|
|
62
98
|
// flag is how a close on an already-closed panel becomes an open.
|
|
63
99
|
if (!this.contentTarget.matches(":popover-open")) return;
|
|
64
100
|
if (this.element.contains(event.target)) return;
|
|
65
|
-
|
|
66
|
-
// Deliberate (matches Radix's modal dismiss): the outside click ONLY
|
|
67
|
-
// dismisses the menu — it is swallowed rather than also acting on
|
|
68
|
-
// whatever was under the pointer (e.g. navigating a link).
|
|
69
|
-
event.preventDefault();
|
|
70
101
|
this.close();
|
|
71
102
|
}
|
|
72
103
|
|
|
@@ -74,6 +105,18 @@ export default class extends Controller {
|
|
|
74
105
|
this.contentTarget.matches(":popover-open") ? this.close() : this.#open();
|
|
75
106
|
}
|
|
76
107
|
|
|
108
|
+
// Tabbing (or otherwise moving real focus) out of the menu closes it —
|
|
109
|
+
// menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
|
|
110
|
+
// null when the window itself blurs — leave the menu alone then. The
|
|
111
|
+
// trigger + content are both DOM children of this.element even though the
|
|
112
|
+
// content is a top-layer popover, so one containment check covers both.
|
|
113
|
+
onFocusout(e) {
|
|
114
|
+
if (!this.contentTarget.matches(":popover-open")) return;
|
|
115
|
+
const to = e.relatedTarget;
|
|
116
|
+
if (!to || this.element.contains(to)) return;
|
|
117
|
+
this.close();
|
|
118
|
+
}
|
|
119
|
+
|
|
77
120
|
#open() {
|
|
78
121
|
this.openValue = true;
|
|
79
122
|
this.#addEventListeners();
|
|
@@ -102,6 +145,16 @@ export default class extends Controller {
|
|
|
102
145
|
}
|
|
103
146
|
|
|
104
147
|
#handleKeydown(e) {
|
|
148
|
+
// This listener is document-level (see #addEventListeners) so it keeps
|
|
149
|
+
// receiving events even once real focus has left the menu (e.g. a click
|
|
150
|
+
// into an unrelated text field the onFocusout listener didn't catch) —
|
|
151
|
+
// scope the roving-nav keys to when focus is actually within the widget.
|
|
152
|
+
// this.element (not just contentTarget) so ArrowDown still works right
|
|
153
|
+
// after a mouse-driven open, when focus is still on the invoker button
|
|
154
|
+
// (#open() doesn't force focus into the first row). Escape stays global
|
|
155
|
+
// while the menu is open (APG).
|
|
156
|
+
if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return;
|
|
157
|
+
|
|
105
158
|
const items = this.#items();
|
|
106
159
|
if (items.length === 0) return;
|
|
107
160
|
const index = items.indexOf(document.activeElement);
|
|
@@ -138,20 +191,27 @@ export default class extends Controller {
|
|
|
138
191
|
this.invoker.focus();
|
|
139
192
|
break;
|
|
140
193
|
case "ArrowRight":
|
|
141
|
-
// On a sub trigger, enter the submenu (focus reveals it via
|
|
142
|
-
// :focus-within, making its rows visible to the roving nav).
|
|
143
|
-
// Same keyboard grammar as context_menu.
|
|
144
|
-
if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
|
|
145
|
-
e.preventDefault();
|
|
146
|
-
this.#enterSub(document.activeElement);
|
|
147
|
-
}
|
|
148
|
-
break;
|
|
149
194
|
case "ArrowLeft": {
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
195
|
+
// Submenus open inline-end — visually LEFT in RTL — so the enter key
|
|
196
|
+
// follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
|
|
197
|
+
// (and the reverse in LTR). Same keyboard grammar as context_menu.
|
|
198
|
+
// Runtime dir check is reliable after a dynamic flip.
|
|
199
|
+
const rtl = getComputedStyle(this.element).direction === "rtl";
|
|
200
|
+
const enterKey = rtl ? "ArrowLeft" : "ArrowRight";
|
|
201
|
+
if (e.key === enterKey) {
|
|
202
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
203
|
+
// :focus-within, making its rows visible to the roving nav).
|
|
204
|
+
if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
this.#enterSub(document.activeElement);
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// Inside a submenu, step back to its trigger (closes it on focus-out).
|
|
210
|
+
const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
|
|
211
|
+
if (sub) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
|
|
214
|
+
}
|
|
155
215
|
}
|
|
156
216
|
break;
|
|
157
217
|
}
|
|
@@ -59,42 +59,50 @@ export default class extends Controller {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
this.shouldValidateValue = true;
|
|
62
|
-
this.#setErrorMessage(
|
|
62
|
+
this.#setErrorMessage();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
onInput(
|
|
66
|
-
this.#setErrorMessage(
|
|
65
|
+
onInput(_event) {
|
|
66
|
+
this.#setErrorMessage();
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
onChange(
|
|
70
|
-
this.#setErrorMessage(
|
|
69
|
+
onChange(_event) {
|
|
70
|
+
this.#setErrorMessage();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
#setErrorMessage(event) {
|
|
73
|
+
// A FormField can carry several input targets (radio/checkbox groups, or
|
|
74
|
+
// independent controls like two required checkboxes sharing one error).
|
|
75
|
+
// Recompute across ALL of them on every validity event — deciding from the
|
|
76
|
+
// event's own target alone left a shared error stuck on/off based on
|
|
77
|
+
// whichever control happened to fire last, out of step with the others'
|
|
78
|
+
// own (correctly-updated) aria-invalid.
|
|
79
|
+
#setErrorMessage() {
|
|
82
80
|
if (!this.shouldValidateValue || !this.hasErrorTarget) return;
|
|
83
81
|
|
|
84
|
-
const input = this.#inputFor(event);
|
|
85
82
|
// aria-invalid drives the red ring for LIVE validation too (input.css
|
|
86
|
-
// only matched server-rendered attrs);
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
// only matched server-rendered attrs); each control reflects its own
|
|
84
|
+
// validity regardless of which one fired the event. Radio buttons
|
|
85
|
+
// sharing a `name` share native validity, so they naturally agree here.
|
|
86
|
+
let firstInvalid = null;
|
|
87
|
+
this.inputTargets.forEach((input) => {
|
|
88
|
+
if (input.validity.valid) {
|
|
89
|
+
input.removeAttribute("aria-invalid");
|
|
90
|
+
} else {
|
|
91
|
+
input.setAttribute("aria-invalid", "true");
|
|
92
|
+
firstInvalid ||= input;
|
|
93
|
+
}
|
|
94
|
+
// Clear the shared error's id everywhere first — it's re-added below,
|
|
95
|
+
// only on the one control whose message is currently shown.
|
|
90
96
|
this.#describeBy(input, false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (firstInvalid) {
|
|
100
|
+
this.#describeBy(firstInvalid, true);
|
|
101
|
+
this.errorTarget.textContent = this.#getValidationMessage(firstInvalid);
|
|
102
|
+
this.errorTarget.classList.remove("pk-hidden");
|
|
103
|
+
} else {
|
|
91
104
|
this.errorTarget.textContent = "";
|
|
92
105
|
this.errorTarget.classList.add("pk-hidden");
|
|
93
|
-
} else {
|
|
94
|
-
input.setAttribute("aria-invalid", "true");
|
|
95
|
-
this.#describeBy(input, true);
|
|
96
|
-
this.errorTarget.textContent = this.#getValidationMessage(input);
|
|
97
|
-
this.errorTarget.classList.remove("pk-hidden");
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
|
|
@@ -15,12 +15,18 @@
|
|
|
15
15
|
position-area: block-end span-inline-end;
|
|
16
16
|
margin: .25rem 0 0;
|
|
17
17
|
position-try-fallbacks: flip-block;
|
|
18
|
+
flex-direction: column; gap: .125rem;
|
|
18
19
|
width: 16rem;
|
|
19
20
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
20
21
|
border-radius: var(--pk-radius); background: var(--pk-surface);
|
|
21
22
|
color: var(--pk-text); padding: .625rem; font-size: .875rem;
|
|
22
23
|
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
|
|
23
24
|
}
|
|
25
|
+
/* display must be gated on :popover-open — an unconditional author
|
|
26
|
+
`display: flex` would beat the UA's [popover] { display: none } (bit the
|
|
27
|
+
Basic docs example's inline style, which relied on this instead of the
|
|
28
|
+
component doing it). */
|
|
29
|
+
.pk-hover-card-content:popover-open { display: flex; }
|
|
24
30
|
.pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
|
|
25
31
|
/* Gap margins are logical to match the logical position-area (and tooltip's
|
|
26
32
|
pattern) — physical margins landed on the wrong side in RTL. */
|
|
@@ -10,6 +10,29 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
10
10
|
export default class extends Controller {
|
|
11
11
|
static targets = ["trigger", "content"]
|
|
12
12
|
static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
// Turbo snapshots BEFORE disconnect, so a stale data-state="open" would
|
|
16
|
+
// otherwise resurrect host CSS keyed on the state hook after a cache
|
|
17
|
+
// restore — close now (popover_controller.js's contentTargetConnected /
|
|
18
|
+
// context_menu_controller.js's turbo:before-cache pattern combined).
|
|
19
|
+
this.onBeforeCache = () => {
|
|
20
|
+
if (this.contentTarget.matches(":popover-open")) {
|
|
21
|
+
clearTimeout(this.t)
|
|
22
|
+
this.#close()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
document.addEventListener("turbo:before-cache", this.onBeforeCache)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
contentTargetConnected(el) {
|
|
29
|
+
// A reconnect (Turbo cache restore, or the detach/reattach it's tested
|
|
30
|
+
// with) can hand us markup whose data-state was serialized while open —
|
|
31
|
+
// :popover-open does not survive the restore, so resync before anything
|
|
32
|
+
// reads the hook.
|
|
33
|
+
el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
show() {
|
|
14
37
|
clearTimeout(this.t)
|
|
15
38
|
this.t = setTimeout(() => {
|
|
@@ -25,6 +48,7 @@ export default class extends Controller {
|
|
|
25
48
|
disconnect() {
|
|
26
49
|
clearTimeout(this.t)
|
|
27
50
|
this.#unbindEscape()
|
|
51
|
+
document.removeEventListener("turbo:before-cache", this.onBeforeCache)
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
#close() {
|
|
@@ -24,7 +24,7 @@ module PhlexKit
|
|
|
24
24
|
# `mix` merges duplicate string attrs ("16 24") — drop a generated attr
|
|
25
25
|
# whenever the caller supplies their own copy, so theirs wins.
|
|
26
26
|
%i[width height viewbox aria-hidden].each do |key|
|
|
27
|
-
base.delete(key) if
|
|
27
|
+
base.delete(key) if attr_set?(key)
|
|
28
28
|
end
|
|
29
29
|
svg(**mix(base, @attrs)) do |s|
|
|
30
30
|
icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
|
|
@@ -15,15 +15,18 @@ module PhlexKit
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def view_template(&block)
|
|
18
|
-
|
|
18
|
+
base = {
|
|
19
19
|
class: "pk-input-otp",
|
|
20
20
|
role: "group",
|
|
21
|
-
aria: { label: @label },
|
|
22
21
|
data: {
|
|
23
22
|
controller: "phlex-kit--input-otp",
|
|
24
23
|
phlex_kit__input_otp_length_value: @length
|
|
25
24
|
}
|
|
26
|
-
}
|
|
25
|
+
}
|
|
26
|
+
# Default label only when the caller didn't supply their own — `mix`
|
|
27
|
+
# would fuse aria-label into a two-token value instead of overriding.
|
|
28
|
+
base[:aria] = { label: @label } unless aria_labelled?
|
|
29
|
+
div(**mix(base, @attrs)) do
|
|
27
30
|
input(type: :hidden, name: @name, value: @value, data: { phlex_kit__input_otp_target: "value" }) if @name
|
|
28
31
|
yield if block
|
|
29
32
|
end
|
|
@@ -30,20 +30,32 @@ export default class extends Controller {
|
|
|
30
30
|
const chars = this.valueTarget.value.split("")
|
|
31
31
|
this.slotTargets.forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
|
|
32
32
|
}
|
|
33
|
+
// Track each slot's last-known-good value so a rejected keystroke (see
|
|
34
|
+
// onInput) can restore it instead of leaving the slot blanked.
|
|
35
|
+
this.slotTargets.forEach((slot) => { slot.dataset.otpValue = slot.value })
|
|
33
36
|
this.syncValue()
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
onInput(e) {
|
|
37
40
|
const slot = e.target
|
|
41
|
+
const isNumeric = slot.getAttribute("inputmode") === "numeric"
|
|
42
|
+
const priorValue = slot.dataset.otpValue || ""
|
|
38
43
|
let text = slot.value.replace(/\s/g, "")
|
|
39
44
|
// Typing rejects the same characters paste strips — a letter keyed into
|
|
40
45
|
// an inputmode=numeric slot must not land.
|
|
41
|
-
if (
|
|
46
|
+
if (isNumeric) text = text.replace(/\D/g, "")
|
|
42
47
|
|
|
43
48
|
if (text.length > 1) {
|
|
44
49
|
if (e.data && e.data.length === 1) {
|
|
50
|
+
if (isNumeric && !/\d/.test(e.data)) {
|
|
51
|
+
// Rejected keystroke into an already-filled/selected slot: restore
|
|
52
|
+
// the prior digit rather than blanking it, and don't advance focus.
|
|
53
|
+
slot.value = priorValue
|
|
54
|
+
return
|
|
55
|
+
}
|
|
45
56
|
// one keystroke into an already-filled slot: keep the newest char
|
|
46
57
|
slot.value = e.data
|
|
58
|
+
slot.dataset.otpValue = e.data
|
|
47
59
|
this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
48
60
|
this.syncValue()
|
|
49
61
|
} else {
|
|
@@ -55,7 +67,16 @@ export default class extends Controller {
|
|
|
55
67
|
return
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
if (!text && e.data) {
|
|
71
|
+
// A keystroke was fully rejected by numeric filtering (e.g. a letter
|
|
72
|
+
// typed into a filled/selected slot) — restore the slot's prior value
|
|
73
|
+
// instead of blanking it, and don't advance focus.
|
|
74
|
+
slot.value = priorValue
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
slot.value = text
|
|
79
|
+
slot.dataset.otpValue = text
|
|
59
80
|
if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
60
81
|
this.syncValue()
|
|
61
82
|
}
|
|
@@ -66,6 +87,7 @@ export default class extends Controller {
|
|
|
66
87
|
e.preventDefault()
|
|
67
88
|
const prev = this.slotTargets[index - 1]
|
|
68
89
|
prev.value = ""
|
|
90
|
+
prev.dataset.otpValue = ""
|
|
69
91
|
prev.focus()
|
|
70
92
|
this.syncValue()
|
|
71
93
|
} else if (e.key === "ArrowLeft" && index > 0) {
|
|
@@ -92,7 +114,7 @@ export default class extends Controller {
|
|
|
92
114
|
fillFrom(slot, text) {
|
|
93
115
|
const chars = text.split("")
|
|
94
116
|
const start = this.slotTargets.indexOf(slot)
|
|
95
|
-
this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
|
|
117
|
+
this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) { s.value = chars[i]; s.dataset.otpValue = chars[i] } })
|
|
96
118
|
this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
|
|
97
119
|
this.syncValue()
|
|
98
120
|
}
|
|
@@ -34,7 +34,8 @@ a.pk-item:hover { background: var(--pk-surface-2); }
|
|
|
34
34
|
transform: translateY(.125rem);
|
|
35
35
|
align-self: flex-start;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
/* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
|
|
38
|
+
.pk-item-media.icon svg:not([width]) { width: 1rem; height: 1rem; }
|
|
38
39
|
.pk-item-media.image {
|
|
39
40
|
width: 2.5rem;
|
|
40
41
|
height: 2.5rem;
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
font-weight: 500;
|
|
16
16
|
}
|
|
17
17
|
.pk-kbd-group { display: inline-flex; align-items: center; gap: .25rem; font-family: inherit; }
|
|
18
|
-
|
|
18
|
+
/* Glyphs default to size-3; explicit width attrs (Icon size:) win. */
|
|
19
|
+
.pk-kbd svg:not([width]) { width: .75rem; height: .75rem; }
|
|
19
20
|
/* Inside an (inverted) tooltip the chip tints from the page background. */
|
|
20
21
|
.pk-tooltip-content .pk-kbd {
|
|
21
22
|
background: color-mix(in oklab, var(--pk-bg) 10%, transparent);
|
|
@@ -20,6 +20,7 @@ module PhlexKit
|
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
22
|
SIZES = {
|
|
23
|
+
xs: "xs",
|
|
23
24
|
sm: "sm",
|
|
24
25
|
md: nil,
|
|
25
26
|
lg: "lg",
|
|
@@ -35,11 +36,20 @@ module PhlexKit
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def view_template(&block)
|
|
38
|
-
|
|
39
|
+
base = { href: @href, class: classes }
|
|
40
|
+
# target="_blank" hands the new page a window.opener reference in older
|
|
41
|
+
# browsers (modern ones imply noopener) — default the safe rel unless
|
|
42
|
+
# the caller supplied their own (`mix` would fuse "noopener external").
|
|
43
|
+
base[:rel] = "noopener" if blank_target? && !attr_set?(:rel)
|
|
44
|
+
a(**mix(base, @attrs), &block)
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
private
|
|
42
48
|
|
|
49
|
+
def blank_target?
|
|
50
|
+
(@attrs[:target] || @attrs["target"]).to_s == "_blank"
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
def classes
|
|
44
54
|
[ "pk-button", fetch_option(VARIANTS, @variant, :variant), fetch_option(SIZES, @size, :size), ("icon" if @icon) ].compact.join(" ")
|
|
45
55
|
end
|
|
@@ -25,7 +25,8 @@ a.pk-marker, button.pk-marker {
|
|
|
25
25
|
font-size: .875rem;
|
|
26
26
|
}
|
|
27
27
|
a.pk-marker:hover, button.pk-marker:hover { color: var(--pk-text); }
|
|
28
|
-
|
|
28
|
+
/* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
|
|
29
|
+
.pk-marker svg:not([width]) { width: 1rem; height: 1rem; }
|
|
29
30
|
.pk-marker-icon { display: inline-flex; flex: none; width: 1rem; height: 1rem; }
|
|
30
31
|
.pk-marker-content { min-width: 0; overflow-wrap: break-word; }
|
|
31
32
|
.pk-marker-content a { text-decoration: underline; text-underline-offset: 3px; color: inherit; }
|
|
@@ -129,6 +129,9 @@
|
|
|
129
129
|
the viewport right edge. */
|
|
130
130
|
.pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
|
|
131
131
|
.pk-menubar-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
132
|
+
/* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
|
|
133
|
+
[dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
|
|
134
|
+
[dir="rtl"] .pk-menubar-sub-chevron { transform: scaleX(-1); }
|
|
132
135
|
.pk-menubar-sub-content {
|
|
133
136
|
position: fixed;
|
|
134
137
|
position-anchor: --pk-menubar-sub;
|
|
@@ -18,7 +18,9 @@ module PhlexKit
|
|
|
18
18
|
controller: "phlex-kit--menubar",
|
|
19
19
|
# focusout: tabbing out of the bar closes the open [popover=manual]
|
|
20
20
|
# panel (which no focus trap holds open).
|
|
21
|
-
|
|
21
|
+
# mousedown@window arms the modal-menu click swallow (deliberately
|
|
22
|
+
# NOT bound by navigation_menu.rb — nav menus are non-modal).
|
|
23
|
+
action: "mousedown@window->phlex-kit--menubar#onMousedownOutside click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
|
|
22
24
|
}
|
|
23
25
|
}, @attrs), &)
|
|
24
26
|
end
|
|
@@ -13,6 +13,17 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
13
13
|
// Connects to data-controller="phlex-kit--menubar"
|
|
14
14
|
let uid = 0
|
|
15
15
|
|
|
16
|
+
// One-shot capture listener swallowing the click an outside mousedown is
|
|
17
|
+
// about to produce (see onMousedownOutside; same helper in dropdown/context
|
|
18
|
+
// menu — duplicated per controller by design). {once} self-removes; the DOM
|
|
19
|
+
// dedupes re-arming with the same fn. If the gesture's click never fires
|
|
20
|
+
// (e.g. suppressed after a selection drag) the armed swallow eats the next
|
|
21
|
+
// one — rare enough to accept.
|
|
22
|
+
const swallowClick = (ev) => ev.preventDefault()
|
|
23
|
+
function armSwallowClick() {
|
|
24
|
+
window.addEventListener("click", swallowClick, { once: true, capture: true })
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
export default class extends Controller {
|
|
17
28
|
static targets = ["menu"]
|
|
18
29
|
|
|
@@ -48,6 +59,12 @@ export default class extends Controller {
|
|
|
48
59
|
// (only on elements that already carry it: plain nav links must not
|
|
49
60
|
// grow an aria-expanded). Covers the trigger and CSS-revealed subs.
|
|
50
61
|
menu.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
|
|
62
|
+
// Same shape for checkbox/radio rows: their `checked` DOM property
|
|
63
|
+
// survives a reconnect but a hand-edited/replayed aria-checked can drift
|
|
64
|
+
// from it — resync every row from its live input.
|
|
65
|
+
menu.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
|
|
66
|
+
i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
|
|
67
|
+
})
|
|
51
68
|
if (this.roving) this.applyRoving()
|
|
52
69
|
}
|
|
53
70
|
|
|
@@ -107,8 +124,15 @@ export default class extends Controller {
|
|
|
107
124
|
if (focus) (this.items(menu)[0] ?? this.trigger(menu))?.focus()
|
|
108
125
|
}
|
|
109
126
|
|
|
127
|
+
// Also wired directly as the click handler on plain nav links (see
|
|
128
|
+
// navigation_menu_link.rb) — `opts` is then the click Event rather than an
|
|
129
|
+
// options hash. Guard the default href="#" (dropdown_menu_controller.js's
|
|
130
|
+
// close() does the same) so a link without a real destination doesn't
|
|
131
|
+
// scroll-to-top/append a hash; a link WITH a real href is left to navigate
|
|
132
|
+
// normally, closing whatever panel is open on the way out.
|
|
110
133
|
close(opts = {}) {
|
|
111
134
|
clearTimeout(this.graceTimer)
|
|
135
|
+
if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault?.()
|
|
112
136
|
const menu = this.openMenu
|
|
113
137
|
if (!menu) return
|
|
114
138
|
const panel = this.panel(menu)
|
|
@@ -118,6 +142,21 @@ export default class extends Controller {
|
|
|
118
142
|
if (opts.refocus === true) trigger?.focus()
|
|
119
143
|
}
|
|
120
144
|
|
|
145
|
+
// Kit-wide dismiss contract (Radix parity): MENU overlays (dropdown,
|
|
146
|
+
// context, menubar, select) are modal — the dismissing outside click is
|
|
147
|
+
// swallowed rather than also acting on what was under the pointer. The
|
|
148
|
+
// decision happens at MOUSEDOWN: the same gesture's focusout closes the
|
|
149
|
+
// panel before the click event fires, so a click-time openMenu check
|
|
150
|
+
// would already be null (that race made the old swallow silently skip
|
|
151
|
+
// focusable click targets). Only menubar.rb binds this action — the
|
|
152
|
+
// navigation menu (hover-open mode) is non-modal like Radix's and its
|
|
153
|
+
// dismissing click clicks through.
|
|
154
|
+
onMousedownOutside(e) {
|
|
155
|
+
if (this.element.contains(e.target)) return
|
|
156
|
+
if (!this.openMenu) return
|
|
157
|
+
armSwallowClick()
|
|
158
|
+
}
|
|
159
|
+
|
|
121
160
|
onClickOutside(e) {
|
|
122
161
|
if (this.element.contains(e.target)) return
|
|
123
162
|
this.close()
|
|
@@ -147,10 +186,14 @@ export default class extends Controller {
|
|
|
147
186
|
e.preventDefault()
|
|
148
187
|
this.show(menu, true)
|
|
149
188
|
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
|
|
150
|
-
// Closed bar: left/right move focus between the triggers (APG).
|
|
189
|
+
// Closed bar: left/right move focus between the triggers (APG). In RTL
|
|
190
|
+
// the bar mirrors, so the physical LEFT arrow moves to the next
|
|
191
|
+
// trigger. Runtime dir check is reliable after a dynamic flip.
|
|
151
192
|
e.preventDefault()
|
|
193
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
194
|
+
const step = (e.key === "ArrowRight" ? 1 : -1) * (rtl ? -1 : 1)
|
|
152
195
|
const menus = this.menuTargets
|
|
153
|
-
const next = menus[(menus.indexOf(menu) +
|
|
196
|
+
const next = menus[(menus.indexOf(menu) + step + menus.length) % menus.length]
|
|
154
197
|
const target = next ? this.trigger(next) : null
|
|
155
198
|
target?.focus()
|
|
156
199
|
if (this.roving && target) this.applyRoving(target)
|
|
@@ -190,23 +233,30 @@ export default class extends Controller {
|
|
|
190
233
|
}
|
|
191
234
|
break
|
|
192
235
|
case "ArrowRight":
|
|
193
|
-
e.preventDefault()
|
|
194
|
-
// On a sub trigger, enter the submenu (focus reveals it via
|
|
195
|
-
// :focus-within) instead of jumping to the next top-level menu.
|
|
196
|
-
if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
|
|
197
|
-
this.enterSub(document.activeElement)
|
|
198
|
-
} else {
|
|
199
|
-
this.shift(1)
|
|
200
|
-
}
|
|
201
|
-
break
|
|
202
236
|
case "ArrowLeft": {
|
|
203
237
|
e.preventDefault()
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
238
|
+
// Both the submenu (opens inline-end = visually LEFT in RTL) and the
|
|
239
|
+
// top-level menu traversal follow visual direction: the "enter/next"
|
|
240
|
+
// key is ArrowLeft in RTL, ArrowRight in LTR. Runtime dir check is
|
|
241
|
+
// reliable after a dynamic flip.
|
|
242
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
243
|
+
const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
|
|
244
|
+
if (e.key === enterKey) {
|
|
245
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
246
|
+
// :focus-within) instead of jumping to the next top-level menu.
|
|
247
|
+
if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
|
|
248
|
+
this.enterSub(document.activeElement)
|
|
249
|
+
} else {
|
|
250
|
+
this.shift(1)
|
|
251
|
+
}
|
|
208
252
|
} else {
|
|
209
|
-
|
|
253
|
+
// Inside a submenu, step back to its trigger instead of switching menus.
|
|
254
|
+
const sub = document.activeElement?.closest(".pk-menubar-sub-content")
|
|
255
|
+
if (sub) {
|
|
256
|
+
sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
|
|
257
|
+
} else {
|
|
258
|
+
this.shift(-1)
|
|
259
|
+
}
|
|
210
260
|
}
|
|
211
261
|
break
|
|
212
262
|
}
|
|
@@ -33,6 +33,20 @@
|
|
|
33
33
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
34
34
|
}
|
|
35
35
|
.pk-native-select-field:disabled { cursor: not-allowed; opacity: .5; }
|
|
36
|
+
/* [size] (a multi-row list box, NativeSelect.new(size: N)) or [multiple]
|
|
37
|
+
renders as a scrollable option list, not a single-line control — the fixed
|
|
38
|
+
2rem row height squashes it and there's no single line for the absolute
|
|
39
|
+
chevron to sit in, so auto-height the field and hide the chevron.
|
|
40
|
+
Note: [size="1"] is a single-line dropdown, not a list box — exclude it. */
|
|
41
|
+
.pk-native-select-field[size]:not([size="1"]),
|
|
42
|
+
.pk-native-select-field[multiple] {
|
|
43
|
+
height: auto;
|
|
44
|
+
padding-inline-end: .625rem;
|
|
45
|
+
}
|
|
46
|
+
.pk-native-select:has(.pk-native-select-field[size]:not([size="1"])) .pk-native-select-icon,
|
|
47
|
+
.pk-native-select:has(.pk-native-select-field[multiple]) .pk-native-select-icon {
|
|
48
|
+
display: none;
|
|
49
|
+
}
|
|
36
50
|
.pk-native-select-field.sm {
|
|
37
51
|
height: 1.75rem;
|
|
38
52
|
padding-top: .125rem;
|
|
@@ -11,7 +11,14 @@ module PhlexKit
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def view_template(&)
|
|
14
|
-
a(**mix({
|
|
14
|
+
a(**mix({
|
|
15
|
+
class: "pk-navigation-menu-link",
|
|
16
|
+
href: @href,
|
|
17
|
+
# click: closes the open panel on real activation (and swallows the
|
|
18
|
+
# default href="#" the way dropdown_menu_controller.js's close() does
|
|
19
|
+
# — see menubar_controller.js#close).
|
|
20
|
+
data: { action: "mouseenter->phlex-kit--menubar#switch click->phlex-kit--menubar#close" }
|
|
21
|
+
}, @attrs), &)
|
|
15
22
|
end
|
|
16
23
|
end
|
|
17
24
|
end
|