phlex_kit 0.10.0 → 0.12.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.css +3 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +38 -7
- data/app/components/phlex_kit/attachment/attachment.css +15 -0
- data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +8 -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/breadcrumb/breadcrumb.css +1 -0
- 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 +56 -15
- data/app/components/phlex_kit/carousel/carousel.rb +3 -2
- data/app/components/phlex_kit/carousel/carousel_controller.js +59 -4
- data/app/components/phlex_kit/chart/chart.rb +10 -2
- data/app/components/phlex_kit/chart/chart_controller.js +37 -8
- 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 +11 -1
- data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox.css +1 -1
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_controller.js +72 -3
- 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 +23 -2
- data/app/components/phlex_kit/command/command_input.rb +3 -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.css +3 -0
- data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +74 -12
- 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 +1 -11
- 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/data_table/data_table_pagination.rb +4 -1
- data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
- data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
- data/app/components/phlex_kit/date_picker/date_picker.rb +19 -2
- data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -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 +16 -2
- data/app/components/phlex_kit/drawer/drawer.rb +1 -1
- data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +9 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +81 -13
- 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 +54 -25
- data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
- data/app/components/phlex_kit/hover_card/hover_card.css +10 -2
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
- 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/input.css +0 -1
- data/app/components/phlex_kit/input_group/input_group.css +0 -1
- data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
- data/app/components/phlex_kit/item/item.css +2 -1
- data/app/components/phlex_kit/kbd/kbd.css +2 -1
- data/app/components/phlex_kit/label/label.css +1 -1
- data/app/components/phlex_kit/link/link.rb +1 -0
- data/app/components/phlex_kit/marker/marker.css +2 -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.css +3 -0
- data/app/components/phlex_kit/menubar/menubar_controller.js +46 -16
- 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.css +14 -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/navigation_menu/navigation_menu_link.rb +8 -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 +42 -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 -15
- data/app/components/phlex_kit/select/select.css +5 -2
- data/app/components/phlex_kit/select/select_controller.js +49 -4
- data/app/components/phlex_kit/select/select_trigger.rb +4 -1
- 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.rb +13 -3
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +40 -0
- data/app/components/phlex_kit/sheet/sheet_controller.js +31 -1
- 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/spinner/spinner.rb +6 -3
- data/app/components/phlex_kit/switch/switch.css +5 -0
- data/app/components/phlex_kit/switch/switch.rb +6 -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_controller.js +8 -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_close.rb +6 -3
- data/app/components/phlex_kit/toast/toast_controller.js +40 -21
- data/app/components/phlex_kit/toast/toast_item.rb +11 -7
- data/app/components/phlex_kit/toast/toast_region.rb +29 -10
- data/app/components/phlex_kit/toast/toaster_controller.js +17 -7
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +17 -2
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +16 -9
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +20 -2
- 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/app/javascript/phlex_kit/controllers/index.js +2 -0
- data/lib/phlex_kit/base_component.rb +23 -0
- data/lib/phlex_kit/version.rb +1 -1
- data/lib/phlex_kit.rb +1 -0
- metadata +4 -1
|
@@ -26,7 +26,7 @@ export default class extends Controller {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
disconnect() {
|
|
29
|
-
this.
|
|
29
|
+
this.disconnectThemeObservers()
|
|
30
30
|
this.chart?.destroy()
|
|
31
31
|
if (!this.chart) {
|
|
32
32
|
this.dispatch("disconnect", { detail: { canvas: this.element } })
|
|
@@ -44,9 +44,14 @@ export default class extends Controller {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// Kit tokens are plain color values (hex / color-mix results), not the hsl
|
|
47
|
-
// triplets ruby_ui reads — return them as-is.
|
|
47
|
+
// triplets ruby_ui reads — return them as-is. Resolved from this.element,
|
|
48
|
+
// not document.documentElement: a .pk-dark island (_tokens.css:123-129)
|
|
49
|
+
// sets the --pk-* custom properties at the SUBTREE root, and per-element
|
|
50
|
+
// getComputedStyle correctly picks up the nearest ancestor's values —
|
|
51
|
+
// reading from the document root would ignore the island and always
|
|
52
|
+
// return the page-level theme.
|
|
48
53
|
getThemeColor(name) {
|
|
49
|
-
return getComputedStyle(
|
|
54
|
+
return getComputedStyle(this.element).getPropertyValue(`--${name}`).trim()
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
// shadcn-style series colors: dataset N takes --pk-chart-(N%5 + 1). Line/area
|
|
@@ -80,6 +85,13 @@ export default class extends Controller {
|
|
|
80
85
|
|
|
81
86
|
setDefaultColorsForChart() {
|
|
82
87
|
const Chart = this.chartLibrary()
|
|
88
|
+
// Chart.defaults is a library-wide singleton (chart.js has no per-instance
|
|
89
|
+
// defaults object), so it can only ever reflect ONE theme — the
|
|
90
|
+
// documented caveat. It's resolved from the document root, not
|
|
91
|
+
// this.element: a chart inside a .pk-dark island still gets per-dataset
|
|
92
|
+
// colors from the island (getThemeColor/seriesColor, above), just not
|
|
93
|
+
// these library-global defaults (grid/tooltip/legend chrome).
|
|
94
|
+
//
|
|
83
95
|
// One computed-style resolve for all nine tokens (this runs on every
|
|
84
96
|
// connect; assigning identical values to Chart.defaults is free — charts
|
|
85
97
|
// read defaults at construction — so the style resolves are the only cost
|
|
@@ -122,20 +134,37 @@ export default class extends Controller {
|
|
|
122
134
|
// unrelated class flip is the expensive path.
|
|
123
135
|
initThemeObserver() {
|
|
124
136
|
this._themeKey = this.currentThemeKey()
|
|
125
|
-
|
|
137
|
+
const onMutate = () => {
|
|
126
138
|
const key = this.currentThemeKey()
|
|
127
139
|
if (key === this._themeKey) return
|
|
128
140
|
this._themeKey = key
|
|
129
141
|
this.refreshChart()
|
|
130
|
-
}
|
|
142
|
+
}
|
|
143
|
+
this.themeObserver = new MutationObserver(onMutate)
|
|
131
144
|
this.themeObserver.observe(document.documentElement, { attributeFilter: ["data-theme", "class"] })
|
|
145
|
+
|
|
146
|
+
// A .pk-dark island (_tokens.css:123-129) re-themes a subtree without
|
|
147
|
+
// touching the document root, so the root-only observer above misses it.
|
|
148
|
+
// Watch the nearest island ancestor too, if this chart lives inside one.
|
|
149
|
+
const island = this.element.closest(".pk-dark")
|
|
150
|
+
if (island && island !== document.documentElement) {
|
|
151
|
+
this.islandObserver = new MutationObserver(onMutate)
|
|
152
|
+
this.islandObserver.observe(island, { attributeFilter: ["data-theme", "class"] })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
disconnectThemeObservers() {
|
|
157
|
+
this.themeObserver?.disconnect()
|
|
158
|
+
this.islandObserver?.disconnect()
|
|
132
159
|
}
|
|
133
160
|
|
|
134
161
|
// Cheap fingerprint of the resolved theme: two tokens that differ between
|
|
135
|
-
// every light/dark pair.
|
|
136
|
-
//
|
|
162
|
+
// every light/dark pair. Resolved from this.element (island-aware, see
|
|
163
|
+
// getThemeColor) so a mutation on an ancestor .pk-dark island is detected
|
|
164
|
+
// even though the document root never changes. One style resolve per
|
|
165
|
+
// mutation vs a full chart rebuild.
|
|
137
166
|
currentThemeKey() {
|
|
138
|
-
const styles = getComputedStyle(
|
|
167
|
+
const styles = getComputedStyle(this.element)
|
|
139
168
|
return `${styles.getPropertyValue("--pk-bg")}|${styles.getPropertyValue("--pk-text")}`
|
|
140
169
|
}
|
|
141
170
|
|
|
@@ -3,7 +3,8 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
3
3
|
// Connects to data-controller="phlex-kit--clipboard". Copies the source text and
|
|
4
4
|
// flashes a success/error popover (CSS-positioned, no @floating-ui). Ported from ruby_ui.
|
|
5
5
|
export default class extends Controller {
|
|
6
|
-
|
|
6
|
+
// No "trigger" target: the click->#copy action alone wires the trigger.
|
|
7
|
+
static targets = ["source", "successPopover", "errorPopover"]
|
|
7
8
|
|
|
8
9
|
connect() {
|
|
9
10
|
// Turbo snapshots at turbo:before-cache, BEFORE disconnect — hide the
|
|
@@ -28,10 +29,16 @@ export default class extends Controller {
|
|
|
28
29
|
// A source usually wraps one element (input/code/…), but bare text is
|
|
29
30
|
// legal too — fall back to the source's own text instead of erroring.
|
|
30
31
|
const el = this.sourceTarget.children[0]
|
|
32
|
+
// TEXTAREA reads .value like INPUT — innerText would return the initial
|
|
33
|
+
// content, not the user-edited value.
|
|
31
34
|
const text = el
|
|
32
|
-
? (el.tagName
|
|
35
|
+
? (["INPUT", "TEXTAREA"].includes(el.tagName) ? el.value : el.innerText)
|
|
33
36
|
: this.sourceTarget.textContent.trim()
|
|
34
37
|
if (!text) { this.show(this.errorPopoverTarget); return }
|
|
38
|
+
// navigator.clipboard is undefined in insecure contexts (plain-HTTP
|
|
39
|
+
// hosts, some webviews) — that's the error popover's job, not an
|
|
40
|
+
// unhandled TypeError's.
|
|
41
|
+
if (!navigator.clipboard) { this.show(this.errorPopoverTarget); return }
|
|
35
42
|
navigator.clipboard.writeText(text)
|
|
36
43
|
.then(() => this.show(this.successPopoverTarget))
|
|
37
44
|
.catch(() => this.show(this.errorPopoverTarget))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
class ClipboardTrigger < BaseComponent
|
|
3
3
|
def initialize(**attrs) = (@attrs = attrs)
|
|
4
|
-
def view_template(&) = div(**mix({ class: "pk-clipboard-trigger", data: {
|
|
4
|
+
def view_template(&) = div(**mix({ class: "pk-clipboard-trigger", data: { action: "click->phlex-kit--clipboard#copy" } }, @attrs), &)
|
|
5
5
|
end
|
|
6
6
|
end
|
|
@@ -12,7 +12,17 @@ module PhlexKit
|
|
|
12
12
|
# tabindex=0 + role=region: the block scrolls (overflow:auto), so it must
|
|
13
13
|
# be keyboard-focusable (WCAG 2.1.1 scrollable-region-focusable). Pass
|
|
14
14
|
# `aria: { label: ... }` to name the region for AT.
|
|
15
|
-
|
|
15
|
+
# Defaults only when the caller didn't supply their own — `mix` would
|
|
16
|
+
# fuse tabindex="0 -1" / role="region article" instead of overriding.
|
|
17
|
+
base = { class: "pk-codeblock", data: { syntax: @syntax } }
|
|
18
|
+
base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
|
|
19
|
+
base[:role] = "region" unless @attrs.key?(:role) || @attrs.key?("role")
|
|
20
|
+
# A region landmark without a name is an axe violation — default one
|
|
21
|
+
# from the syntax; a caller aria: { label: } replaces it (never fused).
|
|
22
|
+
unless aria_labelled?
|
|
23
|
+
base[:aria] = { label: @syntax ? "#{@syntax} code" : "Code" }
|
|
24
|
+
end
|
|
25
|
+
div(**mix(base, @attrs)) do
|
|
16
26
|
pre do
|
|
17
27
|
if block
|
|
18
28
|
code(&block)
|
|
@@ -7,7 +7,10 @@ module PhlexKit
|
|
|
7
7
|
@attrs = attrs
|
|
8
8
|
end
|
|
9
9
|
def view_template(&)
|
|
10
|
-
|
|
10
|
+
# data-state is the CSS styling hook (chevron rotation) — it must be
|
|
11
|
+
# server-rendered or an open: true collapsible renders a closed-pointing
|
|
12
|
+
# chevron until Stimulus connects (and always, with JS off).
|
|
13
|
+
div(**mix({ class: "pk-collapsible", data: { controller: "phlex-kit--collapsible", phlex_kit__collapsible_open_value: @open, state: @open ? "open" : "closed" } }, @attrs), &)
|
|
11
14
|
end
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -30,7 +30,8 @@ module PhlexKit
|
|
|
30
30
|
"keydown.down->phlex-kit--combobox#keyDownPressed",
|
|
31
31
|
"keydown.up->phlex-kit--combobox#keyUpPressed",
|
|
32
32
|
"keydown.enter->phlex-kit--combobox#keyEnterPressed",
|
|
33
|
-
|
|
33
|
+
# no :prevent — closePopover only swallows Escape while open.
|
|
34
|
+
"keydown.esc->phlex-kit--combobox#closePopover"
|
|
34
35
|
].join(" ")
|
|
35
36
|
}
|
|
36
37
|
}, @attrs)) do
|
|
@@ -49,7 +50,10 @@ module PhlexKit
|
|
|
49
50
|
action: [
|
|
50
51
|
"keyup->phlex-kit--combobox#filterItems",
|
|
51
52
|
"input->phlex-kit--combobox#filterItems",
|
|
52
|
-
"
|
|
53
|
+
# plain keydown — "backspace" is not a Stimulus key filter
|
|
54
|
+
# (using it throws "unknown key filter" on every keystroke and
|
|
55
|
+
# the handler never ran); the method guards on e.key itself.
|
|
56
|
+
"keydown->phlex-kit--combobox#handleBadgeInputBackspace"
|
|
53
57
|
].join(" ")
|
|
54
58
|
}
|
|
55
59
|
)
|
|
@@ -45,6 +45,15 @@ export default class extends Controller {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
connect() {
|
|
48
|
+
// A Turbo snapshot serializes aria-expanded / aria-activedescendant even
|
|
49
|
+
// though :popover-open does not survive the restore — normalize here so
|
|
50
|
+
// a restored page doesn't announce an open listbox over a closed combobox.
|
|
51
|
+
this.setExpanded(false)
|
|
52
|
+
this.clearActiveDescendant()
|
|
53
|
+
// Mirrors select_controller.js's connect(): a Turbo snapshot also
|
|
54
|
+
// serializes the keyboard-highlighted item's aria-current even though
|
|
55
|
+
// the popover itself does not survive the restore.
|
|
56
|
+
this.itemTargets.forEach((item) => item.removeAttribute("aria-current"))
|
|
48
57
|
this.generateItemIds()
|
|
49
58
|
this.updateTriggerContent()
|
|
50
59
|
}
|
|
@@ -203,7 +212,19 @@ export default class extends Controller {
|
|
|
203
212
|
remove.textContent = "×"
|
|
204
213
|
remove.addEventListener("click", (e) => {
|
|
205
214
|
e.stopPropagation() // don't reopen the popover via the trigger's click action
|
|
215
|
+
// Removing a focused chip drops focus with it (the remove button is
|
|
216
|
+
// torn out of the DOM by the renderBadges() rebuild below) — hand
|
|
217
|
+
// focus to the badge input instead of stranding it on <body>.
|
|
218
|
+
const wasFocused = document.activeElement === remove
|
|
206
219
|
this.uncheck(input)
|
|
220
|
+
// badgeInput sits inside the trigger wrapper, so focusing it bubbles a
|
|
221
|
+
// focusin the trigger's own focusin->openPopover action would otherwise
|
|
222
|
+
// reopen — same guard closePopover() uses for its own focus restore.
|
|
223
|
+
if (wasFocused && this.hasBadgeInputTarget) {
|
|
224
|
+
this.refocusing = true
|
|
225
|
+
this.badgeInputTarget.focus()
|
|
226
|
+
this.refocusing = false
|
|
227
|
+
}
|
|
207
228
|
})
|
|
208
229
|
|
|
209
230
|
badge.append(remove)
|
|
@@ -217,6 +238,8 @@ export default class extends Controller {
|
|
|
217
238
|
}
|
|
218
239
|
|
|
219
240
|
handleBadgeInputBackspace(e) {
|
|
241
|
+
// Bound as a bare keydown: "backspace" is not a Stimulus key filter.
|
|
242
|
+
if (e.key !== "Backspace") return
|
|
220
243
|
if (this.badgeInputTarget.value !== "") return
|
|
221
244
|
|
|
222
245
|
const checked = this.checkedInputs()
|
|
@@ -228,6 +251,10 @@ export default class extends Controller {
|
|
|
228
251
|
}
|
|
229
252
|
|
|
230
253
|
clearAll(e) {
|
|
254
|
+
// The clear button hides itself once nothing is checked (updateTriggerContent
|
|
255
|
+
// below) — if it held focus, hand focus to the badge input rather than
|
|
256
|
+
// stranding it on a now-hidden element.
|
|
257
|
+
const wasFocused = this.hasClearButtonTarget && document.activeElement === this.clearButtonTarget
|
|
231
258
|
if (e) {
|
|
232
259
|
e.preventDefault()
|
|
233
260
|
e.stopPropagation() // the button sits inside the trigger's click-to-open area
|
|
@@ -239,6 +266,11 @@ export default class extends Controller {
|
|
|
239
266
|
})
|
|
240
267
|
if (this.hasToggleAllTarget) this.toggleAllTarget.checked = false
|
|
241
268
|
this.updateTriggerContent()
|
|
269
|
+
if (wasFocused && this.hasBadgeInputTarget) {
|
|
270
|
+
this.refocusing = true
|
|
271
|
+
this.badgeInputTarget.focus()
|
|
272
|
+
this.refocusing = false
|
|
273
|
+
}
|
|
242
274
|
}
|
|
243
275
|
|
|
244
276
|
togglePopover(event) {
|
|
@@ -278,7 +310,14 @@ export default class extends Controller {
|
|
|
278
310
|
}
|
|
279
311
|
}
|
|
280
312
|
|
|
281
|
-
closePopover() {
|
|
313
|
+
closePopover(e) {
|
|
314
|
+
// Escape must keep its browser default (e.g. cancelling an enclosing
|
|
315
|
+
// <dialog>) when the popover is already closed — only swallow the key
|
|
316
|
+
// when this close actually consumes it.
|
|
317
|
+
if (e?.type === "keydown") {
|
|
318
|
+
if (!this.isOpen()) return
|
|
319
|
+
e.preventDefault()
|
|
320
|
+
}
|
|
282
321
|
// Hiding the popover while focus is inside it (search input, option) drops
|
|
283
322
|
// focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
|
|
284
323
|
// only when focus would actually be orphaned — a click elsewhere on the
|
|
@@ -289,6 +328,11 @@ export default class extends Controller {
|
|
|
289
328
|
// aria-activedescendant holds an element id; on close it must be removed,
|
|
290
329
|
// not left pointing at a hidden option.
|
|
291
330
|
this.clearActiveDescendant()
|
|
331
|
+
// Also drop the highlight itself — otherwise a closed combobox still
|
|
332
|
+
// exposes an aria-current="true" option that Enter would go on to
|
|
333
|
+
// activate (APG: Enter on a closed combobox must be a no-op).
|
|
334
|
+
this.selectedItemIndex = null
|
|
335
|
+
this.itemTargets.forEach(item => item.ariaCurrent = "false")
|
|
292
336
|
if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
|
|
293
337
|
this.updateTriggerContent() // reflect the selection into an input trigger
|
|
294
338
|
|
|
@@ -335,6 +379,12 @@ export default class extends Controller {
|
|
|
335
379
|
this.applyFilter(field.value.toLowerCase())
|
|
336
380
|
}
|
|
337
381
|
|
|
382
|
+
// Strips combining diacritical marks after an NFD decomposition, so "é"
|
|
383
|
+
// (query or candidate) matches "e" instead of never comparing equal.
|
|
384
|
+
normalizeDiacritics(s) {
|
|
385
|
+
return s.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
|
386
|
+
}
|
|
387
|
+
|
|
338
388
|
applyFilter(filterTerm) {
|
|
339
389
|
if (this.hasToggleAllTarget) {
|
|
340
390
|
if (filterTerm) this.toggleAllTarget.parentElement.classList.add("pk-hidden")
|
|
@@ -349,10 +399,12 @@ export default class extends Controller {
|
|
|
349
399
|
// aria-current="true" stayed the Enter target while invisible.
|
|
350
400
|
this.itemTargets.forEach(item => item.ariaCurrent = "false")
|
|
351
401
|
|
|
402
|
+
const normalizedFilterTerm = this.normalizeDiacritics(filterTerm)
|
|
403
|
+
|
|
352
404
|
this.inputTargets.forEach((input) => {
|
|
353
|
-
const text = this.inputContent(input).toLowerCase()
|
|
405
|
+
const text = this.normalizeDiacritics(this.inputContent(input).toLowerCase())
|
|
354
406
|
|
|
355
|
-
if (text.indexOf(
|
|
407
|
+
if (text.indexOf(normalizedFilterTerm) > -1) {
|
|
356
408
|
input.parentElement.classList.remove("pk-hidden")
|
|
357
409
|
resultCount++
|
|
358
410
|
} else {
|
|
@@ -381,6 +433,12 @@ export default class extends Controller {
|
|
|
381
433
|
keyDownPressed(e) {
|
|
382
434
|
if (e) e.preventDefault()
|
|
383
435
|
|
|
436
|
+
// APG: ArrowDown on a closed combobox opens it and highlights the first
|
|
437
|
+
// option, rather than walking aria-current through invisible options.
|
|
438
|
+
// openPopover() resets selectedItemIndex to null, so the branch below
|
|
439
|
+
// still lands on 0.
|
|
440
|
+
if (!this.isOpen()) this.openPopover()
|
|
441
|
+
|
|
384
442
|
if (this.selectedItemIndex !== null) {
|
|
385
443
|
this.selectedItemIndex++
|
|
386
444
|
} else {
|
|
@@ -393,6 +451,11 @@ export default class extends Controller {
|
|
|
393
451
|
keyUpPressed(e) {
|
|
394
452
|
if (e) e.preventDefault()
|
|
395
453
|
|
|
454
|
+
// Same as keyDownPressed: ArrowUp on a closed combobox opens it and
|
|
455
|
+
// highlights the last option (wrapSelectedInputIndex turns -1 into the
|
|
456
|
+
// final index).
|
|
457
|
+
if (!this.isOpen()) this.openPopover()
|
|
458
|
+
|
|
396
459
|
if (this.selectedItemIndex !== null) {
|
|
397
460
|
this.selectedItemIndex--
|
|
398
461
|
} else {
|
|
@@ -425,6 +488,12 @@ export default class extends Controller {
|
|
|
425
488
|
}
|
|
426
489
|
|
|
427
490
|
keyEnterPressed(event) {
|
|
491
|
+
// APG: Enter on a CLOSED combobox is a no-op — it must not activate
|
|
492
|
+
// whatever option happened to keep aria-current="true" from before the
|
|
493
|
+
// popover closed. Bail before preventDefault so a closed field keeps
|
|
494
|
+
// Enter's ordinary default behaviour.
|
|
495
|
+
if (!this.isOpen()) return
|
|
496
|
+
|
|
428
497
|
event.preventDefault()
|
|
429
498
|
const option = this.itemTargets.find(item => item.ariaCurrent === "true")
|
|
430
499
|
|
|
@@ -36,7 +36,10 @@ module PhlexKit
|
|
|
36
36
|
"keydown.down->phlex-kit--combobox#keyDownPressed",
|
|
37
37
|
"keydown.up->phlex-kit--combobox#keyUpPressed",
|
|
38
38
|
"keydown.enter->phlex-kit--combobox#keyEnterPressed",
|
|
39
|
-
|
|
39
|
+
# no :prevent — closePopover only swallows Escape while the
|
|
40
|
+
# popover is open, so a closed field keeps the key's default
|
|
41
|
+
# (e.g. cancelling an enclosing <dialog>).
|
|
42
|
+
"keydown.esc->phlex-kit--combobox#closePopover"
|
|
40
43
|
].join(" ")
|
|
41
44
|
}
|
|
42
45
|
}, @attrs)) do
|
|
@@ -3,13 +3,17 @@ module PhlexKit
|
|
|
3
3
|
# role and an id the controller points the combobox element's aria-controls
|
|
4
4
|
# at (and derives option ids from). See combobox.rb.
|
|
5
5
|
class ComboboxList < BaseComponent
|
|
6
|
-
|
|
6
|
+
# id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
|
|
7
|
+
# caller id with the generated one into an invalid two-token id, breaking
|
|
8
|
+
# aria-controls (the documented list_id: wiring) and derived option ids.
|
|
9
|
+
def initialize(id: nil, **attrs)
|
|
10
|
+
@id = id || "pk-combobox-list-#{SecureRandom.hex(4)}"
|
|
7
11
|
@attrs = attrs
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def view_template(&)
|
|
11
15
|
div(**mix({
|
|
12
|
-
id:
|
|
16
|
+
id: @id,
|
|
13
17
|
class: "pk-combobox-list",
|
|
14
18
|
role: "listbox",
|
|
15
19
|
data: { phlex_kit__combobox_target: "list" }
|
|
@@ -13,6 +13,10 @@ module PhlexKit
|
|
|
13
13
|
input(**mix({
|
|
14
14
|
type: :checkbox,
|
|
15
15
|
class: "pk-combobox-checkbox",
|
|
16
|
+
# The option label is the interactive surface (ARIA option); the real
|
|
17
|
+
# input must not be a second tab stop inside it — same as
|
|
18
|
+
# ComboboxCheckbox/ComboboxRadio.
|
|
19
|
+
tabindex: "-1",
|
|
16
20
|
data: {
|
|
17
21
|
phlex_kit__combobox_target: "toggleAll",
|
|
18
22
|
action: "change->phlex-kit--combobox#toggleAllItems"
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
|
+
// Strips combining diacritical marks after an NFD decomposition, so "é"
|
|
4
|
+
// (query or candidate) matches "e" — the scorer otherwise treats accented
|
|
5
|
+
// characters as never equal to their bare ASCII counterparts.
|
|
6
|
+
function normalizeDiacritics(s) {
|
|
7
|
+
return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
// Fuzzy subsequence score: every query character must appear in order in the
|
|
4
11
|
// value (else null = no match). Consecutive runs compound, word-starts score
|
|
5
12
|
// extra, and a verbatim substring outranks scattered hits — so "set" beats
|
|
@@ -7,6 +14,8 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
7
14
|
// what fuse.js gave ruby_ui.
|
|
8
15
|
function fuzzyScore(query, value) {
|
|
9
16
|
if (!query) return 0;
|
|
17
|
+
query = normalizeDiacritics(query);
|
|
18
|
+
value = normalizeDiacritics(value);
|
|
10
19
|
let qi = 0;
|
|
11
20
|
let score = 0;
|
|
12
21
|
let streak = 0;
|
|
@@ -50,10 +59,17 @@ export default class extends Controller {
|
|
|
50
59
|
// dismiss() can hand focus back instead of dropping it on <body>.
|
|
51
60
|
this.previouslyFocused = document.activeElement;
|
|
52
61
|
|
|
62
|
+
// A Turbo snapshot serializes stale aria-selected on rows / aria-
|
|
63
|
+
// activedescendant on the input even though the live JS highlight state
|
|
64
|
+
// doesn't survive restore — normalize here so a restored page doesn't
|
|
65
|
+
// announce a stale highlight (mirrors select_controller.js's connect()).
|
|
66
|
+
this.itemTargets.forEach((item) => item.removeAttribute("aria-selected"));
|
|
67
|
+
|
|
53
68
|
if (!this.hasInputTarget) {
|
|
54
69
|
return;
|
|
55
70
|
}
|
|
56
71
|
|
|
72
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
57
73
|
this.generateItemIds();
|
|
58
74
|
// Only the cloned dialog overlay grabs focus on connect — an inline
|
|
59
75
|
// palette connecting at page load must not steal it.
|
|
@@ -98,6 +114,9 @@ export default class extends Controller {
|
|
|
98
114
|
this.searchEntries = this.searchEntries.filter(
|
|
99
115
|
(entry) => entry.element !== item,
|
|
100
116
|
);
|
|
117
|
+
// The highlight index is positional — after a removal it points at the
|
|
118
|
+
// wrong (or a missing) row, so Enter would activate the wrong item.
|
|
119
|
+
this.selectedIndex = -1;
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
dismiss() {
|
|
@@ -171,7 +190,7 @@ export default class extends Controller {
|
|
|
171
190
|
}
|
|
172
191
|
|
|
173
192
|
focusInput() {
|
|
174
|
-
this.inputTarget
|
|
193
|
+
if (this.hasInputTarget) this.inputTarget.focus();
|
|
175
194
|
}
|
|
176
195
|
|
|
177
196
|
filter(e) {
|
|
@@ -266,7 +285,9 @@ export default class extends Controller {
|
|
|
266
285
|
this.updateSelectedItem(visibleItems, -1);
|
|
267
286
|
} else if (e.key === "Enter" && this.selectedIndex !== -1) {
|
|
268
287
|
e.preventDefault();
|
|
269
|
-
|
|
288
|
+
// Optional-chained: a stale selectedIndex after dynamic item removal
|
|
289
|
+
// (turbo stream re-render) must not throw.
|
|
290
|
+
visibleItems[this.selectedIndex]?.click();
|
|
270
291
|
}
|
|
271
292
|
}
|
|
272
293
|
|
|
@@ -11,10 +11,11 @@ module PhlexKit
|
|
|
11
11
|
# `autofocus:` defaults off so an inline palette doesn't steal page focus
|
|
12
12
|
# on load; the dialog clone needs no autofocus either — the command
|
|
13
13
|
# controller's connect() focuses the input when the clone is inserted.
|
|
14
|
-
def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, **attrs)
|
|
14
|
+
def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, value: "", **attrs)
|
|
15
15
|
@placeholder = placeholder
|
|
16
16
|
@list_id = list_id
|
|
17
17
|
@autofocus = autofocus
|
|
18
|
+
@value = value
|
|
18
19
|
@attrs = attrs
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -31,7 +32,7 @@ module PhlexKit
|
|
|
31
32
|
spellcheck: "false",
|
|
32
33
|
autofocus: @autofocus,
|
|
33
34
|
role: "combobox",
|
|
34
|
-
value:
|
|
35
|
+
value: @value,
|
|
35
36
|
aria: { autocomplete: "list", expanded: "true", controls: @list_id },
|
|
36
37
|
data: {
|
|
37
38
|
phlex_kit__command_target: "input",
|
|
@@ -18,6 +18,10 @@ module PhlexKit
|
|
|
18
18
|
class: "pk-command-item",
|
|
19
19
|
href: @href,
|
|
20
20
|
role: "option",
|
|
21
|
+
# Options in an aria-activedescendant listbox are not tab stops —
|
|
22
|
+
# keyboard access is arrows/Enter from the input (cmdk parity); a
|
|
23
|
+
# tabbable anchor per row also made disabled items focusable.
|
|
24
|
+
tabindex: "-1",
|
|
21
25
|
aria: { disabled: (@disabled ? "true" : nil) },
|
|
22
26
|
data: {
|
|
23
27
|
phlex_kit__command_target: "item",
|
|
@@ -3,13 +3,17 @@ module PhlexKit
|
|
|
3
3
|
# listbox role and an id the controller points the input's aria-controls at
|
|
4
4
|
# (and derives result ids from, for aria-activedescendant). See command.rb.
|
|
5
5
|
class CommandList < BaseComponent
|
|
6
|
-
|
|
6
|
+
# id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
|
|
7
|
+
# caller id with the generated one into an invalid two-token id, breaking
|
|
8
|
+
# aria-controls (the documented list_id: wiring) and derived result ids.
|
|
9
|
+
def initialize(id: nil, **attrs)
|
|
10
|
+
@id = id || "pk-command-list-#{SecureRandom.hex(4)}"
|
|
7
11
|
@attrs = attrs
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def view_template(&)
|
|
11
15
|
div(**mix({
|
|
12
|
-
id:
|
|
16
|
+
id: @id,
|
|
13
17
|
class: "pk-command-list",
|
|
14
18
|
role: "listbox",
|
|
15
19
|
data: { phlex_kit__command_target: "list" }
|
|
@@ -72,6 +72,9 @@
|
|
|
72
72
|
the viewport right edge. */
|
|
73
73
|
.pk-context-menu-sub { anchor-name: --pk-context-menu-sub; anchor-scope: --pk-context-menu-sub; }
|
|
74
74
|
.pk-context-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
75
|
+
/* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
|
|
76
|
+
[dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
|
|
77
|
+
[dir="rtl"] .pk-context-menu-sub-chevron { transform: scaleX(-1); }
|
|
75
78
|
.pk-context-menu-sub-content {
|
|
76
79
|
position: fixed;
|
|
77
80
|
position-anchor: --pk-context-menu-sub;
|
|
@@ -5,7 +5,15 @@ module PhlexKit
|
|
|
5
5
|
class ContextMenu < BaseComponent
|
|
6
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
7
7
|
def view_template(&)
|
|
8
|
-
div(**mix({
|
|
8
|
+
div(**mix({
|
|
9
|
+
class: "pk-context-menu",
|
|
10
|
+
data: {
|
|
11
|
+
controller: "phlex-kit--context-menu",
|
|
12
|
+
# focusout: tabbing (or otherwise moving real focus) out of the
|
|
13
|
+
# menu closes the open [popover=manual] panel (menubar's pattern).
|
|
14
|
+
action: "focusout->phlex-kit--context-menu#onFocusout"
|
|
15
|
+
}
|
|
16
|
+
}, @attrs), &)
|
|
9
17
|
end
|
|
10
18
|
end
|
|
11
19
|
end
|
|
@@ -23,12 +23,60 @@ export default class extends Controller {
|
|
|
23
23
|
this.close()
|
|
24
24
|
}
|
|
25
25
|
this.onKey = (e) => this.keydown(e)
|
|
26
|
+
// Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
|
|
27
|
+
// stale data-state="open" (the panel itself can't resurrect, but host
|
|
28
|
+
// CSS keyed on the state hook would misfire after a restore).
|
|
29
|
+
this.onBeforeCache = () => {
|
|
30
|
+
if (this.contentTarget.matches(":popover-open")) this.close()
|
|
31
|
+
}
|
|
32
|
+
document.addEventListener("turbo:before-cache", this.onBeforeCache)
|
|
33
|
+
// Outstanding syncSub rAF handles — cancelled on disconnect (menubar's
|
|
34
|
+
// pattern).
|
|
35
|
+
this.subFrames = new Set()
|
|
36
|
+
// A Turbo snapshot (or a detach/reattach that mimics one) serializes
|
|
37
|
+
// aria-expanded="true" on sub triggers even though no submenu can be
|
|
38
|
+
// open at connect time — sweep every stale marker back to false
|
|
39
|
+
// (menubar's pattern).
|
|
40
|
+
this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
|
|
41
|
+
// Same shape for checkbox/radio rows: their `checked` DOM property
|
|
42
|
+
// survives a reconnect but a hand-edited/replayed aria-checked can drift
|
|
43
|
+
// from it — resync every row from its live input.
|
|
44
|
+
this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
|
|
45
|
+
i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
|
|
46
|
+
})
|
|
26
47
|
}
|
|
27
48
|
|
|
28
49
|
disconnect() {
|
|
29
50
|
document.removeEventListener("click", this.onDoc)
|
|
30
51
|
document.removeEventListener("contextmenu", this.onDoc)
|
|
31
52
|
document.removeEventListener("keydown", this.onKey)
|
|
53
|
+
document.removeEventListener("turbo:before-cache", this.onBeforeCache)
|
|
54
|
+
this.subFrames.forEach((id) => cancelAnimationFrame(id))
|
|
55
|
+
this.subFrames.clear()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
|
|
59
|
+
// sub trigger's aria-expanded — same wiring as dropdown_menu/menubar.
|
|
60
|
+
syncSub(e) {
|
|
61
|
+
const sub = e.currentTarget
|
|
62
|
+
const id = requestAnimationFrame(() => {
|
|
63
|
+
this.subFrames.delete(id)
|
|
64
|
+
sub.querySelector(":scope > [aria-haspopup]")
|
|
65
|
+
?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
|
|
66
|
+
})
|
|
67
|
+
this.subFrames.add(id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Tabbing (or otherwise moving real focus) out of the menu closes it —
|
|
71
|
+
// menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
|
|
72
|
+
// null when the window itself blurs — leave the menu alone then. The
|
|
73
|
+
// trigger + content are both DOM children of this.element even though the
|
|
74
|
+
// content is a top-layer popover, so one containment check covers both.
|
|
75
|
+
onFocusout(e) {
|
|
76
|
+
if (!this.contentTarget.matches(":popover-open")) return
|
|
77
|
+
const to = e.relatedTarget
|
|
78
|
+
if (!to || this.element.contains(to)) return
|
|
79
|
+
this.close()
|
|
32
80
|
}
|
|
33
81
|
|
|
34
82
|
open(e) {
|
|
@@ -68,6 +116,13 @@ export default class extends Controller {
|
|
|
68
116
|
}
|
|
69
117
|
|
|
70
118
|
keydown(e) {
|
|
119
|
+
// This listener stays attached document-wide while the menu is open (see
|
|
120
|
+
// open()/close()) so it keeps receiving events even once real focus has
|
|
121
|
+
// left the menu — scope the roving-nav keys to when focus is actually
|
|
122
|
+
// within the widget (this.element, not just contentTarget, so a focused
|
|
123
|
+
// trigger still counts). Escape stays global while the menu is open (APG).
|
|
124
|
+
if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return
|
|
125
|
+
|
|
71
126
|
const items = this.items()
|
|
72
127
|
const index = items.indexOf(document.activeElement)
|
|
73
128
|
switch (e.key) {
|
|
@@ -101,19 +156,26 @@ export default class extends Controller {
|
|
|
101
156
|
}
|
|
102
157
|
break
|
|
103
158
|
case "ArrowRight":
|
|
104
|
-
// On a sub trigger, enter the submenu (focus reveals it via
|
|
105
|
-
// :focus-within, making its rows visible to the roving nav).
|
|
106
|
-
if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
|
|
107
|
-
e.preventDefault()
|
|
108
|
-
this.enterSub(document.activeElement)
|
|
109
|
-
}
|
|
110
|
-
break
|
|
111
159
|
case "ArrowLeft": {
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
160
|
+
// Submenus open inline-end — visually LEFT in RTL — so the enter key
|
|
161
|
+
// follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
|
|
162
|
+
// (and the reverse in LTR). Runtime dir check is reliable after a flip.
|
|
163
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
164
|
+
const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
|
|
165
|
+
if (e.key === enterKey) {
|
|
166
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
167
|
+
// :focus-within, making its rows visible to the roving nav).
|
|
168
|
+
if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
this.enterSub(document.activeElement)
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Inside a submenu, step back to its trigger (closes it on focus-out).
|
|
174
|
+
const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
|
|
175
|
+
if (sub) {
|
|
176
|
+
e.preventDefault()
|
|
177
|
+
sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
|
|
178
|
+
}
|
|
117
179
|
}
|
|
118
180
|
break
|
|
119
181
|
}
|