poetry-core 0.1.10 → 0.1.12
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/CHANGELOG.md +16 -0
- data/app/javascript/poetry/core/behaviors/overlay.js +289 -0
- data/app/javascript/poetry/core/combobox_controller.js +48 -176
- data/app/javascript/poetry/core/command_controller.js +3 -0
- data/app/javascript/poetry/core/menu_controller.js +43 -150
- data/app/javascript/poetry/core/popover_controller.js +31 -159
- data/app/javascript/poetry/core/select_controller.js +41 -160
- data/config/controllers_manifest.json +3 -3
- data/lib/poetry/core/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30f3e5a92218a515eb068a382276bb2ecab9795e9205a265039bac71bac5d27c
|
|
4
|
+
data.tar.gz: 9ba29c74dcd4526e5e4e3b3dbdecb0d877ae86d44280aefb7beb1450bc87b21e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a15e8257a9c946eeba75798bc32848b0c86fb9d7c17a780506b38d2eb98d85fbdf34ce5844da7d584c08a12bceb923f43b8bda0432ccbaef792ed03530ed6337
|
|
7
|
+
data.tar.gz: 139af0f376f0e35b3fbaf93bc25e31ba9fd5e8b9e13c687b867af957a585b1a7482d8f7bfcb67b34da1c3c4260a8621af4fdb2f08c55ed91f141028d4f82c0fc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.12] - 2026-10-07
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Version bump with the family; no changes in this gem.
|
|
8
|
+
|
|
9
|
+
## [0.1.11] - 2026-10-05
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- The four layered popups share one machine: `@poetry/controllers/behaviors/overlay` (`useOverlay`) owns the show and hide sequencing, the late portal on reconcile, the layer tokens, the dismiss, interact-outside and auto-focus vetoes and the focus-restore suppression rule, and popover, menu, select and combobox hand it their policies as hooks: how the content and the trigger resolve, which layers go on, what the open microtask does, what a hide settles first, how the open event reads. Rendered markup, event names and sequencing are unchanged; the controllers lose about a third of their lines between them. Tooltip and hover card stay on their own pointer-timed machines, which share less with the four than the hooks to express the difference would cost.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- The command engine writes `data-empty` on its list when the filter pass leaves no visible item, beside unhiding the empty part, so a theme can restyle an empty list; the Combobox themes' `data-empty:p-0` rule was dead without it.
|
|
18
|
+
|
|
3
19
|
## [0.1.10] - 2026-10-03
|
|
4
20
|
|
|
5
21
|
### Added
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// useOverlay: the popup machine the layered overlays share (popover,
|
|
2
|
+
// menu, select, combobox): show and hide sequencing, the late portal on
|
|
3
|
+
// reconcile, the layer tokens, the dismiss, interact-outside and
|
|
4
|
+
// auto-focus vetoes, and the focus-restore suppression rule. The
|
|
5
|
+
// policies that make each component itself stay in the controller and
|
|
6
|
+
// come in as hooks: how the content and the trigger resolve, which
|
|
7
|
+
// layers go on, what happens on open, what to settle before a hide, how
|
|
8
|
+
// the open event reads. Rendered markup and sequencing are what the six
|
|
9
|
+
// controllers did by hand; this is the skeleton, not the product.
|
|
10
|
+
import { useLayers } from "@poetry/controllers/behaviors/layers"
|
|
11
|
+
import { useListen } from "@poetry/controllers/behaviors/listen"
|
|
12
|
+
import { usePortal } from "@poetry/controllers/behaviors/portal"
|
|
13
|
+
import { usePresence } from "@poetry/controllers/behaviors/presence"
|
|
14
|
+
import { resolvePortalContainer } from "@poetry/controllers/helpers/portal"
|
|
15
|
+
import { setState, stateOf } from "@poetry/controllers/helpers/state"
|
|
16
|
+
|
|
17
|
+
const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
|
|
18
|
+
const DISMISS_EVENT = "poetry--core--dismissable:dismiss"
|
|
19
|
+
const INTERACT_OUTSIDE_EVENT = "poetry--core--dismissable:interact-outside"
|
|
20
|
+
const MOUNT_AUTO_FOCUS_EVENT = "poetry--core--focus-scope:mount-auto-focus"
|
|
21
|
+
const UNMOUNT_AUTO_FOCUS_EVENT = "poetry--core--focus-scope:unmount-auto-focus"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The overlay machine for one controller, over the controller's
|
|
25
|
+
* current scope (its behaviors tear down with `teardown(controller)`).
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} controller - the Stimulus controller instance; reads
|
|
28
|
+
* and writes `openValue`, dispatches through `controller.dispatch`
|
|
29
|
+
* @param {Object} options
|
|
30
|
+
* @param {() => Element | null} options.content - the popup element
|
|
31
|
+
* @param {() => Element | null} options.trigger - the element whose
|
|
32
|
+
* press toggles the popup, the interact-outside veto's inside
|
|
33
|
+
* @param {() => Element | null} [options.expander] - the element that
|
|
34
|
+
* carries aria-expanded and the popup-open state; the trigger by default
|
|
35
|
+
* @param {string[]} options.layers - the layer controllers the content
|
|
36
|
+
* wears while open
|
|
37
|
+
* @param {string} options.eventPrefix - the prefix of the open and closed events
|
|
38
|
+
* @param {() => boolean} [options.modal] - whether the popup is modal;
|
|
39
|
+
* decides the suppression of focus restore after an outside press
|
|
40
|
+
* @param {boolean} [options.reasons=false] - whether the content carries
|
|
41
|
+
* data-open-reason and data-open-seed while open
|
|
42
|
+
* @param {boolean} [options.expandedWhenPresent=false] - write
|
|
43
|
+
* aria-expanded only when the expander already carries it
|
|
44
|
+
* @param {boolean} [options.vetoMountAutoFocus=false] - keep focus-scope
|
|
45
|
+
* from moving focus on mount (the controller places it itself)
|
|
46
|
+
* @param {(content: Element) => void} [options.activateLayers] - writes
|
|
47
|
+
* the layer values before the tokens go on; the tokens go on either way
|
|
48
|
+
* @param {(content: Element, strategy: string) => void} [options.strategy]
|
|
49
|
+
* - writes the popper strategy; the root's attribute by default
|
|
50
|
+
* @param {(content: Element, open: Object) => void} [options.onOpen] -
|
|
51
|
+
* runs on the microtask after a show, before the open event
|
|
52
|
+
* @param {(reason: string, seed: string | null) => Object} [options.openDetail]
|
|
53
|
+
* - the open event's detail
|
|
54
|
+
* @param {(content: Element, reason: string) => void} [options.beforeHide]
|
|
55
|
+
* - settles what a hide must settle first (sub levels, a typeahead)
|
|
56
|
+
* @param {(content: Element) => void} [options.beforeExit] - runs after
|
|
57
|
+
* the state flips closed and before the exit presence starts
|
|
58
|
+
* @param {(content: Element) => void} [options.onHidden] - runs once the
|
|
59
|
+
* exit finished and the content is home, before the closed event
|
|
60
|
+
* @param {(origin: Element) => boolean} [options.insideAlso] - more
|
|
61
|
+
* elements an outside press must not dismiss from (a chips field)
|
|
62
|
+
* @param {(event: Event) => void} [options.onDismiss] - sees every
|
|
63
|
+
* dismiss event on the content before the machine acts
|
|
64
|
+
* @param {(target: Element, escaped: boolean) => void} [options.onDismissElsewhere]
|
|
65
|
+
* - a dismiss from a node inside the content that is not the content
|
|
66
|
+
* @returns {Object} the machine: connect, show, hide, isOpen, portalPinned
|
|
67
|
+
*/
|
|
68
|
+
export function useOverlay(controller, options) {
|
|
69
|
+
const presence = usePresence(controller)
|
|
70
|
+
const portal = usePortal(controller)
|
|
71
|
+
const layers = useLayers(controller)
|
|
72
|
+
const element = controller.element
|
|
73
|
+
const content = options.content
|
|
74
|
+
const trigger = options.trigger
|
|
75
|
+
const expander = options.expander ?? trigger
|
|
76
|
+
const modal = options.modal ?? (() => false)
|
|
77
|
+
const setStrategy = options.strategy ?? ((_content, strategy) => element.setAttribute(POPPER_STRATEGY, strategy))
|
|
78
|
+
const openDetail = options.openDetail ?? ((reason, seed) => (seed ? { reason, seed } : { reason }))
|
|
79
|
+
|
|
80
|
+
let connected = false
|
|
81
|
+
let suppressRestore = false
|
|
82
|
+
|
|
83
|
+
const isOpen = () => {
|
|
84
|
+
const node = content()
|
|
85
|
+
|
|
86
|
+
return Boolean(node) && stateOf(node) === "open"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const activateLayers = (node) => {
|
|
90
|
+
options.activateLayers?.(node)
|
|
91
|
+
layers.activate(node, options.layers)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The reconcile path portals ONE FRAME LATE: connect order within a
|
|
95
|
+
// boot is unordered, and portaling before the sibling popper's connect
|
|
96
|
+
// would rob it of its content target before it could cache the node.
|
|
97
|
+
const portalPinned = (node) => {
|
|
98
|
+
window.requestAnimationFrame(() => {
|
|
99
|
+
if (!connected || !isOpen()) return
|
|
100
|
+
|
|
101
|
+
portal.portal(node, { container: resolvePortalContainer(element) })
|
|
102
|
+
setStrategy(node, "absolute")
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const show = (reason, { seed = null, focus = true } = {}) => {
|
|
107
|
+
const node = content()
|
|
108
|
+
|
|
109
|
+
if (!node || isOpen()) return
|
|
110
|
+
|
|
111
|
+
presence.cancel(node)
|
|
112
|
+
suppressRestore = false
|
|
113
|
+
|
|
114
|
+
const opener = expander()
|
|
115
|
+
|
|
116
|
+
// Portal-on-open: move BEFORE the enter presence (reparenting
|
|
117
|
+
// mid-animation restarts it), re-anchor absolute - static under
|
|
118
|
+
// compositor scroll, transform-immune.
|
|
119
|
+
portal.portal(node, { container: resolvePortalContainer(element) })
|
|
120
|
+
setStrategy(node, "absolute")
|
|
121
|
+
|
|
122
|
+
node.hidden = false
|
|
123
|
+
if (options.reasons) {
|
|
124
|
+
node.setAttribute("data-open-reason", reason)
|
|
125
|
+
if (seed) node.setAttribute("data-open-seed", seed)
|
|
126
|
+
else node.removeAttribute("data-open-seed")
|
|
127
|
+
}
|
|
128
|
+
if (opener && (!options.expandedWhenPresent || opener.hasAttribute("aria-expanded"))) {
|
|
129
|
+
opener.setAttribute("aria-expanded", "true")
|
|
130
|
+
}
|
|
131
|
+
if (opener) setState(opener, "popup-open")
|
|
132
|
+
presence.enter(node)
|
|
133
|
+
activateLayers(node)
|
|
134
|
+
controller.openValue = true
|
|
135
|
+
|
|
136
|
+
queueMicrotask(() => {
|
|
137
|
+
if (!isOpen()) return
|
|
138
|
+
|
|
139
|
+
options.onOpen?.(node, { reason, seed, focus })
|
|
140
|
+
controller.dispatch("open", { prefix: options.eventPrefix, detail: openDetail(reason, seed) })
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const hide = (reason, { restoreFocus = true } = {}) => {
|
|
145
|
+
const node = content()
|
|
146
|
+
|
|
147
|
+
if (!node || !isOpen()) return
|
|
148
|
+
|
|
149
|
+
options.beforeHide?.(node, reason)
|
|
150
|
+
suppressRestore = !restoreFocus || (reason === "outside-press" && !modal())
|
|
151
|
+
|
|
152
|
+
const opener = expander()
|
|
153
|
+
|
|
154
|
+
if (opener && (!options.expandedWhenPresent || opener.hasAttribute("aria-expanded"))) {
|
|
155
|
+
opener.setAttribute("aria-expanded", "false")
|
|
156
|
+
}
|
|
157
|
+
if (opener) setState(opener, "popup-closed")
|
|
158
|
+
if (options.reasons) {
|
|
159
|
+
node.removeAttribute("data-open-reason")
|
|
160
|
+
node.removeAttribute("data-open-seed")
|
|
161
|
+
}
|
|
162
|
+
options.beforeExit?.(node)
|
|
163
|
+
controller.openValue = false
|
|
164
|
+
|
|
165
|
+
presence.exit(node, {
|
|
166
|
+
onRemove: () => {
|
|
167
|
+
node.hidden = true
|
|
168
|
+
// Home AFTER the exit finished and hidden landed; focus return is
|
|
169
|
+
// focus-scope's ref-based job, indifferent to the move.
|
|
170
|
+
portal.restore(node)
|
|
171
|
+
setStrategy(node, "fixed")
|
|
172
|
+
layers.deactivate(node, options.layers)
|
|
173
|
+
options.onHidden?.(node)
|
|
174
|
+
controller.dispatch("closed", { prefix: options.eventPrefix, detail: { reason } })
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A press on the popup's OWN trigger is the toggle's job, not an
|
|
180
|
+
// outside press: veto the dismissal so the toggle can close it.
|
|
181
|
+
const onInteractOutside = (event) => {
|
|
182
|
+
if (event.target !== content()) return
|
|
183
|
+
|
|
184
|
+
const origin = event.detail?.originalEvent?.target
|
|
185
|
+
|
|
186
|
+
if (!(origin instanceof Element)) return
|
|
187
|
+
if (trigger()?.contains(origin) || options.insideAlso?.(origin)) event.preventDefault()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const onDismiss = (event) => {
|
|
191
|
+
const target = event.target instanceof Element ? event.target : null
|
|
192
|
+
|
|
193
|
+
if (!target) return
|
|
194
|
+
|
|
195
|
+
options.onDismiss?.(event)
|
|
196
|
+
|
|
197
|
+
const escaped = event.detail?.originalEvent?.type === "keydown"
|
|
198
|
+
|
|
199
|
+
if (target === content()) hide(escaped ? "escape-key" : "outside-press")
|
|
200
|
+
else options.onDismissElsewhere?.(target, escaped)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const onMountAutoFocus = (event) => {
|
|
204
|
+
if (event.target === content()) event.preventDefault()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const onUnmountAutoFocus = (event) => {
|
|
208
|
+
if (event.target === content() && suppressRestore) event.preventDefault()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const wire = (node) => {
|
|
212
|
+
useListen(controller, node, DISMISS_EVENT, onDismiss)
|
|
213
|
+
useListen(controller, node, INTERACT_OUTSIDE_EVENT, onInteractOutside)
|
|
214
|
+
if (options.vetoMountAutoFocus) useListen(controller, node, MOUNT_AUTO_FOCUS_EVENT, onMountAutoFocus)
|
|
215
|
+
useListen(controller, node, UNMOUNT_AUTO_FOCUS_EVENT, onUnmountAutoFocus)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
/**
|
|
220
|
+
* Wires the content's layer events now; `connect()` reconciles later.
|
|
221
|
+
* @returns {void}
|
|
222
|
+
*/
|
|
223
|
+
wire() {
|
|
224
|
+
const node = content()
|
|
225
|
+
|
|
226
|
+
if (node) wire(node)
|
|
227
|
+
},
|
|
228
|
+
/**
|
|
229
|
+
* Marks the machine connected and reconciles: server-open content
|
|
230
|
+
* adopts the layer stack and re-portals one frame late (the DOM
|
|
231
|
+
* wins); an open value with closed markup shows.
|
|
232
|
+
* @param {Object} [initial] - the show call for an open value
|
|
233
|
+
* @param {string} [initial.reason="trigger-press"]
|
|
234
|
+
* @param {boolean} [initial.focus=true]
|
|
235
|
+
* @returns {void}
|
|
236
|
+
*/
|
|
237
|
+
connect({ reason = "trigger-press", focus = true } = {}) {
|
|
238
|
+
connected = true
|
|
239
|
+
|
|
240
|
+
const node = content()
|
|
241
|
+
|
|
242
|
+
if (isOpen()) {
|
|
243
|
+
if (node) {
|
|
244
|
+
activateLayers(node)
|
|
245
|
+
portalPinned(node)
|
|
246
|
+
}
|
|
247
|
+
controller.openValue = true
|
|
248
|
+
} else if (controller.openValue) {
|
|
249
|
+
show(reason, { focus })
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
/**
|
|
253
|
+
* Marks the machine disconnected; the behaviors tear down with the
|
|
254
|
+
* controller's scope.
|
|
255
|
+
* @returns {void}
|
|
256
|
+
*/
|
|
257
|
+
disconnect() {
|
|
258
|
+
connected = false
|
|
259
|
+
},
|
|
260
|
+
/**
|
|
261
|
+
* Whether the machine has connected and not yet disconnected.
|
|
262
|
+
* @returns {boolean}
|
|
263
|
+
*/
|
|
264
|
+
connected: () => connected,
|
|
265
|
+
show,
|
|
266
|
+
hide,
|
|
267
|
+
isOpen,
|
|
268
|
+
portalPinned,
|
|
269
|
+
// The behaviors under the machine, for a controller with more levels
|
|
270
|
+
// than the root popup (a menu's sub levels ride the same instances,
|
|
271
|
+
// so teardown restores them newest first, subs before the root).
|
|
272
|
+
presence,
|
|
273
|
+
portal,
|
|
274
|
+
layers,
|
|
275
|
+
/**
|
|
276
|
+
* Wires the dismiss and interact-outside handlers onto another node
|
|
277
|
+
* (a portaled sub level, which no longer bubbles through the root);
|
|
278
|
+
* a dismiss from it reaches `onDismissElsewhere`.
|
|
279
|
+
* @param {Element} node
|
|
280
|
+
* @returns {Array<() => void>} the unlisten handles
|
|
281
|
+
*/
|
|
282
|
+
wireLevel(node) {
|
|
283
|
+
return [
|
|
284
|
+
useListen(controller, node, DISMISS_EVENT, onDismiss),
|
|
285
|
+
useListen(controller, node, INTERACT_OUTSIDE_EVENT, onInteractOutside)
|
|
286
|
+
]
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import { collectionItems } from "@poetry/controllers/helpers/collection"
|
|
3
3
|
import { isImeKeydown } from "@poetry/controllers/helpers/escape"
|
|
4
|
-
import { useLayers } from "@poetry/controllers/behaviors/layers"
|
|
5
4
|
import { useListen } from "@poetry/controllers/behaviors/listen"
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { isPortaled, resolvePortalContainer } from "@poetry/controllers/helpers/portal"
|
|
5
|
+
import { useOverlay } from "@poetry/controllers/behaviors/overlay"
|
|
6
|
+
import { isPortaled } from "@poetry/controllers/helpers/portal"
|
|
9
7
|
import { teardown } from "@poetry/controllers/helpers/scope"
|
|
10
|
-
import { setState
|
|
8
|
+
import { setState } from "@poetry/controllers/helpers/state"
|
|
11
9
|
import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
|
|
12
10
|
|
|
13
11
|
const TRIGGER_SELECTOR = '[data-slot="combobox-trigger"]'
|
|
@@ -29,7 +27,6 @@ const COMMAND_IDENTIFIER = "poetry--core--command"
|
|
|
29
27
|
// NO roving-focus (the popup is Command's activedescendant session - the
|
|
30
28
|
// family's first popup without it).
|
|
31
29
|
const CONTENT_LAYER_CONTROLLERS = ["poetry--core--focus-scope", "poetry--core--dismissable"]
|
|
32
|
-
const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
|
|
33
30
|
|
|
34
31
|
/**
|
|
35
32
|
* The Combobox ORCHESTRATOR: Select's shell
|
|
@@ -38,10 +35,12 @@ const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
|
|
|
38
35
|
* listens for the embedded engine's poetry:command:select; it contains NO
|
|
39
36
|
* filter/highlight/scoring code (Command owns those) and Command gained no
|
|
40
37
|
* combobox code (engine purity, fenced both directions by the conformance
|
|
41
|
-
* greps).
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
38
|
+
* greps). What it shares with SelectController is the SKELETON, the
|
|
39
|
+
* overlay machine in behaviors/overlay.js (show and hide sequencing,
|
|
40
|
+
* the late portal, the layer tokens, the vetoes); the policies (the
|
|
41
|
+
* native-first 5-step pipeline, the adoption path, the three deltas
|
|
42
|
+
* below) stay here. A facade over the policies was evaluated and
|
|
43
|
+
* declined once, and that call stands: only the sequence is shared.
|
|
45
44
|
*
|
|
46
45
|
* THE THREE DELIBERATE DELTAS vs Select, each pinned by tests so neither
|
|
47
46
|
* sibling's rules leak:
|
|
@@ -102,11 +101,7 @@ export default class ComboboxController extends Controller {
|
|
|
102
101
|
multiple: { type: Boolean, default: false }
|
|
103
102
|
}
|
|
104
103
|
|
|
105
|
-
#
|
|
106
|
-
#suppressRestore = false
|
|
107
|
-
#presence = null
|
|
108
|
-
#portal = null
|
|
109
|
-
#layers = null
|
|
104
|
+
#overlay = null
|
|
110
105
|
#applied = ""
|
|
111
106
|
#placeholder = ""
|
|
112
107
|
#dismissedEvent = null
|
|
@@ -118,9 +113,21 @@ export default class ComboboxController extends Controller {
|
|
|
118
113
|
* popup up (layers + the late portal).
|
|
119
114
|
*/
|
|
120
115
|
connect() {
|
|
121
|
-
this.#
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
this.#overlay = useOverlay(this, {
|
|
117
|
+
content: () => this.#content(),
|
|
118
|
+
trigger: () => this.#trigger(),
|
|
119
|
+
expander: () => this.#expander(),
|
|
120
|
+
layers: CONTENT_LAYER_CONTROLLERS,
|
|
121
|
+
eventPrefix: EVENT_PREFIX,
|
|
122
|
+
modal: () => this.modalValue,
|
|
123
|
+
reasons: true,
|
|
124
|
+
vetoMountAutoFocus: true,
|
|
125
|
+
activateLayers: (content) => this.#activateLayers(content),
|
|
126
|
+
onOpen: (content, { seed }) => this.#opened(seed),
|
|
127
|
+
onHidden: () => this.#command()?.reset(),
|
|
128
|
+
insideAlso: (origin) => this.multipleValue && Boolean(this.#chips()?.contains(origin)),
|
|
129
|
+
onDismiss: (event) => { this.#dismissedEvent = event.detail?.originalEvent ?? null }
|
|
130
|
+
})
|
|
124
131
|
|
|
125
132
|
const content = this.#content()
|
|
126
133
|
|
|
@@ -147,29 +154,7 @@ export default class ComboboxController extends Controller {
|
|
|
147
154
|
this.#applied = serverValue
|
|
148
155
|
this.#apply(serverValue, { silent: true, force: true })
|
|
149
156
|
|
|
150
|
-
this.#
|
|
151
|
-
|
|
152
|
-
if (this.#isOpen()) {
|
|
153
|
-
if (content) {
|
|
154
|
-
this.#activateLayers(content)
|
|
155
|
-
this.#portalPinned(content)
|
|
156
|
-
}
|
|
157
|
-
this.openValue = true
|
|
158
|
-
} else if (this.openValue) {
|
|
159
|
-
this.#show("trigger-press")
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// The reconcile path portals ONE FRAME LATE: connect order within a boot
|
|
164
|
-
// is unordered, and portaling before the sibling popper's connect would
|
|
165
|
-
// rob it of its content target before it could cache the node.
|
|
166
|
-
#portalPinned(content) {
|
|
167
|
-
window.requestAnimationFrame(() => {
|
|
168
|
-
if (!this.#connected || !this.#isOpen()) return
|
|
169
|
-
|
|
170
|
-
this.#portal.portal(content, { container: resolvePortalContainer(this.element) })
|
|
171
|
-
this.element.setAttribute(POPPER_STRATEGY, "absolute")
|
|
172
|
-
})
|
|
157
|
+
this.#overlay.connect()
|
|
173
158
|
}
|
|
174
159
|
|
|
175
160
|
/**
|
|
@@ -178,7 +163,7 @@ export default class ComboboxController extends Controller {
|
|
|
178
163
|
* dropped when the home is gone - never stranded).
|
|
179
164
|
*/
|
|
180
165
|
disconnect() {
|
|
181
|
-
this.#
|
|
166
|
+
this.#overlay?.disconnect()
|
|
182
167
|
teardown(this)
|
|
183
168
|
}
|
|
184
169
|
|
|
@@ -190,7 +175,7 @@ export default class ComboboxController extends Controller {
|
|
|
190
175
|
* @param {boolean} value
|
|
191
176
|
*/
|
|
192
177
|
openValueChanged(value) {
|
|
193
|
-
if (!this.#connected) return
|
|
178
|
+
if (!this.#overlay?.connected()) return
|
|
194
179
|
|
|
195
180
|
if (value && !this.#isOpen()) this.#show("trigger-press")
|
|
196
181
|
else if (!value && this.#isOpen()) this.#hide("none")
|
|
@@ -203,7 +188,7 @@ export default class ComboboxController extends Controller {
|
|
|
203
188
|
* @param {string} value
|
|
204
189
|
*/
|
|
205
190
|
valueValueChanged(value) {
|
|
206
|
-
if (!this.#connected) return
|
|
191
|
+
if (!this.#overlay?.connected()) return
|
|
207
192
|
|
|
208
193
|
if (this.multipleValue) {
|
|
209
194
|
const values = this.#listValues(value)
|
|
@@ -522,105 +507,31 @@ export default class ComboboxController extends Controller {
|
|
|
522
507
|
// --- open / close ---
|
|
523
508
|
|
|
524
509
|
#show(reason, { seed = "" } = {}) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if (!content || this.#isOpen()) return
|
|
528
|
-
|
|
529
|
-
this.#presence.cancel(content)
|
|
530
|
-
this.#suppressRestore = false
|
|
531
|
-
|
|
532
|
-
const expander = this.#expander()
|
|
533
|
-
|
|
534
|
-
// Portal-on-open: move BEFORE the
|
|
535
|
-
// enter presence (reparenting mid-animation restarts it), re-anchor
|
|
536
|
-
// absolute - static under compositor scroll, transform-immune. In
|
|
537
|
-
// multiple mode only the popup (listbox) moves; the chips field with
|
|
538
|
-
// its inline input stays home as the popper anchor.
|
|
539
|
-
this.#portal.portal(content, { container: resolvePortalContainer(this.element) })
|
|
540
|
-
this.element.setAttribute(POPPER_STRATEGY, "absolute")
|
|
541
|
-
|
|
542
|
-
content.hidden = false
|
|
543
|
-
content.setAttribute("data-open-reason", reason)
|
|
544
|
-
if (seed) content.setAttribute("data-open-seed", seed)
|
|
545
|
-
else content.removeAttribute("data-open-seed")
|
|
546
|
-
expander?.setAttribute("aria-expanded", "true")
|
|
547
|
-
if (expander) setState(expander, "popup-open")
|
|
548
|
-
this.#presence.enter(content)
|
|
549
|
-
this.#activateLayers(content)
|
|
550
|
-
this.openValue = true
|
|
551
|
-
|
|
552
|
-
// Layer controllers connect on the attribute-mutation microtask; focus
|
|
553
|
-
// rides one behind so focus-scope has snapshotted the trigger.
|
|
554
|
-
queueMicrotask(() => {
|
|
555
|
-
if (!this.#isOpen()) return
|
|
556
|
-
|
|
557
|
-
const input = this.#input()
|
|
510
|
+
this.#overlay.show(reason, { seed: seed || null })
|
|
511
|
+
}
|
|
558
512
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
513
|
+
// The open microtask: focus the input, seed the filter or highlight
|
|
514
|
+
// the selected (else the first enabled) option.
|
|
515
|
+
#opened(seed) {
|
|
516
|
+
const input = this.#input()
|
|
562
517
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
// hidden, so the shell seeds every open).
|
|
572
|
-
const items = this.#items()
|
|
573
|
-
const target = items.find((item) => item.getAttribute("aria-selected") === "true") ??
|
|
574
|
-
items.find((item) => !item.hasAttribute("data-disabled") && !item.closest("[hidden]"))
|
|
575
|
-
|
|
576
|
-
if (target) this.#command()?.highlightItem(target)
|
|
577
|
-
}
|
|
518
|
+
input?.focus()
|
|
519
|
+
if (seed && input) {
|
|
520
|
+
input.value = seed
|
|
521
|
+
input.dispatchEvent(new Event("input", { bubbles: true }))
|
|
522
|
+
} else {
|
|
523
|
+
const items = this.#items()
|
|
524
|
+
const target = items.find((item) => item.getAttribute("aria-selected") === "true") ??
|
|
525
|
+
items.find((item) => !item.hasAttribute("data-disabled") && !item.closest("[hidden]"))
|
|
578
526
|
|
|
579
|
-
this
|
|
580
|
-
}
|
|
527
|
+
if (target) this.#command()?.highlightItem(target)
|
|
528
|
+
}
|
|
581
529
|
}
|
|
582
530
|
|
|
583
531
|
#hide(reason, { restoreFocus = true } = {}) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if (!content || !this.#isOpen()) return
|
|
587
|
-
|
|
588
|
-
this.#suppressRestore = !restoreFocus || (reason === "outside-press" && !this.modalValue)
|
|
589
|
-
|
|
590
|
-
const expander = this.#expander()
|
|
591
|
-
|
|
592
|
-
expander?.setAttribute("aria-expanded", "false")
|
|
593
|
-
if (expander) setState(expander, "popup-closed")
|
|
594
|
-
content.removeAttribute("data-open-reason")
|
|
595
|
-
content.removeAttribute("data-open-seed")
|
|
596
|
-
this.openValue = false
|
|
597
|
-
|
|
598
|
-
this.#presence.exit(content, {
|
|
599
|
-
onRemove: () => {
|
|
600
|
-
content.hidden = true
|
|
601
|
-
// Home AFTER the exit finished and hidden landed; focus
|
|
602
|
-
// return is focus-scope's ref-based job, indifferent to the move.
|
|
603
|
-
this.#portal.restore(content)
|
|
604
|
-
this.element.setAttribute(POPPER_STRATEGY, "fixed")
|
|
605
|
-
this.#layers.deactivate(content, CONTENT_LAYER_CONTROLLERS)
|
|
606
|
-
// Reset the query so reopen starts clean (a remount would get
|
|
607
|
-
// this for free; persistent DOM does it deliberately).
|
|
608
|
-
this.#command()?.reset()
|
|
609
|
-
this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
|
|
610
|
-
}
|
|
611
|
-
})
|
|
532
|
+
this.#overlay.hide(reason, { restoreFocus })
|
|
612
533
|
}
|
|
613
534
|
|
|
614
|
-
// --- the commit pipeline ---
|
|
615
|
-
|
|
616
|
-
// Command's select event is the ONLY commit trigger (no click handling
|
|
617
|
-
// here - the composition boundary). Cancelable BEFORE the value commits;
|
|
618
|
-
// vetoing keeps the popup open. Committing the already-selected value is
|
|
619
|
-
// IDEMPOTENT: close, value unchanged, no change events (deselection is a
|
|
620
|
-
// form affordance - include_blank - not a hidden toggle gesture).
|
|
621
|
-
// multiple INVERTS both rules: selection TOGGLES membership
|
|
622
|
-
// (appended at the array END) and the popup STAYS OPEN; a typed query
|
|
623
|
-
// clears immediately so the full list is restored for the next pick.
|
|
624
535
|
#onCommandSelect = (event) => {
|
|
625
536
|
const { item, value = "", label = "" } = event.detail ?? {}
|
|
626
537
|
|
|
@@ -843,10 +754,7 @@ export default class ComboboxController extends Controller {
|
|
|
843
754
|
#wireContent(content) {
|
|
844
755
|
useListen(this, content, "keydown", this.#onKeydown)
|
|
845
756
|
useListen(this, content, "poetry:command:select", this.#onCommandSelect)
|
|
846
|
-
|
|
847
|
-
useListen(this, content, "poetry--core--dismissable:dismiss", this.#onDismiss)
|
|
848
|
-
useListen(this, content, "poetry--core--focus-scope:mount-auto-focus", this.#onMountAutoFocus)
|
|
849
|
-
useListen(this, content, "poetry--core--focus-scope:unmount-auto-focus", this.#onUnmountAutoFocus)
|
|
757
|
+
this.#overlay.wire()
|
|
850
758
|
}
|
|
851
759
|
|
|
852
760
|
// Tab while open CLOSES WITHOUT COMMIT and lets focus proceed (Popover
|
|
@@ -895,38 +803,6 @@ export default class ComboboxController extends Controller {
|
|
|
895
803
|
// trailing click re-opens). In multiple, a press in the chips FIELD is
|
|
896
804
|
// an anchor press too - vetoing the layer keeps the popup open while
|
|
897
805
|
// chips mutate under the pointer.
|
|
898
|
-
#onInteractOutside = (event) => {
|
|
899
|
-
if (event.target !== this.#content()) return
|
|
900
|
-
|
|
901
|
-
const origin = event.detail?.originalEvent?.target
|
|
902
|
-
|
|
903
|
-
if (!(origin instanceof Element)) return
|
|
904
|
-
if (this.#trigger()?.contains(origin)) return event.preventDefault()
|
|
905
|
-
if (this.multipleValue && this.#chips()?.contains(origin)) event.preventDefault()
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// Esc / outside press arrive as the dismissable layer's dismiss event.
|
|
909
|
-
// Neither EVER commits - the value is untouched (family rule).
|
|
910
|
-
#onDismiss = (event) => {
|
|
911
|
-
if (event.target !== this.#content()) return
|
|
912
|
-
|
|
913
|
-
const escaped = event.detail?.originalEvent?.type === "keydown"
|
|
914
|
-
|
|
915
|
-
// Remembered so the SAME keypress cannot double as the closed-popup
|
|
916
|
-
// Escape wipe in the multiple input map.
|
|
917
|
-
this.#dismissedEvent = event.detail?.originalEvent ?? null
|
|
918
|
-
this.#hide(escaped ? "escape-key" : "outside-press")
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// The combobox owns initial focus (the command input), not focus-scope.
|
|
922
|
-
#onMountAutoFocus = (event) => {
|
|
923
|
-
if (event.target === this.#content()) event.preventDefault()
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
#onUnmountAutoFocus = (event) => {
|
|
927
|
-
if (event.target === this.#content() && this.#suppressRestore) event.preventDefault()
|
|
928
|
-
}
|
|
929
|
-
|
|
930
806
|
// --- the layer stack (Select's proven mechanism, MINUS roving-focus) ---
|
|
931
807
|
|
|
932
808
|
#activateLayers(content) {
|
|
@@ -938,8 +814,6 @@ export default class ComboboxController extends Controller {
|
|
|
938
814
|
content.setAttribute(
|
|
939
815
|
"data-poetry--core--dismissable-disable-outside-pointer-events-value", String(this.modalValue)
|
|
940
816
|
)
|
|
941
|
-
|
|
942
|
-
this.#layers.activate(content, CONTENT_LAYER_CONTROLLERS)
|
|
943
817
|
}
|
|
944
818
|
|
|
945
819
|
|
|
@@ -1065,9 +939,7 @@ export default class ComboboxController extends Controller {
|
|
|
1065
939
|
}
|
|
1066
940
|
|
|
1067
941
|
#isOpen() {
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
return Boolean(content) && stateOf(content) === "open"
|
|
942
|
+
return this.#overlay.isOpen()
|
|
1071
943
|
}
|
|
1072
944
|
|
|
1073
945
|
#items() {
|
|
@@ -347,6 +347,9 @@ export default class CommandController extends Controller {
|
|
|
347
347
|
const empty = list.querySelector(EMPTY_SELECTOR) ?? this.element.querySelector(EMPTY_SELECTOR)
|
|
348
348
|
|
|
349
349
|
if (empty) empty.hidden = visible !== 0
|
|
350
|
+
// The list says so too, so a theme can restyle it (the source drops
|
|
351
|
+
// the list padding when only the empty message shows).
|
|
352
|
+
list.toggleAttribute("data-empty", visible === 0)
|
|
350
353
|
|
|
351
354
|
// Re-seat: highest score among visible ∩ enabled, first-in-DOM
|
|
352
355
|
// tiebreak (strictly-greater replacement preserves DOM order as the
|