phlex_kit 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
- data/app/components/phlex_kit/accordion/accordion.css +7 -0
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +3 -0
- data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
- data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
- data/app/components/phlex_kit/badge/badge.css +1 -1
- data/app/components/phlex_kit/bubble/bubble.rb +4 -6
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
- data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
- data/app/components/phlex_kit/carousel/carousel.rb +3 -2
- data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
- data/app/components/phlex_kit/chart/chart.rb +8 -2
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
- data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
- data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
- data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_controller.js +15 -1
- data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
- data/app/components/phlex_kit/command/command_controller.js +6 -1
- data/app/components/phlex_kit/command/command_item.rb +4 -0
- data/app/components/phlex_kit/command/command_list.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
- data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
- data/app/components/phlex_kit/data_table/data_table.css +0 -10
- data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
- data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
- data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
- data/app/components/phlex_kit/dialog/dialog.css +3 -1
- data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_controller.js +13 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
- data/app/components/phlex_kit/field/field_separator.rb +1 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +22 -1
- data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
- data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
- data/app/components/phlex_kit/icon/icon.css +9 -0
- data/app/components/phlex_kit/icon/icon.rb +7 -1
- data/app/components/phlex_kit/input_group/input_group.css +0 -1
- data/app/components/phlex_kit/marker/marker.rb +9 -2
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
- data/app/components/phlex_kit/menubar/menubar_controller.js +6 -0
- data/app/components/phlex_kit/message/message.rb +5 -0
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
- data/app/components/phlex_kit/native_select/native_select.rb +12 -3
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
- data/app/components/phlex_kit/pagination/pagination.rb +10 -2
- data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
- data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
- data/app/components/phlex_kit/popover/popover_controller.js +23 -1
- data/app/components/phlex_kit/progress/progress.css +8 -0
- data/app/components/phlex_kit/progress/progress.rb +14 -5
- data/app/components/phlex_kit/resizable/resizable_controller.js +3 -0
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -2
- data/app/components/phlex_kit/select/select.css +5 -2
- data/app/components/phlex_kit/select/select_controller.js +27 -4
- data/app/components/phlex_kit/separator/separator.rb +5 -1
- data/app/components/phlex_kit/sheet/sheet.css +4 -2
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
- data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
- data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
- data/app/components/phlex_kit/slider/slider.css +15 -0
- data/app/components/phlex_kit/switch/switch.css +5 -0
- data/app/components/phlex_kit/table/table.css +2 -1
- data/app/components/phlex_kit/table/table.rb +5 -2
- data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
- data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
- data/app/components/phlex_kit/toast/toast.css +2 -1
- data/app/components/phlex_kit/toast/toast_action.rb +4 -1
- data/app/components/phlex_kit/toast/toast_controller.js +7 -4
- data/app/components/phlex_kit/toast/toast_region.rb +19 -9
- data/app/components/phlex_kit/toast/toaster_controller.js +15 -7
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +1 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +5 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
- data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
- data/app/components/phlex_kit/typography/heading.rb +7 -1
- data/app/components/phlex_kit/typography/list.rb +22 -0
- data/app/components/phlex_kit/typography/text.rb +7 -1
- data/lib/phlex_kit/base_component.rb +13 -0
- data/lib/phlex_kit/version.rb +1 -1
- metadata +3 -1
|
@@ -27,7 +27,10 @@ module PhlexKit
|
|
|
27
27
|
# invalid ARIA. The change event bubbles from the hidden checkbox
|
|
28
28
|
# to this row, where our toggle action picks it up.
|
|
29
29
|
render DropdownMenuCheckboxItem.new(
|
|
30
|
-
|
|
30
|
+
# visible: false starts the menu row unchecked for a column
|
|
31
|
+
# the host server-renders hidden (pk-hidden on its cells) —
|
|
32
|
+
# checked: true hardcoded meant the two could never agree.
|
|
33
|
+
checked: col.fetch(:visible, true),
|
|
31
34
|
data: {
|
|
32
35
|
column_key: col[:key].to_s,
|
|
33
36
|
action: "change->phlex-kit--data-table-column-visibility#toggle"
|
|
@@ -20,7 +20,11 @@ export default class extends Controller {
|
|
|
20
20
|
toggleAll(event) {
|
|
21
21
|
const checked = event.target.checked;
|
|
22
22
|
this.rowCheckboxTargets.forEach((cb) => {
|
|
23
|
+
if (cb.checked === checked) return;
|
|
23
24
|
cb.checked = checked;
|
|
25
|
+
// Programmatic .checked flips fire no event — hosts (and the
|
|
26
|
+
// form-field wiring on PhlexKit::Checkbox) must still hear select-all.
|
|
27
|
+
cb.dispatchEvent(new Event("change", { bubbles: true }));
|
|
24
28
|
});
|
|
25
29
|
this.updateState();
|
|
26
30
|
}
|
|
@@ -31,6 +31,12 @@ module PhlexKit
|
|
|
31
31
|
# until the first interaction.
|
|
32
32
|
@value = value || format_selected_date
|
|
33
33
|
@placeholder = placeholder
|
|
34
|
+
# An id smuggled through input_attrs would MERGE with the generated one
|
|
35
|
+
# (mix fuses duplicate string attrs), breaking label[for] and the
|
|
36
|
+
# calendar outlet selector — the top-level id: kwarg is the one path.
|
|
37
|
+
if input_attrs.key?(:id) || input_attrs.key?("id")
|
|
38
|
+
raise ArgumentError, "DatePicker: pass id: as the top-level kwarg, not input_attrs[:id]"
|
|
39
|
+
end
|
|
34
40
|
@input_attrs = input_attrs
|
|
35
41
|
@calendar_attrs = calendar_attrs
|
|
36
42
|
@trigger_attrs = trigger_attrs
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
--pk-dialog-spacing: 1rem;
|
|
12
12
|
position: fixed; z-index: 50; width: 100%;
|
|
13
13
|
max-width: min(24rem, calc(100% - 2rem));
|
|
14
|
-
|
|
14
|
+
/* Breathing room like the horizontal inset — 100vh touched the screen
|
|
15
|
+
edges and overflowed behind mobile browser chrome (dvh tracks it). */
|
|
16
|
+
max-height: calc(100dvh - 2rem);
|
|
15
17
|
overflow-y: auto; gap: 1rem;
|
|
16
18
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
17
19
|
border-radius: calc(var(--pk-radius) + 2px); background: var(--pk-surface); color: var(--pk-text);
|
|
@@ -21,7 +21,7 @@ module PhlexKit
|
|
|
21
21
|
aria = {}
|
|
22
22
|
aria[:labelledby] = @labelledby if @labelledby
|
|
23
23
|
aria[:describedby] = @describedby if @describedby
|
|
24
|
-
dialog(**mix({ class: cls, aria: aria, data: { phlex_kit__dialog_target: "dialog", action: "click->phlex-kit--dialog#backdropClick" } }, @attrs)) do
|
|
24
|
+
dialog(**mix({ class: cls, aria: aria, data: { phlex_kit__dialog_target: "dialog", action: "pointerdown->phlex-kit--dialog#backdropPointerdown click->phlex-kit--dialog#backdropClick" } }, @attrs)) do
|
|
25
25
|
yield
|
|
26
26
|
if @show_close_button
|
|
27
27
|
button(type: "button", class: "pk-overlay-close", data: { action: "click->phlex-kit--dialog#dismiss" }) do
|
|
@@ -44,15 +44,26 @@ export default class extends Controller {
|
|
|
44
44
|
document.body.style.overflow = "hidden"
|
|
45
45
|
}
|
|
46
46
|
dismiss() { this.dialogTarget.close() }
|
|
47
|
+
// Only a press that STARTED on the backdrop may dismiss: a drag-select
|
|
48
|
+
// beginning inside the panel and released over the backdrop fires the
|
|
49
|
+
// click on <dialog> with outside coordinates — that must not close it
|
|
50
|
+
// (Radix guards on pointerdown origin the same way).
|
|
51
|
+
backdropPointerdown(e) {
|
|
52
|
+
this.pressStartedOutside = e.target === this.dialogTarget && !this.#insideBox(e)
|
|
53
|
+
}
|
|
47
54
|
// Only a click on the ::backdrop dismisses. e.target is the <dialog> for
|
|
48
55
|
// backdrop clicks, but ALSO for clicks on the dialog's own padding/flex
|
|
49
56
|
// gaps — disambiguate by testing the click point against the dialog's box.
|
|
50
57
|
backdropClick(e) {
|
|
51
58
|
if (e.target !== this.dialogTarget) return
|
|
52
59
|
if (e.detail === 0) return // synthetic/keyboard click — no coordinates
|
|
60
|
+
if (!this.pressStartedOutside) return
|
|
61
|
+
this.pressStartedOutside = false
|
|
62
|
+
if (!this.#insideBox(e)) this.dismiss()
|
|
63
|
+
}
|
|
64
|
+
#insideBox(e) {
|
|
53
65
|
const r = this.dialogTarget.getBoundingClientRect()
|
|
54
|
-
|
|
55
|
-
if (!inside) this.dismiss()
|
|
66
|
+
return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
|
|
56
67
|
}
|
|
57
68
|
handleClose = () => { document.body.style.overflow = this.previousOverflow ?? "" }
|
|
58
69
|
}
|
|
@@ -20,14 +20,17 @@
|
|
|
20
20
|
position-try-fallbacks: flip-block;
|
|
21
21
|
}
|
|
22
22
|
.pk-dropdown-menu-content-up { position-area: block-start span-inline-end; margin: 0 0 4px; }
|
|
23
|
+
/* Gap margins are logical to match the logical position-area — physical
|
|
24
|
+
margins landed on the wrong side in RTL (and flip-inline flips logical
|
|
25
|
+
margins along with the placement). */
|
|
23
26
|
.pk-dropdown-menu-content-right {
|
|
24
27
|
position-area: inline-end span-block-end;
|
|
25
|
-
margin: 0
|
|
28
|
+
margin: 0; margin-inline-start: 4px;
|
|
26
29
|
position-try-fallbacks: flip-inline;
|
|
27
30
|
}
|
|
28
31
|
.pk-dropdown-menu-content-left {
|
|
29
32
|
position-area: inline-start span-block-end;
|
|
30
|
-
margin: 0 4px
|
|
33
|
+
margin: 0; margin-inline-end: 4px;
|
|
31
34
|
position-try-fallbacks: flip-inline;
|
|
32
35
|
}
|
|
33
36
|
.pk-dropdown-menu-viewport {
|
|
@@ -16,20 +16,47 @@ export default class extends Controller {
|
|
|
16
16
|
|
|
17
17
|
connect() {
|
|
18
18
|
this.boundHandleKeydown = this.#handleKeydown.bind(this);
|
|
19
|
+
// Outstanding syncSub rAF handles — cancelled on disconnect so a queued
|
|
20
|
+
// frame never touches a torn-down menu (menubar's pattern).
|
|
21
|
+
this.subFrames = new Set();
|
|
19
22
|
// The visible control is the caller's button/link inside the trigger
|
|
20
23
|
// wrapper — that's what AT reads, so the popup wiring belongs on it.
|
|
21
24
|
this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
|
|
22
25
|
this.invoker.setAttribute("aria-haspopup", "menu");
|
|
23
26
|
this.invoker.setAttribute("aria-expanded", "false");
|
|
27
|
+
// Associate trigger and menu for AT (the dialog controller wires its
|
|
28
|
+
// ids the same way).
|
|
29
|
+
if (!this.contentTarget.id) this.contentTarget.id = `pk-dropdown-menu-${Math.random().toString(36).slice(2, 8)}`;
|
|
30
|
+
this.invoker.setAttribute("aria-controls", this.contentTarget.id);
|
|
31
|
+
// Turbo snapshots BEFORE disconnect — an open menu (reflected in the
|
|
32
|
+
// open value) would be resurrected from the page cache on restore.
|
|
33
|
+
this.boundBeforeCache = () => this.close();
|
|
34
|
+
document.addEventListener("turbo:before-cache", this.boundBeforeCache);
|
|
24
35
|
// `open:` on the Ruby side renders the value — start open (same
|
|
25
36
|
// kwarg -> value -> connect flow as Collapsible).
|
|
26
37
|
if (this.openValue) this.#open();
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
disconnect() {
|
|
41
|
+
document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
|
|
42
|
+
this.subFrames.forEach((id) => cancelAnimationFrame(id));
|
|
43
|
+
this.subFrames.clear();
|
|
30
44
|
this.#removeEventListeners();
|
|
31
45
|
}
|
|
32
46
|
|
|
47
|
+
// Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
|
|
48
|
+
// sub trigger's aria-expanded. rAF: on mouseleave/focusout the pseudo-class
|
|
49
|
+
// state isn't settled until the event finishes dispatching.
|
|
50
|
+
syncSub(e) {
|
|
51
|
+
const sub = e.currentTarget;
|
|
52
|
+
const id = requestAnimationFrame(() => {
|
|
53
|
+
this.subFrames.delete(id);
|
|
54
|
+
sub.querySelector(":scope > [aria-haspopup]")
|
|
55
|
+
?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"));
|
|
56
|
+
});
|
|
57
|
+
this.subFrames.add(id);
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
onClickOutside(event) {
|
|
34
61
|
// Gate on the live :popover-open state, not the stored flag — a stale
|
|
35
62
|
// flag is how a close on an already-closed panel becomes an open.
|
|
@@ -5,7 +5,9 @@ module PhlexKit
|
|
|
5
5
|
class DropdownMenuSub < BaseComponent
|
|
6
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
7
7
|
def view_template(&)
|
|
8
|
-
|
|
8
|
+
# syncSub mirrors the CSS-driven reveal state onto the sub trigger's
|
|
9
|
+
# aria-expanded (same wiring as MenubarSub).
|
|
10
|
+
div(**mix({ class: "pk-dropdown-menu-sub", data: { action: "mouseenter->phlex-kit--dropdown-menu#syncSub mouseleave->phlex-kit--dropdown-menu#syncSub focusin->phlex-kit--dropdown-menu#syncSub focusout->phlex-kit--dropdown-menu#syncSub" } }, @attrs), &)
|
|
9
11
|
end
|
|
10
12
|
end
|
|
11
13
|
end
|
|
@@ -10,7 +10,9 @@ module PhlexKit
|
|
|
10
10
|
# -1: roving focus reaches it via arrows (focus-within opens the sub);
|
|
11
11
|
# tabindex 0 made it a stray tab stop inside the menu.
|
|
12
12
|
tabindex: "-1",
|
|
13
|
-
|
|
13
|
+
# expanded starts false and is mirrored live by the controller's
|
|
14
|
+
# syncSub (the reveal itself is pure CSS :hover/:focus-within).
|
|
15
|
+
aria: { haspopup: "menu", expanded: "false" },
|
|
14
16
|
data: { phlex_kit__dropdown_menu_target: "menuItem" }
|
|
15
17
|
}, @attrs)) do
|
|
16
18
|
block&.call
|
|
@@ -6,7 +6,7 @@ module PhlexKit
|
|
|
6
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
7
7
|
|
|
8
8
|
def view_template(&block)
|
|
9
|
-
div(**mix({ class: "pk-field-separator"
|
|
9
|
+
div(**mix({ class: "pk-field-separator" }, @attrs)) do
|
|
10
10
|
render Separator.new(class: "pk-field-separator-line")
|
|
11
11
|
span(class: "pk-field-separator-content", &block) if block
|
|
12
12
|
end
|
|
@@ -9,10 +9,21 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
9
9
|
// back to the browser's validationMessage.
|
|
10
10
|
// Connects to data-controller="phlex-kit--form-field"
|
|
11
11
|
export default class extends Controller {
|
|
12
|
-
static targets = ["input", "error"];
|
|
12
|
+
static targets = ["input", "error", "hint"];
|
|
13
13
|
static values = { shouldValidate: false };
|
|
14
14
|
|
|
15
15
|
connect() {
|
|
16
|
+
// Hints are static description text — wire them onto every control's
|
|
17
|
+
// aria-describedby up front (errors join/leave dynamically via
|
|
18
|
+
// #describeBy, which preserves foreign tokens like these).
|
|
19
|
+
this.hintTargets.forEach((hint) => {
|
|
20
|
+
if (!hint.id) return;
|
|
21
|
+
this.inputTargets.forEach((input) => {
|
|
22
|
+
const tokens = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean);
|
|
23
|
+
if (!tokens.includes(hint.id)) tokens.push(hint.id);
|
|
24
|
+
input.setAttribute("aria-describedby", tokens.join(" "));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
16
27
|
if (this.hasErrorTarget) {
|
|
17
28
|
if (this.errorTarget.textContent.trim()) {
|
|
18
29
|
this.shouldValidateValue = true;
|
|
@@ -37,6 +48,16 @@ export default class extends Controller {
|
|
|
37
48
|
if (!this.hasErrorTarget) return;
|
|
38
49
|
error.preventDefault();
|
|
39
50
|
|
|
51
|
+
// preventDefault also cancels the browser's focus-first-invalid — restore
|
|
52
|
+
// it for the first invalid control of the submit pass (all invalid events
|
|
53
|
+
// fire in the same task, so the microtask reset scopes "first" correctly).
|
|
54
|
+
if (!this._focusedInvalid) {
|
|
55
|
+
this._focusedInvalid = true;
|
|
56
|
+
queueMicrotask(() => { this._focusedInvalid = false; });
|
|
57
|
+
error.target.focus({ preventScroll: true });
|
|
58
|
+
error.target.scrollIntoView({ block: "nearest" });
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
this.shouldValidateValue = true;
|
|
41
62
|
this.#setErrorMessage(error);
|
|
42
63
|
}
|
|
@@ -2,12 +2,16 @@ module PhlexKit
|
|
|
2
2
|
# Muted helper text under a PhlexKit::FormField control. Hidden when empty.
|
|
3
3
|
# See form_field.rb.
|
|
4
4
|
class FormFieldHint < BaseComponent
|
|
5
|
-
|
|
5
|
+
# A default id (like FormFieldError) so the controller can wire the hint
|
|
6
|
+
# into every control's aria-describedby — without it SR users never heard
|
|
7
|
+
# the hint text. id: is a named kwarg to avoid mix id-fusion.
|
|
8
|
+
def initialize(id: nil, **attrs)
|
|
9
|
+
@id = id || "pk-form-field-hint-#{SecureRandom.hex(4)}"
|
|
6
10
|
@attrs = attrs
|
|
7
11
|
end
|
|
8
12
|
|
|
9
13
|
def view_template(&block)
|
|
10
|
-
p(**mix({ class: "pk-form-field-hint" }, @attrs), &block)
|
|
14
|
+
p(**mix({ id: @id, class: "pk-form-field-hint", data: { phlex_kit__form_field_target: "hint" } }, @attrs), &block)
|
|
11
15
|
end
|
|
12
16
|
end
|
|
13
17
|
end
|
|
@@ -22,12 +22,14 @@
|
|
|
22
22
|
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
|
|
23
23
|
}
|
|
24
24
|
.pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
|
|
25
|
+
/* Gap margins are logical to match the logical position-area (and tooltip's
|
|
26
|
+
pattern) — physical margins landed on the wrong side in RTL. */
|
|
25
27
|
.pk-hover-card-content.left {
|
|
26
|
-
position-area: inline-start; margin: 0 .25rem
|
|
28
|
+
position-area: inline-start; margin: 0; margin-inline-end: .25rem;
|
|
27
29
|
position-try-fallbacks: flip-inline;
|
|
28
30
|
}
|
|
29
31
|
.pk-hover-card-content.right {
|
|
30
|
-
position-area: inline-end; margin: 0
|
|
32
|
+
position-area: inline-end; margin: 0; margin-inline-start: .25rem;
|
|
31
33
|
position-try-fallbacks: flip-inline;
|
|
32
34
|
}
|
|
33
35
|
/* Hiding must survive author display values (a host class like
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* Co-located with icon.rb — PhlexKit::Icon. The .pk-icon class was emitted
|
|
2
|
+
with no rule anywhere (audit round 6): the bare SVG inherited the UA's
|
|
3
|
+
inline/baseline layout (descender gap under icons) and could be squashed
|
|
4
|
+
inside height-constrained flex scroll panes. */
|
|
5
|
+
.pk-icon {
|
|
6
|
+
display: inline-block;
|
|
7
|
+
vertical-align: middle;
|
|
8
|
+
flex: none;
|
|
9
|
+
}
|
|
@@ -20,7 +20,13 @@ module PhlexKit
|
|
|
20
20
|
|
|
21
21
|
def view_template
|
|
22
22
|
icon = Icons.fetch(@name, library: @library || PhlexKit.config.icon_library)
|
|
23
|
-
|
|
23
|
+
base = base_attrs(icon)
|
|
24
|
+
# `mix` merges duplicate string attrs ("16 24") — drop a generated attr
|
|
25
|
+
# whenever the caller supplies their own copy, so theirs wins.
|
|
26
|
+
%i[width height viewbox aria-hidden].each do |key|
|
|
27
|
+
base.delete(key) if @attrs.key?(key) || @attrs.key?(key.to_s)
|
|
28
|
+
end
|
|
29
|
+
svg(**mix(base, @attrs)) do |s|
|
|
24
30
|
icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
|
|
25
31
|
end
|
|
26
32
|
end
|
|
@@ -10,13 +10,20 @@ module PhlexKit
|
|
|
10
10
|
# unknown values used to fall through silently to <div> — fail loud instead.
|
|
11
11
|
AS_TAGS = %i[div button].freeze
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# type: is a named kwarg (not a mix default) because `mix` would *fuse* a
|
|
14
|
+
# caller type: "submit" with the generated "button" into an invalid
|
|
15
|
+
# two-token value the browser resolves back to submit.
|
|
16
|
+
def initialize(variant: :default, href: nil, as: :div, type: :button, **attrs)
|
|
14
17
|
@variant = variant.to_sym
|
|
15
18
|
@href = href
|
|
16
19
|
@as = as.to_sym
|
|
17
20
|
unless AS_TAGS.include?(@as)
|
|
18
21
|
raise ArgumentError, "unknown Marker as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
19
22
|
end
|
|
23
|
+
if @href && @as != :div
|
|
24
|
+
raise ArgumentError, "Marker href: renders an <a> — it can't combine with as: #{@as.inspect}"
|
|
25
|
+
end
|
|
26
|
+
@type = type
|
|
20
27
|
@attrs = attrs
|
|
21
28
|
end
|
|
22
29
|
|
|
@@ -25,7 +32,7 @@ module PhlexKit
|
|
|
25
32
|
if @href
|
|
26
33
|
a(**mix({ class: classes, href: @href }, @attrs), &)
|
|
27
34
|
elsif @as == :button
|
|
28
|
-
button(**mix({ class: classes, type:
|
|
35
|
+
button(**mix({ class: classes, type: @type }, @attrs), &)
|
|
29
36
|
else
|
|
30
37
|
div(**mix({ class: classes }, @attrs), &)
|
|
31
38
|
end
|
|
@@ -26,13 +26,27 @@ export default class extends Controller {
|
|
|
26
26
|
apply() {
|
|
27
27
|
const el = this.element
|
|
28
28
|
// The whole value is rewritten below, which throws the caret to the end —
|
|
29
|
-
//
|
|
30
|
-
//
|
|
29
|
+
// re-seat it after the same number of TOKEN-filled (user) characters.
|
|
30
|
+
// Counting is done in token space, not "any alphanumeric" space: mask
|
|
31
|
+
// literals can themselves be alphanumeric (the "1" in "+1 (###) ###-####"),
|
|
32
|
+
// and counting them as user input reordered every subsequent keystroke.
|
|
31
33
|
const caret = el.selectionStart ?? el.value.length
|
|
32
|
-
const
|
|
34
|
+
const filledBefore = this.maskFill(this.maskable(el.value.slice(0, caret))).tokenPos.length
|
|
33
35
|
|
|
34
|
-
const
|
|
36
|
+
const { out, tokenPos } = this.maskFill(this.maskable(el.value))
|
|
37
|
+
el.value = out
|
|
38
|
+
|
|
39
|
+
if (document.activeElement === el) {
|
|
40
|
+
const pos = filledBefore === 0 ? 0 : tokenPos[filledBefore - 1] + 1
|
|
41
|
+
el.setSelectionRange(pos, pos)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fill the mask from `raw`, returning the output and the output indices
|
|
46
|
+
// that were filled from user input (vs emitted as mask literals).
|
|
47
|
+
maskFill(raw) {
|
|
35
48
|
let out = "", i = 0
|
|
49
|
+
const tokenPos = []
|
|
36
50
|
for (const t of this.mask) {
|
|
37
51
|
if (i >= raw.length) break
|
|
38
52
|
if (t === "#" || t === "A" || t === "*") {
|
|
@@ -41,19 +55,11 @@ export default class extends Controller {
|
|
|
41
55
|
const re = t === "#" ? /\d/ : t === "A" ? /[A-Za-z]/ : /[0-9A-Za-z]/
|
|
42
56
|
while (i < raw.length && !re.test(raw[i])) i++
|
|
43
57
|
if (i >= raw.length) break
|
|
58
|
+
tokenPos.push(out.length)
|
|
44
59
|
out += raw[i++]
|
|
45
60
|
} else { out += t; if (raw[i] === t) i++ }
|
|
46
61
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (document.activeElement === el) {
|
|
50
|
-
let pos = 0, seen = 0
|
|
51
|
-
while (pos < out.length && seen < rawBefore) {
|
|
52
|
-
if (/[0-9A-Za-z]/.test(out[pos])) seen++
|
|
53
|
-
pos++
|
|
54
|
-
}
|
|
55
|
-
el.setSelectionRange(pos, pos)
|
|
56
|
-
}
|
|
62
|
+
return { out, tokenPos }
|
|
57
63
|
}
|
|
58
64
|
// Transliterate full-width digits (0-9, U+FF10–FF19 — common IME numeric
|
|
59
65
|
// output) to ASCII before the charset filter would silently drop them.
|
|
@@ -42,6 +42,12 @@ export default class extends Controller {
|
|
|
42
42
|
if (!panel.id) panel.id = `pk-menu-panel-${++uid}`
|
|
43
43
|
trigger.setAttribute("aria-controls", panel.id)
|
|
44
44
|
}
|
|
45
|
+
// A Turbo snapshot serializes aria-expanded="true" even though the
|
|
46
|
+
// panel's :popover-open does not survive the restore — no panel can be
|
|
47
|
+
// open at connect time, so stamp every expanded marker back to false
|
|
48
|
+
// (only on elements that already carry it: plain nav links must not
|
|
49
|
+
// grow an aria-expanded). Covers the trigger and CSS-revealed subs.
|
|
50
|
+
menu.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
|
|
45
51
|
if (this.roving) this.applyRoving()
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -3,8 +3,13 @@ module PhlexKit
|
|
|
3
3
|
# align: :end reverses the row. Compose Message > MessageContent (+ Avatar/
|
|
4
4
|
# Header/Footer); group several in a MessageGroup.
|
|
5
5
|
class Message < BaseComponent
|
|
6
|
+
ALIGNS = %i[start end].freeze
|
|
7
|
+
|
|
6
8
|
def initialize(align: :start, **attrs)
|
|
7
9
|
@align = align.to_sym
|
|
10
|
+
# Fail loud like sibling Bubble: message.css only styles data-align="end",
|
|
11
|
+
# so an unknown value silently rendered a start-aligned row.
|
|
12
|
+
raise KeyError, "unknown Message align #{@align}" unless ALIGNS.include?(@align)
|
|
8
13
|
@attrs = attrs
|
|
9
14
|
end
|
|
10
15
|
def view_template(&)
|
|
@@ -378,6 +378,9 @@ export default class extends Controller {
|
|
|
378
378
|
button.setAttribute("data-active", active ? "true" : "false");
|
|
379
379
|
// Remove the inert button from the tab order so there are no ghost stops.
|
|
380
380
|
button.setAttribute("tabindex", active ? "0" : "-1");
|
|
381
|
+
// opacity-only hiding leaves it in the accessibility tree — a SR
|
|
382
|
+
// virtual cursor would still find a phantom jump button.
|
|
383
|
+
button.setAttribute("aria-hidden", active ? "false" : "true");
|
|
381
384
|
});
|
|
382
385
|
}
|
|
383
386
|
}
|
|
@@ -21,20 +21,29 @@ module PhlexKit
|
|
|
21
21
|
sm: "sm"
|
|
22
22
|
}.freeze
|
|
23
23
|
|
|
24
|
+
# An Integer size: is the NATIVE <select size> (multi-row list box) and
|
|
25
|
+
# passes straight through — the kit's visual sizes stay symbols.
|
|
24
26
|
def initialize(size: :default, **attrs)
|
|
25
|
-
|
|
27
|
+
if size.is_a?(Integer)
|
|
28
|
+
@native_size = size
|
|
29
|
+
@size = :default
|
|
30
|
+
else
|
|
31
|
+
@size = size.to_sym
|
|
32
|
+
end
|
|
26
33
|
@attrs = attrs
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
def view_template(&block)
|
|
30
37
|
div(class: "pk-native-select") do
|
|
31
|
-
|
|
38
|
+
base = {
|
|
32
39
|
class: classes,
|
|
33
40
|
data: {
|
|
34
41
|
phlex_kit__form_field_target: "input",
|
|
35
42
|
action: "change->phlex-kit--form-field#onChange invalid->phlex-kit--form-field#onInvalid"
|
|
36
43
|
}
|
|
37
|
-
}
|
|
44
|
+
}
|
|
45
|
+
base[:size] = @native_size if @native_size
|
|
46
|
+
select(**mix(base, @attrs), &block)
|
|
38
47
|
render PhlexKit::NativeSelectIcon.new
|
|
39
48
|
end
|
|
40
49
|
end
|
|
@@ -23,7 +23,10 @@ module PhlexKit
|
|
|
23
23
|
# mouseenter->cancelClose: re-entering the nav over list padding /
|
|
24
24
|
# whitespace must cancel the pending mouseleave grace-close (only
|
|
25
25
|
# trigger/panel mouseenter did before).
|
|
26
|
-
|
|
26
|
+
# focusout->onFocusout: a keyboard user tabbing past the panel's
|
|
27
|
+
# last link must close it (menubar wires the same handler; closeSoon
|
|
28
|
+
# is pointer-only and never fires for this path).
|
|
29
|
+
action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout mouseleave->phlex-kit--menubar#closeSoon mouseenter->phlex-kit--menubar#cancelClose"
|
|
27
30
|
}
|
|
28
31
|
}, @attrs), &)
|
|
29
32
|
end
|
|
@@ -2,9 +2,17 @@ module PhlexKit
|
|
|
2
2
|
# Page navigation. Ported from ruby_ui's RubyUI::Pagination. Compose Pagination >
|
|
3
3
|
# PaginationContent > PaginationItem(href:, active:) / PaginationEllipsis.
|
|
4
4
|
class Pagination < BaseComponent
|
|
5
|
-
|
|
5
|
+
# label: is a named kwarg (not a mix default) because `mix` would *fuse* a
|
|
6
|
+
# caller aria-label with the hardcoded "pagination" ("pagination Résultats").
|
|
7
|
+
def initialize(label: "pagination", **attrs)
|
|
8
|
+
@label = label
|
|
9
|
+
@attrs = attrs
|
|
10
|
+
end
|
|
11
|
+
|
|
6
12
|
def view_template(&)
|
|
7
|
-
|
|
13
|
+
base = { class: "pk-pagination", role: "navigation" }
|
|
14
|
+
base[:aria] = { label: @label } unless aria_labelled?
|
|
15
|
+
nav(**mix(base, @attrs), &)
|
|
8
16
|
end
|
|
9
17
|
end
|
|
10
18
|
end
|
|
@@ -15,7 +15,9 @@ module PhlexKit
|
|
|
15
15
|
# custom (possibly localized) label: speaks for itself — hardcoding
|
|
16
16
|
# "Go to next page" over it would make AT announce the wrong language.
|
|
17
17
|
base = { href: @href, class: "pk-button ghost pk-pagination-next" }
|
|
18
|
-
|
|
18
|
+
# ...and never over a caller-supplied aria label — `mix` would fuse
|
|
19
|
+
# the two strings instead of overriding.
|
|
20
|
+
base[:aria] = { label: "Go to next page" } if @label == DEFAULT_LABEL && !aria_labelled?
|
|
19
21
|
li do
|
|
20
22
|
a(**mix(base, @attrs)) do
|
|
21
23
|
span(class: "pk-pagination-label") { @label }
|
|
@@ -16,7 +16,9 @@ module PhlexKit
|
|
|
16
16
|
# custom (possibly localized) label: speaks for itself — hardcoding
|
|
17
17
|
# "Go to previous page" over it would make AT announce the wrong language.
|
|
18
18
|
base = { href: @href, class: "pk-button ghost pk-pagination-previous" }
|
|
19
|
-
|
|
19
|
+
# ...and never over a caller-supplied aria label — `mix` would fuse
|
|
20
|
+
# the two strings instead of overriding.
|
|
21
|
+
base[:aria] = { label: "Go to previous page" } if @label == DEFAULT_LABEL && !aria_labelled?
|
|
20
22
|
li do
|
|
21
23
|
a(**mix(base, @attrs)) do
|
|
22
24
|
render Icon.new(:chevron_left, size: nil)
|
|
@@ -17,14 +17,23 @@ export default class extends Controller {
|
|
|
17
17
|
if (invoker) {
|
|
18
18
|
invoker.popoverTargetElement = this.contentTarget
|
|
19
19
|
} else {
|
|
20
|
+
// Button-less fallback: keyboard toggling and popup semantics don't
|
|
21
|
+
// come for free like they do with popoverTargetElement — wire Enter
|
|
22
|
+
// (and Space, off text-entry controls) plus aria-haspopup/-expanded
|
|
23
|
+
// onto the focusable child so AT users can discover and open it.
|
|
24
|
+
this.fallbackInvoker = this.triggerTarget.querySelector("input, a[href], select, textarea, [tabindex]") || this.triggerTarget
|
|
25
|
+
this.fallbackInvoker.setAttribute("aria-haspopup", "dialog")
|
|
26
|
+
this.fallbackInvoker.setAttribute("aria-expanded", this.contentTarget.matches(":popover-open") ? "true" : "false")
|
|
20
27
|
this.triggerTarget.addEventListener("pointerdown", this.armToggle)
|
|
21
28
|
this.triggerTarget.addEventListener("click", this.toggle)
|
|
29
|
+
this.triggerTarget.addEventListener("keydown", this.keyToggle)
|
|
22
30
|
}
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
disconnect() {
|
|
26
34
|
this.triggerTarget.removeEventListener("pointerdown", this.armToggle)
|
|
27
35
|
this.triggerTarget.removeEventListener("click", this.toggle)
|
|
36
|
+
this.triggerTarget.removeEventListener("keydown", this.keyToggle)
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
armToggle = () => { this.wasOpen = this.contentTarget.matches(":popover-open") }
|
|
@@ -32,12 +41,25 @@ export default class extends Controller {
|
|
|
32
41
|
if (this.wasOpen) { this.wasOpen = false; return }
|
|
33
42
|
this.contentTarget.togglePopover()
|
|
34
43
|
}
|
|
44
|
+
keyToggle = (e) => {
|
|
45
|
+
const textEntry = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)
|
|
46
|
+
if (e.key !== "Enter" && !(e.key === " " && !textEntry)) return
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
this.contentTarget.togglePopover()
|
|
49
|
+
}
|
|
35
50
|
|
|
36
51
|
// One stable handler added/removed symmetrically — an anonymous listener
|
|
37
52
|
// here would pile up a duplicate on every target reconnect.
|
|
38
|
-
syncState = (e) => {
|
|
53
|
+
syncState = (e) => {
|
|
54
|
+
e.target.dataset.state = e.newState === "open" ? "open" : "closed"
|
|
55
|
+
this.fallbackInvoker?.setAttribute("aria-expanded", e.newState === "open" ? "true" : "false")
|
|
56
|
+
}
|
|
39
57
|
|
|
40
58
|
contentTargetConnected(el) {
|
|
59
|
+
// A Turbo snapshot serializes data-state="open" but :popover-open does
|
|
60
|
+
// not survive the restore — resync before listening so host CSS keyed
|
|
61
|
+
// on the state hook doesn't misfire.
|
|
62
|
+
el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
|
|
41
63
|
el.addEventListener("toggle", this.syncState)
|
|
42
64
|
}
|
|
43
65
|
|
|
@@ -14,4 +14,12 @@
|
|
|
14
14
|
width: 100%;
|
|
15
15
|
background: var(--pk-brand);
|
|
16
16
|
transition: transform .3s ease;
|
|
17
|
+
/* value% − 100%: slides the full-width indicator into view from the
|
|
18
|
+
inline-start edge. --pk-progress-value is a unitless 0–100 set inline
|
|
19
|
+
by progress.rb. */
|
|
20
|
+
transform: translateX(calc((var(--pk-progress-value, 0) - 100) * 1%));
|
|
21
|
+
}
|
|
22
|
+
/* RTL: the bar fills from the right, so travel flips. */
|
|
23
|
+
.pk-progress:dir(rtl) .pk-progress-indicator {
|
|
24
|
+
transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
|
|
17
25
|
}
|
|
@@ -4,21 +4,30 @@ module PhlexKit
|
|
|
4
4
|
# JS; width comes from a caller `class:`/`style:`. Tailwind → vanilla
|
|
5
5
|
# `.pk-progress*` (progress.css).
|
|
6
6
|
class Progress < BaseComponent
|
|
7
|
-
|
|
7
|
+
# value_text: is a named kwarg (not read from **attrs) because `mix`
|
|
8
|
+
# would *fuse* a caller "aria-valuetext" with the generated default.
|
|
9
|
+
def initialize(value: 0, value_text: nil, **attrs)
|
|
8
10
|
@value = value.to_f.clamp(0.0, 100.0)
|
|
11
|
+
@value_text = value_text
|
|
9
12
|
@attrs = attrs
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def view_template
|
|
16
|
+
# %g drops float artifacts ("40.0" would be announced as "forty point
|
|
17
|
+
# zero percent"). The value travels as a custom property so the fill
|
|
18
|
+
# transform lives in progress.css, where a :dir(rtl) arm can flip it —
|
|
19
|
+
# an inline translateX always filled from the physical left.
|
|
20
|
+
v = Kernel.format("%g", @value)
|
|
13
21
|
div(**mix({
|
|
14
22
|
role: "progressbar",
|
|
15
|
-
"aria-valuenow":
|
|
23
|
+
"aria-valuenow": v,
|
|
16
24
|
"aria-valuemin": 0,
|
|
17
25
|
"aria-valuemax": 100,
|
|
18
|
-
"aria-valuetext": "#{
|
|
19
|
-
class: "pk-progress"
|
|
26
|
+
"aria-valuetext": @value_text || "#{v}%",
|
|
27
|
+
class: "pk-progress",
|
|
28
|
+
style: "--pk-progress-value: #{v};"
|
|
20
29
|
}, @attrs)) do
|
|
21
|
-
div(class: "pk-progress-indicator"
|
|
30
|
+
div(class: "pk-progress-indicator")
|
|
22
31
|
end
|
|
23
32
|
end
|
|
24
33
|
end
|