phlex_kit 0.9.1 → 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/_tokens.css +8 -0
- 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/alert.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +15 -0
- data/app/components/phlex_kit/attachment/attachment.css +6 -3
- 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/attachment/scroll_fade_controller.js +7 -2
- data/app/components/phlex_kit/avatar/avatar.css +2 -2
- 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/breadcrumb/breadcrumb.css +0 -4
- data/app/components/phlex_kit/bubble/bubble.css +1 -1
- data/app/components/phlex_kit/bubble/bubble.rb +4 -6
- data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
- data/app/components/phlex_kit/button_group/button_group.css +16 -16
- data/app/components/phlex_kit/calendar/calendar.css +10 -2
- data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
- data/app/components/phlex_kit/carousel/carousel.css +4 -4
- 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 +26 -3
- 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/collapsible/collapsible_controller.js +15 -6
- data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
- data/app/components/phlex_kit/combobox/combobox.css +3 -3
- data/app/components/phlex_kit/combobox/combobox.rb +3 -3
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_controller.js +32 -10
- 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_search_input.rb +3 -1
- data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +7 -1
- data/app/components/phlex_kit/command/command.css +2 -2
- data/app/components/phlex_kit/command/command_controller.js +63 -35
- data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
- data/app/components/phlex_kit/command/command_group.rb +5 -2
- data/app/components/phlex_kit/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 +5 -3
- data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_controller.js +22 -2
- data/app/components/phlex_kit/drawer/drawer.css +4 -4
- 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/empty/empty.css +1 -1
- data/app/components/phlex_kit/field/field.css +2 -1
- data/app/components/phlex_kit/field/field_separator.rb +1 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +29 -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/input_otp/input_otp.css +4 -4
- data/app/components/phlex_kit/marker/marker.rb +15 -2
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +35 -15
- data/app/components/phlex_kit/menubar/menubar.rb +3 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +28 -1
- data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
- data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
- data/app/components/phlex_kit/message/message.rb +5 -0
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +12 -5
- data/app/components/phlex_kit/native_select/native_select.css +4 -2
- data/app/components/phlex_kit/native_select/native_select.rb +12 -3
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +7 -1
- data/app/components/phlex_kit/pagination/pagination.rb +10 -2
- data/app/components/phlex_kit/pagination/pagination_next.rb +11 -2
- data/app/components/phlex_kit/pagination/pagination_previous.rb +11 -2
- data/app/components/phlex_kit/popover/popover_controller.js +31 -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 +52 -5
- data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +23 -4
- data/app/components/phlex_kit/select/select.css +5 -3
- data/app/components/phlex_kit/select/select_content.rb +9 -3
- data/app/components/phlex_kit/select/select_controller.js +59 -6
- data/app/components/phlex_kit/select/select_item.rb +5 -1
- data/app/components/phlex_kit/separator/separator.rb +12 -2
- data/app/components/phlex_kit/sheet/sheet.css +4 -2
- data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
- 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.css +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -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.css +2 -2
- data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
- data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
- data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +15 -3
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
- data/app/components/phlex_kit/toast/toast.css +3 -2
- data/app/components/phlex_kit/toast/toast_action.rb +4 -1
- data/app/components/phlex_kit/toast/toast_controller.js +13 -5
- data/app/components/phlex_kit/toast/toast_region.rb +19 -9
- data/app/components/phlex_kit/toast/toaster_controller.js +34 -12
- data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +7 -2
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +13 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
- data/app/components/phlex_kit/tooltip/tooltip.css +7 -3
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
- data/app/components/phlex_kit/typography/heading.rb +13 -5
- 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
|
@@ -9,7 +9,9 @@ module PhlexKit
|
|
|
9
9
|
# -1: roving focus reaches it via arrows (focus-within opens the sub);
|
|
10
10
|
# tabindex 0 made it a stray tab stop inside the menu.
|
|
11
11
|
tabindex: "-1",
|
|
12
|
-
|
|
12
|
+
# expanded starts false and is mirrored live by the controller's
|
|
13
|
+
# syncSub (the reveal itself is pure CSS :hover/:focus-within).
|
|
14
|
+
aria: { haspopup: "menu", expanded: "false" },
|
|
13
15
|
data: { phlex_kit__context_menu_target: "menuItem" }
|
|
14
16
|
}, @attrs)) do
|
|
15
17
|
block&.call
|
|
@@ -23,16 +23,6 @@
|
|
|
23
23
|
/* "Columns" dropdown rows. */
|
|
24
24
|
.pk-data-table-column-toggle { position: relative; }
|
|
25
25
|
.pk-data-table-column-toggle-icon { width: 1rem; height: 1rem; margin-inline-start: .25rem; }
|
|
26
|
-
.pk-data-table-column-option {
|
|
27
|
-
display: flex;
|
|
28
|
-
align-items: center;
|
|
29
|
-
gap: .5rem;
|
|
30
|
-
border-radius: calc(var(--pk-radius) - 4px);
|
|
31
|
-
padding: .375rem .5rem;
|
|
32
|
-
font-size: .875rem;
|
|
33
|
-
cursor: pointer;
|
|
34
|
-
}
|
|
35
|
-
.pk-data-table-column-option:hover { background: var(--pk-accent); }
|
|
36
26
|
|
|
37
27
|
/* Sortable header link. */
|
|
38
28
|
.pk-data-table-sort-head { color: var(--pk-text); white-space: nowrap; }
|
|
@@ -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,10 +11,12 @@
|
|
|
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
|
-
border-radius:
|
|
19
|
+
border-radius: calc(var(--pk-radius) + 2px); background: var(--pk-surface); color: var(--pk-text);
|
|
18
20
|
padding: var(--pk-dialog-spacing);
|
|
19
21
|
font-size: .875rem;
|
|
20
22
|
box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
margin: 0 calc(-1 * var(--pk-dialog-spacing)) calc(-1 * var(--pk-dialog-spacing));
|
|
36
38
|
padding: var(--pk-dialog-spacing);
|
|
37
39
|
border-top: 1px solid var(--pk-border);
|
|
38
|
-
border-radius: 0 0
|
|
40
|
+
border-radius: 0 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px);
|
|
39
41
|
background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent);
|
|
40
42
|
}
|
|
41
43
|
.pk-dialog-title { font-size: 1rem; font-weight: 500; line-height: 1; }
|
|
@@ -7,7 +7,7 @@ module PhlexKit
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def view_template(&)
|
|
10
|
-
span(**mix({ style: "display: contents", data: { action: "click->phlex-kit--dialog#dismiss" } }, @attrs), &)
|
|
10
|
+
span(**mix({ style: "display: contents;", data: { action: "click->phlex-kit--dialog#dismiss" } }, @attrs), &)
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -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
|
|
@@ -10,6 +10,9 @@ export default class extends Controller {
|
|
|
10
10
|
|
|
11
11
|
connect() {
|
|
12
12
|
this.dialogTarget.addEventListener("close", this.handleClose)
|
|
13
|
+
// Turbo snapshots at turbo:before-cache, BEFORE disconnect — close now or
|
|
14
|
+
// the cached page restores a non-modal <dialog open> with scroll locked.
|
|
15
|
+
document.addEventListener("turbo:before-cache", this.beforeCache)
|
|
13
16
|
this.#wireAria("aria-labelledby", ".pk-dialog-title")
|
|
14
17
|
this.#wireAria("aria-describedby", ".pk-dialog-description")
|
|
15
18
|
if (this.openValue) this.open()
|
|
@@ -24,8 +27,14 @@ export default class extends Controller {
|
|
|
24
27
|
}
|
|
25
28
|
disconnect() {
|
|
26
29
|
this.dialogTarget.removeEventListener("close", this.handleClose)
|
|
30
|
+
document.removeEventListener("turbo:before-cache", this.beforeCache)
|
|
27
31
|
if (this.dialogTarget.open) this.handleClose()
|
|
28
32
|
}
|
|
33
|
+
beforeCache = () => {
|
|
34
|
+
if (!this.dialogTarget.open) return
|
|
35
|
+
this.dialogTarget.close()
|
|
36
|
+
this.handleClose() // the close event is a queued task — too late for the snapshot
|
|
37
|
+
}
|
|
29
38
|
open(e) {
|
|
30
39
|
e?.preventDefault()
|
|
31
40
|
this.dialogTarget.showModal()
|
|
@@ -35,15 +44,26 @@ export default class extends Controller {
|
|
|
35
44
|
document.body.style.overflow = "hidden"
|
|
36
45
|
}
|
|
37
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
|
+
}
|
|
38
54
|
// Only a click on the ::backdrop dismisses. e.target is the <dialog> for
|
|
39
55
|
// backdrop clicks, but ALSO for clicks on the dialog's own padding/flex
|
|
40
56
|
// gaps — disambiguate by testing the click point against the dialog's box.
|
|
41
57
|
backdropClick(e) {
|
|
42
58
|
if (e.target !== this.dialogTarget) return
|
|
43
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) {
|
|
44
65
|
const r = this.dialogTarget.getBoundingClientRect()
|
|
45
|
-
|
|
46
|
-
if (!inside) this.dismiss()
|
|
66
|
+
return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
|
|
47
67
|
}
|
|
48
68
|
handleClose = () => { document.body.style.overflow = this.previousOverflow ?? "" }
|
|
49
69
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
bottom: 0;
|
|
28
28
|
max-height: 85vh;
|
|
29
29
|
border-top: 1px solid var(--pk-border);
|
|
30
|
-
border-radius:
|
|
30
|
+
border-radius: calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px) 0 0;
|
|
31
31
|
animation: pk-drawer-up .3s ease;
|
|
32
32
|
}
|
|
33
33
|
.pk-drawer.top {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
top: 0;
|
|
36
36
|
max-height: 85vh;
|
|
37
37
|
border-bottom: 1px solid var(--pk-border);
|
|
38
|
-
border-radius: 0 0
|
|
38
|
+
border-radius: 0 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px);
|
|
39
39
|
animation: pk-drawer-down .3s ease;
|
|
40
40
|
}
|
|
41
41
|
.pk-drawer.left,
|
|
@@ -48,13 +48,13 @@
|
|
|
48
48
|
.pk-drawer.left {
|
|
49
49
|
left: 0;
|
|
50
50
|
border-right: 1px solid var(--pk-border);
|
|
51
|
-
border-radius: 0
|
|
51
|
+
border-radius: 0 calc(var(--pk-radius) + 2px) calc(var(--pk-radius) + 2px) 0;
|
|
52
52
|
animation: pk-drawer-from-left .3s ease;
|
|
53
53
|
}
|
|
54
54
|
.pk-drawer.right {
|
|
55
55
|
right: 0;
|
|
56
56
|
border-left: 1px solid var(--pk-border);
|
|
57
|
-
border-radius:
|
|
57
|
+
border-radius: calc(var(--pk-radius) + 2px) 0 0 calc(var(--pk-radius) + 2px);
|
|
58
58
|
animation: pk-drawer-from-right .3s ease;
|
|
59
59
|
}
|
|
60
60
|
@keyframes pk-drawer-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
|
@@ -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
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
.pk-empty {
|
|
6
6
|
display: flex; width: 100%; min-width: 0; flex: 1; flex-direction: column;
|
|
7
7
|
align-items: center; justify-content: center; gap: 1rem;
|
|
8
|
-
border: 0 dashed var(--pk-border); border-radius:
|
|
8
|
+
border: 0 dashed var(--pk-border); border-radius: calc(var(--pk-radius) + 2px);
|
|
9
9
|
padding: 1.5rem; text-align: center; text-wrap: balance;
|
|
10
10
|
}
|
|
11
11
|
.pk-empty.outline { border-width: 1px; }
|
|
@@ -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,13 +9,31 @@ 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;
|
|
30
|
+
// A server-rendered error is already visible — give the control(s)
|
|
31
|
+
// the same aria wiring onInvalid applies, instead of leaving
|
|
32
|
+
// aria-invalid/aria-describedby unset until the first input/change.
|
|
33
|
+
this.inputTargets.forEach((input) => {
|
|
34
|
+
input.setAttribute("aria-invalid", "true");
|
|
35
|
+
this.#describeBy(input, true);
|
|
36
|
+
});
|
|
19
37
|
} else {
|
|
20
38
|
this.errorTarget.classList.add("pk-hidden");
|
|
21
39
|
}
|
|
@@ -30,6 +48,16 @@ export default class extends Controller {
|
|
|
30
48
|
if (!this.hasErrorTarget) return;
|
|
31
49
|
error.preventDefault();
|
|
32
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
|
+
|
|
33
61
|
this.shouldValidateValue = true;
|
|
34
62
|
this.#setErrorMessage(error);
|
|
35
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
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
}
|
|
19
19
|
.pk-input-otp-slot:not(:first-child) { margin-inline-start: -1px; }
|
|
20
20
|
.pk-input-otp-group .pk-input-otp-slot:first-child {
|
|
21
|
-
border-
|
|
22
|
-
border-
|
|
21
|
+
border-start-start-radius: var(--pk-radius);
|
|
22
|
+
border-end-start-radius: var(--pk-radius);
|
|
23
23
|
}
|
|
24
24
|
.pk-input-otp-group .pk-input-otp-slot:last-child {
|
|
25
|
-
border-
|
|
26
|
-
border-
|
|
25
|
+
border-start-end-radius: var(--pk-radius);
|
|
26
|
+
border-end-end-radius: var(--pk-radius);
|
|
27
27
|
}
|
|
28
28
|
.pk-input-otp-slot:focus {
|
|
29
29
|
position: relative;
|
|
@@ -6,11 +6,24 @@ module PhlexKit
|
|
|
6
6
|
# underline and brighten on hover. `.pk-marker*` (marker.css).
|
|
7
7
|
class Marker < BaseComponent
|
|
8
8
|
VARIANTS = { default: nil, border: "border", separator: "separator" }.freeze
|
|
9
|
+
# The elements this component knows how to render (href: forces <a>);
|
|
10
|
+
# unknown values used to fall through silently to <div> — fail loud instead.
|
|
11
|
+
AS_TAGS = %i[div button].freeze
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
# 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)
|
|
11
17
|
@variant = variant.to_sym
|
|
12
18
|
@href = href
|
|
13
19
|
@as = as.to_sym
|
|
20
|
+
unless AS_TAGS.include?(@as)
|
|
21
|
+
raise ArgumentError, "unknown Marker as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
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
|
|
14
27
|
@attrs = attrs
|
|
15
28
|
end
|
|
16
29
|
|
|
@@ -19,7 +32,7 @@ module PhlexKit
|
|
|
19
32
|
if @href
|
|
20
33
|
a(**mix({ class: classes, href: @href }, @attrs), &)
|
|
21
34
|
elsif @as == :button
|
|
22
|
-
button(**mix({ class: classes, type:
|
|
35
|
+
button(**mix({ class: classes, type: @type }, @attrs), &)
|
|
23
36
|
else
|
|
24
37
|
div(**mix({ class: classes }, @attrs), &)
|
|
25
38
|
end
|
|
@@ -11,21 +11,42 @@ export default class extends Controller {
|
|
|
11
11
|
if (!this.mask) return
|
|
12
12
|
// Skip IME composition updates — masking mid-composition mangles the text.
|
|
13
13
|
this.onInput = (e) => { if (!e.isComposing) this.apply() }
|
|
14
|
+
// Some browsers (Safari) fire the final input event with isComposing still
|
|
15
|
+
// true, so IME-committed text would never be masked — catch the commit here.
|
|
16
|
+
this.onCompositionEnd = () => this.apply()
|
|
14
17
|
this.element.addEventListener("input", this.onInput)
|
|
18
|
+
this.element.addEventListener("compositionend", this.onCompositionEnd)
|
|
15
19
|
// A server-prefilled value renders unmasked — format it once up front.
|
|
16
20
|
if (this.element.value) this.apply()
|
|
17
21
|
}
|
|
18
|
-
disconnect() {
|
|
22
|
+
disconnect() {
|
|
23
|
+
if (this.onInput) this.element.removeEventListener("input", this.onInput)
|
|
24
|
+
if (this.onCompositionEnd) this.element.removeEventListener("compositionend", this.onCompositionEnd)
|
|
25
|
+
}
|
|
19
26
|
apply() {
|
|
20
27
|
const el = this.element
|
|
21
28
|
// The whole value is rewritten below, which throws the caret to the end —
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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.
|
|
24
33
|
const caret = el.selectionStart ?? el.value.length
|
|
25
|
-
const
|
|
34
|
+
const filledBefore = this.maskFill(this.maskable(el.value.slice(0, caret))).tokenPos.length
|
|
35
|
+
|
|
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
|
+
}
|
|
26
44
|
|
|
27
|
-
|
|
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) {
|
|
28
48
|
let out = "", i = 0
|
|
49
|
+
const tokenPos = []
|
|
29
50
|
for (const t of this.mask) {
|
|
30
51
|
if (i >= raw.length) break
|
|
31
52
|
if (t === "#" || t === "A" || t === "*") {
|
|
@@ -34,18 +55,17 @@ export default class extends Controller {
|
|
|
34
55
|
const re = t === "#" ? /\d/ : t === "A" ? /[A-Za-z]/ : /[0-9A-Za-z]/
|
|
35
56
|
while (i < raw.length && !re.test(raw[i])) i++
|
|
36
57
|
if (i >= raw.length) break
|
|
58
|
+
tokenPos.push(out.length)
|
|
37
59
|
out += raw[i++]
|
|
38
60
|
} else { out += t; if (raw[i] === t) i++ }
|
|
39
61
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
el.setSelectionRange(pos, pos)
|
|
49
|
-
}
|
|
62
|
+
return { out, tokenPos }
|
|
63
|
+
}
|
|
64
|
+
// Transliterate full-width digits (0-9, U+FF10–FF19 — common IME numeric
|
|
65
|
+
// output) to ASCII before the charset filter would silently drop them.
|
|
66
|
+
maskable(value) {
|
|
67
|
+
return value
|
|
68
|
+
.replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xFEE0))
|
|
69
|
+
.replace(/[^0-9A-Za-z]/g, "")
|
|
50
70
|
}
|
|
51
71
|
}
|
|
@@ -16,7 +16,9 @@ module PhlexKit
|
|
|
16
16
|
role: "menubar",
|
|
17
17
|
data: {
|
|
18
18
|
controller: "phlex-kit--menubar",
|
|
19
|
-
|
|
19
|
+
# focusout: tabbing out of the bar closes the open [popover=manual]
|
|
20
|
+
# panel (which no focus trap holds open).
|
|
21
|
+
action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
|
|
20
22
|
}
|
|
21
23
|
}, @attrs), &)
|
|
22
24
|
end
|