poetry-core 0.1.9 → 0.1.11
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/.jsdoc_floor +1 -1
- data/CHANGELOG.md +28 -0
- data/app/components/poetry/core/concerns/styles.rb +27 -4
- data/app/javascript/poetry/core/behaviors/announce.js +18 -0
- data/app/javascript/poetry/core/behaviors/before_cache.js +15 -0
- data/app/javascript/poetry/core/behaviors/escape.js +16 -0
- data/app/javascript/poetry/core/behaviors/layers.js +55 -0
- data/app/javascript/poetry/core/behaviors/listen.js +18 -0
- data/app/javascript/poetry/core/behaviors/mobile.js +16 -0
- data/app/javascript/poetry/core/behaviors/overlay.js +289 -0
- data/app/javascript/poetry/core/behaviors/portal.js +50 -0
- data/app/javascript/poetry/core/behaviors/presence.js +85 -0
- data/app/javascript/poetry/core/behaviors/scroll_lock.js +41 -0
- data/app/javascript/poetry/core/behaviors/typeahead.js +21 -0
- data/app/javascript/poetry/core/combobox_controller.js +58 -209
- data/app/javascript/poetry/core/command_controller.js +3 -0
- data/app/javascript/poetry/core/date_field_controller.js +11 -10
- data/app/javascript/poetry/core/deferred_controller.js +9 -12
- data/app/javascript/poetry/core/dialog_controller.js +17 -30
- data/app/javascript/poetry/core/dismissable_controller.js +10 -15
- data/app/javascript/poetry/core/helpers/scope.js +120 -0
- data/app/javascript/poetry/core/hover_card_controller.js +39 -57
- data/app/javascript/poetry/core/menu_controller.js +80 -212
- data/app/javascript/poetry/core/message_scroller_controller.js +4 -3
- data/app/javascript/poetry/core/navigation_menu_controller.js +17 -21
- data/app/javascript/poetry/core/popover_controller.js +36 -188
- data/app/javascript/poetry/core/select_controller.js +58 -200
- data/app/javascript/poetry/core/sidebar_controller.js +18 -31
- data/app/javascript/poetry/core/toast_controller.js +8 -15
- data/app/javascript/poetry/core/toaster_controller.js +13 -15
- data/app/javascript/poetry/core/tooltip_controller.js +35 -65
- data/app/javascript/poetry/core/tree_controller.js +10 -2
- data/config/controllers_manifest.json +24 -22
- data/lib/poetry/core/html/attributes.rb +18 -6
- data/lib/poetry/core/version.rb +1 -1
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc22978b709fc22e859b79edce9929b57a358c7fec1969176f5f3d82e8dacd7a
|
|
4
|
+
data.tar.gz: 0765f9ded7f711d893f11dba287c2e4014b05a2906e9a3bb3ecf72e3249d7b94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 342c1ca9b06f0945c31deb4dab0e25c56e52d2f2afa9ec6dbda9173f1be20e4df699385d070aeb90bbc81a0d43438476c0e3d4a361ca980ea6f4fb163de670cd
|
|
7
|
+
data.tar.gz: a0a1285525f488233c29823543cb111ea2afc18fc1d2342d3ece955f0770538604367131c82f18792d4a6137bcaa9c1e87e12d7a991d6decd23f1484842393a6
|
data/.jsdoc_floor
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
586
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.11] - 2026-10-05
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
## [0.1.10] - 2026-10-03
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- 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).
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
3
31
|
## [0.1.9] - 2026-09-30
|
|
4
32
|
|
|
5
33
|
### Added
|
|
@@ -161,11 +161,26 @@ module Poetry
|
|
|
161
161
|
style_attributes.include?(name.to_sym)
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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
|
|
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
|
-
|
|
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,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
|
+
}
|
|
@@ -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
|
+
}
|