poetry-core 0.1.9 → 0.1.10

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.jsdoc_floor +1 -1
  3. data/CHANGELOG.md +18 -0
  4. data/app/components/poetry/core/concerns/styles.rb +27 -4
  5. data/app/javascript/poetry/core/behaviors/announce.js +18 -0
  6. data/app/javascript/poetry/core/behaviors/before_cache.js +15 -0
  7. data/app/javascript/poetry/core/behaviors/escape.js +16 -0
  8. data/app/javascript/poetry/core/behaviors/layers.js +55 -0
  9. data/app/javascript/poetry/core/behaviors/listen.js +18 -0
  10. data/app/javascript/poetry/core/behaviors/mobile.js +16 -0
  11. data/app/javascript/poetry/core/behaviors/portal.js +50 -0
  12. data/app/javascript/poetry/core/behaviors/presence.js +85 -0
  13. data/app/javascript/poetry/core/behaviors/scroll_lock.js +41 -0
  14. data/app/javascript/poetry/core/behaviors/typeahead.js +21 -0
  15. data/app/javascript/poetry/core/combobox_controller.js +32 -55
  16. data/app/javascript/poetry/core/date_field_controller.js +11 -10
  17. data/app/javascript/poetry/core/deferred_controller.js +9 -12
  18. data/app/javascript/poetry/core/dialog_controller.js +17 -30
  19. data/app/javascript/poetry/core/dismissable_controller.js +10 -15
  20. data/app/javascript/poetry/core/helpers/scope.js +120 -0
  21. data/app/javascript/poetry/core/hover_card_controller.js +39 -57
  22. data/app/javascript/poetry/core/menu_controller.js +57 -82
  23. data/app/javascript/poetry/core/message_scroller_controller.js +4 -3
  24. data/app/javascript/poetry/core/navigation_menu_controller.js +17 -21
  25. data/app/javascript/poetry/core/popover_controller.js +28 -52
  26. data/app/javascript/poetry/core/select_controller.js +39 -62
  27. data/app/javascript/poetry/core/sidebar_controller.js +18 -31
  28. data/app/javascript/poetry/core/toast_controller.js +8 -15
  29. data/app/javascript/poetry/core/toaster_controller.js +13 -15
  30. data/app/javascript/poetry/core/tooltip_controller.js +35 -65
  31. data/app/javascript/poetry/core/tree_controller.js +10 -2
  32. data/config/controllers_manifest.json +21 -19
  33. data/lib/poetry/core/html/attributes.rb +18 -6
  34. data/lib/poetry/core/version.rb +1 -1
  35. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1831a4d45731d76d83cfdbb9a65b20433a32bf7c57a15297f7838e04d0a55c21
4
- data.tar.gz: a65f3175560897a70c6f2f8e29d3373bf3c5ef02f7baae20c35fcc3ddb4b3b6f
3
+ metadata.gz: f473efd9c0d12c6d6c8df6fd9273881e7c47ae51049041ec77b4e8ca1cbd4287
4
+ data.tar.gz: 87b7bc488972de80728431ab4888b9160c9a7676ae5f956ff155fe0fa52c6cad
5
5
  SHA512:
