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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f473efd9c0d12c6d6c8df6fd9273881e7c47ae51049041ec77b4e8ca1cbd4287
4
- data.tar.gz: 87b7bc488972de80728431ab4888b9160c9a7676ae5f956ff155fe0fa52c6cad
3
+ metadata.gz: 30f3e5a92218a515eb068a382276bb2ecab9795e9205a265039bac71bac5d27c
4
+ data.tar.gz: 9ba29c74dcd4526e5e4e3b3dbdecb0d877ae86d44280aefb7beb1450bc87b21e
5
5
  SHA512:
6
- metadata.gz: 167f3b0a36846778aeda28b072567ed91af0a67c3f55c1eeae79db7785afc4a947a71314d7e2895cf2205dd829eabbfa88a4d1365a5b7a0d423fd8d7fddf6181
7
- data.tar.gz: 249b441648327a54d8d906a526c269e8194add9badd1468111636ab03d7499962cd9949f6c981c8e0a158cceb41d1e1490e3f18d2414e48786dab70c90b2466f
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 { usePortal } from "@poetry/controllers/behaviors/portal"
7
- import { usePresence } from "@poetry/controllers/behaviors/presence"
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, stateOf } from "@poetry/controllers/helpers/state"
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). Zero code is shared with SelectController - the PATTERNS are
42
- * (the layer mechanism, the native-first 5-step pipeline, the adoption
43
- * path), re-instantiated here because a facade extraction was explicitly
44
- * declined (the Select contract's open question, answered by this build).
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
- #connected = false
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.#presence = usePresence(this)
122
- this.#portal = usePortal(this)
123
- this.#layers = useLayers(this)
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.#connected = true
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.#connected = false
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
- const content = this.#content()
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
- // FOCUS GOES TO THE INPUT for every reason (the typing-session call);
560
- // the selected option is communicated by highlight, not focus.
561
- input?.focus()
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
- if (seed && input) {
564
- input.value = seed
565
- // The engine's own action runs the pass (and re-seats highlight).
566
- input.dispatchEvent(new Event("input", { bubbles: true }))
567
- } else {
568
- // Seed the highlight on the COMMITTED option via the Command
569
- // controller (activedescendant + scrollIntoView) - first enabled
570
- // when no value (the engine cannot seat while the popup was
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.dispatch("open", { prefix: EVENT_PREFIX, detail: seed ? { reason, seed } : { reason } })
580
- })
527
+ if (target) this.#command()?.highlightItem(target)
528
+ }
581
529
  }
582
530
 
583
531
  #hide(reason, { restoreFocus = true } = {}) {
584
- const content = this.#content()
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
- useListen(this, content, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
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
- const content = this.#content()
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