poetry-core 0.0.2
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 +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- metadata +267 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { collectionItems } from "@poetry/controllers/helpers/collection"
|
|
3
|
+
import { directionOf } from "@poetry/controllers/helpers/direction"
|
|
4
|
+
import { portalContent, resolvePortalContainer, restoreContent } from "@poetry/controllers/helpers/portal"
|
|
5
|
+
import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
|
|
6
|
+
import { setState, stateOf } from "@poetry/controllers/helpers/state"
|
|
7
|
+
import { createTypeahead } from "@poetry/controllers/helpers/typeahead"
|
|
8
|
+
|
|
9
|
+
// The menus-family controller (the DropdownMenu contract's ANCHOR - ONE
|
|
10
|
+
// menu engine behind DropdownMenu / ContextMenu / Menubar).
|
|
11
|
+
// This owns ONLY what is menu-specific: open/close with the data-open-reason
|
|
12
|
+
// initial-focus contract, item activation (the cancelable poetry:menu:select),
|
|
13
|
+
// checkbox/radio state (poetry:menu:change), the APG typeahead buffer,
|
|
14
|
+
// submenu open/close with hover intent + sibling exclusivity, and the
|
|
15
|
+
// cancelable poetry:menu:edge-navigate seam a Menubar coordinator consumes.
|
|
16
|
+
// Everything else is composed BY REFERENCE: focus-scope (trap + focus return
|
|
17
|
+
// to the trigger), dismissable (topmost-only Esc + outside press, arriving
|
|
18
|
+
// here as its "dismiss" event), roving-focus (arrows/Home/End per menu
|
|
19
|
+
// level), popper (positioning - markup-owned, untouched here), presence
|
|
20
|
+
// (data-open/data-closed flip -> animationend -> hidden).
|
|
21
|
+
//
|
|
22
|
+
// STRUCTURAL RESOLUTION, no targets: the content is found via the trigger's
|
|
23
|
+
// aria-controls id (portal-safe - a Stimulus target cannot cross a portal
|
|
24
|
+
// move), items via the collection helper over the family's data-slot suffix
|
|
25
|
+
// selectors ([data-slot$=menu-item] etc., so dropdown-menu-*, context-menu-*
|
|
26
|
+
// and menubar-* anatomies all resolve), subs via their own aria-controls
|
|
27
|
+
// pairs. Content-level listeners are wired programmatically in connect for
|
|
28
|
+
// the same portal-safety reason.
|
|
29
|
+
//
|
|
30
|
+
// The layer stack is ACTIVATED on open: the focus-scope / dismissable /
|
|
31
|
+
// roving-focus identifiers are appended to the content's data-controller (a
|
|
32
|
+
// statically-connected trap or dismiss layer on a hidden menu would steal
|
|
33
|
+
// focus at page load and swallow topmost-Esc). Close reverses: presence exit
|
|
34
|
+
// -> hidden -> tokens removed -> focus-scope's disconnect restores focus to
|
|
35
|
+
// the trigger. Each open sub level adds its own dismissable layer (the
|
|
36
|
+
// close-one-level-at-a-time Esc chain for free) + its own roving group; subs
|
|
37
|
+
// join the ROOT focus scope (no nested traps, contractual).
|
|
38
|
+
// Both family spellings resolve: dropdown-menu-* / context-menu-* share the
|
|
39
|
+
// "menu-" suffix; menubar-* is its own word (menubar-item does NOT end with
|
|
40
|
+
// "menu-item"), so every part selector carries the pair.
|
|
41
|
+
const menuSlot = (suffix) => `[data-slot$="menu-${suffix}"], [data-slot$="menubar-${suffix}"]`
|
|
42
|
+
const MENU_SELECTOR = '[role="menu"]'
|
|
43
|
+
const TRIGGER_SELECTOR = menuSlot("trigger")
|
|
44
|
+
const SUB_SELECTOR = menuSlot("sub")
|
|
45
|
+
const SUB_TRIGGER_SELECTOR = menuSlot("sub-trigger")
|
|
46
|
+
const SUB_CONTENT_SELECTOR = menuSlot("sub-content")
|
|
47
|
+
const CHECKBOX_ITEM_SELECTOR = menuSlot("checkbox-item")
|
|
48
|
+
const RADIO_ITEM_SELECTOR = menuSlot("radio-item")
|
|
49
|
+
const RADIO_GROUP_SELECTOR = menuSlot("radio-group")
|
|
50
|
+
const ITEM_SELECTOR = [
|
|
51
|
+
menuSlot("item"),
|
|
52
|
+
CHECKBOX_ITEM_SELECTOR,
|
|
53
|
+
RADIO_ITEM_SELECTOR,
|
|
54
|
+
SUB_TRIGGER_SELECTOR
|
|
55
|
+
].join(", ")
|
|
56
|
+
|
|
57
|
+
// The family event namespace (poetry:menu:open|closed|select|change|edge-navigate).
|
|
58
|
+
const EVENT_PREFIX = "poetry:menu"
|
|
59
|
+
|
|
60
|
+
const ROVING = "poetry--core--roving-focus"
|
|
61
|
+
const ROVING_ACTION = `keydown->${ROVING}#keydown`
|
|
62
|
+
const CONTENT_LAYER_CONTROLLERS = ["poetry--core--focus-scope", "poetry--core--dismissable", ROVING]
|
|
63
|
+
const SUB_LAYER_CONTROLLERS = ["poetry--core--dismissable", ROVING]
|
|
64
|
+
const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
|
|
65
|
+
|
|
66
|
+
// Hover intent (the contract's spec): open after 100ms of rest on a
|
|
67
|
+
// sub-trigger; close 300ms after the pointer leaves the sub pair - entering
|
|
68
|
+
// the sub-content (or the trigger again) within the window cancels it, which
|
|
69
|
+
// is the simple-closeDelay answer to the grace-area open question.
|
|
70
|
+
const SUB_OPEN_DELAY = 100
|
|
71
|
+
const SUB_CLOSE_DELAY = 300
|
|
72
|
+
|
|
73
|
+
export default class MenuController extends Controller {
|
|
74
|
+
// The events this controller dispatches (manifest surface;
|
|
75
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
76
|
+
static events = [
|
|
77
|
+
"poetry:menu:change", "poetry:menu:closed", "poetry:menu:edge-navigate",
|
|
78
|
+
"poetry:menu:open", "poetry:menu:select"
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
static values = {
|
|
82
|
+
open: { type: Boolean, default: false },
|
|
83
|
+
modal: { type: Boolean, default: true },
|
|
84
|
+
loop: { type: Boolean, default: false },
|
|
85
|
+
typeaheadTimeout: { type: Number, default: 1000 },
|
|
86
|
+
closeOnSelect: { type: Boolean, default: true }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#connected = false
|
|
90
|
+
#wired = []
|
|
91
|
+
#claimed = new WeakSet() // events already handled once (data-action + delegation both firing)
|
|
92
|
+
#suppressRestore = false
|
|
93
|
+
#cancelExit = null
|
|
94
|
+
#typeahead = createTypeahead()
|
|
95
|
+
#subOpenTimers = new Map()
|
|
96
|
+
#subCloseTimers = new Map()
|
|
97
|
+
#portaledSubs = new Set()
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Wires the content listeners (portal-safe) and reconciles: server-open
|
|
101
|
+
* content adopts the layer stack and re-portals one frame late.
|
|
102
|
+
*/
|
|
103
|
+
connect() {
|
|
104
|
+
const content = this.#content()
|
|
105
|
+
|
|
106
|
+
if (content) this.#wireContent(content)
|
|
107
|
+
|
|
108
|
+
this.#connected = true
|
|
109
|
+
|
|
110
|
+
// Reconcile-on-connect: the server may own the open state (Turbo Stream
|
|
111
|
+
// re-render). DOM attributes win; the layer stack catches up.
|
|
112
|
+
if (this.#isOpen()) {
|
|
113
|
+
if (content) {
|
|
114
|
+
this.#activateLayers(content)
|
|
115
|
+
this.#portalPinned(content)
|
|
116
|
+
}
|
|
117
|
+
this.openValue = true
|
|
118
|
+
} else if (this.openValue) {
|
|
119
|
+
this.#show("trigger-press", { focus: false })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The reconcile path portals ONE FRAME LATE: connect order within a boot
|
|
124
|
+
// is unordered, and portaling before the sibling popper's connect would
|
|
125
|
+
// rob it of its content target before it could cache the node.
|
|
126
|
+
#portalPinned(content) {
|
|
127
|
+
window.requestAnimationFrame(() => {
|
|
128
|
+
if (!this.#connected || !this.#isOpen()) return
|
|
129
|
+
|
|
130
|
+
portalContent(content, { container: resolvePortalContainer(this.element) })
|
|
131
|
+
this.#setStrategy(content, "absolute")
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Restores portaled subs then the content (drop-never-strand), unwires
|
|
137
|
+
* everything, and clears the sub hover-intent timers.
|
|
138
|
+
*/
|
|
139
|
+
disconnect() {
|
|
140
|
+
this.#connected = false
|
|
141
|
+
|
|
142
|
+
// Never leave content stranded at the container (drop-never-strand) -
|
|
143
|
+
// portaled subs first, their placeholders live inside the root content.
|
|
144
|
+
for (const subContent of this.#portaledSubs) {
|
|
145
|
+
restoreContent(subContent)
|
|
146
|
+
subContent.style.pointerEvents = ""
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.#portaledSubs.clear()
|
|
150
|
+
|
|
151
|
+
const content = this.#content()
|
|
152
|
+
|
|
153
|
+
if (content) restoreContent(content)
|
|
154
|
+
|
|
155
|
+
for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
|
|
156
|
+
|
|
157
|
+
this.#wired = []
|
|
158
|
+
|
|
159
|
+
for (const timer of this.#subOpenTimers.values()) window.clearTimeout(timer)
|
|
160
|
+
for (const timer of this.#subCloseTimers.values()) window.clearTimeout(timer)
|
|
161
|
+
|
|
162
|
+
this.#subOpenTimers.clear()
|
|
163
|
+
this.#subCloseTimers.clear()
|
|
164
|
+
this.#typeahead.reset()
|
|
165
|
+
this.#cancelExit?.()
|
|
166
|
+
this.#cancelExit = null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Stimulus value callback - controllable state: a host (outlet / Turbo
|
|
171
|
+
* Stream / URL param) may own the open value; flipping the attribute
|
|
172
|
+
* drives the same machine.
|
|
173
|
+
*
|
|
174
|
+
* @param {boolean} value
|
|
175
|
+
*/
|
|
176
|
+
openValueChanged(value) {
|
|
177
|
+
if (!this.#connected) return
|
|
178
|
+
|
|
179
|
+
if (value && !this.#isOpen()) this.#show("trigger-press")
|
|
180
|
+
else if (!value && this.#isOpen()) this.#hide("none")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- trigger actions ---
|
|
184
|
+
|
|
185
|
+
/** The trigger's click action: open <-> close. */
|
|
186
|
+
toggle() {
|
|
187
|
+
if (this.#isOpen()) this.#hide("trigger-press")
|
|
188
|
+
else this.#show("trigger-press")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The trigger's keydown action: Enter / Space / ArrowDown open + focus
|
|
193
|
+
* the FIRST enabled item; ArrowUp opens + focuses the LAST (the APG
|
|
194
|
+
* menu-button map). preventDefault also suppresses the button's
|
|
195
|
+
* synthetic click, so toggle cannot double-fire.
|
|
196
|
+
*
|
|
197
|
+
* @param {KeyboardEvent} event
|
|
198
|
+
*/
|
|
199
|
+
triggerKeydown(event) {
|
|
200
|
+
if (this.#isOpen()) return
|
|
201
|
+
|
|
202
|
+
if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
|
|
203
|
+
event.preventDefault()
|
|
204
|
+
this.#show("list-navigation", { seed: "first" })
|
|
205
|
+
} else if (event.key === "ArrowUp") {
|
|
206
|
+
event.preventDefault()
|
|
207
|
+
this.#show("list-navigation", { seed: "last" })
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- programmatic API (the family surface Menubar's coordinator calls) ---
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Programmatically opens (the family surface Menubar's coordinator
|
|
215
|
+
* calls).
|
|
216
|
+
*
|
|
217
|
+
* @param {string | Event} [reason="trigger-press"] - a family reason
|
|
218
|
+
* string; an Event (data-action use) reads as trigger-press
|
|
219
|
+
* @param {Object} [options]
|
|
220
|
+
* @param {boolean} [options.focus=true] - false is the coordinator's
|
|
221
|
+
* pointer-toggle contract (a menubar pointer-open leaves focus on the
|
|
222
|
+
* trigger; keyboard-open focuses an item)
|
|
223
|
+
* @param {"first" | "last" | null} [options.seed=null] - the
|
|
224
|
+
* initial-focus seed
|
|
225
|
+
*/
|
|
226
|
+
open(reason = "trigger-press", { focus = true, seed = null } = {}) {
|
|
227
|
+
if (reason instanceof Event) this.#show("trigger-press")
|
|
228
|
+
else this.#show(reason, { focus, seed })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Programmatically closes.
|
|
233
|
+
*
|
|
234
|
+
* @param {string | Event} [reason="none"]
|
|
235
|
+
* @param {Object} [options]
|
|
236
|
+
* @param {boolean} [options.restoreFocus=true] - false is the
|
|
237
|
+
* hover-slide/edge-navigate contract (the outgoing menu must not yank
|
|
238
|
+
* focus back mid-swap)
|
|
239
|
+
*/
|
|
240
|
+
close(reason = "none", { restoreFocus = true } = {}) {
|
|
241
|
+
if (reason instanceof Event) this.#hide("none")
|
|
242
|
+
else this.#hide(reason, { restoreFocus })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- item activation ---
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Item activation - click/Enter/Space unified. Kept as a public action
|
|
249
|
+
* for markup-declared data-action; the delegated content listener
|
|
250
|
+
* claims the event first, so both paths never double-activate.
|
|
251
|
+
*
|
|
252
|
+
* @param {Event} event
|
|
253
|
+
*/
|
|
254
|
+
activate(event) {
|
|
255
|
+
if (this.#claim(event)) return
|
|
256
|
+
|
|
257
|
+
const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
|
|
258
|
+
const item = origin instanceof Element ? origin.closest(ITEM_SELECTOR) : null
|
|
259
|
+
|
|
260
|
+
if (!item || this.#isDisabled(item)) return
|
|
261
|
+
|
|
262
|
+
if (item.matches(SUB_TRIGGER_SELECTOR)) this.#showSub(item, { focusFirst: false })
|
|
263
|
+
else this.#activate(item)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- content keydown (Enter/Space, submenu arrows, edges, Tab, typeahead) ---
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The content's keydown route (also wired programmatically): Tab closes
|
|
270
|
+
* and lets focus move on, Enter/Space activate (Space defers to a live
|
|
271
|
+
* typeahead; native link/submit items act through the element itself),
|
|
272
|
+
* the direction-aware horizontal pair opens/closes submenus or fires
|
|
273
|
+
* the Menubar edge seam, and printable keys run typeahead per level.
|
|
274
|
+
*
|
|
275
|
+
* @param {KeyboardEvent} event
|
|
276
|
+
*/
|
|
277
|
+
keydown(event) {
|
|
278
|
+
if (this.#claim(event)) return
|
|
279
|
+
if (!this.#isOpen()) return
|
|
280
|
+
|
|
281
|
+
const content = this.#content()
|
|
282
|
+
|
|
283
|
+
if (!content) return
|
|
284
|
+
|
|
285
|
+
const target = event.target instanceof Element ? event.target : content
|
|
286
|
+
const menu = target.closest(MENU_SELECTOR) ?? content
|
|
287
|
+
const item = target.closest(ITEM_SELECTOR)
|
|
288
|
+
|
|
289
|
+
switch (event.key) {
|
|
290
|
+
case "Tab":
|
|
291
|
+
// Menus are at most ONE Tab stop: close and let focus move on
|
|
292
|
+
// naturally. The still-attached focus trap must not see this Tab.
|
|
293
|
+
event.stopImmediatePropagation()
|
|
294
|
+
this.#hide("none", { restoreFocus: false })
|
|
295
|
+
return
|
|
296
|
+
case "Enter":
|
|
297
|
+
case " ":
|
|
298
|
+
if (event.key === " " && this.#typeahead.pending()) break // space extends a live search
|
|
299
|
+
|
|
300
|
+
event.preventDefault()
|
|
301
|
+
|
|
302
|
+
if (!item || this.#isDisabled(item)) return
|
|
303
|
+
if (item.matches(SUB_TRIGGER_SELECTOR)) this.#showSub(item, { focusFirst: true })
|
|
304
|
+
else if (this.#isNativeItem(item)) item.click() // link/submit items act through the element itself; the synthetic click also closes the menu
|
|
305
|
+
else this.#activate(item)
|
|
306
|
+
|
|
307
|
+
return
|
|
308
|
+
case "ArrowRight":
|
|
309
|
+
case "ArrowLeft":
|
|
310
|
+
this.#horizontalKeydown(event, { content, menu, item })
|
|
311
|
+
return
|
|
312
|
+
default:
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
317
|
+
event.preventDefault()
|
|
318
|
+
this.#search(event.key, menu)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- submenu actions (pointer + markup-declared) ---
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Each sub-trigger's pointerenter action: hover intent toward opening
|
|
326
|
+
* its sub level.
|
|
327
|
+
*
|
|
328
|
+
* @param {PointerEvent} event
|
|
329
|
+
*/
|
|
330
|
+
subEnter(event) {
|
|
331
|
+
const subTrigger = this.#subTriggerFrom(event)
|
|
332
|
+
|
|
333
|
+
if (subTrigger) this.#subPointerEnter(subTrigger)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Each sub-trigger's pointerleave action: hover intent toward closing
|
|
338
|
+
* (entering the sub pair within the grace cancels it).
|
|
339
|
+
*
|
|
340
|
+
* @param {PointerEvent} event
|
|
341
|
+
*/
|
|
342
|
+
subLeave(event) {
|
|
343
|
+
const subTrigger = this.#subTriggerFrom(event)
|
|
344
|
+
const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
|
|
345
|
+
|
|
346
|
+
if (subTrigger) this.#subPointerLeave(subTrigger, related)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The sub-trigger's click action: opens its sub level (the delegated
|
|
351
|
+
* click claims first).
|
|
352
|
+
*
|
|
353
|
+
* @param {MouseEvent} event
|
|
354
|
+
*/
|
|
355
|
+
openSub(event) {
|
|
356
|
+
if (this.#claim(event)) return
|
|
357
|
+
|
|
358
|
+
const subTrigger = this.#subTriggerFrom(event)
|
|
359
|
+
|
|
360
|
+
if (subTrigger && !this.#isDisabled(subTrigger)) this.#showSub(subTrigger, { focusFirst: false })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Closes a sub-trigger's whole subtree (markup-declared affordances).
|
|
365
|
+
*
|
|
366
|
+
* @param {Event} event
|
|
367
|
+
*/
|
|
368
|
+
closeSub(event) {
|
|
369
|
+
const subTrigger = this.#subTriggerFrom(event)
|
|
370
|
+
|
|
371
|
+
if (subTrigger) this.#closeSubTree(subTrigger, { focusTrigger: false })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- open / close ---
|
|
375
|
+
|
|
376
|
+
#show(reason, { focus = true, seed = null } = {}) {
|
|
377
|
+
const content = this.#content()
|
|
378
|
+
|
|
379
|
+
if (!content || this.#isOpen()) return
|
|
380
|
+
|
|
381
|
+
this.#cancelExit?.()
|
|
382
|
+
this.#cancelExit = null
|
|
383
|
+
this.#suppressRestore = false
|
|
384
|
+
|
|
385
|
+
const trigger = this.#trigger()
|
|
386
|
+
|
|
387
|
+
// Portal-on-open: move BEFORE the
|
|
388
|
+
// enter presence (reparenting mid-animation restarts it), re-anchor
|
|
389
|
+
// absolute - static under compositor scroll, transform-immune. Each
|
|
390
|
+
// SUB level portals on its own open (#showSub) - kept inside, the
|
|
391
|
+
// absolute sub is clipped by the content's overflow-y-auto scroller.
|
|
392
|
+
portalContent(content, { container: resolvePortalContainer(this.element) })
|
|
393
|
+
this.#setStrategy(content, "absolute")
|
|
394
|
+
|
|
395
|
+
content.hidden = false
|
|
396
|
+
content.setAttribute("data-open-reason", reason)
|
|
397
|
+
if (seed) content.setAttribute("data-open-seed", seed)
|
|
398
|
+
else content.removeAttribute("data-open-seed")
|
|
399
|
+
// ContextMenu's trigger SURFACE is not a widget: aria-expanded is only
|
|
400
|
+
// flipped where the server declared it (DropdownMenu button, Menubar
|
|
401
|
+
// menuitem) - never introduced onto a role-less span.
|
|
402
|
+
if (trigger?.hasAttribute("aria-expanded")) trigger.setAttribute("aria-expanded", "true")
|
|
403
|
+
if (trigger) setState(trigger, "popup-open")
|
|
404
|
+
enterPresence(content)
|
|
405
|
+
this.#activateLayers(content)
|
|
406
|
+
this.openValue = true
|
|
407
|
+
|
|
408
|
+
// The layer controllers connect on the attribute-mutation microtask;
|
|
409
|
+
// initial focus must land AFTER focus-scope snapshots the trigger (its
|
|
410
|
+
// focus-return target), so it rides one microtask behind.
|
|
411
|
+
queueMicrotask(() => {
|
|
412
|
+
if (!this.#isOpen()) return
|
|
413
|
+
if (focus) this.#applyInitialFocus(content, seed)
|
|
414
|
+
|
|
415
|
+
this.dispatch("open", { prefix: EVENT_PREFIX, detail: seed ? { reason, seed } : { reason } })
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#hide(reason, { restoreFocus = true } = {}) {
|
|
420
|
+
const content = this.#content()
|
|
421
|
+
|
|
422
|
+
if (!content || !this.#isOpen()) return
|
|
423
|
+
|
|
424
|
+
// The whole chain closes: subs first (no focus juggling on the way down).
|
|
425
|
+
for (const subTrigger of this.#openSubTriggersIn(content)) {
|
|
426
|
+
this.#closeSubTree(subTrigger, { focusTrigger: false })
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.#typeahead.reset()
|
|
430
|
+
// Focus return to the trigger is focus-scope's disconnect job - vetoed
|
|
431
|
+
// for Tab-out and for outside interaction on a non-modal menu
|
|
432
|
+
// (non-modal semantics: focus follows the click).
|
|
433
|
+
this.#suppressRestore = !restoreFocus || (reason === "outside-press" && !this.modalValue)
|
|
434
|
+
|
|
435
|
+
const trigger = this.#trigger()
|
|
436
|
+
|
|
437
|
+
if (trigger?.hasAttribute("aria-expanded")) trigger.setAttribute("aria-expanded", "false")
|
|
438
|
+
if (trigger) setState(trigger, "popup-closed")
|
|
439
|
+
content.removeAttribute("data-open-reason")
|
|
440
|
+
content.removeAttribute("data-open-seed")
|
|
441
|
+
this.openValue = false
|
|
442
|
+
|
|
443
|
+
this.#cancelExit = exitPresence(content, {
|
|
444
|
+
onRemove: () => {
|
|
445
|
+
this.#cancelExit = null
|
|
446
|
+
content.hidden = true
|
|
447
|
+
// Home AFTER the exit finished and hidden landed; focus
|
|
448
|
+
// return is focus-scope's ref-based job, indifferent to the move.
|
|
449
|
+
restoreContent(content)
|
|
450
|
+
this.#setStrategy(content, "fixed")
|
|
451
|
+
this.#removeControllers(content, CONTENT_LAYER_CONTROLLERS)
|
|
452
|
+
this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The root popper rides this.element; each sub hosts its OWN popper on
|
|
458
|
+
// its wrapper inside the content - one coordinate space for the family.
|
|
459
|
+
#setStrategy(content, strategy) {
|
|
460
|
+
this.element.setAttribute(POPPER_STRATEGY, strategy)
|
|
461
|
+
|
|
462
|
+
for (const sub of content.querySelectorAll(SUB_SELECTOR)) {
|
|
463
|
+
sub.setAttribute(POPPER_STRATEGY, strategy)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Initial focus per the family data-open-reason contract: trigger-press ->
|
|
468
|
+
// the content itself (roving arms on the first arrow);
|
|
469
|
+
// list-navigation seeds via data-open-seed: "first" / "last" -> first /
|
|
470
|
+
// last enabled item.
|
|
471
|
+
#applyInitialFocus(content, seed) {
|
|
472
|
+
if (seed === "first") this.#enabledItems(content)[0]?.focus()
|
|
473
|
+
else if (seed === "last") this.#enabledItems(content).at(-1)?.focus()
|
|
474
|
+
else content.focus()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// --- activation ---
|
|
478
|
+
|
|
479
|
+
#activate(item) {
|
|
480
|
+
const kind = item.matches(CHECKBOX_ITEM_SELECTOR)
|
|
481
|
+
? "checkbox"
|
|
482
|
+
: item.matches(RADIO_ITEM_SELECTOR) ? "radio" : "item"
|
|
483
|
+
const value = item.dataset.value ?? null
|
|
484
|
+
|
|
485
|
+
// Checkbox/radio state commits BEFORE the select event, so a canceled
|
|
486
|
+
// close still keeps the new state (the contract's activation semantics).
|
|
487
|
+
if (kind === "checkbox") {
|
|
488
|
+
const checked = item.getAttribute("aria-checked") !== "true"
|
|
489
|
+
|
|
490
|
+
this.#writeChecked(item, checked)
|
|
491
|
+
this.dispatch("change", {
|
|
492
|
+
prefix: EVENT_PREFIX, target: item, detail: { kind, value, checked, group_value: null }
|
|
493
|
+
})
|
|
494
|
+
} else if (kind === "radio") {
|
|
495
|
+
const group = item.closest(RADIO_GROUP_SELECTOR)
|
|
496
|
+
const radios = group ? Array.from(group.querySelectorAll(RADIO_ITEM_SELECTOR)) : [item]
|
|
497
|
+
|
|
498
|
+
for (const radio of radios) this.#writeChecked(radio, radio === item)
|
|
499
|
+
|
|
500
|
+
group?.setAttribute("data-value", value ?? "")
|
|
501
|
+
this.dispatch("change", {
|
|
502
|
+
prefix: EVENT_PREFIX, target: item, detail: { kind, value, checked: true, group_value: value }
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// The cancelable select (veto by preventDefault):
|
|
507
|
+
// a canceled select keeps the menu open.
|
|
508
|
+
const select = this.dispatch("select", {
|
|
509
|
+
prefix: EVENT_PREFIX,
|
|
510
|
+
target: item,
|
|
511
|
+
cancelable: true,
|
|
512
|
+
detail: { item, value, variant: item.dataset.variant ?? "default", kind }
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
if (select.defaultPrevented) return
|
|
516
|
+
if (!this.#closeOnSelectFor(item)) return
|
|
517
|
+
|
|
518
|
+
this.#hide("item-press")
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// A NATIVE item is an anchor (link) or a button (form submit) wearing
|
|
522
|
+
// role=menuitem: click and Enter/Space act through the element itself - the
|
|
523
|
+
// browser navigates / submits (Turbo-intercepted), never a JS-only activate.
|
|
524
|
+
// Keyboard routes through a synthetic item.click() so the same path fires -
|
|
525
|
+
// no recursion, since activate() never calls click().
|
|
526
|
+
#isNativeItem(item) {
|
|
527
|
+
return item.matches("a[href], button")
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// aria-checked and data-checked/data-unchecked are written TOGETHER, never separately.
|
|
531
|
+
#writeChecked(item, checked) {
|
|
532
|
+
item.setAttribute("aria-checked", String(checked))
|
|
533
|
+
setState(item, checked ? "checked" : "unchecked")
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Per-item close_on_select overrides the menu-level default (checkbox /
|
|
537
|
+
// radio preference toggles commonly declare data-close-on-select="false").
|
|
538
|
+
#closeOnSelectFor(item) {
|
|
539
|
+
const override = item.dataset.closeOnSelect
|
|
540
|
+
|
|
541
|
+
if (override !== undefined) return override !== "false"
|
|
542
|
+
|
|
543
|
+
return this.closeOnSelectValue
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// --- horizontal arrows: submenus + the Menubar edge seam ---
|
|
547
|
+
|
|
548
|
+
#horizontalKeydown(event, { content, menu, item }) {
|
|
549
|
+
const rtl = directionOf(menu) === "rtl"
|
|
550
|
+
const openKey = rtl ? "ArrowLeft" : "ArrowRight"
|
|
551
|
+
|
|
552
|
+
if (event.key === openKey) {
|
|
553
|
+
if (item?.matches(SUB_TRIGGER_SELECTOR) && !this.#isDisabled(item)) {
|
|
554
|
+
event.preventDefault()
|
|
555
|
+
this.#showSub(item, { focusFirst: true })
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// No submenu meaning at the ROOT level only -> the Menubar seam.
|
|
560
|
+
if (menu === content) this.#edgeNavigate(event)
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// The close arrow: inside a sub it closes one level back to its
|
|
565
|
+
// sub-trigger; at the root there is nothing to close -> the edge seam.
|
|
566
|
+
// NEVER fired from inside a sub (ArrowLeft there means close-sub).
|
|
567
|
+
if (menu !== content) {
|
|
568
|
+
event.preventDefault()
|
|
569
|
+
|
|
570
|
+
const subTrigger = this.#subTriggerFor(menu)
|
|
571
|
+
|
|
572
|
+
if (subTrigger) this.#closeSubTree(subTrigger, { focusTrigger: true })
|
|
573
|
+
} else {
|
|
574
|
+
this.#edgeNavigate(event)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Cancelable, bubbling, from the ROOT content only. direction is the
|
|
579
|
+
// PHYSICAL key; the consumer (the Menubar coordinator) applies RTL
|
|
580
|
+
// mapping. Unconsumed (standalone DropdownMenu) -> no-op.
|
|
581
|
+
#edgeNavigate(event) {
|
|
582
|
+
const direction = event.key === "ArrowRight" ? "right" : "left"
|
|
583
|
+
const dispatched = this.dispatch("edge-navigate", {
|
|
584
|
+
prefix: EVENT_PREFIX,
|
|
585
|
+
target: this.#content(),
|
|
586
|
+
cancelable: true,
|
|
587
|
+
detail: { direction }
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
if (dispatched.defaultPrevented) event.preventDefault()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// --- typeahead (APG: 1s buffer, wrap, same-letter cycling, per level) ---
|
|
594
|
+
// The algorithm lives in helpers/typeahead.js (shared with Select); the
|
|
595
|
+
// menu keeps only its own wiring: enabled items per level, the focused
|
|
596
|
+
// item as the search anchor, and focus as the match action.
|
|
597
|
+
|
|
598
|
+
#search(key, menu) {
|
|
599
|
+
const items = this.#enabledItems(menu)
|
|
600
|
+
const active = document.activeElement instanceof Element
|
|
601
|
+
? document.activeElement.closest(ITEM_SELECTOR)
|
|
602
|
+
: null
|
|
603
|
+
const match = this.#typeahead.search(key, items, { active, timeout: this.typeaheadTimeoutValue })
|
|
604
|
+
|
|
605
|
+
match?.focus()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// --- submenus ---
|
|
609
|
+
|
|
610
|
+
#showSub(subTrigger, { focusFirst = false } = {}) {
|
|
611
|
+
if (this.#isDisabled(subTrigger)) return
|
|
612
|
+
|
|
613
|
+
const subContent = this.#subContentFor(subTrigger)
|
|
614
|
+
|
|
615
|
+
if (!subContent) return
|
|
616
|
+
|
|
617
|
+
if (!subTrigger.hasAttribute("data-popup-open")) {
|
|
618
|
+
// Sibling exclusivity: at most ONE open sub per level - the open-sub
|
|
619
|
+
// chain is a path.
|
|
620
|
+
const menu = subTrigger.closest(MENU_SELECTOR)
|
|
621
|
+
|
|
622
|
+
for (const sibling of this.#openSubTriggersIn(menu ?? this.#content(), { level: menu })) {
|
|
623
|
+
if (sibling !== subTrigger) this.#closeSubTree(sibling, { focusTrigger: false })
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Portal the sub level like the root: left
|
|
627
|
+
// absolute INSIDE the content it is clipped by the menu's own
|
|
628
|
+
// overflow-y-auto scroller - the flyout opens invisible (every sub
|
|
629
|
+
// level must escape that clip). Native events no
|
|
630
|
+
// longer bubble through the root content from the container, so the
|
|
631
|
+
// delegated listeners ride the portaled node too - the bridge
|
|
632
|
+
// carries only poetry events home.
|
|
633
|
+
portalContent(subContent, { container: resolvePortalContainer(this.element) })
|
|
634
|
+
this.#portaledSubs.add(subContent)
|
|
635
|
+
this.#wireSub(subContent)
|
|
636
|
+
// The modal scrim is body pointer-events none + per-layer auto; the
|
|
637
|
+
// sub inherited the root layer's auto while inside it - at the
|
|
638
|
+
// container it must carry its own.
|
|
639
|
+
subContent.style.pointerEvents = "auto"
|
|
640
|
+
|
|
641
|
+
subContent.hidden = false
|
|
642
|
+
subTrigger.setAttribute("aria-expanded", "true")
|
|
643
|
+
setState(subTrigger, "popup-open")
|
|
644
|
+
enterPresence(subContent)
|
|
645
|
+
|
|
646
|
+
// Each sub level = its own dismissable layer (topmost-only Esc closes
|
|
647
|
+
// just the deepest level) + its own roving group; NO focus-scope (subs
|
|
648
|
+
// join the root trap) and the sub's popper root is markup-owned.
|
|
649
|
+
subContent.setAttribute("data-poetry--core--dismissable-disable-outside-pointer-events-value", "false")
|
|
650
|
+
this.#wireRoving(subContent)
|
|
651
|
+
this.#addControllers(subContent, SUB_LAYER_CONTROLLERS)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (focusFirst) this.#enabledItems(subContent)[0]?.focus()
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
#closeSubTree(subTrigger, { focusTrigger = false } = {}) {
|
|
658
|
+
this.#cancelSubOpen(subTrigger)
|
|
659
|
+
this.#cancelSubClose(subTrigger)
|
|
660
|
+
|
|
661
|
+
if (!subTrigger.hasAttribute("data-popup-open")) return
|
|
662
|
+
|
|
663
|
+
const subContent = this.#subContentFor(subTrigger)
|
|
664
|
+
|
|
665
|
+
if (subContent) {
|
|
666
|
+
for (const nested of this.#openSubTriggersIn(subContent)) {
|
|
667
|
+
this.#closeSubTree(nested, { focusTrigger: false })
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
subTrigger.setAttribute("aria-expanded", "false")
|
|
672
|
+
setState(subTrigger, "popup-closed")
|
|
673
|
+
|
|
674
|
+
if (subContent) {
|
|
675
|
+
exitPresence(subContent, {
|
|
676
|
+
onRemove: () => {
|
|
677
|
+
subContent.hidden = true
|
|
678
|
+
// Home AFTER the exit finished and hidden landed.
|
|
679
|
+
restoreContent(subContent)
|
|
680
|
+
subContent.style.pointerEvents = ""
|
|
681
|
+
this.#portaledSubs.delete(subContent)
|
|
682
|
+
this.#unwireSub(subContent)
|
|
683
|
+
this.#removeControllers(subContent, SUB_LAYER_CONTROLLERS)
|
|
684
|
+
}
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (focusTrigger) subTrigger.focus()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- submenu hover intent ---
|
|
692
|
+
|
|
693
|
+
#subPointerEnter(subTrigger) {
|
|
694
|
+
this.#cancelSubClose(subTrigger)
|
|
695
|
+
|
|
696
|
+
for (const ancestor of this.#ancestorSubTriggers(subTrigger)) this.#cancelSubClose(ancestor)
|
|
697
|
+
|
|
698
|
+
if (this.#isDisabled(subTrigger) || subTrigger.hasAttribute("data-popup-open")) return
|
|
699
|
+
if (this.#subOpenTimers.has(subTrigger)) return
|
|
700
|
+
|
|
701
|
+
this.#subOpenTimers.set(subTrigger, window.setTimeout(() => {
|
|
702
|
+
this.#subOpenTimers.delete(subTrigger)
|
|
703
|
+
this.#showSub(subTrigger, { focusFirst: false }) // hover-open never moves focus
|
|
704
|
+
}, SUB_OPEN_DELAY))
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
#subPointerLeave(subTrigger, related) {
|
|
708
|
+
this.#cancelSubOpen(subTrigger)
|
|
709
|
+
|
|
710
|
+
const subContent = this.#subContentFor(subTrigger)
|
|
711
|
+
|
|
712
|
+
if (related && (subTrigger.contains(related) || subContent?.contains(related))) return
|
|
713
|
+
if (subTrigger.hasAttribute("data-popup-open")) this.#scheduleSubClose(subTrigger)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
#scheduleSubClose(subTrigger) {
|
|
717
|
+
this.#cancelSubClose(subTrigger)
|
|
718
|
+
this.#subCloseTimers.set(subTrigger, window.setTimeout(() => {
|
|
719
|
+
this.#subCloseTimers.delete(subTrigger)
|
|
720
|
+
this.#closeSubTree(subTrigger, { focusTrigger: false })
|
|
721
|
+
}, SUB_CLOSE_DELAY))
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
#cancelSubOpen(subTrigger) {
|
|
725
|
+
const timer = this.#subOpenTimers.get(subTrigger)
|
|
726
|
+
|
|
727
|
+
if (timer === undefined) return
|
|
728
|
+
|
|
729
|
+
window.clearTimeout(timer)
|
|
730
|
+
this.#subOpenTimers.delete(subTrigger)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
#cancelSubClose(subTrigger) {
|
|
734
|
+
const timer = this.#subCloseTimers.get(subTrigger)
|
|
735
|
+
|
|
736
|
+
if (timer === undefined) return
|
|
737
|
+
|
|
738
|
+
window.clearTimeout(timer)
|
|
739
|
+
this.#subCloseTimers.delete(subTrigger)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// --- content wiring (programmatic: portal-safe, no data-action required) ---
|
|
743
|
+
|
|
744
|
+
#wireContent(content) {
|
|
745
|
+
this.#listen(content, "keydown", (event) => this.keydown(event))
|
|
746
|
+
this.#listen(content, "click", this.#onClick)
|
|
747
|
+
this.#listen(content, "pointerover", this.#onPointerover)
|
|
748
|
+
this.#listen(content, "pointerout", this.#onPointerout)
|
|
749
|
+
this.#listen(content, "poetry--core--dismissable:dismiss", this.#onDismiss)
|
|
750
|
+
this.#listen(content, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
|
|
751
|
+
this.#listen(content, "poetry--core--focus-scope:mount-auto-focus", this.#onMountAutoFocus)
|
|
752
|
+
this.#listen(content, "poetry--core--focus-scope:unmount-auto-focus", this.#onUnmountAutoFocus)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
#listen(target, type, listener) {
|
|
756
|
+
target.addEventListener(type, listener)
|
|
757
|
+
this.#wired.push([target, type, listener])
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// The portaled sub is self-sufficient: while at the container it no
|
|
761
|
+
// longer bubbles through the root content, and item data-actions are out
|
|
762
|
+
// of scope - so the native delegation AND the sub's own
|
|
763
|
+
// dismissable events ride the portaled node. The bridge still re-emits
|
|
764
|
+
// the dismissable pair from home (the wrapper), where level resolution
|
|
765
|
+
// finds no sub content and the duplicate no-ops.
|
|
766
|
+
#wireSub(subContent) {
|
|
767
|
+
this.#listen(subContent, "keydown", (event) => this.keydown(event))
|
|
768
|
+
this.#listen(subContent, "click", this.#onClick)
|
|
769
|
+
this.#listen(subContent, "pointerover", this.#onPointerover)
|
|
770
|
+
this.#listen(subContent, "pointerout", this.#onPointerout)
|
|
771
|
+
this.#listen(subContent, "poetry--core--dismissable:dismiss", this.#onDismiss)
|
|
772
|
+
this.#listen(subContent, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
#unwireSub(subContent) {
|
|
776
|
+
this.#wired = this.#wired.filter(([target, type, listener]) => {
|
|
777
|
+
if (target !== subContent) return true
|
|
778
|
+
|
|
779
|
+
target.removeEventListener(type, listener)
|
|
780
|
+
return false
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
#onClick = (event) => {
|
|
785
|
+
if (this.#claim(event)) return
|
|
786
|
+
|
|
787
|
+
const item = event.target instanceof Element ? event.target.closest(ITEM_SELECTOR) : null
|
|
788
|
+
|
|
789
|
+
if (!item || this.#isDisabled(item)) return
|
|
790
|
+
|
|
791
|
+
if (item.matches(SUB_TRIGGER_SELECTOR)) this.#showSub(item, { focusFirst: false })
|
|
792
|
+
else this.#activate(item)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Esc / outside-press arrive as the dismissable layer's dismiss event: the
|
|
796
|
+
// root content's layer closes the menu; a sub-content's layer (the topmost
|
|
797
|
+
// while a sub is open) closes just that level - Esc focuses ITS sub-trigger.
|
|
798
|
+
// A press on the menu's OWN root trigger is the toggle's job, not an
|
|
799
|
+
// outside dismissal: without the veto the pointerdown closes and the
|
|
800
|
+
// trailing click re-opens (the popover trigger-press rule; the menubar
|
|
801
|
+
// coordinator vetoes the same way for bar-wide triggers).
|
|
802
|
+
#onInteractOutside = (event) => {
|
|
803
|
+
if (event.target !== this.#content()) return
|
|
804
|
+
|
|
805
|
+
const origin = event.detail?.originalEvent?.target
|
|
806
|
+
|
|
807
|
+
if (origin instanceof Element && this.#trigger()?.contains(origin)) event.preventDefault()
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
#onDismiss = (event) => {
|
|
811
|
+
const target = event.target instanceof Element ? event.target : null
|
|
812
|
+
|
|
813
|
+
if (!target) return
|
|
814
|
+
|
|
815
|
+
const escaped = event.detail?.originalEvent?.type === "keydown"
|
|
816
|
+
|
|
817
|
+
if (target === this.#content()) {
|
|
818
|
+
this.#hide(escaped ? "escape-key" : "outside-press")
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const subContent = target.closest(SUB_CONTENT_SELECTOR)
|
|
823
|
+
const subTrigger = subContent && this.#subTriggerFor(subContent)
|
|
824
|
+
|
|
825
|
+
if (subTrigger) this.#closeSubTree(subTrigger, { focusTrigger: escaped })
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// The menu owns initial focus (per data-open-reason), not focus-scope.
|
|
829
|
+
#onMountAutoFocus = (event) => {
|
|
830
|
+
if (event.target === this.#content()) event.preventDefault()
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#onUnmountAutoFocus = (event) => {
|
|
834
|
+
if (event.target === this.#content() && this.#suppressRestore) event.preventDefault()
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
#onPointerover = (event) => {
|
|
838
|
+
const target = event.target instanceof Element ? event.target : null
|
|
839
|
+
|
|
840
|
+
if (!target) return
|
|
841
|
+
|
|
842
|
+
// Travel into an open sub keeps its whole chain alive (the grace window).
|
|
843
|
+
for (const subTrigger of this.#ancestorSubTriggers(target)) this.#cancelSubClose(subTrigger)
|
|
844
|
+
|
|
845
|
+
const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
|
|
846
|
+
const subTrigger = target.closest(SUB_TRIGGER_SELECTOR)
|
|
847
|
+
|
|
848
|
+
if (subTrigger) {
|
|
849
|
+
if (!related || !subTrigger.contains(related)) this.#subPointerEnter(subTrigger)
|
|
850
|
+
|
|
851
|
+
this.#pointerHighlight(subTrigger, event)
|
|
852
|
+
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Hovering a plain sibling item schedules the open sub at that level to close.
|
|
857
|
+
const item = target.closest(ITEM_SELECTOR)
|
|
858
|
+
|
|
859
|
+
if (!item) return
|
|
860
|
+
|
|
861
|
+
const menu = item.closest(MENU_SELECTOR)
|
|
862
|
+
|
|
863
|
+
if (!menu) return
|
|
864
|
+
|
|
865
|
+
for (const open of this.#openSubTriggersIn(menu, { level: menu })) this.#scheduleSubClose(open)
|
|
866
|
+
|
|
867
|
+
this.#pointerHighlight(item, event, menu)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Focus follows the pointer (menu semantics): the themes
|
|
871
|
+
// style item highlight through focus: alone - no :hover rule - so hover
|
|
872
|
+
// highlight IS this focus move. A disabled item clears the highlight
|
|
873
|
+
// instead (the menu takes focus back), and touch pointers never
|
|
874
|
+
// hover-highlight (tap semantics).
|
|
875
|
+
#pointerHighlight(item, event, menu = item.closest(MENU_SELECTOR)) {
|
|
876
|
+
if (event.pointerType === "touch") return
|
|
877
|
+
|
|
878
|
+
if (this.#isDisabled(item)) menu?.focus()
|
|
879
|
+
else if (document.activeElement !== item) item.focus()
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
#onPointerout = (event) => {
|
|
883
|
+
const target = event.target instanceof Element ? event.target : null
|
|
884
|
+
|
|
885
|
+
if (!target) return
|
|
886
|
+
|
|
887
|
+
const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
|
|
888
|
+
const subTrigger = target.closest(SUB_TRIGGER_SELECTOR)
|
|
889
|
+
|
|
890
|
+
if (subTrigger && (!related || !subTrigger.contains(related))) {
|
|
891
|
+
this.#subPointerLeave(subTrigger, related)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const subContent = target.closest(SUB_CONTENT_SELECTOR)
|
|
895
|
+
|
|
896
|
+
if (subContent && (!related || !subContent.contains(related))) {
|
|
897
|
+
const owner = this.#subTriggerFor(subContent)
|
|
898
|
+
|
|
899
|
+
if (owner && !(related && owner.contains(related)) && owner.hasAttribute("data-popup-open")) {
|
|
900
|
+
this.#scheduleSubClose(owner)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Leaving a plain item for blank space hands focus back to its menu so
|
|
905
|
+
// the hover highlight clears. Item-to-item travel skips
|
|
906
|
+
// this - the entering item's pointerover refocuses anyway - and an open
|
|
907
|
+
// sub-trigger keeps its data-popup-open styling regardless.
|
|
908
|
+
const item = target.closest(ITEM_SELECTOR)
|
|
909
|
+
|
|
910
|
+
if (item && !item.matches(SUB_TRIGGER_SELECTOR) && document.activeElement === item &&
|
|
911
|
+
(!related || !related.closest(ITEM_SELECTOR))) {
|
|
912
|
+
item.closest(MENU_SELECTOR)?.focus()
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// --- the layer stack ---
|
|
917
|
+
|
|
918
|
+
#activateLayers(content) {
|
|
919
|
+
content.setAttribute("data-poetry--core--focus-scope-trapped-value", String(this.modalValue))
|
|
920
|
+
content.setAttribute(
|
|
921
|
+
"data-poetry--core--dismissable-disable-outside-pointer-events-value", String(this.modalValue)
|
|
922
|
+
)
|
|
923
|
+
this.#wireRoving(content)
|
|
924
|
+
this.#addControllers(content, CONTENT_LAYER_CONTROLLERS)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Menus are roving-focus's DEFAULT mode: vertical, manageTabindex TRUE
|
|
928
|
+
// (items tabindex=-1, real focus roves - the mode Accordion's
|
|
929
|
+
// focus-nav-only flag contrasted against). loop forwards the menu value
|
|
930
|
+
// (default false).
|
|
931
|
+
#wireRoving(element) {
|
|
932
|
+
element.setAttribute(`data-${ROVING}-orientation-value`, "vertical")
|
|
933
|
+
element.setAttribute(`data-${ROVING}-manage-tabindex-value`, "true")
|
|
934
|
+
element.setAttribute(`data-${ROVING}-loop-value`, String(this.loopValue))
|
|
935
|
+
|
|
936
|
+
const action = element.getAttribute("data-action") ?? ""
|
|
937
|
+
|
|
938
|
+
if (!action.includes(ROVING_ACTION)) {
|
|
939
|
+
element.setAttribute("data-action", `${action} ${ROVING_ACTION}`.trim())
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
#addControllers(element, identifiers) {
|
|
944
|
+
const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
|
|
945
|
+
|
|
946
|
+
for (const identifier of identifiers) {
|
|
947
|
+
if (!tokens.includes(identifier)) tokens.push(identifier)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
element.setAttribute("data-controller", tokens.join(" "))
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
#removeControllers(element, identifiers) {
|
|
954
|
+
const tokens = (element.getAttribute("data-controller") ?? "")
|
|
955
|
+
.split(/\s+/)
|
|
956
|
+
.filter((token) => token && !identifiers.includes(token))
|
|
957
|
+
|
|
958
|
+
element.setAttribute("data-controller", tokens.join(" "))
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// --- structural resolution (the DOM is the registry; ids are the seams) ---
|
|
962
|
+
|
|
963
|
+
#trigger() {
|
|
964
|
+
for (const candidate of this.element.querySelectorAll(TRIGGER_SELECTOR)) {
|
|
965
|
+
if (!candidate.closest(MENU_SELECTOR)) return candidate
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return null
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
#content() {
|
|
972
|
+
const id = this.#trigger()?.getAttribute("aria-controls")
|
|
973
|
+
|
|
974
|
+
return id ? document.getElementById(id) : null
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
#isOpen() {
|
|
978
|
+
const content = this.#content()
|
|
979
|
+
|
|
980
|
+
return Boolean(content) && stateOf(content) === "open"
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#subContentFor(subTrigger) {
|
|
984
|
+
const id = subTrigger.getAttribute("aria-controls")
|
|
985
|
+
const byId = id ? document.getElementById(id) : null
|
|
986
|
+
|
|
987
|
+
return byId ?? subTrigger.closest(SUB_SELECTOR)?.querySelector(SUB_CONTENT_SELECTOR) ?? null
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
#subTriggerFor(subContent) {
|
|
991
|
+
if (subContent.id) {
|
|
992
|
+
for (const candidate of document.querySelectorAll(SUB_TRIGGER_SELECTOR)) {
|
|
993
|
+
if (candidate.getAttribute("aria-controls") === subContent.id) return candidate
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return subContent.closest(SUB_SELECTOR)?.querySelector(SUB_TRIGGER_SELECTOR) ?? null
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
#subTriggerFrom(event) {
|
|
1001
|
+
const origin = event?.currentTarget instanceof Element ? event.currentTarget : event?.target
|
|
1002
|
+
|
|
1003
|
+
return origin instanceof Element ? origin.closest(SUB_TRIGGER_SELECTOR) : null
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Open sub-triggers within a scope; level restricts to one menu level.
|
|
1007
|
+
#openSubTriggersIn(scope, { level = null } = {}) {
|
|
1008
|
+
if (!scope) return []
|
|
1009
|
+
|
|
1010
|
+
return Array.from(scope.querySelectorAll(SUB_TRIGGER_SELECTOR)).filter((subTrigger) =>
|
|
1011
|
+
subTrigger.hasAttribute("data-popup-open") && (!level || subTrigger.closest(MENU_SELECTOR) === level))
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
#ancestorSubTriggers(node) {
|
|
1015
|
+
const ancestors = []
|
|
1016
|
+
let subContent = node.closest(SUB_CONTENT_SELECTOR)
|
|
1017
|
+
|
|
1018
|
+
while (subContent) {
|
|
1019
|
+
const subTrigger = this.#subTriggerFor(subContent)
|
|
1020
|
+
|
|
1021
|
+
if (!subTrigger) break
|
|
1022
|
+
|
|
1023
|
+
ancestors.push(subTrigger)
|
|
1024
|
+
subContent = subTrigger.closest(SUB_CONTENT_SELECTOR)
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return ancestors
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Items of ONE menu level, in DOM order, disabled filtered at query time.
|
|
1031
|
+
#itemsOf(menu) {
|
|
1032
|
+
return collectionItems(menu, ITEM_SELECTOR).filter((item) => item.closest(MENU_SELECTOR) === menu)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
#enabledItems(menu) {
|
|
1036
|
+
return this.#itemsOf(menu).filter((item) => !this.#isDisabled(item))
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
#isDisabled(item) {
|
|
1040
|
+
return item.hasAttribute("data-disabled") || item.getAttribute("aria-disabled") === "true"
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
#claim(event) {
|
|
1044
|
+
if (this.#claimed.has(event)) return true
|
|
1045
|
+
|
|
1046
|
+
this.#claimed.add(event)
|
|
1047
|
+
|
|
1048
|
+
return false
|
|
1049
|
+
}
|
|
1050
|
+
}
|