6
- metadata.gz: 2f77187b648438d1e648956744425e82efbeb28f4b8efe0aa19b04ec647db0420bc6320405f01ccb938d6594856974c26702a2572c972a5a71134c16afa67e85
7
- data.tar.gz: 59f36c4032dc99a6643486ae88f1cacfe71cf7f1ab94f046ee22654276feba9baa4a95244bfa8a011edd2731d269a8dee2408b458e00d00c52367d38076ee647
6
+ metadata.gz: 167f3b0a36846778aeda28b072567ed91af0a67c3f55c1eeae79db7785afc4a947a71314d7e2895cf2205dd829eabbfa88a4d1365a5b7a0d423fd8d7fddf6181
7
+ data.tar.gz: 249b441648327a54d8d906a526c269e8194add9badd1468111636ab03d7499962cd9949f6c981c8e0a158cceb41d1e1490e3f18d2414e48786dab70c90b2466f
data/.jsdoc_floor CHANGED
@@ -1 +1 @@
1
- 605
1
+ 586
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.10] - 2026-10-03
4
+
5
+ ### Added
6
+
7
+ - The per-connect scope for Stimulus controllers, `@poetry/controllers/helpers/scope`: `scopeOf(controller)` hands out one disposer stack per connect, `teardown(controller)` runs it newest first, aborts the scope's signal and drops it, and the next `connect()` starts clean (Stimulus keeps one instance per element and re-runs `connect()`, which the suite now pins). A controller's `disconnect()` is that one line.
8
+ - Ten behaviors over it, under `@poetry/controllers/behaviors/`: `useListen`, `useEscape`, `useBeforeCache`, `usePresence`, `usePortal`, `useScrollLock`, `useAnnounce`, `useLayers`, `useTypeahead` and `useMobile`. Each is a plain function that does its setup, registers its undo in the scope and returns a handle; none assigns onto the controller. `usePresence` tracks exits per node, so an enter interrupts that node's exit alone; `usePortal` restores every node it moved at teardown, newest first; `useScrollLock` is idempotent per instance; `useLayers` is the one implementation of the layer-token add and remove.
9
+ - A fourth source-scan gate, `behaviors_teardown.test.js`: a controller that uses the scope without `teardown(this)` in its `disconnect()`, or a subclass that overrides `disconnect()` without `super.disconnect()`, fails the suite.
10
+
11
+ ### Changed
12
+
13
+ - Sixteen controllers run their lifecycle through the scope: the seven hand-rolled `#wired` lists (combobox, hover card, menu, popover, select, toast, tooltip), the `#unsubscribe` fields (dialog, dismissable, navigation menu, sidebar, deferred), the six copies of the layer-token add and remove, the typeahead and scroll-lock instances, and the message scroller's raw before-cache string. Rendered markup and behaviour are unchanged; the suites, the dommy tier and a browser pass are the oracle.
14
+
15
+ ### Fixed
16
+
17
+ - Three teardown leaks: a typeahead's reset timer outlived its controller (menu, select, tree), a date field's four per-segment listeners were never removed, and a submenu re-opened while its exit was still running had that exit's removal hide the re-opened level (the sub exits carried no cancel; the per-node presence gives them one).
18
+ - A composed trigger lists its own wiring ahead of the caller's actions again. `Attributes` unified the flat and nested spellings of `data-action` and `data-controller` by appending the flat tokens after the nested ones whichever came first, so a Button composed as a Collapsible, Popover, Tooltip or DropdownMenu trigger rendered the caller's `data: { action: }` ahead of the component's `data-action`. Stimulus runs actions in attribute order, and a caller's action written to read the state the toggle leaves behind read the state before it instead: the docs sidebar remembered each disclosure as the opposite of what was chosen. The two spellings now keep the order the hash gave them.
19
+ - A subclass without a `Style` sidecar of its own renders through its parent's dictionary. `Acme::Badge2 < Poetry::Ui::Badge::Component` with nothing beside it rendered no class attribute at all, in any mode, with no message: the dictionary lookup stopped at the class's own name. It now walks up to the nearest ancestor with a sidecar, short of `Poetry::Core::Component`, so a bare subclass renders what its parent renders and a sidecar of its own still takes over. A component built directly on the base with no sidecar stays dictionaryless, as before.
20
+
3
21
  ## [0.1.9] - 2026-09-30
4
22
 
5
23
  ### Added
@@ -161,11 +161,26 @@ module Poetry
161
161
  style_attributes.include?(name.to_sym)
162
162
  end
163
163
 
164
- # Automatically determines the corresponding Style class for this component.
165
- # Example: Poetry::Core::Dot::Component -> Poetry::Core::Dot::Style
164
+ # The dictionary this component renders through: its own sidecar
165
+ # Style by the naming convention (Poetry::Core::Dot::Component ->
166
+ # Poetry::Core::Dot::Style), else the nearest ancestor's. A bare
167
+ # subclass (`Acme::Badge2 < Poetry::Ui::Badge::Component` with no
168
+ # `Acme::Badge2::Style`) keeps rendering the classes its parent
169
+ # renders, the way a subclass keeps the parent's methods; a
170
+ # sidecar of its own takes over, extending a copy of the parent's
171
+ # dictionary. A component with no dictionary anywhere in its
172
+ # chain (Icon) has none.
166
173
  #
