poetry-core 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- metadata +267 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
|
|
3
|
+
import { ensureFocusGuards, removeFocusGuards } from "@poetry/controllers/helpers/focus_guards"
|
|
4
|
+
import { logicallyContains } from "@poetry/controllers/helpers/portal"
|
|
5
|
+
|
|
6
|
+
// The overlay focus scope: traps Tab/Shift+Tab within the
|
|
7
|
+
// subtree, loops at the edges, and - the part to get exact - snapshots
|
|
8
|
+
// document.activeElement on connect and RESTORES it on disconnect (focus
|
|
9
|
+
// return). Backs Dialog-family overlays, Popover, Menus, Select, Command.
|
|
10
|
+
//
|
|
11
|
+
// Listeners are wired here, not as data-actions: pause/resume must attach
|
|
12
|
+
// and detach them dynamically as scopes stack, which data-action cannot do.
|
|
13
|
+
export default class FocusScopeController extends Controller {
|
|
14
|
+
// The events this controller dispatches (manifest surface;
|
|
15
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
16
|
+
static events = [
|
|
17
|
+
"poetry--core--focus-scope:mount-auto-focus", "poetry--core--focus-scope:unmount-auto-focus"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// Nested scopes: only the TOP scope has live listeners. Opening a child
|
|
21
|
+
// pauses the parent; closing it resumes the parent (one shared stack -
|
|
22
|
+
// class-level so it is inspectable).
|
|
23
|
+
static stack = []
|
|
24
|
+
|
|
25
|
+
static values = {
|
|
26
|
+
trapped: { type: Boolean, default: true },
|
|
27
|
+
loop: { type: Boolean, default: true }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#active = false
|
|
31
|
+
#guarded = false
|
|
32
|
+
#previouslyFocused = null
|
|
33
|
+
#lastFocusedWithin = null
|
|
34
|
+
#onKeydown = (event) => this.#handleKeydown(event)
|
|
35
|
+
#onFocusin = (event) => this.#handleFocusin(event)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Snapshots the focus-return element BEFORE any focus moves, pauses the
|
|
39
|
+
* previous scope and takes the top of the stack, takes the guard
|
|
40
|
+
* refcount when trapped, then runs mount auto-focus (cancelable - a
|
|
41
|
+
* consumer owns initial focus by vetoing it).
|
|
42
|
+
*/
|
|
43
|
+
connect() {
|
|
44
|
+
// The container is the focus fallback when the scope has no tabbables.
|
|
45
|
+
if (!this.element.hasAttribute("tabindex")) this.element.setAttribute("tabindex", "-1")
|
|
46
|
+
|
|
47
|
+
// Snapshot BEFORE any focus moves: this is where focus RETURNS to.
|
|
48
|
+
this.#previouslyFocused = document.activeElement
|
|
49
|
+
|
|
50
|
+
FocusScopeController.stack.at(-1)?.#pause()
|
|
51
|
+
FocusScopeController.stack.push(this)
|
|
52
|
+
this.#resume()
|
|
53
|
+
|
|
54
|
+
if (this.trappedValue) {
|
|
55
|
+
ensureFocusGuards()
|
|
56
|
+
this.#guarded = true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Cancelable: a consumer preventDefault()s to own initial focus itself
|
|
60
|
+
// (e.g. Select focusing the active option, not the first tabbable).
|
|
61
|
+
const mount = this.dispatch("mount-auto-focus", { cancelable: true })
|
|
62
|
+
|
|
63
|
+
if (!mount.defaultPrevented) this.#focus(tabbableWithin(this.element)[0] ?? this.element)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Leaves the stack (resuming the scope below when this was the top),
|
|
68
|
+
* releases the guards, and restores focus to the snapshot (cancelable -
|
|
69
|
+
* a consumer sends focus elsewhere by vetoing it).
|
|
70
|
+
*/
|
|
71
|
+
disconnect() {
|
|
72
|
+
const wasTop = FocusScopeController.stack.at(-1) === this
|
|
73
|
+
const index = FocusScopeController.stack.indexOf(this)
|
|
74
|
+
|
|
75
|
+
if (index !== -1) FocusScopeController.stack.splice(index, 1)
|
|
76
|
+
|
|
77
|
+
this.#pause()
|
|
78
|
+
|
|
79
|
+
if (wasTop) FocusScopeController.stack.at(-1)?.#resume()
|
|
80
|
+
|
|
81
|
+
if (this.#guarded) {
|
|
82
|
+
removeFocusGuards()
|
|
83
|
+
this.#guarded = false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Focus return, cancelable so a consumer can send focus elsewhere
|
|
87
|
+
// (e.g. a submenu handing focus back to its subtrigger).
|
|
88
|
+
const unmount = this.dispatch("unmount-auto-focus", { cancelable: true })
|
|
89
|
+
|
|
90
|
+
if (!unmount.defaultPrevented && this.#previouslyFocused?.isConnected) {
|
|
91
|
+
this.#previouslyFocused.focus()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.#previouslyFocused = null
|
|
95
|
+
this.#lastFocusedWithin = null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Paused = no listeners at all: a paused parent neither traps nor loops
|
|
99
|
+
// while a child scope is on top of it.
|
|
100
|
+
#pause() {
|
|
101
|
+
if (!this.#active) return
|
|
102
|
+
|
|
103
|
+
this.#active = false
|
|
104
|
+
this.element.removeEventListener("keydown", this.#onKeydown)
|
|
105
|
+
document.removeEventListener("focusin", this.#onFocusin)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#resume() {
|
|
109
|
+
if (this.#active) return
|
|
110
|
+
|
|
111
|
+
this.#active = true
|
|
112
|
+
this.element.addEventListener("keydown", this.#onKeydown)
|
|
113
|
+
|
|
114
|
+
if (this.trappedValue) document.addEventListener("focusin", this.#onFocusin)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Tab at the EDGES only: wrap under loop, hard-stop under trapped. A
|
|
118
|
+
// mid-list Tab is the browser's job - only the edges need help.
|
|
119
|
+
#handleKeydown(event) {
|
|
120
|
+
if (event.key !== "Tab") return
|
|
121
|
+
// A Tab mid-IME-composition commits the candidate text, it does not
|
|
122
|
+
// navigate - intercepting it eats the commit, so let it pass.
|
|
123
|
+
if (event.isComposing) return
|
|
124
|
+
if (!this.trappedValue && !this.loopValue) return
|
|
125
|
+
|
|
126
|
+
const candidates = tabbableWithin(this.element)
|
|
127
|
+
|
|
128
|
+
if (candidates.length === 0) {
|
|
129
|
+
event.preventDefault() // nothing to land on; focus stays on the container
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const first = candidates[0]
|
|
134
|
+
const last = candidates[candidates.length - 1]
|
|
135
|
+
|
|
136
|
+
if (!event.shiftKey && document.activeElement === last) {
|
|
137
|
+
event.preventDefault()
|
|
138
|
+
if (this.loopValue) this.#focus(first)
|
|
139
|
+
} else if (event.shiftKey && document.activeElement === first) {
|
|
140
|
+
event.preventDefault()
|
|
141
|
+
if (this.loopValue) this.#focus(last)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// trapped: focus escaping the scope (a body-edge guard sentinel, a
|
|
146
|
+
// programmatic move, a click outside) is yanked back to the last focused
|
|
147
|
+
// element within the scope.
|
|
148
|
+
#handleFocusin(event) {
|
|
149
|
+
// Logical containment: a portaled sub level (menus portal each sub on
|
|
150
|
+
// its own open) sits outside the scope's SUBTREE but inside its tree.
|
|
151
|
+
if (logicallyContains(this.element, event.target)) {
|
|
152
|
+
this.#lastFocusedWithin = event.target
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.#focus(this.#lastFocusedWithin ?? tabbableWithin(this.element)[0] ?? this.element)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#focus(element) {
|
|
160
|
+
element?.focus?.()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// The announce SINGLETON: shared screen-reader live regions - a plain
|
|
2
|
+
// module, not a controller. Injecting a node that IS a live region is
|
|
3
|
+
// unreliably announced across SR/browser pairs (the region must exist
|
|
4
|
+
// BEFORE its text changes), so consumers keep their own nodes
|
|
5
|
+
// aria-live=off and route announcements
|
|
6
|
+
// through here: TWO lazily-created sr-only regions on body (polite +
|
|
7
|
+
// assertive), refcounted via acquire()/release() (regions removed when the
|
|
8
|
+
// last consumer releases; consumers: Toast, async form status, Combobox
|
|
9
|
+
// result counts - API changes after Toast ships are breaking, so the
|
|
10
|
+
// surface stays exactly this).
|
|
11
|
+
//
|
|
12
|
+
// Announcement mechanics: clear-then-set on a microtask (identical
|
|
13
|
+
// consecutive messages re-announce), a per-region message queue with a
|
|
14
|
+
// small gap between messages, textContent ONLY (live-region injection is a
|
|
15
|
+
// real sink - never innerHTML).
|
|
16
|
+
//
|
|
17
|
+
// Tab-visibility muting: while the tab is hidden both regions flip
|
|
18
|
+
// aria-live=off (muted); announcements made while hidden keep at most the
|
|
19
|
+
// LAST message, flushed on return (no backlog flood).
|
|
20
|
+
|
|
21
|
+
// The gap (ms) between queued messages per region - long enough for SRs to
|
|
22
|
+
// treat consecutive messages as separate announcements.
|
|
23
|
+
const QUEUE_GAP = 150
|
|
24
|
+
|
|
25
|
+
// Safari drops messages announced right after a live region is INSERTED
|
|
26
|
+
// (~100ms of post-creation warmup is required in practice; a WebKit
|
|
27
|
+
// behavior, not a spec timing) - a fresh region holds its queue until
|
|
28
|
+
// this warmup elapses. The clear-then-set rAF alone (~one frame) is
|
|
29
|
+
// shorter than Safari needs.
|
|
30
|
+
const REGION_WARMUP = 100
|
|
31
|
+
|
|
32
|
+
const POLITENESS = {
|
|
33
|
+
polite: { role: "status", live: "polite" },
|
|
34
|
+
assertive: { role: "alert", live: "assertive" }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let refCount = 0
|
|
38
|
+
let regions = null // { polite: region, assertive: region } | null
|
|
39
|
+
let hiddenBacklog = null // { message, politeness } - at most the LAST while hidden
|
|
40
|
+
|
|
41
|
+
const onVisibilityChange = () => {
|
|
42
|
+
if (document.hidden) mute()
|
|
43
|
+
else unmute()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Takes a refcount on the shared live regions (created on the first
|
|
48
|
+
* acquire): a consumer acquires while it is connected and releases on
|
|
49
|
+
* teardown.
|
|
50
|
+
*/
|
|
51
|
+
export function acquire() {
|
|
52
|
+
refCount += 1
|
|
53
|
+
ensureRegions()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Releases one refcount; the last release removes the regions and their
|
|
58
|
+
* visibility listener.
|
|
59
|
+
*/
|
|
60
|
+
export function release() {
|
|
61
|
+
if (refCount === 0) return
|
|
62
|
+
|
|
63
|
+
refCount -= 1
|
|
64
|
+
|
|
65
|
+
if (refCount === 0) teardown()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The announcement surface: queues `message` on the shared region for
|
|
70
|
+
* `politeness`. While the tab is hidden only the LAST message is kept,
|
|
71
|
+
* flushed on return.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} message - stringified; injected as textContent only
|
|
74
|
+
* @param {"polite" | "assertive"} [politeness="polite"] - unknown values
|
|
75
|
+
* fall back to polite
|
|
76
|
+
*/
|
|
77
|
+
export function announce(message, politeness = "polite") {
|
|
78
|
+
ensureRegions()
|
|
79
|
+
|
|
80
|
+
const resolved = Object.hasOwn(POLITENESS, politeness) ? politeness : "polite"
|
|
81
|
+
|
|
82
|
+
if (document.hidden) {
|
|
83
|
+
hiddenBacklog = { message, politeness: resolved }
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
enqueue(regions[resolved], message)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ensureRegions() {
|
|
91
|
+
// A body swap (Turbo render, test reset) can detach live regions without
|
|
92
|
+
// a release - rebuild rather than announce into detached nodes.
|
|
93
|
+
if (regions && !regions.polite.element.isConnected) teardown()
|
|
94
|
+
if (regions) return
|
|
95
|
+
|
|
96
|
+
regions = {
|
|
97
|
+
polite: createRegion("polite"),
|
|
98
|
+
assertive: createRegion("assertive")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
document.addEventListener("visibilitychange", onVisibilityChange)
|
|
102
|
+
|
|
103
|
+
if (document.hidden) mute()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createRegion(politeness) {
|
|
107
|
+
const element = document.createElement("div")
|
|
108
|
+
const { role, live } = POLITENESS[politeness]
|
|
109
|
+
|
|
110
|
+
element.setAttribute("data-poetry-announce-region", politeness)
|
|
111
|
+
element.setAttribute("role", role)
|
|
112
|
+
element.setAttribute("aria-live", live)
|
|
113
|
+
element.setAttribute("aria-atomic", "true")
|
|
114
|
+
|
|
115
|
+
// sr-only, inline (the helper cannot assume a utility class exists).
|
|
116
|
+
Object.assign(element.style, {
|
|
117
|
+
position: "absolute",
|
|
118
|
+
width: "1px",
|
|
119
|
+
height: "1px",
|
|
120
|
+
padding: "0",
|
|
121
|
+
margin: "-1px",
|
|
122
|
+
overflow: "hidden",
|
|
123
|
+
clip: "rect(0, 0, 0, 0)",
|
|
124
|
+
whiteSpace: "nowrap",
|
|
125
|
+
border: "0"
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
document.body.appendChild(element)
|
|
129
|
+
|
|
130
|
+
const region = {
|
|
131
|
+
politeness, element, queue: [], draining: false, timer: null,
|
|
132
|
+
warm: false, warmupTimer: null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
region.warmupTimer = window.setTimeout(() => {
|
|
136
|
+
region.warmupTimer = null
|
|
137
|
+
region.warm = true
|
|
138
|
+
drain(region)
|
|
139
|
+
}, REGION_WARMUP)
|
|
140
|
+
|
|
141
|
+
return region
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Per-region queue: clear-then-set across an animation FRAME, not a
|
|
145
|
+
// microtask (so a message identical to the region's current text still
|
|
146
|
+
// re-announces), then a small gap before the next queued message. The
|
|
147
|
+
// frame matters twice over: a microtask lands in the SAME accessibility-
|
|
148
|
+
// tree flush, so (a) a freshly created region would be seen "born with
|
|
149
|
+
// content" - a mutation pattern ATs reliably skip - and (b) clear+set
|
|
150
|
+
// would coalesce into one text change, letting AT dedup swallow repeats.
|
|
151
|
+
// rAF pushes the set into the next frame's flush; a hidden document has
|
|
152
|
+
// no frames, but the visibility contract already routes those to the
|
|
153
|
+
// backlog before enqueue.
|
|
154
|
+
function enqueue(region, message) {
|
|
155
|
+
region.queue.push(String(message))
|
|
156
|
+
drain(region)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function drain(region) {
|
|
160
|
+
if (!region.warm || region.draining) return
|
|
161
|
+
|
|
162
|
+
const message = region.queue.shift()
|
|
163
|
+
|
|
164
|
+
if (message === undefined) return
|
|
165
|
+
|
|
166
|
+
region.draining = true
|
|
167
|
+
region.element.textContent = ""
|
|
168
|
+
|
|
169
|
+
requestAnimationFrame(() => {
|
|
170
|
+
// The regions may have been torn down or muted between ticks.
|
|
171
|
+
if (!regions || regions[region.politeness] !== region) return
|
|
172
|
+
|
|
173
|
+
region.element.textContent = message
|
|
174
|
+
region.timer = window.setTimeout(() => {
|
|
175
|
+
region.timer = null
|
|
176
|
+
region.draining = false
|
|
177
|
+
drain(region)
|
|
178
|
+
}, QUEUE_GAP)
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function mute() {
|
|
183
|
+
if (!regions) return
|
|
184
|
+
|
|
185
|
+
for (const region of Object.values(regions)) {
|
|
186
|
+
region.element.setAttribute("aria-live", "off")
|
|
187
|
+
// Muted messages are dropped - only announce() calls made while hidden
|
|
188
|
+
// keep their LAST message (the backlog contract).
|
|
189
|
+
region.queue.length = 0
|
|
190
|
+
|
|
191
|
+
if (region.timer !== null) {
|
|
192
|
+
window.clearTimeout(region.timer)
|
|
193
|
+
region.timer = null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
region.draining = false
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function unmute() {
|
|
201
|
+
if (!regions) return
|
|
202
|
+
|
|
203
|
+
for (const region of Object.values(regions)) {
|
|
204
|
+
region.element.setAttribute("aria-live", POLITENESS[region.politeness].live)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (hiddenBacklog) {
|
|
208
|
+
const { message, politeness } = hiddenBacklog
|
|
209
|
+
|
|
210
|
+
hiddenBacklog = null
|
|
211
|
+
enqueue(regions[politeness], message)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function teardown() {
|
|
216
|
+
if (!regions) return
|
|
217
|
+
|
|
218
|
+
for (const region of Object.values(regions)) {
|
|
219
|
+
if (region.timer !== null) window.clearTimeout(region.timer)
|
|
220
|
+
if (region.warmupTimer !== null) window.clearTimeout(region.warmupTimer)
|
|
221
|
+
|
|
222
|
+
region.element.remove()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
regions = null
|
|
226
|
+
hiddenBacklog = null
|
|
227
|
+
document.removeEventListener("visibilitychange", onVisibilityChange)
|
|
228
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The mobile breakpoint: matchMedia below Tailwind's md (768px), with a
|
|
2
|
+
// change listener. The
|
|
3
|
+
// first poetry consumer is the Sidebar's mobile-Sheet mode. Environments
|
|
4
|
+
// without matchMedia (the dommy QuickJS engine, bare jsdom) report
|
|
5
|
+
// DESKTOP - the server-rendered desktop shell is the safe default.
|
|
6
|
+
/** The mobile cutoff (px): viewports strictly narrower are mobile. */
|
|
7
|
+
export const MOBILE_BREAKPOINT = 768
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Watches the mobile breakpoint: calls `onChange(isMobile)` immediately
|
|
11
|
+
* with the current state and again on every crossing. Environments
|
|
12
|
+
* without matchMedia report desktop once and never call again.
|
|
13
|
+
*
|
|
14
|
+
* @param {(isMobile: boolean) => void} onChange
|
|
15
|
+
* @returns {() => void} unwatch
|
|
16
|
+
*/
|
|
17
|
+
export function watchMobile(onChange) {
|
|
18
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
19
|
+
onChange(false)
|
|
20
|
+
return () => {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const query = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
24
|
+
const listener = () => onChange(query.matches)
|
|
25
|
+
query.addEventListener("change", listener)
|
|
26
|
+
onChange(query.matches)
|
|
27
|
+
return () => query.removeEventListener("change", listener)
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The DOM is the registry: collection items are read from the document
|
|
2
|
+
// in DOM order - no client-side bookkeeping, no registration step; a
|
|
3
|
+
// membership question is always a fresh query.
|
|
4
|
+
|
|
5
|
+
/** The attribute marking an element as a collection item. */
|
|
6
|
+
export const COLLECTION_ITEM_SELECTOR = "[data-poetry-collection-item]"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The collection items under `root`, in DOM order.
|
|
10
|
+
*
|
|
11
|
+
* @param {ParentNode} root - the element (or document) to query
|
|
12
|
+
* @param {string} [selector] - override for consumers with their own item
|
|
13
|
+
* marker (defaults to {@link COLLECTION_ITEM_SELECTOR})
|
|
14
|
+
* @returns {Element[]}
|
|
15
|
+
*/
|
|
16
|
+
export function collectionItems(root, selector = COLLECTION_ITEM_SELECTOR) {
|
|
17
|
+
return Array.from(root.querySelectorAll(selector))
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Reading direction: the platform mechanism - the closest [dir]
|
|
2
|
+
// ancestor - consumed by roving-focus (Left/Right flip) and popper (side flip).
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The reading direction in effect at `element`: the closest `[dir]`
|
|
6
|
+
* ancestor's value. Only an explicit rtl flips - dir="auto", dir="ltr" and
|
|
7
|
+
* no [dir] ancestor at all resolve "ltr".
|
|
8
|
+
*
|
|
9
|
+
* @param {Element} element
|
|
10
|
+
* @returns {"ltr" | "rtl"}
|
|
11
|
+
*/
|
|
12
|
+
export function directionOf(element) {
|
|
13
|
+
const dir = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
|
|
14
|
+
return dir === "rtl" ? "rtl" : "ltr"
|
|
15
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Capture-phase Escape handling: the primitive under dismissable's
|
|
2
|
+
// topmost-only Esc behavior.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* True when a keydown belongs to an IME composition. An Escape that
|
|
6
|
+
* cancels IME composition must never reach dismissal: CJK users press
|
|
7
|
+
* Escape to drop an in-progress composition, and closing the overlay
|
|
8
|
+
* under them destroys the field they were typing into. Chromium reports
|
|
9
|
+
* isComposing on the cancel keydown; some engines only mark it with the
|
|
10
|
+
* legacy 229 keyCode - check both. Every Escape consumer (dismissable via
|
|
11
|
+
* onEscapeKeydown, plus controllers with their own Escape branches) gates
|
|
12
|
+
* through this predicate.
|
|
13
|
+
*
|
|
14
|
+
* @param {KeyboardEvent} event
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function isImeKeydown(event) {
|
|
18
|
+
return event.isComposing || event.keyCode === 229
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Subscribes `callback` to Escape keydowns, with IME-cancel presses
|
|
23
|
+
* filtered out via {@link isImeKeydown}.
|
|
24
|
+
*
|
|
25
|
+
* @param {(event: KeyboardEvent) => void} callback
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {boolean} [options.capture=true] - capture phase, so dismissal
|
|
28
|
+
* sees the key before bubble-phase consumers can swallow it
|
|
29
|
+
* @param {EventTarget} [options.target=window]
|
|
30
|
+
* @returns {() => void} unsubscribe
|
|
31
|
+
*/
|
|
32
|
+
export function onEscapeKeydown(callback, { capture = true, target = window } = {}) {
|
|
33
|
+
const listener = (event) => {
|
|
34
|
+
if (event.key === "Escape" && !isImeKeydown(event)) callback(event)
|
|
35
|
+
}
|
|
36
|
+
target.addEventListener("keydown", listener, { capture })
|
|
37
|
+
return () => target.removeEventListener("keydown", listener, { capture })
|
|
38
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The Command filter spec: deterministic
|
|
2
|
+
// substring + a 5-band rank - deliberately NOT a fuzzy command-score. Pure string
|
|
3
|
+
// functions so the CI spec table pins the contract: any scoring change is
|
|
4
|
+
// a reviewed table change, never a silent reorder of every palette. The
|
|
5
|
+
// score's ONLY job is picking the auto-highlighted first match - the
|
|
6
|
+
// controller hides score-0 items and NEVER reorders the DOM (DOM order is
|
|
7
|
+
// the ranking authority within a band).
|
|
8
|
+
//
|
|
9
|
+
// Bands: prefix 4 > word-boundary 3 > substring 2 > keyword 1 > hidden 0;
|
|
10
|
+
// an empty query scores 1 (everything visible). Matching is
|
|
11
|
+
// diacritic-folded ("creme" matches "Crème") and case-insensitive.
|
|
12
|
+
|
|
13
|
+
/** An empty query: everything stays visible. */
|
|
14
|
+
export const SCORE_EMPTY_QUERY = 1
|
|
15
|
+
/** The label starts with the query. */
|
|
16
|
+
export const SCORE_PREFIX = 4
|
|
17
|
+
/** A word inside the label starts with the query. */
|
|
18
|
+
export const SCORE_WORD = 3
|
|
19
|
+
/** The query appears anywhere in the label. */
|
|
20
|
+
export const SCORE_SUBSTRING = 2
|
|
21
|
+
/** Only a keyword starts with the query. */
|
|
22
|
+
export const SCORE_KEYWORD = 1
|
|
23
|
+
/** No match - the controller hides the item. */
|
|
24
|
+
export const SCORE_HIDDEN = 0
|
|
25
|
+
|
|
26
|
+
// Words split on whitespace / dash / underscore / slash (the contract's
|
|
27
|
+
// word-boundary definition).
|
|
28
|
+
const WORD_SPLIT = /[\s\-_/]+/
|
|
29
|
+
|
|
30
|
+
const ITEM_TEXT_SELECTOR = '[data-slot="command-item-text"], [data-slot="combobox-item-text"]'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* trim + lowercase + NFKD-fold combining marks: diacritic-insensitive
|
|
34
|
+
* matching for free in every locale that marks are decorative in (folding
|
|
35
|
+
* is always-on - the contract's documented call).
|
|
36
|
+
*
|
|
37
|
+
* @param {*} value - stringified; null/undefined normalize to ""
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function normalize(value) {
|
|
41
|
+
return String(value ?? "").trim().toLowerCase().normalize("NFKD").replace(/\p{M}/gu, "")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The pure scorer. label/query arrive RAW (normalization is this
|
|
46
|
+
* function's business); keywords is an array of raw strings.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} label
|
|
49
|
+
* @param {string} query
|
|
50
|
+
* @param {string[]} [keywords=[]]
|
|
51
|
+
* @returns {number} a SCORE_* band
|
|
52
|
+
*/
|
|
53
|
+
export function scoreText(label, query, keywords = []) {
|
|
54
|
+
const q = normalize(query)
|
|
55
|
+
|
|
56
|
+
if (q === "") return SCORE_EMPTY_QUERY
|
|
57
|
+
|
|
58
|
+
const folded = normalize(label)
|
|
59
|
+
|
|
60
|
+
if (folded.startsWith(q)) return SCORE_PREFIX
|
|
61
|
+
if (folded.split(WORD_SPLIT).some((word) => word !== "" && word.startsWith(q))) return SCORE_WORD
|
|
62
|
+
if (folded.includes(q)) return SCORE_SUBSTRING
|
|
63
|
+
if (keywords.some((keyword) => normalize(keyword) !== "" && normalize(keyword).startsWith(q))) {
|
|
64
|
+
return SCORE_KEYWORD
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return SCORE_HIDDEN
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The label an item filters against: data-filter-value overrides
|
|
72
|
+
* (icon-rich content), else the item-text part, else the item's own text.
|
|
73
|
+
*
|
|
74
|
+
* @param {Element} item
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function filterLabel(item) {
|
|
78
|
+
return item.dataset.filterValue ??
|
|
79
|
+
item.querySelector(ITEM_TEXT_SELECTOR)?.textContent ??
|
|
80
|
+
item.textContent ?? ""
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* data-keywords: whitespace-separated extra filter terms an item may
|
|
85
|
+
* carry beyond its visible label.
|
|
86
|
+
*
|
|
87
|
+
* @param {Element} item
|
|
88
|
+
* @returns {string[]}
|
|
89
|
+
*/
|
|
90
|
+
export function filterKeywords(item) {
|
|
91
|
+
return (item.dataset.keywords ?? "").split(/\s+/).filter(Boolean)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Scores one collection item against the query via its filter label and
|
|
96
|
+
* keywords.
|
|
97
|
+
*
|
|
98
|
+
* @param {Element} item
|
|
99
|
+
* @param {string} query
|
|
100
|
+
* @returns {number} a SCORE_* band
|
|
101
|
+
*/
|
|
102
|
+
export function scoreItem(item, query) {
|
|
103
|
+
return scoreText(filterLabel(item), query, filterKeywords(item))
|
|
104
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Focus guards: two visually-hidden tabindex=0 sentinels at the
|
|
2
|
+
// body edges so focusin/focusout fire predictably at the document boundary
|
|
3
|
+
// while any trapped overlay is open - a Tab out of the last real element
|
|
4
|
+
// lands on a guard (which focus-scope yanks back), never on nothing.
|
|
5
|
+
// Refcounted module state: one pair per page no matter how many overlays.
|
|
6
|
+
|
|
7
|
+
/** Selector matching the two page-edge focus-guard sentinels. */
|
|
8
|
+
export const FOCUS_GUARD_SELECTOR = "[data-poetry-focus-guard]"
|
|
9
|
+
|
|
10
|
+
let guardCount = 0
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Takes a refcount on the page-edge guard pair, creating the two
|
|
14
|
+
* sentinels on the first acquire. Pair every call with
|
|
15
|
+
* {@link removeFocusGuards} on teardown.
|
|
16
|
+
*/
|
|
17
|
+
export function ensureFocusGuards() {
|
|
18
|
+
if (guardCount === 0) {
|
|
19
|
+
document.body.insertAdjacentElement("afterbegin", createGuard())
|
|
20
|
+
document.body.insertAdjacentElement("beforeend", createGuard())
|
|
21
|
+
}
|
|
22
|
+
guardCount += 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Releases one refcount on the guard pair; the last release removes both
|
|
27
|
+
* sentinels from the document.
|
|
28
|
+
*/
|
|
29
|
+
export function removeFocusGuards() {
|
|
30
|
+
if (guardCount === 0) return
|
|
31
|
+
|
|
32
|
+
guardCount -= 1
|
|
33
|
+
|
|
34
|
+
if (guardCount === 0) {
|
|
35
|
+
for (const guard of document.querySelectorAll(FOCUS_GUARD_SELECTOR)) guard.remove()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createGuard() {
|
|
40
|
+
const guard = document.createElement("span")
|
|
41
|
+
guard.setAttribute("data-poetry-focus-guard", "")
|
|
42
|
+
guard.setAttribute("tabindex", "0")
|
|
43
|
+
guard.setAttribute("aria-hidden", "true")
|
|
44
|
+
guard.style.cssText = "position: fixed; opacity: 0; pointer-events: none; outline: none;"
|
|
45
|
+
return guard
|
|
46
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The hotkey descriptor grammar, extracted from the dialog controller so
|
|
2
|
+
// any surface can speak it (the generic hotkey controller, future
|
|
3
|
+
// data-hotkey affordances). "meta+k" / "ctrl+shift+p": '+'-separated
|
|
4
|
+
// modifiers plus one final key token, matched exactly - unlisted modifiers
|
|
5
|
+
// must be UP, so plain typing never triggers. "meta" matches metaKey OR
|
|
6
|
+
// ctrlKey (⌘K on mac, ^K elsewhere - the command-palette convention).
|
|
7
|
+
/**
|
|
8
|
+
* Whether a keydown satisfies a hotkey descriptor (the grammar above).
|
|
9
|
+
*
|
|
10
|
+
* @param {KeyboardEvent} event
|
|
11
|
+
* @param {string} descriptor - e.g. "meta+k", "ctrl+shift+p", "?"
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
export function matchesHotkey(event, descriptor) {
|
|
15
|
+
const tokens = descriptor.toLowerCase().split("+").map((token) => token.trim())
|
|
16
|
+
const key = tokens.pop()
|
|
17
|
+
|
|
18
|
+
if ((event.key ?? "").toLowerCase() !== key) return false
|
|
19
|
+
|
|
20
|
+
const meta = tokens.includes("meta") ? (event.metaKey || event.ctrlKey) : true
|
|
21
|
+
const ctrl = tokens.includes("ctrl") ? event.ctrlKey : true
|
|
22
|
+
const shift = tokens.includes("shift") === event.shiftKey
|
|
23
|
+
const alt = tokens.includes("alt") === event.altKey
|
|
24
|
+
const noStray = tokens.includes("meta") || tokens.includes("ctrl") ||
|
|
25
|
+
(!event.metaKey && !event.ctrlKey)
|
|
26
|
+
|
|
27
|
+
return meta && ctrl && shift && alt && noStray
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True when the event originates in a text-editing context - unmodified
|
|
32
|
+
* single-key shortcuts ("/", "?") must stay inert while the user types
|
|
33
|
+
* (the standard hotkey ignore list: form fields and contenteditable).
|
|
34
|
+
*
|
|
35
|
+
* @param {KeyboardEvent} event
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function isEditingTarget(event) {
|
|
39
|
+
const target = event.composedPath?.()[0] ?? event.target
|
|
40
|
+
if (!target || !(target instanceof Element)) return false
|
|
41
|
+
|
|
42
|
+
return Boolean(["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable)
|
|
43
|
+
}
|