phlex_kit 0.8.0 → 0.8.1
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/accordion/accordion_content.rb +11 -2
- data/app/components/phlex_kit/accordion/accordion_controller.js +15 -2
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +5 -2
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +80 -6
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +8 -4
- data/app/components/phlex_kit/calendar/calendar_body.rb +9 -1
- data/app/components/phlex_kit/calendar/calendar_controller.js +63 -1
- data/app/components/phlex_kit/chart/chart_controller.js +5 -1
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
- data/app/components/phlex_kit/combobox/combobox_controller.js +15 -2
- data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +51 -1
- data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +68 -47
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
- data/app/components/phlex_kit/form_field/form_field_controller.js +14 -1
- data/app/components/phlex_kit/form_field/form_field_error.rb +3 -0
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +30 -4
- data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +24 -5
- data/app/components/phlex_kit/menubar/menubar_controller.js +51 -7
- data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +3 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu.css +4 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +65 -3
- data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
- data/app/components/phlex_kit/select/select.rb +8 -7
- data/app/components/phlex_kit/select/select_controller.js +10 -4
- data/app/components/phlex_kit/select/select_item.rb +0 -1
- data/app/components/phlex_kit/sidebar/sidebar.css +19 -7
- data/app/components/phlex_kit/slider/slider.css +2 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +6 -0
- data/app/components/phlex_kit/toast/toast_region.rb +6 -2
- data/app/components/phlex_kit/toast/toaster_controller.js +7 -3
- data/app/components/phlex_kit/toggle/toggle.rb +3 -1
- data/app/javascript/phlex_kit/controllers/index.js +0 -2
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -2
- data/app/components/phlex_kit/select/select_item_controller.js +0 -14
|
@@ -4,14 +4,23 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
4
4
|
// @floating-ui/dom dependency: the panel is a native [popover=manual] in the
|
|
5
5
|
// top layer, anchor-positioned with viewport-edge flipping by
|
|
6
6
|
// dropdown_menu.css (manual so this controller keeps owning click-outside,
|
|
7
|
-
// Escape, and
|
|
7
|
+
// Escape, and keyboard nav). Keyboard nav moves REAL focus over the rows
|
|
8
|
+
// (upstream stamped aria-selected on menuitems, which AT ignores): :focus
|
|
9
|
+
// styles the highlight and :focus-within opens/closes submenus, so hidden
|
|
10
|
+
// sub-rows are skipped via a visibility filter. Escape returns focus to the
|
|
11
|
+
// trigger; Enter/Space activate the focused row (links, checkbox/radio
|
|
12
|
+
// labels, and sub triggers alike).
|
|
8
13
|
export default class extends Controller {
|
|
9
14
|
static targets = ["trigger", "content", "menuItem"];
|
|
10
15
|
static values = { open: { type: Boolean, default: false } };
|
|
11
16
|
|
|
12
17
|
connect() {
|
|
13
18
|
this.boundHandleKeydown = this.#handleKeydown.bind(this);
|
|
14
|
-
|
|
19
|
+
// The visible control is the caller's button/link inside the trigger
|
|
20
|
+
// wrapper — that's what AT reads, so the popup wiring belongs on it.
|
|
21
|
+
this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
|
|
22
|
+
this.invoker.setAttribute("aria-haspopup", "menu");
|
|
23
|
+
this.invoker.setAttribute("aria-expanded", "false");
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
disconnect() {
|
|
@@ -34,65 +43,77 @@ export default class extends Controller {
|
|
|
34
43
|
|
|
35
44
|
#open() {
|
|
36
45
|
this.openValue = true;
|
|
37
|
-
this.#deselectAll();
|
|
38
46
|
this.#addEventListeners();
|
|
39
47
|
this.contentTarget.showPopover();
|
|
48
|
+
this.invoker.setAttribute("aria-expanded", "true");
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
close() {
|
|
51
|
+
close(event) {
|
|
52
|
+
// Menu rows default to href="#"; without this an Enter/click on a
|
|
53
|
+
// non-link row scrolls to top and appends # to the URL.
|
|
54
|
+
if (event?.target?.closest?.('a[href="#"]')) event.preventDefault();
|
|
43
55
|
this.openValue = false;
|
|
44
56
|
this.#removeEventListeners();
|
|
45
57
|
if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover();
|
|
58
|
+
this.invoker.setAttribute("aria-expanded", "false");
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.#updateSelectedItem(-1);
|
|
57
|
-
} else if (e.key === "Enter" && this.selectedIndex !== -1) {
|
|
58
|
-
e.preventDefault();
|
|
59
|
-
this.menuItemTargets[this.selectedIndex].click();
|
|
60
|
-
} else if (e.key === "Escape") {
|
|
61
|
-
this.close();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
#updateSelectedItem(direction) {
|
|
66
|
-
this.menuItemTargets.forEach((item, index) => {
|
|
67
|
-
if (item.getAttribute("aria-selected") === "true") this.selectedIndex = index;
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
if (this.selectedIndex >= 0) {
|
|
71
|
-
this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
this.selectedIndex += direction;
|
|
75
|
-
|
|
76
|
-
if (this.selectedIndex < 0) {
|
|
77
|
-
this.selectedIndex = this.menuItemTargets.length - 1;
|
|
78
|
-
} else if (this.selectedIndex >= this.menuItemTargets.length) {
|
|
79
|
-
this.selectedIndex = 0;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);
|
|
61
|
+
// Mirrors a checkbox/radio row's native input state onto the row's
|
|
62
|
+
// aria-checked (radios also reset their group's siblings).
|
|
63
|
+
syncChecked(event) {
|
|
64
|
+
const input = event.target;
|
|
65
|
+
const group = input.name
|
|
66
|
+
? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
|
|
67
|
+
: [input];
|
|
68
|
+
group.forEach((i) => i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked));
|
|
83
69
|
}
|
|
84
70
|
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
71
|
+
#handleKeydown(e) {
|
|
72
|
+
const items = this.#items();
|
|
73
|
+
if (items.length === 0) return;
|
|
74
|
+
const index = items.indexOf(document.activeElement);
|
|
75
|
+
|
|
76
|
+
switch (e.key) {
|
|
77
|
+
case "ArrowDown":
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
items[(index + 1) % items.length]?.focus();
|
|
80
|
+
break;
|
|
81
|
+
case "ArrowUp":
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
items[index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length]?.focus();
|
|
84
|
+
break;
|
|
85
|
+
case "Home":
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
items[0]?.focus();
|
|
88
|
+
break;
|
|
89
|
+
case "End":
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
items[items.length - 1]?.focus();
|
|
92
|
+
break;
|
|
93
|
+
case "Enter":
|
|
94
|
+
case " ":
|
|
95
|
+
// Explicit click so label rows (checkbox/radio) activate too —
|
|
96
|
+
// labels have no native keyboard activation.
|
|
97
|
+
if (index >= 0) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
items[index].click();
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
case "Escape":
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
this.close();
|
|
105
|
+
this.invoker.focus();
|
|
106
|
+
break;
|
|
90
107
|
}
|
|
91
108
|
}
|
|
92
109
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
// Rows inside a closed submenu are display:none (revealed by CSS on
|
|
111
|
+
// hover/focus-within) — focus() on them silently fails and the roving
|
|
112
|
+
// navigation would jam at the submenu boundary.
|
|
113
|
+
#items() {
|
|
114
|
+
return this.menuItemTargets.filter(
|
|
115
|
+
(el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0,
|
|
116
|
+
);
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
#addEventListeners() {
|
|
@@ -13,9 +13,13 @@ module PhlexKit
|
|
|
13
13
|
def view_template(&block)
|
|
14
14
|
label(**mix({
|
|
15
15
|
class: "pk-dropdown-menu-item pk-dropdown-menu-radio-item",
|
|
16
|
-
role: "menuitemradio"
|
|
16
|
+
role: "menuitemradio",
|
|
17
|
+
tabindex: "-1",
|
|
18
|
+
aria: { checked: @checked ? "true" : "false" },
|
|
19
|
+
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
17
20
|
}, @attrs)) do
|
|
18
|
-
input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked
|
|
21
|
+
input(type: :radio, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
22
|
+
tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
|
|
19
23
|
span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) do
|
|
20
24
|
# Deliberately NOT a PhlexKit::Icon: a filled selection dot is
|
|
21
25
|
# geometry, not icon-library vocabulary — it stays identical across
|
|
@@ -7,8 +7,11 @@ module PhlexKit
|
|
|
7
7
|
div(**mix({
|
|
8
8
|
class: "pk-dropdown-menu-item pk-dropdown-menu-sub-trigger",
|
|
9
9
|
role: "menuitem",
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
# -1: roving focus reaches it via arrows (focus-within opens the sub);
|
|
11
|
+
# tabindex 0 made it a stray tab stop inside the menu.
|
|
12
|
+
tabindex: "-1",
|
|
13
|
+
aria: { haspopup: "menu" },
|
|
14
|
+
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
12
15
|
}, @attrs)) do
|
|
13
16
|
block&.call
|
|
14
17
|
render Icon.new(:chevron_right, size: nil, class: "pk-dropdown-menu-sub-chevron")
|
|
@@ -23,6 +23,11 @@ export default class extends Controller {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
onInvalid(error) {
|
|
26
|
+
// Only suppress the native validation bubble when we can show our own
|
|
27
|
+
// message — a FormField without a FormFieldError previously ate the
|
|
28
|
+
// bubble AND crashed on the missing target, so a required-but-empty
|
|
29
|
+
// form gave zero feedback and never submitted.
|
|
30
|
+
if (!this.hasErrorTarget) return;
|
|
26
31
|
error.preventDefault();
|
|
27
32
|
|
|
28
33
|
this.shouldValidateValue = true;
|
|
@@ -38,12 +43,20 @@ export default class extends Controller {
|
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
#setErrorMessage() {
|
|
41
|
-
if (!this.shouldValidateValue) return;
|
|
46
|
+
if (!this.shouldValidateValue || !this.hasErrorTarget) return;
|
|
42
47
|
|
|
48
|
+
// aria-invalid drives the red ring for LIVE validation too (input.css
|
|
49
|
+
// only matched server-rendered attrs); aria-describedby ties the message
|
|
50
|
+
// to the control for AT.
|
|
51
|
+
if (this.errorTarget.id) {
|
|
52
|
+
this.inputTarget.setAttribute("aria-describedby", this.errorTarget.id);
|
|
53
|
+
}
|
|
43
54
|
if (this.inputTarget.validity.valid) {
|
|
55
|
+
this.inputTarget.removeAttribute("aria-invalid");
|
|
44
56
|
this.errorTarget.textContent = "";
|
|
45
57
|
this.errorTarget.classList.add("pk-hidden");
|
|
46
58
|
} else {
|
|
59
|
+
this.inputTarget.setAttribute("aria-invalid", "true");
|
|
47
60
|
this.errorTarget.textContent = this.#getValidationMessage();
|
|
48
61
|
this.errorTarget.classList.remove("pk-hidden");
|
|
49
62
|
}
|
|
@@ -10,8 +10,11 @@ module PhlexKit
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def view_template(&block)
|
|
13
|
+
# role="alert": the controller swaps this text live on invalid/input —
|
|
14
|
+
# without a live region the announcement never reaches screen readers.
|
|
13
15
|
p(**mix({
|
|
14
16
|
class: "pk-form-field-error",
|
|
17
|
+
role: "alert",
|
|
15
18
|
data: { phlex_kit__form_field_target: "error" }
|
|
16
19
|
}, @attrs), &block)
|
|
17
20
|
end
|
|
@@ -22,7 +22,27 @@ export default class extends Controller {
|
|
|
22
22
|
|
|
23
23
|
onInput(e) {
|
|
24
24
|
const slot = e.target
|
|
25
|
-
|
|
25
|
+
let text = slot.value.replace(/\s/g, "")
|
|
26
|
+
// Typing rejects the same characters paste strips — a letter keyed into
|
|
27
|
+
// an inputmode=numeric slot must not land.
|
|
28
|
+
if (slot.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
|
|
29
|
+
|
|
30
|
+
if (text.length > 1) {
|
|
31
|
+
if (e.data && e.data.length === 1) {
|
|
32
|
+
// one keystroke into an already-filled slot: keep the newest char
|
|
33
|
+
slot.value = e.data
|
|
34
|
+
this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
35
|
+
this.syncValue()
|
|
36
|
+
} else {
|
|
37
|
+
// OTP autofill (autocomplete="one-time-code") delivers the whole
|
|
38
|
+
// code into the focused slot — distribute it like a paste instead
|
|
39
|
+
// of discarding all but one digit.
|
|
40
|
+
this.fillFrom(slot, text)
|
|
41
|
+
}
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
slot.value = text
|
|
26
46
|
if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
27
47
|
this.syncValue()
|
|
28
48
|
}
|
|
@@ -50,10 +70,16 @@ export default class extends Controller {
|
|
|
50
70
|
// Numeric slots reject non-digits on typing; pasted text like
|
|
51
71
|
// "code: 123456" should distribute only the digits.
|
|
52
72
|
if (e.target.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
|
|
73
|
+
if (!text.length) return
|
|
74
|
+
this.fillFrom(e.target, text)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Distribute a multi-character string across the slots starting at `slot`
|
|
78
|
+
// (shared by paste and autofill), then park focus after the last filled one.
|
|
79
|
+
fillFrom(slot, text) {
|
|
53
80
|
const chars = text.split("")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.slotTargets.slice(start).forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
|
|
81
|
+
const start = this.slotTargets.indexOf(slot)
|
|
82
|
+
this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
|
|
57
83
|
this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
|
|
58
84
|
this.syncValue()
|
|
59
85
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Text input with an inline mask. Ported from ruby_ui's RubyUI::MaskedInput —
|
|
3
3
|
# ruby_ui uses the `maska` JS lib; PhlexKit ships a small dependency-free mask
|
|
4
|
-
# controller (#=digit, A=letter, *=any) driven by a data-mask attribute. Swap in
|
|
4
|
+
# controller (#=digit, A=letter, *=any alphanumeric) driven by a data-mask attribute. Swap in
|
|
5
5
|
# maska by replacing masked_input_controller.js if you need its full feature set.
|
|
6
6
|
class MaskedInput < BaseComponent
|
|
7
7
|
def initialize(**attrs) = (@attrs = attrs)
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="phlex-kit--masked-input". Lightweight, dependency-
|
|
4
|
-
// free mask driven by a data-mask pattern (# = digit, A = letter, * = any
|
|
5
|
-
//
|
|
4
|
+
// free mask driven by a data-mask pattern (# = digit, A = letter, * = any
|
|
5
|
+
// alphanumeric — formatting characters are stripped before matching, so *
|
|
6
|
+
// cannot match punctuation; swap in `maska` (ruby_ui's choice) if you need a
|
|
7
|
+
// fuller mask engine).
|
|
6
8
|
export default class extends Controller {
|
|
7
9
|
connect() {
|
|
8
10
|
this.mask = this.element.getAttribute("data-mask") || ""
|
|
9
11
|
if (!this.mask) return
|
|
10
|
-
|
|
12
|
+
// Skip IME composition updates — masking mid-composition mangles the text.
|
|
13
|
+
this.onInput = (e) => { if (!e.isComposing) this.apply() }
|
|
11
14
|
this.element.addEventListener("input", this.onInput)
|
|
12
15
|
}
|
|
13
16
|
disconnect() { if (this.onInput) this.element.removeEventListener("input", this.onInput) }
|
|
14
17
|
apply() {
|
|
15
|
-
const
|
|
18
|
+
const el = this.element
|
|
19
|
+
// The whole value is rewritten below, which throws the caret to the end —
|
|
20
|
+
// remember how many maskable chars sit before it and re-seat it after
|
|
21
|
+
// the same count in the masked output (mid-field edits keep their place).
|
|
22
|
+
const caret = el.selectionStart ?? el.value.length
|
|
23
|
+
const rawBefore = el.value.slice(0, caret).replace(/[^0-9A-Za-z]/g, "").length
|
|
24
|
+
|
|
25
|
+
const raw = el.value.replace(/[^0-9A-Za-z]/g, "")
|
|
16
26
|
let out = "", i = 0
|
|
17
27
|
for (const t of this.mask) {
|
|
18
28
|
if (i >= raw.length) break
|
|
@@ -21,6 +31,15 @@ export default class extends Controller {
|
|
|
21
31
|
else if (t === "*") { out += raw[i++] }
|
|
22
32
|
else { out += t; if (raw[i] === t) i++ }
|
|
23
33
|
}
|
|
24
|
-
|
|
34
|
+
el.value = out
|
|
35
|
+
|
|
36
|
+
if (document.activeElement === el) {
|
|
37
|
+
let pos = 0, seen = 0
|
|
38
|
+
while (pos < out.length && seen < rawBefore) {
|
|
39
|
+
if (/[0-9A-Za-z]/.test(out[pos])) seen++
|
|
40
|
+
pos++
|
|
41
|
+
}
|
|
42
|
+
el.setSelectionRange(pos, pos)
|
|
43
|
+
}
|
|
25
44
|
}
|
|
26
45
|
}
|
|
@@ -84,11 +84,18 @@ export default class extends Controller {
|
|
|
84
84
|
// cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
|
|
85
85
|
onKeydown(e) {
|
|
86
86
|
if (!this.openMenu) {
|
|
87
|
-
if (e.key !== "ArrowDown") return
|
|
88
87
|
const menu = e.target.closest("[data-phlex-kit--menubar-target=\"menu\"]")
|
|
89
88
|
if (!menu) return
|
|
90
|
-
e.
|
|
91
|
-
|
|
89
|
+
if (e.key === "ArrowDown") {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
this.show(menu, true)
|
|
92
|
+
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
|
|
93
|
+
// Closed bar: left/right move focus between the triggers (APG).
|
|
94
|
+
e.preventDefault()
|
|
95
|
+
const menus = this.menuTargets
|
|
96
|
+
const next = menus[(menus.indexOf(menu) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length]
|
|
97
|
+
next?.querySelector("[aria-expanded], a, button, [tabindex]")?.focus()
|
|
98
|
+
}
|
|
92
99
|
return
|
|
93
100
|
}
|
|
94
101
|
const items = this.items(this.openMenu)
|
|
@@ -114,17 +121,51 @@ export default class extends Controller {
|
|
|
114
121
|
e.preventDefault()
|
|
115
122
|
items[items.length - 1]?.focus()
|
|
116
123
|
break
|
|
124
|
+
case "Enter":
|
|
125
|
+
case " ":
|
|
126
|
+
// Explicit click so label rows (checkbox/radio) activate — labels
|
|
127
|
+
// have no native keyboard activation (and this gives links Space).
|
|
128
|
+
if (index >= 0) {
|
|
129
|
+
e.preventDefault()
|
|
130
|
+
items[index].click()
|
|
131
|
+
}
|
|
132
|
+
break
|
|
117
133
|
case "ArrowRight":
|
|
118
134
|
e.preventDefault()
|
|
119
|
-
|
|
135
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
136
|
+
// :focus-within) instead of jumping to the next top-level menu.
|
|
137
|
+
if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
|
|
138
|
+
this.enterSub(document.activeElement)
|
|
139
|
+
} else {
|
|
140
|
+
this.shift(1)
|
|
141
|
+
}
|
|
120
142
|
break
|
|
121
|
-
case "ArrowLeft":
|
|
143
|
+
case "ArrowLeft": {
|
|
122
144
|
e.preventDefault()
|
|
123
|
-
|
|
145
|
+
// Inside a submenu, step back to its trigger instead of switching menus.
|
|
146
|
+
const sub = document.activeElement?.closest(".pk-menubar-sub-content")
|
|
147
|
+
if (sub) {
|
|
148
|
+
sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
|
|
149
|
+
} else {
|
|
150
|
+
this.shift(-1)
|
|
151
|
+
}
|
|
124
152
|
break
|
|
153
|
+
}
|
|
125
154
|
}
|
|
126
155
|
}
|
|
127
156
|
|
|
157
|
+
// Focus the first row of a sub trigger's panel. The panel opens on
|
|
158
|
+
// :focus-within, so focus the trigger first, then hop into the revealed
|
|
159
|
+
// panel's first visible item.
|
|
160
|
+
enterSub(trigger) {
|
|
161
|
+
trigger.focus()
|
|
162
|
+
const panel = trigger.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-content")
|
|
163
|
+
if (!panel) return
|
|
164
|
+
const first = [...panel.querySelectorAll("[role^=\"menuitem\"]")]
|
|
165
|
+
.find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
|
|
166
|
+
first?.focus()
|
|
167
|
+
}
|
|
168
|
+
|
|
128
169
|
// Mirrors a checkbox/radio item's native input state onto the item's
|
|
129
170
|
// aria-checked (radios also reset their group's siblings).
|
|
130
171
|
syncChecked(e) {
|
|
@@ -148,7 +189,10 @@ export default class extends Controller {
|
|
|
148
189
|
items(menu) {
|
|
149
190
|
const panel = this.panel(menu)
|
|
150
191
|
if (!panel) return []
|
|
192
|
+
// getClientRects also skips rows inside a CLOSED sub panel (its
|
|
193
|
+
// display:none comes from the hover/focus-within CSS, not .pk-hidden) —
|
|
194
|
+
// focus() on those silently fails and the roving nav jams there.
|
|
151
195
|
return [...panel.querySelectorAll("[role^=\"menuitem\"]")]
|
|
152
|
-
.filter((el) => !el.closest("[data-disabled]") &&
|
|
196
|
+
.filter((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
|
|
153
197
|
}
|
|
154
198
|
}
|
|
@@ -6,7 +6,9 @@ module PhlexKit
|
|
|
6
6
|
div(**mix({
|
|
7
7
|
class: "pk-menubar-item pk-menubar-sub-trigger",
|
|
8
8
|
role: "menuitem",
|
|
9
|
-
|
|
9
|
+
# -1: the roving focus reaches it via arrows (focus-within opens the
|
|
10
|
+
# sub); tabindex 0 made it a stray tab stop inside the open panel.
|
|
11
|
+
tabindex: "-1",
|
|
10
12
|
aria: { haspopup: "menu" }
|
|
11
13
|
}, @attrs)) do
|
|
12
14
|
block&.call
|
|
@@ -39,7 +39,10 @@
|
|
|
39
39
|
position-anchor: --pk-nav-item;
|
|
40
40
|
inset: auto;
|
|
41
41
|
position-area: block-end span-inline-end;
|
|
42
|
-
|
|
42
|
+
/* flip-inline too: a 16rem panel on an item near the right viewport edge
|
|
43
|
+
otherwise clips (same bug class popover fixed in #38). The hover
|
|
44
|
+
bridges span the block-axis gap, which flip-inline doesn't change. */
|
|
45
|
+
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
|
|
43
46
|
/* the [popover] UA style sets overflow:auto, which would clip the
|
|
44
47
|
negative-offset ::before bridge below */
|
|
45
48
|
overflow: visible;
|
|
@@ -6,7 +6,7 @@ module PhlexKit
|
|
|
6
6
|
button(**mix({
|
|
7
7
|
type: :button,
|
|
8
8
|
class: "pk-navigation-menu-trigger",
|
|
9
|
-
aria: {
|
|
9
|
+
aria: { expanded: "false" }, # no haspopup="menu": the panel deliberately has no menu role (matching Radix/shadcn)
|
|
10
10
|
data: { action: "click->phlex-kit--menubar#toggle mouseenter->phlex-kit--menubar#switch" }
|
|
11
11
|
}, @attrs)) do
|
|
12
12
|
block&.call
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// PhlexKit's stand-in for react-resizable-panels behind shadcn's Resizable:
|
|
4
|
-
// dragging a handle rebalances the flex-grow of the two
|
|
4
|
+
// dragging (or arrow-keying) a handle rebalances the flex-grow of the two
|
|
5
|
+
// panels around it. The handle is a focusable role="separator", so it also
|
|
6
|
+
// carries the ARIA value contract: aria-valuenow tracks the leading panel's
|
|
7
|
+
// share of the pair, and aria-orientation follows the group direction (a
|
|
8
|
+
// handle in a horizontal group is a vertical separator).
|
|
5
9
|
// Connects to data-controller="phlex-kit--resizable"
|
|
6
10
|
export default class extends Controller {
|
|
7
11
|
static targets = ["panel", "handle"]
|
|
8
12
|
static values = { direction: { type: String, default: "horizontal" } }
|
|
9
13
|
|
|
14
|
+
handleTargetConnected(handle) {
|
|
15
|
+
handle.setAttribute("aria-orientation", this.directionValue === "horizontal" ? "vertical" : "horizontal")
|
|
16
|
+
handle.setAttribute("aria-valuemin", "0")
|
|
17
|
+
handle.setAttribute("aria-valuemax", "100")
|
|
18
|
+
this.syncValuenow(handle)
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
start(e) {
|
|
22
|
+
if (e.button !== 0 || !e.isPrimary) return
|
|
11
23
|
const handle = e.currentTarget
|
|
12
24
|
const prev = handle.previousElementSibling
|
|
13
25
|
const next = handle.nextElementSibling
|
|
@@ -20,6 +32,10 @@ export default class extends Controller {
|
|
|
20
32
|
startPos: horizontal ? e.clientX : e.clientY,
|
|
21
33
|
prevSize: sizeOf(prev),
|
|
22
34
|
nextSize: sizeOf(next),
|
|
35
|
+
// The drag redistributes the PAIR's combined flex-grow, not a fixed
|
|
36
|
+
// constant: normalizing to 2 rescaled the pair against untouched
|
|
37
|
+
// siblings, so in a 25/25/50 group the 50 panel ballooned on first drag.
|
|
38
|
+
pairGrow: this.growOf(prev) + this.growOf(next) || 2,
|
|
23
39
|
}
|
|
24
40
|
try { handle.setPointerCapture(e.pointerId) } catch {}
|
|
25
41
|
|
|
@@ -27,8 +43,9 @@ export default class extends Controller {
|
|
|
27
43
|
const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
|
|
28
44
|
const total = drag.prevSize + drag.nextSize
|
|
29
45
|
const prevSize = Math.min(Math.max(drag.prevSize + delta, 0), total)
|
|
30
|
-
prev.style.flexGrow = (prevSize / total) *
|
|
31
|
-
next.style.flexGrow = ((total - prevSize) / total) *
|
|
46
|
+
prev.style.flexGrow = (prevSize / total) * drag.pairGrow
|
|
47
|
+
next.style.flexGrow = ((total - prevSize) / total) * drag.pairGrow
|
|
48
|
+
this.syncValuenow(handle)
|
|
32
49
|
}
|
|
33
50
|
const onUp = () => {
|
|
34
51
|
handle.removeEventListener("pointermove", onMove)
|
|
@@ -39,4 +56,49 @@ export default class extends Controller {
|
|
|
39
56
|
handle.addEventListener("pointerup", onUp)
|
|
40
57
|
handle.addEventListener("pointercancel", onUp)
|
|
41
58
|
}
|
|
59
|
+
|
|
60
|
+
// Arrow keys resize by 5% of the pair per press (matching the drag axis);
|
|
61
|
+
// Home/End collapse the leading panel to its min/max.
|
|
62
|
+
keydown(e) {
|
|
63
|
+
const handle = e.currentTarget
|
|
64
|
+
const prev = handle.previousElementSibling
|
|
65
|
+
const next = handle.nextElementSibling
|
|
66
|
+
if (!prev || !next) return
|
|
67
|
+
|
|
68
|
+
const horizontal = this.directionValue === "horizontal"
|
|
69
|
+
const steps = horizontal
|
|
70
|
+
? { ArrowLeft: -0.05, ArrowRight: 0.05 }
|
|
71
|
+
: { ArrowUp: -0.05, ArrowDown: 0.05 }
|
|
72
|
+
|
|
73
|
+
const pairGrow = this.growOf(prev) + this.growOf(next) || 2
|
|
74
|
+
let share = this.growOf(prev) / (pairGrow || 1)
|
|
75
|
+
if (e.key in steps) {
|
|
76
|
+
share = Math.min(1, Math.max(0, share + steps[e.key]))
|
|
77
|
+
} else if (e.key === "Home") {
|
|
78
|
+
share = 0
|
|
79
|
+
} else if (e.key === "End") {
|
|
80
|
+
share = 1
|
|
81
|
+
} else {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
e.preventDefault()
|
|
85
|
+
|
|
86
|
+
prev.style.flexGrow = share * pairGrow
|
|
87
|
+
next.style.flexGrow = (1 - share) * pairGrow
|
|
88
|
+
this.syncValuenow(handle)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
growOf(el) {
|
|
92
|
+
const g = parseFloat(getComputedStyle(el).flexGrow)
|
|
93
|
+
return Number.isFinite(g) ? g : 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
syncValuenow(handle) {
|
|
97
|
+
const prev = handle.previousElementSibling
|
|
98
|
+
const next = handle.nextElementSibling
|
|
99
|
+
if (!prev || !next) return
|
|
100
|
+
const pair = this.growOf(prev) + this.growOf(next)
|
|
101
|
+
const share = pair > 0 ? this.growOf(prev) / pair : 0.5
|
|
102
|
+
handle.setAttribute("aria-valuenow", String(Math.round(share * 100)))
|
|
103
|
+
}
|
|
42
104
|
}
|
|
@@ -12,10 +12,12 @@ module PhlexKit
|
|
|
12
12
|
class: "pk-resizable-handle",
|
|
13
13
|
role: "separator",
|
|
14
14
|
tabindex: "0",
|
|
15
|
+
# Correct for the default horizontal group; the controller re-stamps
|
|
16
|
+
# orientation (and aria-valuenow/min/max) per group direction on connect.
|
|
15
17
|
aria: { orientation: "vertical" },
|
|
16
18
|
data: {
|
|
17
19
|
phlex_kit__resizable_target: "handle",
|
|
18
|
-
action: "pointerdown->phlex-kit--resizable#start"
|
|
20
|
+
action: "pointerdown->phlex-kit--resizable#start keydown->phlex-kit--resizable#keydown"
|
|
19
21
|
}
|
|
20
22
|
}, @attrs)) do
|
|
21
23
|
span(class: "pk-resizable-handle-grip", aria: { hidden: "true" }) if @with_handle
|
|
@@ -2,11 +2,13 @@ module PhlexKit
|
|
|
2
2
|
# Custom dropdown select, ported from ruby_ui's RubyUI::Select — the styled
|
|
3
3
|
# popover (Image #2), NOT the native <select> (that's PhlexKit::NativeSelect). Unlike
|
|
4
4
|
# the rest of the kit this component IS JS-driven: it keeps ruby_ui's Stimulus
|
|
5
|
-
# wiring (`phlex-kit--select` /
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# the
|
|
9
|
-
#
|
|
5
|
+
# wiring (`phlex-kit--select`), since the open/close, selection, and keyboard
|
|
6
|
+
# nav are the point. Two changes from upstream: the controller drops the
|
|
7
|
+
# `@floating-ui/dom` dependency and positions the panel with plain CSS (opens
|
|
8
|
+
# directly below the trigger), and selection flips aria-selected via the
|
|
9
|
+
# instance-scoped itemTargets rather than upstream's document-scoped
|
|
10
|
+
# `.pk-select-item` outlet (which clobbered every other Select on the page).
|
|
11
|
+
# Tailwind → vanilla `.pk-select-*` (select.css).
|
|
10
12
|
#
|
|
11
13
|
# Multi-part. The hidden SelectInput carries the form value/name; SelectTrigger +
|
|
12
14
|
# SelectValue are the closed-state button; SelectContent > SelectGroup >
|
|
@@ -34,8 +36,7 @@ module PhlexKit
|
|
|
34
36
|
data: {
|
|
35
37
|
controller: "phlex-kit--select",
|
|
36
38
|
phlex_kit__select_open_value: "false",
|
|
37
|
-
action: "click@window->phlex-kit--select#clickOutside"
|
|
38
|
-
phlex_kit__select_phlex_kit__select_item_outlet: ".pk-select-item"
|
|
39
|
+
action: "click@window->phlex-kit--select#clickOutside"
|
|
39
40
|
}
|
|
40
41
|
}, @attrs), &block)
|
|
41
42
|
end
|
|
@@ -8,7 +8,6 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
8
8
|
export default class extends Controller {
|
|
9
9
|
static targets = ["trigger", "content", "input", "value", "item"];
|
|
10
10
|
static values = { open: Boolean };
|
|
11
|
-
static outlets = ["phlex-kit--select-item"];
|
|
12
11
|
|
|
13
12
|
connect() {
|
|
14
13
|
this.generateItemsIds();
|
|
@@ -17,13 +16,20 @@ export default class extends Controller {
|
|
|
17
16
|
selectItem(event) {
|
|
18
17
|
event.preventDefault();
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
// currentTarget, not target: the click may land on a child of the item
|
|
20
|
+
// (target.dataset.value would be undefined). itemTargets, not a
|
|
21
|
+
// document-scoped outlet: outlets match `.pk-select-item` page-wide and
|
|
22
|
+
// clobbered every other Select's aria-selected.
|
|
23
|
+
const item = event.currentTarget;
|
|
24
|
+
this.itemTargets.forEach((el) => {
|
|
25
|
+
el.setAttribute("aria-selected", el === item ? "true" : "false");
|
|
26
|
+
});
|
|
21
27
|
|
|
22
28
|
const oldValue = this.inputTarget.value;
|
|
23
|
-
const newValue =
|
|
29
|
+
const newValue = item.dataset.value;
|
|
24
30
|
|
|
25
31
|
this.inputTarget.value = newValue;
|
|
26
|
-
this.valueTarget.innerText =
|
|
32
|
+
this.valueTarget.innerText = item.innerText;
|
|
27
33
|
|
|
28
34
|
this.dispatchOnChange(oldValue, newValue);
|
|
29
35
|
this.closeContent();
|
|
@@ -21,7 +21,6 @@ module PhlexKit
|
|
|
21
21
|
"aria-selected": (@selected ? "true" : "false"),
|
|
22
22
|
data: {
|
|
23
23
|
value: @value,
|
|
24
|
-
controller: "phlex-kit--select-item",
|
|
25
24
|
action: "click->phlex-kit--select#selectItem keydown.enter->phlex-kit--select#selectItem " \
|
|
26
25
|
"keydown.down->phlex-kit--select#handleKeyDown keydown.up->phlex-kit--select#handleKeyUp " \
|
|
27
26
|
"keydown.esc->phlex-kit--select#handleEsc",
|