167
- # @return [Class, nil] the style class if it exists, nil otherwise
174
+ # @return [Class, nil] the style class, or nil when neither this
175
+ # class nor an ancestor below Poetry::Core::Component has one
168
176
  def style_class
177
+ own_style_class || inherited_style_class
178
+ end
179
+
180
+ private
181
+
182
+ # This class's own sidecar Style, by name, or nil.
183
+ def own_style_class
169
184
  style_class_name = component_module + STYLE_CLASS_SUFFIX
170
185
  style_class_name.constantize
171
186
  rescue NameError => e
@@ -178,7 +193,15 @@ module Poetry
178
193
  nil
179
194
  end
180
195
 
181
- private
196
+ # The dictionary of the nearest ancestor that has one, stopping
197
+ # short of Poetry::Core::Component: the base class owns no
198
+ # component dictionary, so a component built directly on it with
199
+ # no sidecar stays dictionaryless.
200
+ def inherited_style_class
201
+ return nil if superclass == Poetry::Core::Component || !superclass.respond_to?(:style_class)
202
+
203
+ superclass.style_class
204
+ end
182
205
 
183
206
  # Extracts and processes style-specific options from the options hash.
184
207
  #
@@ -0,0 +1,18 @@
1
+ // useAnnounce: the shared live regions held for one connect, over the
2
+ // announce helper; the refcount is released at teardown.
3
+ import { acquire, announce, release } from "@poetry/controllers/helpers/announce"
4
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
5
+
6
+ /**
7
+ * Takes a refcount on the shared live regions until teardown(controller)
8
+ * and hands back the announcement surface.
9
+ *
10
+ * @param {Object} controller - the Stimulus controller instance
11
+ * @returns {(message: string, politeness?: "polite" | "assertive") => void} announce
12
+ */
13
+ export function useAnnounce(controller) {
14
+ acquire()
15
+ scopeOf(controller).defer(release)
16
+
17
+ return announce
18
+ }
@@ -0,0 +1,15 @@
1
+ // useBeforeCache: Turbo's before-cache moment for one connect, over the
2
+ // turbo_cache helper; the unsubscribe is registered for you.
3
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
4
+ import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
5
+
6
+ /**
7
+ * Subscribes `callback` to turbo:before-cache until teardown(controller).
8
+ *
9
+ * @param {Object} controller - the Stimulus controller instance
10
+ * @param {(event: Event) => void} callback - must finish synchronously
11
+ * @returns {() => void} unsubscribes early; teardown unsubscribes otherwise
12
+ */
13
+ export function useBeforeCache(controller, callback) {
14
+ return scopeOf(controller).defer(onBeforeCache(callback))
15
+ }
@@ -0,0 +1,16 @@
1
+ // useEscape: capture-phase Escape handling (IME cancels filtered) for one
2
+ // connect, over the escape helper; the unsubscribe is registered for you.
3
+ import { onEscapeKeydown } from "@poetry/controllers/helpers/escape"
4
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
5
+
6
+ /**
7
+ * Subscribes `callback` to Escape keydowns until teardown(controller).
8
+ *
9
+ * @param {Object} controller - the Stimulus controller instance
10
+ * @param {(event: KeyboardEvent) => void} callback - the handler
11
+ * @param {Object} [options] - the escape helper's options (capture, target)
12
+ * @returns {() => void} unsubscribes early; teardown unsubscribes otherwise
13
+ */
14
+ export function useEscape(controller, callback, options) {
15
+ return scopeOf(controller).defer(onEscapeKeydown(callback, options))
16
+ }
@@ -0,0 +1,55 @@
1
+ // useLayers: the runtime layer activation (focus-scope, dismissable,
2
+ // roving-focus stacked onto content while it is open) for one connect.
3
+ // One implementation of the data-controller token add and remove that
4
+ // six overlay controllers each carried; what is still active at teardown
5
+ // is deactivated, so a torn-down overlay leaves no layer controller alive
6
+ // on an orphaned node.
7
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
8
+
9
+ /**
10
+ * Layer activation over the controller's current scope.
11
+ *
12
+ * @param {Object} controller - the Stimulus controller instance
13
+ * @returns {{ activate: (element: Element, identifiers: string[]) => void,
14
+ * deactivate: (element: Element, identifiers: string[]) => void }}
15
+ */
16
+ export function useLayers(controller) {
17
+ const active = new Map()
18
+
19
+ const deactivate = (element, identifiers) => {
20
+ const tokens = (element.getAttribute("data-controller") ?? "")
21
+ .split(/\s+/)
22
+ .filter((token) => token && !identifiers.includes(token))
23
+
24
+ element.setAttribute("data-controller", tokens.join(" "))
25
+
26
+ const remaining = (active.get(element) ?? []).filter((token) => !identifiers.includes(token))
27
+
28
+ if (remaining.length > 0) active.set(element, remaining)
29
+ else active.delete(element)
30
+ }
31
+
32
+ scopeOf(controller).defer(() => {
33
+ for (const [element, identifiers] of [...active]) deactivate(element, identifiers)
34
+ })
35
+
36
+ return {
37
+ /**
38
+ * Adds the identifiers to the element's data-controller, once each.
39
+ * @param {Element} element
40
+ * @param {string[]} identifiers
41
+ * @returns {void}
42
+ */
43
+ activate(element, identifiers) {
44
+ const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
45
+
46
+ for (const identifier of identifiers) {
47
+ if (!tokens.includes(identifier)) tokens.push(identifier)
48
+ }
49
+
50
+ element.setAttribute("data-controller", tokens.join(" "))
51
+ active.set(element, [...new Set([...(active.get(element) ?? []), ...identifiers])])
52
+ },
53
+ deactivate
54
+ }
55
+ }
@@ -0,0 +1,18 @@
1
+ // useListen: a DOM listener that lives for one connect. Replaces the
2
+ // hand-rolled `#wired` list seven controllers copied: the listener is
3
+ // added now and removed at teardown(controller), newest first.
4
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
5
+
6
+ /**
7
+ * Adds `listener` to `target` for the controller's current connect.
8
+ *
9
+ * @param {Object} controller - the Stimulus controller instance
10
+ * @param {EventTarget} target - the element, document or window to listen on
11
+ * @param {string} type - the event type
12
+ * @param {EventListener} listener - the handler
13
+ * @param {AddEventListenerOptions | boolean} [options] - addEventListener's options
14
+ * @returns {() => void} removes the listener early; teardown removes it otherwise
15
+ */
16
+ export function useListen(controller, target, type, listener, options) {
17
+ return scopeOf(controller).listen(target, type, listener, options)
18
+ }
@@ -0,0 +1,16 @@
1
+ // useMobile: the mobile-breakpoint watch for one connect, over the
2
+ // breakpoint helper; the unwatch is registered for you.
3
+ import { watchMobile } from "@poetry/controllers/helpers/breakpoint"
4
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
5
+
6
+ /**
7
+ * Watches the mobile breakpoint until teardown(controller): `onChange`
8
+ * runs now with the current state and again on every crossing.
9
+ *
10
+ * @param {Object} controller - the Stimulus controller instance
11
+ * @param {(isMobile: boolean) => void} onChange - the handler
12
+ * @returns {() => void} unwatches early; teardown unwatches otherwise
13
+ */
14
+ export function useMobile(controller, onChange) {
15
+ return scopeOf(controller).defer(watchMobile(onChange))
16
+ }
@@ -0,0 +1,50 @@
1
+ // usePortal: content moved to a portal container for one connect, over
2
+ // the portal helper. Every node this instance portals is restored at
3
+ // teardown (drop-never-strand: a node whose origin is gone is dropped by
4
+ // the helper), so no controller writes its own restore-on-disconnect.
5
+ import { portalContent, restoreContent } from "@poetry/controllers/helpers/portal"
6
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
7
+
8
+ /**
9
+ * Portal moves over the controller's current scope.
10
+ *
11
+ * @param {Object} controller - the Stimulus controller instance
12
+ * @returns {{ portal: (content: Element, options?: Object) => boolean,
13
+ * restore: (content: Element) => boolean }}
14
+ */
15
+ export function usePortal(controller) {
16
+ const moved = new Set()
17
+
18
+ // Newest first: a sub level portaled after its parent goes home before
19
+ // the parent does, while its placeholder still sits inside the parent.
20
+ scopeOf(controller).defer(() => {
21
+ for (const content of [...moved].reverse()) restoreContent(content)
22
+ moved.clear()
23
+ })
24
+
25
+ return {
26
+ /**
27
+ * Moves `content` to the container; a no-op when already portaled.
28
+ * @param {Element} content
29
+ * @param {Object} [options] - the portal helper's options (container)
30
+ * @returns {boolean} true when the move happened
31
+ */
32
+ portal(content, options) {
33
+ const happened = portalContent(content, options)
34
+
35
+ if (happened) moved.add(content)
36
+
37
+ return happened
38
+ },
39
+ /**
40
+ * Returns `content` home now.
41
+ * @param {Element} content
42
+ * @returns {boolean} true when the content went home
43
+ */
44
+ restore(content) {
45
+ moved.delete(content)
46
+
47
+ return restoreContent(content)
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,85 @@
1
+ // usePresence: enter and exit animations for one connect, over the
2
+ // presence helper, with the pending exits tracked PER NODE. An exit's
3
+ // cancel used to live in one `#cancelExit` slot per controller, which a
4
+ // second exiting node (NavigationMenu's lesson) silently overwrote; here
5
+ // every node keeps its own, an enter interrupts that node's exit alone,
6
+ // and teardown abandons whatever is still exiting without running its
7
+ // removal (an interrupted exit, never a removal from a torn-down scope).
8
+ import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
9
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
10
+
11
+ /**
12
+ * Presence over the controller's current scope.
13
+ *
14
+ * @param {Object} controller - the Stimulus controller instance
15
+ * @returns {{ enter: (element: HTMLElement, options?: Object) => HTMLElement,
16
+ * exit: (element: HTMLElement, options?: Object) => () => void,
17
+ * cancel: (element: HTMLElement) => boolean,
18
+ * exiting: (element: HTMLElement) => boolean }}
19
+ */
20
+ export function usePresence(controller) {
21
+ const pending = new Map()
22
+
23
+ const cancel = (element) => {
24
+ const abandon = pending.get(element)
25
+
26
+ if (!abandon) return false
27
+
28
+ pending.delete(element)
29
+ abandon()
30
+
31
+ return true
32
+ }
33
+
34
+ scopeOf(controller).defer(() => {
35
+ for (const abandon of [...pending.values()]) abandon()
36
+ pending.clear()
37
+ })
38
+
39
+ return {
40
+ /**
41
+ * Runs the entry on `element`, interrupting an exit of the same node.
42
+ * @param {HTMLElement} element
43
+ * @param {Object} [options] - the presence helper's enter options
44
+ * @returns {HTMLElement} the element
45
+ */
46
+ enter(element, options) {
47
+ cancel(element)
48
+
49
+ return enterPresence(element, options)
50
+ },
51
+ /**
52
+ * Runs the exit on `element`; `options.onRemove` runs once the exit
53
+ * finishes, unless an enter or a teardown interrupts it first.
54
+ * @param {HTMLElement} element
55
+ * @param {Object} [options] - the presence helper's exit options
56
+ * @param {() => void} [options.onRemove] - the caller's removal step
57
+ * @returns {() => void} abandons this exit without removal
58
+ */
59
+ exit(element, { onRemove, ...options } = {}) {
60
+ cancel(element)
61
+
62
+ let settled = false
63
+ const abandon = exitPresence(element, {
64
+ ...options,
65
+ onRemove: () => {
66
+ settled = true
67
+ pending.delete(element)
68
+ onRemove?.()
69
+ }
70
+ })
71
+
72
+ // A node without an exit animation settled inside the call.
73
+ if (!settled) pending.set(element, abandon)
74
+
75
+ return () => cancel(element)
76
+ },
77
+ cancel,
78
+ /**
79
+ * Whether `element` is mid-exit.
80
+ * @param {HTMLElement} element
81
+ * @returns {boolean}
82
+ */
83
+ exiting: (element) => pending.has(element)
84
+ }
85
+ }
@@ -0,0 +1,41 @@
1
+ // useScrollLock: the body scroll lock for one connect, over the
2
+ // scroll_lock helper. Idempotent per instance (the dialog's `#locked`
3
+ // flag, generalized): lock twice holds one refcount, unlock when not
4
+ // locked is a no-op, and teardown balances a lock still held.
5
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
6
+ import { lockScroll, unlockScroll } from "@poetry/controllers/helpers/scroll_lock"
7
+
8
+ /**
9
+ * A per-instance scroll lock, balanced at teardown(controller).
10
+ *
11
+ * @param {Object} controller - the Stimulus controller instance
12
+ * @returns {{ lock: () => void, unlock: () => void, locked: () => boolean }}
13
+ */
14
+ export function useScrollLock(controller) {
15
+ let locked = false
16
+
17
+ const unlock = () => {
18
+ if (!locked) return
19
+
20
+ locked = false
21
+ unlockScroll()
22
+ }
23
+
24
+ scopeOf(controller).defer(unlock)
25
+
26
+ return {
27
+ /** Takes the instance's one refcount on the body lock. */
28
+ lock() {
29
+ if (locked) return
30
+
31
+ locked = true
32
+ lockScroll()
33
+ },
34
+ unlock,
35
+ /**
36
+ * Whether this instance holds its refcount.
37
+ * @returns {boolean}
38
+ */
39
+ locked: () => locked
40
+ }
41
+ }
@@ -0,0 +1,21 @@
1
+ // useTypeahead: one typeahead per connect, over the typeahead helper,
2
+ // with its buffer timer reset at teardown - the leak the hand-rolled
3
+ // field initializers (menu, select, tree) never closed.
4
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
5
+ import { createTypeahead } from "@poetry/controllers/helpers/typeahead"
6
+
7
+ /**
8
+ * A typeahead instance whose pending timer is cleared at
9
+ * teardown(controller).
10
+ *
11
+ * @param {Object} controller - the Stimulus controller instance
12
+ * @returns {{ pending: () => boolean, reset: () => void,
13
+ * search: (key: string, items: Element[], options?: Object) => Element | null }}
14
+ */
15
+ export function useTypeahead(controller) {
16
+ const typeahead = createTypeahead()
17
+
18
+ scopeOf(controller).defer(() => typeahead.reset())
19
+
20
+ return typeahead
21
+ }
@@ -1,8 +1,12 @@
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 { isPortaled, portalContent, resolvePortalContainer, restoreContent } from "@poetry/controllers/helpers/portal"
5
- import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
4
+ import { useLayers } from "@poetry/controllers/behaviors/layers"
5
+ 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"
9
+ import { teardown } from "@poetry/controllers/helpers/scope"
6
10
  import { setState, stateOf } from "@poetry/controllers/helpers/state"
7
11
  import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
8
12
 
@@ -99,9 +103,10 @@ export default class ComboboxController extends Controller {
99
103
  }
100
104
 
101
105
  #connected = false
102
- #wired = []
103
106
  #suppressRestore = false
104
- #cancelExit = null
107
+ #presence = null
108
+ #portal = null
109
+ #layers = null
105
110
  #applied = ""
106
111
  #placeholder = ""
107
112
  #dismissedEvent = null
@@ -113,6 +118,10 @@ export default class ComboboxController extends Controller {
113
118
  * popup up (layers + the late portal).
114
119
  */
115
120
  connect() {
121
+ this.#presence = usePresence(this)
122
+ this.#portal = usePortal(this)
123
+ this.#layers = useLayers(this)
124
+
116
125
  const content = this.#content()
117
126
 
118
127
  if (content) this.#wireContent(content)
@@ -121,7 +130,7 @@ export default class ComboboxController extends Controller {
121
130
 
122
131
  // The chips FIELD is a second wired keyboard surface in multiple
123
132
  // (Tab-out closes from the inline input exactly like from the popup).
124
- if (chips) this.#listen(chips, "keydown", this.#onKeydown)
133
+ if (chips) useListen(this, chips, "keydown", this.#onKeydown)
125
134
 
126
135
  const display = this.#display()
127
136
 
@@ -158,27 +167,19 @@ export default class ComboboxController extends Controller {
158
167
  window.requestAnimationFrame(() => {
159
168
  if (!this.#connected || !this.#isOpen()) return
160
169
 
161
- portalContent(content, { container: resolvePortalContainer(this.element) })
170
+ this.#portal.portal(content, { container: resolvePortalContainer(this.element) })
162
171
  this.element.setAttribute(POPPER_STRATEGY, "absolute")
163
172
  })
164
173
  }
165
174
 
166
175
  /**
167
- * Restores portaled content (drop-never-strand) and unwires everything.
176
+ * Tears the scope down: the content and chips listeners, a pending
177
+ * exit, the layer tokens and portaled content (restored home, or
178
+ * dropped when the home is gone - never stranded).
168
179
  */
169
180
  disconnect() {
170
181
  this.#connected = false
171
-
172
- // Never leave content stranded at the container (drop-never-strand).
173
- const content = this.#content()
174
-
175
- if (content) restoreContent(content)
176
-
177
- for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
178
-
179
- this.#wired = []
180
- this.#cancelExit?.()
181
- this.#cancelExit = null
182
+ teardown(this)
182
183
  }
183
184
 
184
185
  // --- controllable state ---
@@ -525,8 +526,7 @@ export default class ComboboxController extends Controller {
525
526
 
526
527
  if (!content || this.#isOpen()) return
527
528
 
528
- this.#cancelExit?.()
529
- this.#cancelExit = null
529
+ this.#presence.cancel(content)
530
530
  this.#suppressRestore = false
531
531
 
532
532
  const expander = this.#expander()
@@ -536,7 +536,7 @@ export default class ComboboxController extends Controller {
536
536
  // absolute - static under compositor scroll, transform-immune. In
537
537
  // multiple mode only the popup (listbox) moves; the chips field with
538
538
  // its inline input stays home as the popper anchor.
539
- portalContent(content, { container: resolvePortalContainer(this.element) })
539
+ this.#portal.portal(content, { container: resolvePortalContainer(this.element) })
540
540
  this.element.setAttribute(POPPER_STRATEGY, "absolute")
541
541
 
542
542
  content.hidden = false
@@ -545,7 +545,7 @@ export default class ComboboxController extends Controller {
545
545
  else content.removeAttribute("data-open-seed")
546
546
  expander?.setAttribute("aria-expanded", "true")
547
547
  if (expander) setState(expander, "popup-open")
548
- enterPresence(content)
548
+ this.#presence.enter(content)
549
549
  this.#activateLayers(content)
550
550
  this.openValue = true
551
551
 
@@ -595,15 +595,14 @@ export default class ComboboxController extends Controller {
595
595
  content.removeAttribute("data-open-seed")
596
596
  this.openValue = false
597
597
 
598
- this.#cancelExit = exitPresence(content, {
598
+ this.#presence.exit(content, {
599
599
  onRemove: () => {
600
- this.#cancelExit = null
601
600
  content.hidden = true
602
601
  // Home AFTER the exit finished and hidden landed; focus
603
602
  // return is focus-scope's ref-based job, indifferent to the move.
604
- restoreContent(content)
603
+ this.#portal.restore(content)
605
604
  this.element.setAttribute(POPPER_STRATEGY, "fixed")
606
- this.#removeControllers(content, CONTENT_LAYER_CONTROLLERS)
605
+ this.#layers.deactivate(content, CONTENT_LAYER_CONTROLLERS)
607
606
  // Reset the query so reopen starts clean (a remount would get
608
607
  // this for free; persistent DOM does it deliberately).
609
608
  this.#command()?.reset()
@@ -842,17 +841,12 @@ export default class ComboboxController extends Controller {
842
841
  // --- content wiring (programmatic: portal-safe) ---
843
842
 
844
843
  #wireContent(content) {
845
- this.#listen(content, "keydown", this.#onKeydown)
846
- this.#listen(content, "poetry:command:select", this.#onCommandSelect)
847
- this.#listen(content, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
848
- this.#listen(content, "poetry--core--dismissable:dismiss", this.#onDismiss)
849
- this.#listen(content, "poetry--core--focus-scope:mount-auto-focus", this.#onMountAutoFocus)
850
- this.#listen(content, "poetry--core--focus-scope:unmount-auto-focus", this.#onUnmountAutoFocus)
851
- }
852
-
853
- #listen(target, type, listener) {
854
- target.addEventListener(type, listener)
855
- this.#wired.push([target, type, listener])
844
+ useListen(this, content, "keydown", this.#onKeydown)
845
+ 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)
856
850
  }
857
851
 
858
852
  // Tab while open CLOSES WITHOUT COMMIT and lets focus proceed (Popover
@@ -945,26 +939,9 @@ export default class ComboboxController extends Controller {
945
939
  "data-poetry--core--dismissable-disable-outside-pointer-events-value", String(this.modalValue)
946
940
  )
947
941
 
948
- this.#addControllers(content, CONTENT_LAYER_CONTROLLERS)
942
+ this.#layers.activate(content, CONTENT_LAYER_CONTROLLERS)
949
943
  }
950
944
 
951
- #addControllers(element, identifiers) {
952
- const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
953
-
954
- for (const identifier of identifiers) {
955
- if (!tokens.includes(identifier)) tokens.push(identifier)
956
- }
957
-
958
- element.setAttribute("data-controller", tokens.join(" "))
959
- }
960
-
961
- #removeControllers(element, identifiers) {
962
- const tokens = (element.getAttribute("data-controller") ?? "")
963
- .split(/\s+/)
964
- .filter((token) => token && !identifiers.includes(token))
965
-
966
- element.setAttribute("data-controller", tokens.join(" "))
967
- }
968
945
 
969
946
  // --- structural resolution (ids are the seams; portal/stream-safe) ---
970
947
 
@@ -1,4 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useListen } from "@poetry/controllers/behaviors/listen"
3
+ import { teardown } from "@poetry/controllers/helpers/scope"
2
4
  import {
3
5
  IncompleteDate, PAGE_STEP, resolveHourCycle
4
6
  } from "@poetry/controllers/helpers/incomplete_date"
@@ -80,17 +82,16 @@ export default class DateFieldController extends Controller {
80
82
 
81
83
  // Native required validation fires on the (visually hidden) input;
82
84
  // route the browser's focus request to the first empty segment.
83
- this.inputTarget.addEventListener("invalid", this.#onInvalid)
84
- document.addEventListener("selectionchange", this.#onSelectionChange)
85
+ useListen(this, this.inputTarget, "invalid", this.#onInvalid)
86
+ useListen(this, document, "selectionchange", this.#onSelectionChange)
85
87
  }
86
88
 
87
89
  /**
88
- * Restores the native input's tab order / AT visibility and unwires the
89
- * document listeners.
90
+ * Restores the native input's tab order / AT visibility and tears the
91
+ * scope down: the input, document and per-segment listeners.
90
92
  */
91
93
  disconnect() {
92
- this.inputTarget?.removeEventListener("invalid", this.#onInvalid)
93
- document.removeEventListener("selectionchange", this.#onSelectionChange)
94
+ teardown(this)
94
95
  this.element.removeAttribute("data-enhanced")
95
96
  this.inputTarget?.removeAttribute("tabindex")
96
97
  this.inputTarget?.removeAttribute("aria-hidden")
@@ -232,10 +233,10 @@ export default class DateFieldController extends Controller {
232
233
 
233
234
  span.style.caretColor = "transparent"
234
235
 
235
- span.addEventListener("keydown", (event) => this.#onKeydown(event, span, type))
236
- span.addEventListener("beforeinput", (event) => this.#onBeforeinput(event, span, type))
237
- span.addEventListener("input", (event) => this.#onInput(event, span, type))
238
- span.addEventListener("focus", () => { this.#entered = "" })
236
+ useListen(this, span, "keydown", (event) => this.#onKeydown(event, span, type))
237
+ useListen(this, span, "beforeinput", (event) => this.#onBeforeinput(event, span, type))
238
+ useListen(this, span, "input", (event) => this.#onInput(event, span, type))
239
+ useListen(this, span, "focus", () => { this.#entered = "" })
239
240
 
240
241
  return span
241
242
  }