phlex_kit 0.8.1 → 0.9.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 +11 -0
- data/app/components/phlex_kit/accordion/accordion.css +2 -2
- data/app/components/phlex_kit/accordion/accordion_controller.js +8 -2
- data/app/components/phlex_kit/alert/alert.css +3 -3
- data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +12 -8
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
- data/app/components/phlex_kit/attachment/attachment.css +1 -1
- data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
- data/app/components/phlex_kit/avatar/avatar.css +16 -5
- data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
- data/app/components/phlex_kit/badge/badge.css +4 -4
- data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
- data/app/components/phlex_kit/bubble/bubble.css +3 -3
- data/app/components/phlex_kit/bubble/bubble.rb +2 -0
- data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
- data/app/components/phlex_kit/button/button.css +6 -6
- data/app/components/phlex_kit/button_group/button_group.css +2 -2
- data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
- data/app/components/phlex_kit/calendar/calendar.css +2 -2
- data/app/components/phlex_kit/calendar/calendar.rb +10 -3
- data/app/components/phlex_kit/calendar/calendar_body.rb +3 -0
- data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
- data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
- data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
- data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
- data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
- data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
- data/app/components/phlex_kit/carousel/carousel.rb +11 -1
- data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
- data/app/components/phlex_kit/chart/chart_controller.js +32 -9
- data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
- data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
- data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
- data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
- data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
- data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
- data/app/components/phlex_kit/combobox/combobox.css +10 -8
- data/app/components/phlex_kit/combobox/combobox_controller.js +52 -9
- data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
- data/app/components/phlex_kit/command/command.css +4 -4
- data/app/components/phlex_kit/command/command_controller.js +51 -10
- data/app/components/phlex_kit/command/command_dialog.rb +5 -6
- data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
- data/app/components/phlex_kit/command/command_input.rb +6 -2
- data/app/components/phlex_kit/command/command_item.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +18 -1
- data/app/components/phlex_kit/data_table/data_table.css +1 -1
- data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
- data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
- data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
- data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
- data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
- data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
- data/app/components/phlex_kit/dialog/dialog.css +4 -4
- data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
- data/app/components/phlex_kit/drawer/drawer.css +3 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
- data/app/components/phlex_kit/field/field.css +1 -1
- data/app/components/phlex_kit/field/field_error.rb +5 -6
- data/app/components/phlex_kit/form_field/form_field_controller.js +35 -16
- data/app/components/phlex_kit/form_field/form_field_error.rb +5 -1
- data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
- data/app/components/phlex_kit/input/input.css +1 -1
- data/app/components/phlex_kit/input_group/input_group.css +9 -7
- data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
- data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +13 -0
- data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
- data/app/components/phlex_kit/item/item.css +1 -1
- data/app/components/phlex_kit/item/item_group.rb +4 -2
- data/app/components/phlex_kit/item/item_separator.rb +3 -1
- data/app/components/phlex_kit/marker/marker.css +1 -1
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +10 -4
- data/app/components/phlex_kit/menubar/menubar.css +10 -9
- data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
- data/app/components/phlex_kit/menubar/menubar_controller.js +70 -7
- data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
- data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +4 -1
- data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
- data/app/components/phlex_kit/native_select/native_select.css +1 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu.css +7 -2
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
- data/app/components/phlex_kit/pagination/pagination.css +2 -2
- data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
- data/app/components/phlex_kit/popover/popover.css +1 -1
- data/app/components/phlex_kit/resizable/resizable.css +3 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +13 -0
- data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
- data/app/components/phlex_kit/select/select.css +3 -3
- data/app/components/phlex_kit/select/select.rb +4 -1
- data/app/components/phlex_kit/select/select_controller.js +25 -0
- data/app/components/phlex_kit/select/select_item.rb +5 -1
- data/app/components/phlex_kit/sheet/sheet.css +3 -3
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
- data/app/components/phlex_kit/sidebar/sidebar.css +8 -8
- data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
- data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
- data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
- data/app/components/phlex_kit/slider/slider.css +1 -1
- data/app/components/phlex_kit/slider/slider.rb +3 -1
- data/app/components/phlex_kit/switch/switch.rb +6 -1
- data/app/components/phlex_kit/table/table.css +5 -3
- data/app/components/phlex_kit/tabs/tabs.css +4 -4
- data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
- data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
- data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +2 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
- data/app/components/phlex_kit/toast/toast.css +32 -14
- data/app/components/phlex_kit/toast/toast_controller.js +24 -4
- data/app/components/phlex_kit/toast/toast_region.rb +13 -3
- data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
- data/app/components/phlex_kit/toggle/toggle.css +4 -4
- data/app/components/phlex_kit/toggle/toggle.rb +6 -2
- data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
- data/app/components/phlex_kit/typography/heading.rb +9 -3
- data/app/components/phlex_kit/typography/typography.css +4 -3
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# Divider between OTP groups. See input_otp.rb.
|
|
2
|
+
# Divider between OTP groups. See input_otp.rb. Purely decorative — the dash
|
|
3
|
+
# is aria-hidden (no separator role) so AT doesn't announce a stray "-"
|
|
4
|
+
# between the digit fields.
|
|
3
5
|
class InputOtpSeparator < BaseComponent
|
|
4
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
5
|
-
def view_template = div(**mix({ class: "pk-input-otp-separator",
|
|
7
|
+
def view_template = div(**mix({ class: "pk-input-otp-separator", aria_hidden: "true" }, @attrs)) { "-" }
|
|
6
8
|
end
|
|
7
9
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# Stack of Items. See item.rb.
|
|
2
|
+
# Stack of Items. See item.rb. No role="list": Items carry no
|
|
3
|
+
# role="listitem" (an href Item is an <a> that must keep its link role), so
|
|
4
|
+
# claiming a list here made AT announce an empty list.
|
|
3
5
|
class ItemGroup < BaseComponent
|
|
4
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
5
|
-
def view_template(&) = div(**mix({ class: "pk-item-group"
|
|
7
|
+
def view_template(&) = div(**mix({ class: "pk-item-group" }, @attrs), &)
|
|
6
8
|
end
|
|
7
9
|
end
|
|
@@ -4,7 +4,9 @@ module PhlexKit
|
|
|
4
4
|
class ItemSeparator < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template
|
|
7
|
-
|
|
7
|
+
# Purely decorative — aria-hidden only (role="separator" would
|
|
8
|
+
# contradict aria-hidden and confuse AT).
|
|
9
|
+
div(**mix({ class: "pk-item-separator", aria: { hidden: true } }, @attrs))
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
end
|
|
@@ -12,6 +12,8 @@ export default class extends Controller {
|
|
|
12
12
|
// Skip IME composition updates — masking mid-composition mangles the text.
|
|
13
13
|
this.onInput = (e) => { if (!e.isComposing) this.apply() }
|
|
14
14
|
this.element.addEventListener("input", this.onInput)
|
|
15
|
+
// A server-prefilled value renders unmasked — format it once up front.
|
|
16
|
+
if (this.element.value) this.apply()
|
|
15
17
|
}
|
|
16
18
|
disconnect() { if (this.onInput) this.element.removeEventListener("input", this.onInput) }
|
|
17
19
|
apply() {
|
|
@@ -26,10 +28,14 @@ export default class extends Controller {
|
|
|
26
28
|
let out = "", i = 0
|
|
27
29
|
for (const t of this.mask) {
|
|
28
30
|
if (i >= raw.length) break
|
|
29
|
-
if (t === "#"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
if (t === "#" || t === "A" || t === "*") {
|
|
32
|
+
// Skip wrong-class characters instead of aborting the fill —
|
|
33
|
+
// "12a34" against ##/## must yield "12/34", not "12".
|
|
34
|
+
const re = t === "#" ? /\d/ : t === "A" ? /[A-Za-z]/ : /[0-9A-Za-z]/
|
|
35
|
+
while (i < raw.length && !re.test(raw[i])) i++
|
|
36
|
+
if (i >= raw.length) break
|
|
37
|
+
out += raw[i++]
|
|
38
|
+
} else { out += t; if (raw[i] === t) i++ }
|
|
33
39
|
}
|
|
34
40
|
el.value = out
|
|
35
41
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
}
|
|
31
31
|
.pk-menubar-trigger:hover,
|
|
32
32
|
.pk-menubar-trigger[aria-expanded="true"] { background: var(--pk-surface-2); }
|
|
33
|
+
.pk-menubar-trigger:focus-visible { box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent); }
|
|
33
34
|
.pk-menubar-content {
|
|
34
35
|
position: fixed;
|
|
35
36
|
position-anchor: --pk-menubar-menu;
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
background: var(--pk-surface);
|
|
47
48
|
color: var(--pk-text);
|
|
48
49
|
padding: .25rem;
|
|
49
|
-
box-shadow: 0 8px 24px
|
|
50
|
+
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
|
|
50
51
|
}
|
|
51
52
|
/* Hover bridge over the trigger→panel gap — both sides, since a flip
|
|
52
53
|
fallback puts the gap below the panel instead of above. */
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
.pk-menubar-item svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
|
|
83
84
|
.pk-menubar-item:hover, .pk-menubar-item:focus { background: var(--pk-accent); }
|
|
84
85
|
.pk-menubar-item[data-disabled="true"] { pointer-events: none; opacity: .5; }
|
|
85
|
-
.pk-menubar-item.inset { padding-
|
|
86
|
+
.pk-menubar-item.inset { padding-inline-start: 1.75rem; }
|
|
86
87
|
.pk-menubar-item.destructive { color: var(--pk-red); }
|
|
87
88
|
.pk-menubar-item.destructive > svg { color: var(--pk-red); }
|
|
88
89
|
.pk-menubar-item.destructive:hover,
|
|
@@ -90,9 +91,9 @@
|
|
|
90
91
|
background: color-mix(in oklab, var(--pk-red) 12%, transparent);
|
|
91
92
|
color: var(--pk-red);
|
|
92
93
|
}
|
|
93
|
-
.pk-menubar-shortcut { margin-
|
|
94
|
+
.pk-menubar-shortcut { margin-inline-start: auto; font-size: .75rem; color: var(--pk-muted); letter-spacing: .1em; }
|
|
94
95
|
.pk-menubar-label { padding: .25rem .375rem; font-size: .875rem; font-weight: 500; }
|
|
95
|
-
.pk-menubar-label.inset { padding-
|
|
96
|
+
.pk-menubar-label.inset { padding-inline-start: 1.75rem; }
|
|
96
97
|
.pk-menubar-separator { height: 1px; margin: .25rem -0.25rem; background: var(--pk-border); }
|
|
97
98
|
.pk-menubar-group { display: contents; }
|
|
98
99
|
|
|
@@ -105,10 +106,10 @@
|
|
|
105
106
|
pointer-events: none;
|
|
106
107
|
}
|
|
107
108
|
.pk-menubar-checkbox-item,
|
|
108
|
-
.pk-menubar-radio-item { position: relative; padding-
|
|
109
|
+
.pk-menubar-radio-item { position: relative; padding-inline-start: 1.75rem; }
|
|
109
110
|
.pk-menubar-item-indicator {
|
|
110
111
|
position: absolute;
|
|
111
|
-
|
|
112
|
+
inset-inline-start: .375rem;
|
|
112
113
|
display: inline-flex;
|
|
113
114
|
align-items: center;
|
|
114
115
|
justify-content: center;
|
|
@@ -120,14 +121,14 @@
|
|
|
120
121
|
.pk-menubar-radio-item .pk-menubar-item-indicator svg { width: .5rem; height: .5rem; }
|
|
121
122
|
.pk-menubar-item:has(.pk-menubar-item-input:checked) .pk-menubar-item-indicator { visibility: visible; }
|
|
122
123
|
.pk-menubar-item:has(.pk-menubar-item-input:focus-visible) {
|
|
123
|
-
box-shadow: 0 0 0
|
|
124
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
/* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS.
|
|
127
128
|
The row is its own scoped anchor; the panel flips to the other side at
|
|
128
129
|
the viewport right edge. */
|
|
129
130
|
.pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
|
|
130
|
-
.pk-menubar-sub-chevron { margin-
|
|
131
|
+
.pk-menubar-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
131
132
|
.pk-menubar-sub-content {
|
|
132
133
|
position: fixed;
|
|
133
134
|
position-anchor: --pk-menubar-sub;
|
|
@@ -148,7 +149,7 @@
|
|
|
148
149
|
background: var(--pk-surface);
|
|
149
150
|
color: var(--pk-text);
|
|
150
151
|
padding: .25rem;
|
|
151
|
-
box-shadow: 0 8px 24px
|
|
152
|
+
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
|
|
152
153
|
}
|
|
153
154
|
.pk-menubar-sub:hover > .pk-menubar-sub-content,
|
|
154
155
|
.pk-menubar-sub:focus-within > .pk-menubar-sub-content { display: block; }
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Dropdown panel of a MenubarMenu. See menubar.rb.
|
|
3
3
|
class MenubarContent < BaseComponent
|
|
4
|
-
def initialize(**attrs)
|
|
4
|
+
def initialize(**attrs)
|
|
5
|
+
# Default id so the controller can wire the trigger's aria-controls to
|
|
6
|
+
# this panel in menuTargetConnected (same pattern as accordion).
|
|
7
|
+
@id = attrs.delete(:id) || "pk-menubar-content-#{SecureRandom.hex(4)}"
|
|
8
|
+
@attrs = attrs
|
|
9
|
+
end
|
|
10
|
+
|
|
5
11
|
def view_template(&)
|
|
6
|
-
div(**mix({ class: "pk-menubar-content", popover: "manual", role: "menu" }, @attrs), &)
|
|
12
|
+
div(**mix({ id: @id, class: "pk-menubar-content", popover: "manual", role: "menu" }, @attrs), &)
|
|
7
13
|
end
|
|
8
14
|
end
|
|
9
15
|
end
|
|
@@ -11,6 +11,8 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
11
11
|
// (skipping [data-disabled]); ArrowLeft/ArrowRight jump to the sibling menus;
|
|
12
12
|
// Escape closes and returns focus to the trigger.
|
|
13
13
|
// Connects to data-controller="phlex-kit--menubar"
|
|
14
|
+
let uid = 0
|
|
15
|
+
|
|
14
16
|
export default class extends Controller {
|
|
15
17
|
static targets = ["menu"]
|
|
16
18
|
|
|
@@ -21,13 +23,38 @@ export default class extends Controller {
|
|
|
21
23
|
return this.menuTargets.find((menu) => this.panel(menu)?.matches(":popover-open")) ?? null
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
initialize() {
|
|
27
|
+
// menuTargetConnected fires BEFORE connect() — state it reads must be
|
|
28
|
+
// set here. Roving tabindex applies only in menubar mode: a nav's
|
|
29
|
+
// triggers and links stay natural tab stops.
|
|
30
|
+
this.roving = this.element.dataset.hoverOpen === undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Pair each trigger with its panel for AT (aria-controls), and fold the
|
|
34
|
+
// trigger into the roving tabindex (first trigger 0, rest -1).
|
|
35
|
+
menuTargetConnected(menu) {
|
|
36
|
+
const trigger = this.trigger(menu)
|
|
37
|
+
const panel = this.panel(menu)
|
|
38
|
+
if (trigger && panel) {
|
|
39
|
+
if (!panel.id) panel.id = `pk-menu-panel-${++uid}`
|
|
40
|
+
trigger.setAttribute("aria-controls", panel.id)
|
|
41
|
+
}
|
|
42
|
+
if (this.roving) this.applyRoving()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
menuTargetDisconnected() {
|
|
46
|
+
if (this.roving) this.applyRoving()
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
disconnect() {
|
|
25
50
|
clearTimeout(this.graceTimer)
|
|
26
51
|
}
|
|
27
52
|
|
|
28
53
|
toggle(e) {
|
|
29
54
|
const menu = e.currentTarget.closest("[data-phlex-kit--menubar-target=\"menu\"]")
|
|
30
|
-
|
|
55
|
+
// detail 0 = keyboard-activated click (Enter/Space): only then does the
|
|
56
|
+
// first item get force-focused — a mouse open leaves focus on the trigger.
|
|
57
|
+
this.openMenu === menu ? this.close() : this.show(menu, e.detail === 0)
|
|
31
58
|
}
|
|
32
59
|
|
|
33
60
|
// Hover: switches between menus while one is open (menubar), or opens
|
|
@@ -58,10 +85,15 @@ export default class extends Controller {
|
|
|
58
85
|
show(menu, focus = false) {
|
|
59
86
|
if (this.openMenu !== menu) {
|
|
60
87
|
this.close()
|
|
61
|
-
this.panel(menu)
|
|
62
|
-
|
|
88
|
+
if (this.panel(menu)) {
|
|
89
|
+
this.panel(menu).showPopover()
|
|
90
|
+
menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "true")
|
|
91
|
+
}
|
|
63
92
|
}
|
|
64
|
-
if (
|
|
93
|
+
if (this.roving) this.applyRoving(this.trigger(menu))
|
|
94
|
+
// No panel items (e.g. a panel-less nav link item reached via arrows):
|
|
95
|
+
// land on the trigger itself instead of stranding focus.
|
|
96
|
+
if (focus) (this.items(menu)[0] ?? this.trigger(menu))?.focus()
|
|
65
97
|
}
|
|
66
98
|
|
|
67
99
|
close(opts = {}) {
|
|
@@ -94,7 +126,9 @@ export default class extends Controller {
|
|
|
94
126
|
e.preventDefault()
|
|
95
127
|
const menus = this.menuTargets
|
|
96
128
|
const next = menus[(menus.indexOf(menu) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length]
|
|
97
|
-
next
|
|
129
|
+
const target = next ? this.trigger(next) : null
|
|
130
|
+
target?.focus()
|
|
131
|
+
if (this.roving && target) this.applyRoving(target)
|
|
98
132
|
}
|
|
99
133
|
return
|
|
100
134
|
}
|
|
@@ -176,12 +210,38 @@ export default class extends Controller {
|
|
|
176
210
|
group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
|
|
177
211
|
}
|
|
178
212
|
|
|
213
|
+
// Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
|
|
214
|
+
// sub trigger's aria-expanded. rAF: on mouseleave/focusout the pseudo-class
|
|
215
|
+
// state isn't settled until the event finishes dispatching.
|
|
216
|
+
syncSub(e) {
|
|
217
|
+
const sub = e.currentTarget
|
|
218
|
+
requestAnimationFrame(() => {
|
|
219
|
+
sub.querySelector(":scope > [aria-haspopup]")
|
|
220
|
+
?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
179
224
|
shift(dir) {
|
|
180
225
|
const menus = this.menuTargets
|
|
181
226
|
const index = menus.indexOf(this.openMenu)
|
|
182
227
|
this.show(menus[(index + dir + menus.length) % menus.length], true)
|
|
183
228
|
}
|
|
184
229
|
|
|
230
|
+
// The bar's single tab stop (APG menubar): `current` keeps tabindex 0,
|
|
231
|
+
// every other trigger gets -1.
|
|
232
|
+
applyRoving(current = null) {
|
|
233
|
+
const triggers = this.menuTargets.map((menu) => this.trigger(menu)).filter(Boolean)
|
|
234
|
+
if (triggers.length === 0) return
|
|
235
|
+
if (!current || !triggers.includes(current)) {
|
|
236
|
+
current = triggers.find((t) => t.getAttribute("tabindex") === "0") ?? triggers[0]
|
|
237
|
+
}
|
|
238
|
+
triggers.forEach((t) => t.setAttribute("tabindex", t === current ? "0" : "-1"))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
trigger(menu) {
|
|
242
|
+
return menu?.querySelector("[aria-expanded], a, button, [tabindex]")
|
|
243
|
+
}
|
|
244
|
+
|
|
185
245
|
panel(menu) {
|
|
186
246
|
return menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")
|
|
187
247
|
}
|
|
@@ -192,7 +252,10 @@ export default class extends Controller {
|
|
|
192
252
|
// getClientRects also skips rows inside a CLOSED sub panel (its
|
|
193
253
|
// display:none comes from the hover/focus-within CSS, not .pk-hidden) —
|
|
194
254
|
// focus() on those silently fails and the roving nav jams there.
|
|
195
|
-
|
|
196
|
-
|
|
255
|
+
// Nav panels hold plain links (no menu roles, matching Radix/shadcn) —
|
|
256
|
+
// fall back to them so arrow keys navigate there too.
|
|
257
|
+
const rows = [...panel.querySelectorAll("[role^=\"menuitem\"]")]
|
|
258
|
+
const list = rows.length ? rows : [...panel.querySelectorAll("a[href], button")]
|
|
259
|
+
return list.filter((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0)
|
|
197
260
|
}
|
|
198
261
|
}
|
|
@@ -3,7 +3,10 @@ module PhlexKit
|
|
|
3
3
|
class MenubarMenu < BaseComponent
|
|
4
4
|
def initialize(**attrs) = (@attrs = attrs)
|
|
5
5
|
def view_template(&)
|
|
6
|
-
|
|
6
|
+
# role="none": role="menubar" only allows menuitem children in the
|
|
7
|
+
# accessibility tree — this layout wrapper must be presentational so
|
|
8
|
+
# the tree flattens to menubar > menuitem (axe: required-children).
|
|
9
|
+
div(**mix({ class: "pk-menubar-menu", role: "none", data: { phlex_kit__menubar_target: "menu" } }, @attrs), &)
|
|
7
10
|
end
|
|
8
11
|
end
|
|
9
12
|
end
|
|
@@ -4,7 +4,12 @@ module PhlexKit
|
|
|
4
4
|
class MenubarSub < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template(&)
|
|
7
|
-
|
|
7
|
+
# syncSub mirrors the CSS-driven open state (:hover / :focus-within)
|
|
8
|
+
# onto the sub trigger's aria-expanded.
|
|
9
|
+
div(**mix({
|
|
10
|
+
class: "pk-menubar-sub",
|
|
11
|
+
data: { action: "mouseenter->phlex-kit--menubar#syncSub mouseleave->phlex-kit--menubar#syncSub focusin->phlex-kit--menubar#syncSub focusout->phlex-kit--menubar#syncSub" }
|
|
12
|
+
}, @attrs), &)
|
|
8
13
|
end
|
|
9
14
|
end
|
|
10
15
|
end
|
|
@@ -9,7 +9,10 @@ module PhlexKit
|
|
|
9
9
|
# -1: the roving focus reaches it via arrows (focus-within opens the
|
|
10
10
|
# sub); tabindex 0 made it a stray tab stop inside the open panel.
|
|
11
11
|
tabindex: "-1",
|
|
12
|
-
|
|
12
|
+
# expanded starts false; the sub is revealed by CSS (:hover /
|
|
13
|
+
# :focus-within), so MenubarSub's syncSub actions mirror that state
|
|
14
|
+
# onto this attribute.
|
|
15
|
+
aria: { haspopup: "menu", expanded: "false" }
|
|
13
16
|
}, @attrs)) do
|
|
14
17
|
block&.call
|
|
15
18
|
render Icon.new(:chevron_right, size: nil, class: "pk-menubar-sub-chevron")
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
opacity: 0;
|
|
12
12
|
pointer-events: none;
|
|
13
13
|
transition: opacity .15s ease, transform .15s ease;
|
|
14
|
-
box-shadow: 0 2px 8px
|
|
14
|
+
box-shadow: 0 2px 8px color-mix(in srgb, var(--pk-shadow-color) 25%, transparent);
|
|
15
15
|
}
|
|
16
16
|
.pk-message-scroller-jump[data-active="true"] {
|
|
17
17
|
opacity: 1;
|
|
@@ -65,8 +65,19 @@ export default class extends Controller {
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// The MutationObserver only sees DOM changes — late image loads and
|
|
69
|
+
// viewport resizes also move the live edge, so keep the pin through both.
|
|
70
|
+
this.onContentGrowth = this.onContentGrowth.bind(this);
|
|
71
|
+
this.resizeObserver = new ResizeObserver(this.onContentGrowth);
|
|
72
|
+
if (this.hasContentTarget) this.resizeObserver.observe(this.contentTarget);
|
|
73
|
+
if (this.hasViewportTarget) {
|
|
74
|
+
this.resizeObserver.observe(this.viewportTarget);
|
|
75
|
+
// load doesn't bubble — capture catches images/media inside messages.
|
|
76
|
+
this.viewportTarget.addEventListener("load", this.onContentGrowth, true);
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
// Apply the opening position after layout settles.
|
|
69
|
-
requestAnimationFrame(() => {
|
|
80
|
+
this.initialFrame = requestAnimationFrame(() => {
|
|
70
81
|
this.applyDefaultPosition();
|
|
71
82
|
this.updateButton();
|
|
72
83
|
});
|
|
@@ -78,9 +89,22 @@ export default class extends Controller {
|
|
|
78
89
|
this.viewportTarget.removeEventListener("wheel", this.onWheel);
|
|
79
90
|
this.viewportTarget.removeEventListener("touchstart", this.onTouchStart);
|
|
80
91
|
this.viewportTarget.removeEventListener("keydown", this.onKeydown);
|
|
92
|
+
this.viewportTarget.removeEventListener("load", this.onContentGrowth, true);
|
|
81
93
|
}
|
|
82
94
|
this.observer?.disconnect();
|
|
95
|
+
this.resizeObserver?.disconnect();
|
|
83
96
|
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
|
97
|
+
if (this.initialFrame) cancelAnimationFrame(this.initialFrame);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Content/viewport geometry changed (image decoded, panel resized, …):
|
|
101
|
+
// re-pin to the live edge when following, otherwise just refresh the button.
|
|
102
|
+
onContentGrowth() {
|
|
103
|
+
if (this.programmatic) return;
|
|
104
|
+
if (this.autoScrollValue && this.following && !this.isAtEnd()) {
|
|
105
|
+
this.scrollToEnd("auto");
|
|
106
|
+
}
|
|
107
|
+
this.updateButton();
|
|
84
108
|
}
|
|
85
109
|
|
|
86
110
|
// --- Reader intent -------------------------------------------------------
|
|
@@ -91,17 +115,37 @@ export default class extends Controller {
|
|
|
91
115
|
this.updateButton();
|
|
92
116
|
}
|
|
93
117
|
|
|
94
|
-
// Any upward wheel is a deliberate move away from the live edge.
|
|
118
|
+
// Any upward wheel is a deliberate move away from the live edge. Any wheel
|
|
119
|
+
// during a programmatic scroll hands control straight back to the reader.
|
|
95
120
|
onWheel(event) {
|
|
121
|
+
if (this.programmatic) {
|
|
122
|
+
this.interrupt();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
96
125
|
if (event.deltaY < 0) this.release();
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
onTouchStart() {
|
|
100
129
|
// A touch that turns into an upward drag surfaces through onScroll; this
|
|
101
130
|
// just makes the release feel immediate when the reader grabs the list.
|
|
131
|
+
if (this.programmatic) {
|
|
132
|
+
this.interrupt();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
102
135
|
if (!this.isAtEnd()) this.release();
|
|
103
136
|
}
|
|
104
137
|
|
|
138
|
+
// Cancel an in-flight programmatic scroll and release the pin — the
|
|
139
|
+
// reader's wheel/touch always wins over our 300ms animation.
|
|
140
|
+
interrupt() {
|
|
141
|
+
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
|
142
|
+
this.programmatic = false;
|
|
143
|
+
this.element.removeAttribute("data-autoscrolling");
|
|
144
|
+
this.viewportTarget?.removeAttribute("data-autoscrolling");
|
|
145
|
+
this.following = false;
|
|
146
|
+
this.updateButton();
|
|
147
|
+
}
|
|
148
|
+
|
|
105
149
|
onKeydown(event) {
|
|
106
150
|
const navKeys = ["ArrowUp", "PageUp", "Home", "ArrowDown", "PageDown", "End", " "];
|
|
107
151
|
if (navKeys.includes(event.key)) this.release();
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
background: var(--pk-surface);
|
|
58
58
|
color: var(--pk-text);
|
|
59
59
|
padding: .25rem;
|
|
60
|
-
box-shadow: 0 8px 24px
|
|
60
|
+
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
|
|
61
61
|
}
|
|
62
62
|
.pk-navigation-menu-content .pk-navigation-menu-link {
|
|
63
63
|
display: flex;
|
|
@@ -68,7 +68,12 @@
|
|
|
68
68
|
align-items: flex-start;
|
|
69
69
|
gap: .25rem;
|
|
70
70
|
}
|
|
71
|
-
/* Gap bridges on both sides — a flip fallback puts the gap below.
|
|
71
|
+
/* Gap bridges on both sides — a flip fallback puts the gap below. They only
|
|
72
|
+
exist while the panel is open (the closed panel is display:none) and are
|
|
73
|
+
already sized to the actual gap; the ~7px hit-area spill on the non-gap
|
|
74
|
+
side is the accepted cost: CSS has no selector for which position-try
|
|
75
|
+
fallback applied, so we can't scope the bridge to the active side, and
|
|
76
|
+
pointer-events:none would defeat the mouseleave continuity they exist for. */
|
|
72
77
|
.pk-navigation-menu-content::before {
|
|
73
78
|
content: "";
|
|
74
79
|
position: absolute;
|
|
@@ -15,7 +15,12 @@ module PhlexKit
|
|
|
15
15
|
data: {
|
|
16
16
|
controller: "phlex-kit--menubar",
|
|
17
17
|
hover_open: true,
|
|
18
|
-
|
|
18
|
+
# onKeydown (not keydown.esc->close): the shared handler drives
|
|
19
|
+
# arrow-key navigation over the panels AND closes on Escape with
|
|
20
|
+
# focus returned to the trigger (bare #close as an action received
|
|
21
|
+
# the event as `opts`, so refocus never happened — focus fell to
|
|
22
|
+
# <body> when the panel hid).
|
|
23
|
+
action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown mouseleave->phlex-kit--menubar#closeSoon"
|
|
19
24
|
}
|
|
20
25
|
}, @attrs), &)
|
|
21
26
|
end
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
.pk-pagination-content { display: flex; flex-direction: row; align-items: center; gap: .125rem; list-style: none; margin: 0; padding: 0; }
|
|
7
7
|
.pk-pagination-ellipsis { display: flex; height: 2rem; width: 2rem; align-items: center; justify-content: center; }
|
|
8
8
|
.pk-pagination-ellipsis > svg { width: 1rem; height: 1rem; }
|
|
9
|
-
.pk-button.pk-pagination-previous { padding-
|
|
10
|
-
.pk-button.pk-pagination-next { padding-
|
|
9
|
+
.pk-button.pk-pagination-previous { padding-inline-start: .375rem; }
|
|
10
|
+
.pk-button.pk-pagination-next { padding-inline-end: .375rem; }
|
|
11
11
|
.pk-pagination-label { display: none; }
|
|
12
12
|
@media (min-width: 640px) { .pk-pagination-label { display: block; } }
|
|
@@ -3,7 +3,9 @@ module PhlexKit
|
|
|
3
3
|
def initialize(**attrs) = (@attrs = attrs)
|
|
4
4
|
def view_template
|
|
5
5
|
li do
|
|
6
|
-
|
|
6
|
+
# No aria-hidden on the wrapper — it would swallow the sr-only text.
|
|
7
|
+
# The Icon renders its own aria-hidden="true"; only the glyph is hidden.
|
|
8
|
+
span(**mix({ class: "pk-pagination-ellipsis" }, @attrs)) do
|
|
7
9
|
render Icon.new(:ellipsis, size: nil)
|
|
8
10
|
span(class: "pk-sr-only") { "More pages" }
|
|
9
11
|
end
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
border-radius: var(--pk-radius);
|
|
22
22
|
background: var(--pk-surface); color: var(--pk-text); padding: .625rem;
|
|
23
23
|
font-size: .875rem;
|
|
24
|
-
box-shadow: 0 8px 24px
|
|
24
|
+
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
|
|
25
25
|
}
|
|
26
26
|
/* display must be gated on :popover-open — an unconditional author
|
|
27
27
|
`display: flex` would beat the UA's [popover] { display: none }. */
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
position: relative;
|
|
8
8
|
flex: none;
|
|
9
9
|
background: var(--pk-border);
|
|
10
|
+
/* without this, touch drags scroll the page instead of resizing */
|
|
11
|
+
touch-action: none;
|
|
10
12
|
}
|
|
11
13
|
.pk-resizable-group.horizontal > .pk-resizable-handle { width: 1px; cursor: col-resize; }
|
|
12
14
|
.pk-resizable-group.vertical > .pk-resizable-handle { height: 1px; cursor: row-resize; }
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
}
|
|
20
22
|
.pk-resizable-handle:focus-visible {
|
|
21
23
|
outline: none;
|
|
22
|
-
box-shadow: 0 0 0
|
|
24
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/* Visible grip pill (their withHandle / .cn-resizable-handle-icon). */
|
|
@@ -11,6 +11,17 @@ export default class extends Controller {
|
|
|
11
11
|
static targets = ["panel", "handle"]
|
|
12
12
|
static values = { direction: { type: String, default: "horizontal" } }
|
|
13
13
|
|
|
14
|
+
disconnect() {
|
|
15
|
+
// a disconnect mid-drag must drop the handle's move/up listeners
|
|
16
|
+
if (this._activeDrag) {
|
|
17
|
+
const { handle, onMove, onUp } = this._activeDrag
|
|
18
|
+
handle.removeEventListener("pointermove", onMove)
|
|
19
|
+
handle.removeEventListener("pointerup", onUp)
|
|
20
|
+
handle.removeEventListener("pointercancel", onUp)
|
|
21
|
+
this._activeDrag = null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
handleTargetConnected(handle) {
|
|
15
26
|
handle.setAttribute("aria-orientation", this.directionValue === "horizontal" ? "vertical" : "horizontal")
|
|
16
27
|
handle.setAttribute("aria-valuemin", "0")
|
|
@@ -51,10 +62,12 @@ export default class extends Controller {
|
|
|
51
62
|
handle.removeEventListener("pointermove", onMove)
|
|
52
63
|
handle.removeEventListener("pointerup", onUp)
|
|
53
64
|
handle.removeEventListener("pointercancel", onUp)
|
|
65
|
+
this._activeDrag = null
|
|
54
66
|
}
|
|
55
67
|
handle.addEventListener("pointermove", onMove)
|
|
56
68
|
handle.addEventListener("pointerup", onUp)
|
|
57
69
|
handle.addEventListener("pointercancel", onUp)
|
|
70
|
+
this._activeDrag = { handle, onMove, onUp } // for disconnect() cleanup
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
// Arrow keys resize by 5% of the pair per press (matching the drag axis);
|
|
@@ -10,7 +10,9 @@ module PhlexKit
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def view_template(&)
|
|
13
|
-
|
|
13
|
+
# Trailing ";" matters: Phlex's mix joins string attrs with a space, so
|
|
14
|
+
# a caller style: would otherwise fuse into one invalid declaration.
|
|
15
|
+
style = @default_size ? "flex-grow: #{Kernel.format("%g", @default_size)};" : nil
|
|
14
16
|
div(**mix({
|
|
15
17
|
class: "pk-resizable-panel",
|
|
16
18
|
style: style,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Styled scroll container, ported from shadcn/ui's ScrollArea. Radix's custom
|
|
3
3
|
# scrollbars are replaced with native thin scrollbars themed via CSS — no JS.
|
|
4
|
-
# Constrain it with a height/width from the caller.
|
|
4
|
+
# Constrain it with a height/width from the caller. tabindex=0 makes the
|
|
5
|
+
# scrollable region keyboard-focusable (WCAG); role=region gives that focus
|
|
6
|
+
# stop a role — pass `aria: { label: ... }` to name it. `.pk-scroll-area`
|
|
5
7
|
# (scroll_area.css).
|
|
6
8
|
class ScrollArea < BaseComponent
|
|
7
9
|
def initialize(**attrs)
|
|
@@ -9,7 +11,7 @@ module PhlexKit
|
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def view_template(&)
|
|
12
|
-
div(**mix({ class: "pk-scroll-area", tabindex: "0" }, @attrs), &)
|
|
14
|
+
div(**mix({ class: "pk-scroll-area", tabindex: "0", role: "region" }, @attrs), &)
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
17
|
end
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
45
45
|
}
|
|
46
46
|
.pk-select-trigger:disabled { cursor: not-allowed; opacity: .5; }
|
|
47
|
-
.pk-select-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-
|
|
47
|
+
.pk-select-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-inline-start: .5rem; opacity: .5; }
|
|
48
48
|
.pk-select-value { overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
|
49
49
|
|
|
50
50
|
/* Dropdown panel — anchored directly below the trigger, flips above at the
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
background: var(--pk-surface);
|
|
76
76
|
color: var(--pk-text);
|
|
77
77
|
padding: .25rem;
|
|
78
|
-
box-shadow: 0 4px 16px
|
|
78
|
+
box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
.pk-select-label { margin: 0; padding: .25rem .375rem; font-size: .75rem; font-weight: 400; color: var(--pk-muted); }
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
.pk-select-item-check {
|
|
103
103
|
visibility: hidden;
|
|
104
104
|
position: absolute;
|
|
105
|
-
|
|
105
|
+
inset-inline-end: .5rem;
|
|
106
106
|
display: flex;
|
|
107
107
|
align-items: center;
|
|
108
108
|
justify-content: center;
|