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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b7f736afc68bb09cd4706468a8a92b9b4d6085844e0e6813b12181c5b28a9fc
|
|
4
|
+
data.tar.gz: 480e8e1bc05eb96f3d05a409664737207d76a384cd2ea82d6c401c4298c42c45
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 62a50bb438b7c8814f8f32e91619b24f9ba41a3a668991c384b56bcc9fa3f317c9967dc12411a52372476e59b35ad3ef0675f11ad94a0f640d2619d25f02d8e9
|
|
7
|
+
data.tar.gz: 385f3b984cde9d92cd1e3e21dd78c95583e327f7a57a914183c64504029bf72cb515f29eb155311cd8ecb3cbb2cda053af205e31a161ddb2031333e0ad5f9013
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
class AccordionContent < BaseComponent
|
|
3
|
-
|
|
3
|
+
# Pass `open: true` alongside AccordionItem(open: true) so the content is
|
|
4
|
+
# readable without JS — the closed-state hidden + height:0 otherwise makes
|
|
5
|
+
# a server-rendered open item unreachable pre-hydration.
|
|
6
|
+
def initialize(open: false, **attrs)
|
|
7
|
+
@open = open
|
|
8
|
+
@attrs = attrs
|
|
9
|
+
end
|
|
10
|
+
|
|
4
11
|
def view_template(&)
|
|
5
12
|
# role="region" + aria-labelledby → trigger id (wired by the controller
|
|
6
13
|
# in connect(), which also assigns the ids), per the APG accordion pattern.
|
|
7
|
-
|
|
14
|
+
base = { class: "pk-accordion-content", role: "region", data: { phlex_kit__accordion_target: "content", state: @open ? "open" : "closed" } }
|
|
15
|
+
base.merge!(style: "height: 0px;", hidden: true) unless @open
|
|
16
|
+
div(**mix(base, @attrs), &)
|
|
8
17
|
end
|
|
9
18
|
end
|
|
10
19
|
end
|
|
@@ -15,8 +15,11 @@ export default class extends Controller {
|
|
|
15
15
|
|
|
16
16
|
connect() {
|
|
17
17
|
this.wireAria()
|
|
18
|
+
// Apply the initial state without animating (the initial valueChanged
|
|
19
|
+
// callback is skipped — it fires before connect()).
|
|
18
20
|
const d = this.animationDurationValue
|
|
19
21
|
this.animationDurationValue = 0
|
|
22
|
+
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", this.openValue ? "true" : "false")
|
|
20
23
|
this.openValue ? this.open() : this.close()
|
|
21
24
|
this.animationDurationValue = d
|
|
22
25
|
}
|
|
@@ -34,11 +37,18 @@ export default class extends Controller {
|
|
|
34
37
|
if (!root || root.dataset.type !== "single") return
|
|
35
38
|
root.querySelectorAll('[data-controller~="phlex-kit--accordion"]').forEach((el) => {
|
|
36
39
|
if (el === this.element) return
|
|
40
|
+
// Skip items of a NESTED accordion — querySelectorAll descends into
|
|
41
|
+
// item content, and opening an outer item collapsed inner ones too.
|
|
42
|
+
if (el.closest(".pk-accordion") !== root) return
|
|
37
43
|
const ctrl = this.application.getControllerForElementAndIdentifier(el, "phlex-kit--accordion")
|
|
38
44
|
if (ctrl && ctrl.openValue) ctrl.openValue = false
|
|
39
45
|
})
|
|
40
46
|
}
|
|
41
|
-
openValueChanged(isOpen) {
|
|
47
|
+
openValueChanged(isOpen, wasOpen) {
|
|
48
|
+
// Stimulus fires the initial value callback BEFORE connect() — before
|
|
49
|
+
// connect zeroes the duration — so a server-rendered open item visibly
|
|
50
|
+
// animated open on every page load. connect() applies the initial state.
|
|
51
|
+
if (wasOpen === undefined) return
|
|
42
52
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
|
|
43
53
|
isOpen ? this.open() : this.close()
|
|
44
54
|
}
|
|
@@ -78,9 +88,12 @@ export default class extends Controller {
|
|
|
78
88
|
c.removeAttribute("hidden")
|
|
79
89
|
c.dataset.state = "open"
|
|
80
90
|
const h = c.scrollHeight
|
|
81
|
-
c.animate([{ height: c.style.height || "0px" }, { height: `${h}px` }],
|
|
91
|
+
const a = c.animate([{ height: c.style.height || "0px" }, { height: `${h}px` }],
|
|
82
92
|
{ duration: this.animationDurationValue, easing: "ease-in-out" })
|
|
83
93
|
c.style.height = `${h}px`
|
|
94
|
+
// Settle at auto, not the frozen pixel height — with overflow hidden a
|
|
95
|
+
// later content growth (or a resize that rewraps text) silently clipped.
|
|
96
|
+
a.finished.then(() => { if (c.dataset.state === "open") c.style.height = "auto" }).catch(() => {})
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
hide() {
|
|
@@ -13,9 +13,12 @@ module PhlexKit
|
|
|
13
13
|
|
|
14
14
|
def view_template(&block)
|
|
15
15
|
template(**mix({ data: { phlex_kit__alert_dialog_target: "content" } }, @attrs)) do
|
|
16
|
-
|
|
16
|
+
# keydown on the clone root: focus is trapped inside the panel, so
|
|
17
|
+
# Escape/Tab always bubble here; tabindex="-1" on the panel is the
|
|
18
|
+
# focus fallback when the dialog has no focusable children.
|
|
19
|
+
div(data: { controller: "phlex-kit--alert-dialog", action: "keydown->phlex-kit--alert-dialog#keydown" }) do
|
|
17
20
|
div(class: "pk-alert-dialog-overlay", "aria-hidden": "true")
|
|
18
|
-
div(role: "alertdialog", "aria-modal": "true", class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" "), &block)
|
|
21
|
+
div(role: "alertdialog", "aria-modal": "true", tabindex: "-1", class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" "), &block)
|
|
19
22
|
end
|
|
20
23
|
end
|
|
21
24
|
end
|
|
@@ -1,24 +1,98 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
3
|
// Ported from ruby_ui's phlex-kit--alert-dialog controller. open() clones the
|
|
4
|
-
// <template> content into <body> as a modal
|
|
5
|
-
// of this controller
|
|
6
|
-
//
|
|
4
|
+
// <template> content into <body> as a modal; the clone carries its own
|
|
5
|
+
// instance of this controller, which owns the full modal contract for the
|
|
6
|
+
// clone's lifetime (mirroring sheet_content_controller): scroll lock, initial
|
|
7
|
+
// focus (the Cancel action when present — APG wants the least destructive
|
|
8
|
+
// control), Tab focus trap, Escape-to-dismiss, focus restore to the opener,
|
|
9
|
+
// and aria-labelledby/-describedby wiring to Title/Description. connect and
|
|
10
|
+
// disconnect exactly bracket the clone, so removal by any path restores state.
|
|
11
|
+
const FOCUSABLE =
|
|
12
|
+
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
13
|
+
|
|
7
14
|
export default class extends Controller {
|
|
8
15
|
static targets = ["content"];
|
|
9
16
|
static values = { open: { type: Boolean, default: false } };
|
|
10
17
|
|
|
11
18
|
connect() {
|
|
12
|
-
|
|
19
|
+
// The source element holds the <template>; the clone in <body> does not.
|
|
20
|
+
if (this.hasContentTarget) {
|
|
21
|
+
if (this.openValue) this.open();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
this.opener = document.activeElement;
|
|
25
|
+
this.previousOverflow = document.body.style.overflow;
|
|
26
|
+
document.body.style.overflow = "hidden";
|
|
27
|
+
this.#wireAria();
|
|
28
|
+
const focusables = this.#focusables();
|
|
29
|
+
const cancel = focusables.find((el) => (el.dataset.action || "").includes("#dismiss"));
|
|
30
|
+
(cancel || focusables[0] || this.#panel())?.focus();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
disconnect() {
|
|
34
|
+
if (this.hasContentTarget) return;
|
|
35
|
+
document.body.style.overflow = this.previousOverflow;
|
|
36
|
+
if (this.opener?.isConnected) this.opener.focus();
|
|
13
37
|
}
|
|
14
38
|
|
|
15
39
|
open() {
|
|
40
|
+
// Double-open guard: Enter+click (or a double-click) on the trigger must
|
|
41
|
+
// not stack a second clone over the first.
|
|
42
|
+
if (this.clone?.isConnected) return;
|
|
16
43
|
document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
|
|
17
|
-
document.body.
|
|
44
|
+
this.clone = document.body.lastElementChild;
|
|
18
45
|
}
|
|
19
46
|
|
|
20
47
|
dismiss() {
|
|
21
|
-
document.body.style.overflow = "";
|
|
22
48
|
this.element.remove();
|
|
23
49
|
}
|
|
50
|
+
|
|
51
|
+
keydown(event) {
|
|
52
|
+
if (event.key === "Escape") {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
this.dismiss();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (event.key !== "Tab") return;
|
|
58
|
+
const focusables = this.#focusables();
|
|
59
|
+
if (focusables.length === 0) {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
this.#panel()?.focus();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const first = focusables[0];
|
|
65
|
+
const last = focusables[focusables.length - 1];
|
|
66
|
+
if (event.shiftKey && (document.activeElement === first || document.activeElement === this.#panel())) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
last.focus();
|
|
69
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
first.focus();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#panel() {
|
|
76
|
+
return this.element.querySelector('[role="alertdialog"]');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#focusables() {
|
|
80
|
+
const panel = this.#panel();
|
|
81
|
+
if (!panel) return [];
|
|
82
|
+
return [...panel.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#wireAria() {
|
|
86
|
+
const panel = this.#panel();
|
|
87
|
+
if (!panel) return;
|
|
88
|
+
const wire = (attr, selector) => {
|
|
89
|
+
if (panel.getAttribute(attr)) return;
|
|
90
|
+
const el = this.element.querySelector(selector);
|
|
91
|
+
if (!el) return;
|
|
92
|
+
if (!el.id) el.id = `pk-alert-dialog-${Math.random().toString(36).slice(2, 10)}`;
|
|
93
|
+
panel.setAttribute(attr, el.id);
|
|
94
|
+
};
|
|
95
|
+
wire("aria-labelledby", ".pk-alert-dialog-title");
|
|
96
|
+
wire("aria-describedby", ".pk-alert-dialog-description");
|
|
97
|
+
}
|
|
24
98
|
}
|
|
@@ -5,16 +5,20 @@ module PhlexKit
|
|
|
5
5
|
# `.pk-aspect-ratio*` (aspect_ratio.css).
|
|
6
6
|
class AspectRatio < BaseComponent
|
|
7
7
|
def initialize(aspect_ratio: "16/9", **attrs)
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
# Both terms must be positive integers — "16/0" yields Infinity% and
|
|
9
|
+
# "a/b" NaN% padding, silently breaking the layout.
|
|
10
|
+
unless aspect_ratio.is_a?(String) && aspect_ratio.match?(%r{\A[1-9]\d*/[1-9]\d*\z})
|
|
11
|
+
raise ArgumentError, %(aspect_ratio must be a "w/h" string of positive integers (e.g. "16/9"))
|
|
10
12
|
end
|
|
11
13
|
@aspect_ratio = aspect_ratio
|
|
12
14
|
@attrs = attrs
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def view_template(&block)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
# **mix on the ROOT (kit rule) — caller attrs previously landed on the
|
|
19
|
+
# inner div and could never reach the actual root element.
|
|
20
|
+
div(**mix({ class: "pk-aspect-ratio", style: "padding-bottom: #{padding_bottom}%" }, @attrs)) do
|
|
21
|
+
div(class: "pk-aspect-ratio-inner", &block)
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
|
|
@@ -7,7 +7,15 @@ module PhlexKit
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def view_template
|
|
10
|
-
table(**mix({
|
|
10
|
+
table(**mix({
|
|
11
|
+
class: "pk-calendar-body",
|
|
12
|
+
data: {
|
|
13
|
+
phlex_kit__calendar_target: "calendar",
|
|
14
|
+
# Delegated arrow-key/Home/End navigation for the day grid (the
|
|
15
|
+
# buttons are re-rendered wholesale, so the action lives here).
|
|
16
|
+
action: "keydown->phlex-kit--calendar#onKeydown"
|
|
17
|
+
}
|
|
18
|
+
}, @attrs))
|
|
11
19
|
end
|
|
12
20
|
end
|
|
13
21
|
end
|
|
@@ -119,6 +119,7 @@ export default class extends Controller {
|
|
|
119
119
|
}
|
|
120
120
|
this.selectedDatesValue = current;
|
|
121
121
|
this.updateCalendar();
|
|
122
|
+
this.pushToOutlets();
|
|
122
123
|
this.dispatchChange();
|
|
123
124
|
}
|
|
124
125
|
|
|
@@ -192,7 +193,66 @@ export default class extends Controller {
|
|
|
192
193
|
this.titleTarget.textContent = this.monthAndYear();
|
|
193
194
|
}
|
|
194
195
|
this.syncDropdowns();
|
|
196
|
+
// innerHTML replacement drops focus — remember the focused day so
|
|
197
|
+
// keyboard selection/month hops don't strand focus on <body>.
|
|
198
|
+
const focusedDay = this.calendarTarget.contains(document.activeElement)
|
|
199
|
+
? document.activeElement.dataset?.day
|
|
200
|
+
: null;
|
|
195
201
|
this.calendarTarget.innerHTML = this.calendarHTML();
|
|
202
|
+
this.ensureGridTabStop(focusedDay);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Roving tabindex: exactly one day button is tabbable — the previously
|
|
206
|
+
// focused day (refocused across re-renders), else the selected day (its
|
|
207
|
+
// template ships tabindex="0"), else today, else the first enabled day of
|
|
208
|
+
// the current month. Without this, a calendar with no selection has no
|
|
209
|
+
// keyboard entry point at all.
|
|
210
|
+
ensureGridTabStop(focusedDay) {
|
|
211
|
+
const days = [...this.calendarTarget.querySelectorAll(".pk-calendar-day:not([disabled])")];
|
|
212
|
+
if (!days.length) return;
|
|
213
|
+
const focused = focusedDay ? days.find((d) => d.dataset.day === focusedDay) : null;
|
|
214
|
+
const stop =
|
|
215
|
+
focused ||
|
|
216
|
+
days.find((d) => d.getAttribute("tabindex") === "0") ||
|
|
217
|
+
days.find((d) => d.classList.contains("today")) ||
|
|
218
|
+
days.find((d) => !d.classList.contains("other")) ||
|
|
219
|
+
days[0];
|
|
220
|
+
days.forEach((d) => d.setAttribute("tabindex", d === stop ? "0" : "-1"));
|
|
221
|
+
if (focused) stop.focus({ preventScroll: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Arrow keys move by day/week, Home/End jump within the rendered week row;
|
|
225
|
+
// crossing a month boundary re-renders the grid on the target month.
|
|
226
|
+
// Enter/Space activate natively (the days are real <button>s).
|
|
227
|
+
onKeydown(e) {
|
|
228
|
+
const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
|
|
229
|
+
const day = e.target.closest?.("[data-day]");
|
|
230
|
+
if (!day) return;
|
|
231
|
+
|
|
232
|
+
let target;
|
|
233
|
+
if (e.key in STEPS) {
|
|
234
|
+
target = this.parseDate(day.dataset.day);
|
|
235
|
+
target.setDate(target.getDate() + STEPS[e.key]);
|
|
236
|
+
} else if (e.key === "Home" || e.key === "End") {
|
|
237
|
+
const rowDays = [...day.closest("tr").querySelectorAll("[data-day]")];
|
|
238
|
+
target = this.parseDate(rowDays[e.key === "Home" ? 0 : rowDays.length - 1].dataset.day);
|
|
239
|
+
} else {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
|
|
244
|
+
const iso = this.isoDate(target);
|
|
245
|
+
if (this.isDateDisabled(iso)) return; // min/max/booked boundary: stay put
|
|
246
|
+
|
|
247
|
+
if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
|
|
248
|
+
this.viewDateValue = iso; // re-renders the grid on the target month
|
|
249
|
+
}
|
|
250
|
+
const next = this.calendarTarget.querySelector(`[data-day="${iso}"]:not([disabled])`);
|
|
251
|
+
if (!next) return;
|
|
252
|
+
this.calendarTarget.querySelectorAll(".pk-calendar-day").forEach((d) => {
|
|
253
|
+
d.setAttribute("tabindex", d === next ? "0" : "-1");
|
|
254
|
+
});
|
|
255
|
+
next.focus({ preventScroll: true });
|
|
196
256
|
}
|
|
197
257
|
|
|
198
258
|
syncDropdowns() {
|
|
@@ -404,8 +464,10 @@ export default class extends Controller {
|
|
|
404
464
|
PPPP: `${dayOfWeek}, ${monthName} ${day}${daySuffix}, ${year}`,
|
|
405
465
|
};
|
|
406
466
|
|
|
467
|
+
// Longest tokens first: MMMM must precede MM or the alternation eats
|
|
468
|
+
// "MMMM" as two "MM"s and renders the month number twice.
|
|
407
469
|
const formattedDate = format.replace(
|
|
408
|
-
/yyyy|MM|dd|HH|mm|ss|EEEE|
|
|
470
|
+
/yyyy|MMMM|MM|dd|HH|mm|ss|EEEE|PPPP|do/g,
|
|
409
471
|
(matched) => map[matched],
|
|
410
472
|
);
|
|
411
473
|
return formattedDate;
|
|
@@ -59,8 +59,12 @@ export default class extends Controller {
|
|
|
59
59
|
defaultThemeColor(index) {
|
|
60
60
|
const color = this.seriesColor(index)
|
|
61
61
|
const translucentFill = ["line", "radar"].includes(this.optionsValue.type)
|
|
62
|
+
// color-mix, not hex + "33": the token may be any CSS color — the shipped
|
|
63
|
+
// zinc/neutral themes use oklch(...), and appending an alpha suffix to
|
|
64
|
+
// those produced invalid colors (fills silently broke).
|
|
65
|
+
const translucent = `color-mix(in oklab, ${color} 20%, transparent)`
|
|
62
66
|
return {
|
|
63
|
-
backgroundColor: this.isCircular() ? undefined : (translucentFill ?
|
|
67
|
+
backgroundColor: this.isCircular() ? undefined : (translucentFill ? translucent : color),
|
|
64
68
|
hoverBackgroundColor: color,
|
|
65
69
|
borderColor: color,
|
|
66
70
|
borderWidth: 2,
|
|
@@ -6,7 +6,9 @@ module PhlexKit
|
|
|
6
6
|
@attrs = attrs
|
|
7
7
|
end
|
|
8
8
|
def view_template(&block)
|
|
9
|
-
|
|
9
|
+
# role="status": the shown/hidden confirmation is a live region, so
|
|
10
|
+
# screen readers announce "Copied!" instead of a silent visual flash.
|
|
11
|
+
div(class: "pk-clipboard-popover pk-hidden", role: "status", data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
|
|
10
12
|
div(**mix({ class: "pk-clipboard-popover-inner" }, @attrs), &block)
|
|
11
13
|
end
|
|
12
14
|
end
|
|
@@ -120,7 +120,13 @@ export default class extends Controller {
|
|
|
120
120
|
|
|
121
121
|
toggleAllItems() {
|
|
122
122
|
const isChecked = this.toggleAllTarget.checked
|
|
123
|
-
this.inputTargets.forEach(input =>
|
|
123
|
+
this.inputTargets.forEach(input => {
|
|
124
|
+
if (input.checked === isChecked) return
|
|
125
|
+
input.checked = isChecked
|
|
126
|
+
// Programmatic .checked flips fire no event — host listeners and the
|
|
127
|
+
// form-field validation must still hear select-all (uncheck() does this).
|
|
128
|
+
input.dispatchEvent(new Event("change", { bubbles: true }))
|
|
129
|
+
})
|
|
124
130
|
this.updateTriggerContent()
|
|
125
131
|
}
|
|
126
132
|
|
|
@@ -207,7 +213,11 @@ export default class extends Controller {
|
|
|
207
213
|
e.preventDefault()
|
|
208
214
|
e.stopPropagation() // the button sits inside the trigger's click-to-open area
|
|
209
215
|
}
|
|
210
|
-
this.inputTargets.forEach(input =>
|
|
216
|
+
this.inputTargets.forEach(input => {
|
|
217
|
+
if (!input.checked) return
|
|
218
|
+
input.checked = false
|
|
219
|
+
input.dispatchEvent(new Event("change", { bubbles: true })) // see toggleAllItems
|
|
220
|
+
})
|
|
211
221
|
if (this.hasToggleAllTarget) this.toggleAllTarget.checked = false
|
|
212
222
|
this.updateTriggerContent()
|
|
213
223
|
}
|
|
@@ -287,6 +297,9 @@ export default class extends Controller {
|
|
|
287
297
|
|
|
288
298
|
this.selectedItemIndex = null
|
|
289
299
|
this.clearActiveDescendant()
|
|
300
|
+
// Also drop the highlight itself: a filtered-out option that kept
|
|
301
|
+
// aria-current="true" stayed the Enter target while invisible.
|
|
302
|
+
this.itemTargets.forEach(item => item.ariaCurrent = "false")
|
|
290
303
|
|
|
291
304
|
this.inputTargets.forEach((input) => {
|
|
292
305
|
const text = this.inputContent(input).toLowerCase()
|
|
@@ -14,9 +14,13 @@ module PhlexKit
|
|
|
14
14
|
def view_template(&block)
|
|
15
15
|
label(**mix({
|
|
16
16
|
class: "pk-context-menu-item pk-context-menu-checkbox-item",
|
|
17
|
-
role: "menuitemcheckbox"
|
|
17
|
+
role: "menuitemcheckbox",
|
|
18
|
+
tabindex: "-1",
|
|
19
|
+
aria: { checked: @checked ? "true" : "false" },
|
|
20
|
+
data: { phlex_kit__context_menu_target: "menuItem" }
|
|
18
21
|
}, @attrs)) do
|
|
19
|
-
input(type: :checkbox, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked
|
|
22
|
+
input(type: :checkbox, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
23
|
+
tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
|
|
20
24
|
span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
|
|
21
25
|
render Icon.new(:check, size: nil)
|
|
22
26
|
end
|
|
@@ -74,14 +74,64 @@ export default class extends Controller {
|
|
|
74
74
|
e.preventDefault()
|
|
75
75
|
items[items.length - 1]?.focus()
|
|
76
76
|
break
|
|
77
|
+
case "Enter":
|
|
78
|
+
case " ":
|
|
79
|
+
// Explicit click so label rows (checkbox/radio) activate — labels
|
|
80
|
+
// have no native keyboard activation (and this gives links Space).
|
|
81
|
+
if (index >= 0) {
|
|
82
|
+
e.preventDefault()
|
|
83
|
+
items[index].click()
|
|
84
|
+
}
|
|
85
|
+
break
|
|
86
|
+
case "ArrowRight":
|
|
87
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
88
|
+
// :focus-within, making its rows visible to the roving nav).
|
|
89
|
+
if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
this.enterSub(document.activeElement)
|
|
92
|
+
}
|
|
93
|
+
break
|
|
94
|
+
case "ArrowLeft": {
|
|
95
|
+
// Inside a submenu, step back to its trigger (closes it on focus-out).
|
|
96
|
+
const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
|
|
97
|
+
if (sub) {
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
|
|
100
|
+
}
|
|
101
|
+
break
|
|
102
|
+
}
|
|
77
103
|
}
|
|
78
104
|
}
|
|
79
105
|
|
|
106
|
+
enterSub(trigger) {
|
|
107
|
+
trigger.focus()
|
|
108
|
+
const panel = trigger.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-content")
|
|
109
|
+
if (!panel) return
|
|
110
|
+
const first = [...panel.querySelectorAll("[role^=\"menuitem\"]")]
|
|
111
|
+
.find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
|
|
112
|
+
first?.focus()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Mirrors a checkbox/radio row's native input state onto the row's
|
|
116
|
+
// aria-checked (radios also reset their group's siblings).
|
|
117
|
+
syncChecked(e) {
|
|
118
|
+
const input = e.target
|
|
119
|
+
const group = input.name
|
|
120
|
+
? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
|
|
121
|
+
: [input]
|
|
122
|
+
group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
|
|
123
|
+
}
|
|
124
|
+
|
|
80
125
|
anchor() {
|
|
81
126
|
return this.hasTriggerTarget ? this.triggerTarget : this.element
|
|
82
127
|
}
|
|
83
128
|
|
|
84
129
|
items() {
|
|
85
|
-
|
|
130
|
+
// getClientRects also skips rows inside a CLOSED sub panel (its
|
|
131
|
+
// display:none comes from the hover/focus-within CSS, not .pk-hidden) —
|
|
132
|
+
// focus() on those silently fails and the roving nav jams there.
|
|
133
|
+
return this.menuItemTargets.filter(
|
|
134
|
+
(el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0
|
|
135
|
+
)
|
|
86
136
|
}
|
|
87
137
|
}
|
|
@@ -13,9 +13,13 @@ module PhlexKit
|
|
|
13
13
|
def view_template(&block)
|
|
14
14
|
label(**mix({
|
|
15
15
|
class: "pk-context-menu-item pk-context-menu-radio-item",
|
|
16
|
-
role: "menuitemradio"
|
|
16
|
+
role: "menuitemradio",
|
|
17
|
+
tabindex: "-1",
|
|
18
|
+
aria: { checked: @checked ? "true" : "false" },
|
|
19
|
+
data: { phlex_kit__context_menu_target: "menuItem" }
|
|
17
20
|
}, @attrs)) do
|
|
18
|
-
input(type: :radio, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked
|
|
21
|
+
input(type: :radio, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
22
|
+
tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
|
|
19
23
|
span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
|
|
20
24
|
# A filled selection dot is geometry, not icon-library vocabulary —
|
|
21
25
|
# identical across icon_library settings (matches the dropdown).
|
|
@@ -6,8 +6,11 @@ module PhlexKit
|
|
|
6
6
|
div(**mix({
|
|
7
7
|
class: "pk-context-menu-item pk-context-menu-sub-trigger",
|
|
8
8
|
role: "menuitem",
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
# -1: roving focus reaches it via arrows (focus-within opens the sub);
|
|
10
|
+
# tabindex 0 made it a stray tab stop inside the menu.
|
|
11
|
+
tabindex: "-1",
|
|
12
|
+
aria: { haspopup: "menu" },
|
|
13
|
+
data: { phlex_kit__context_menu_target: "menuItem" }
|
|
11
14
|
}, @attrs)) do
|
|
12
15
|
block&.call
|
|
13
16
|
render Icon.new(:chevron_right, size: nil, class: "pk-context-menu-sub-chevron")
|
|
@@ -54,9 +54,10 @@
|
|
|
54
54
|
text-decoration: none; /* href items render as <a> — no UA underline at rest */
|
|
55
55
|
outline: none;
|
|
56
56
|
}
|
|
57
|
+
/* Keyboard nav moves real focus — :focus is the highlight (no aria-selected:
|
|
58
|
+
that attribute is unsupported on menuitems and AT ignores it). */
|
|
57
59
|
.pk-dropdown-menu-item:hover,
|
|
58
|
-
.pk-dropdown-menu-item:focus
|
|
59
|
-
.pk-dropdown-menu-item[aria-selected="true"] { background: var(--pk-accent); text-decoration: none; }
|
|
60
|
+
.pk-dropdown-menu-item:focus { background: var(--pk-accent); text-decoration: none; }
|
|
60
61
|
|
|
61
62
|
.pk-dropdown-menu-label { margin: 0; padding: .375rem .5rem; font-size: .875rem; font-weight: 600; }
|
|
62
63
|
.pk-dropdown-menu-separator { height: 1px; margin: .25rem -.25rem; background: var(--pk-border); }
|
|
@@ -14,9 +14,13 @@ module PhlexKit
|
|
|
14
14
|
def view_template(&block)
|
|
15
15
|
label(**mix({
|
|
16
16
|
class: "pk-dropdown-menu-item pk-dropdown-menu-checkbox-item",
|
|
17
|
-
role: "menuitemcheckbox"
|
|
17
|
+
role: "menuitemcheckbox",
|
|
18
|
+
tabindex: "-1",
|
|
19
|
+
aria: { checked: @checked ? "true" : "false" },
|
|
20
|
+
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
18
21
|
}, @attrs)) do
|
|
19
|
-
input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked
|
|
22
|
+
input(type: :checkbox, class: "pk-dropdown-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
23
|
+
tabindex: "-1", data: { action: "change->phlex-kit--dropdown-menu#syncChecked" })
|
|
20
24
|
span(class: "pk-dropdown-menu-item-indicator", aria: { hidden: "true" }) { check_icon }
|
|
21
25
|
yield if block
|
|
22
26
|
end
|
|
@@ -14,7 +14,9 @@ module PhlexKit
|
|
|
14
14
|
|
|
15
15
|
def view_template(&block)
|
|
16
16
|
div(class: wrapper_classes, popover: "manual", data: { phlex_kit__dropdown_menu_target: "content" }) do
|
|
17
|
-
|
|
17
|
+
# role="menu" on the items' direct parent — the rows render
|
|
18
|
+
# role="menuitem", which is invalid ARIA without a menu ancestor.
|
|
19
|
+
div(**mix({ class: "pk-dropdown-menu-viewport", role: "menu" }, @attrs), &block)
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
|