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,510 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { collectionItems } from "@poetry/controllers/helpers/collection"
|
|
3
|
+
import { scoreItem } from "@poetry/controllers/helpers/filter_rank"
|
|
4
|
+
|
|
5
|
+
// The Command palette engine - the suite's
|
|
6
|
+
// first ACTIVEDESCENDANT component (the APG editable-combobox pattern, the
|
|
7
|
+
// deliberate delta vs the menus/Select family's roving focus): real DOM
|
|
8
|
+
// focus stays pinned to the input for the whole session; the highlighted
|
|
9
|
+
// option carries data-highlighted + its server-stable id in the input's
|
|
10
|
+
// aria-activedescendant (the twin-write, activedescendant-flavored).
|
|
11
|
+
// Options never get tabindex; roving-focus is NOT attached; aria-selected
|
|
12
|
+
// is NEVER written here (reserved for committed values - Combobox's
|
|
13
|
+
// twin-write; a bare palette has no committed value).
|
|
14
|
+
//
|
|
15
|
+
// THE FILTER is the deterministic helpers/filter_rank spec (prefix 4 >
|
|
16
|
+
// word-boundary 3 > substring 2 > keyword 1 > hidden 0, diacritic-folded)
|
|
17
|
+
// and it is HIDE-ONLY: score-0 items get hidden + data-hidden, matches get
|
|
18
|
+
// both removed, and children are NEVER reordered - DOM order is the
|
|
19
|
+
// ranking authority within a band; the score's only job is seating the
|
|
20
|
+
// auto-highlight. filter:false is the server-driven mode: steps 1-2
|
|
21
|
+
// (hiding) are skipped entirely and only
|
|
22
|
+
// highlight/activation/announcement run over whatever the server rendered
|
|
23
|
+
// - the Turbo-frame async seam Combobox's recipe plugs into.
|
|
24
|
+
//
|
|
25
|
+
// ACTIVATION IS AN EVENT, NOT AN ACTION: Enter/click dispatches cancelable
|
|
26
|
+
// poetry:command:select and this controller does nothing further - no
|
|
27
|
+
// navigation, no close, no value write. The listener (host data-action,
|
|
28
|
+
// Combobox's commit pipeline) owns the consequences - the engine-purity
|
|
29
|
+
// Combobox's composition depends on (no popper/commit/native-select code
|
|
30
|
+
// lives here, fenced by the conformance greps).
|
|
31
|
+
//
|
|
32
|
+
// The DOM is the store, filtering included: query in the input, visibility
|
|
33
|
+
// as hidden + data-hidden, highlight as data-highlighted +
|
|
34
|
+
// aria-activedescendant, group visibility derived - a Turbo Stream can
|
|
35
|
+
// append items mid-session and the next keystroke ranks them.
|
|
36
|
+
// Each part matches both families' vocabularies: Command's own names and the
|
|
37
|
+
// combobox's (two names for the same parts; the engine rides both).
|
|
38
|
+
const INPUT_SELECTOR = '[data-slot="command-input"], [data-slot="combobox-input"], [data-slot="combobox-chip-input"]'
|
|
39
|
+
const LIST_SELECTOR = '[data-slot="command-list"], [data-slot="combobox-list"]'
|
|
40
|
+
const ITEM_SELECTOR = '[data-slot="command-item"], [data-slot="combobox-item"]'
|
|
41
|
+
const ITEM_TEXT_SELECTOR = '[data-slot="command-item-text"], [data-slot="combobox-item-text"]'
|
|
42
|
+
const GROUP_SELECTOR = '[data-slot="command-group"], [data-slot="combobox-group"]'
|
|
43
|
+
const SEPARATOR_SELECTOR = '[data-slot="command-separator"], [data-slot="combobox-separator"]'
|
|
44
|
+
const EMPTY_SELECTOR = '[data-slot="command-empty"], [data-slot="combobox-empty"]'
|
|
45
|
+
const STATUS_SELECTOR = '[data-slot="command-status"], [data-slot="combobox-status"]'
|
|
46
|
+
|
|
47
|
+
const EVENT_PREFIX = "poetry:command"
|
|
48
|
+
|
|
49
|
+
// The status live region is ALWAYS debounced (keystroke bursts announce
|
|
50
|
+
// once) - activedescendant says WHERE you are, the count says HOW MANY
|
|
51
|
+
// remain (the count is the piece filter UIs usually leave silent).
|
|
52
|
+
const STATUS_DEBOUNCE = 100
|
|
53
|
+
|
|
54
|
+
export default class CommandController extends Controller {
|
|
55
|
+
// The events this controller dispatches (manifest surface;
|
|
56
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
57
|
+
static events = ["poetry:command:filter", "poetry:command:highlight", "poetry:command:select"]
|
|
58
|
+
|
|
59
|
+
static values = {
|
|
60
|
+
// false = SERVER-DRIVEN mode: never hide -
|
|
61
|
+
// the host re-renders the list (Turbo frame) and Command only runs
|
|
62
|
+
// highlight/activation/announcement over what is rendered.
|
|
63
|
+
filter: { type: Boolean, default: true },
|
|
64
|
+
// Arrow-key wrap over visible enabled items.
|
|
65
|
+
loop: { type: Boolean, default: false },
|
|
66
|
+
// Filter-pass debounce in ms (0 = synchronous - client filtering is
|
|
67
|
+
// cheap string work; the status announcement is debounced separately).
|
|
68
|
+
debounce: { type: Number, default: 0 }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#connected = false
|
|
72
|
+
#passTimer = null
|
|
73
|
+
#statusTimer = null
|
|
74
|
+
#delegatedList = null
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reconciles (a non-empty input re-derives visibility + highlight
|
|
78
|
+
* silently; an empty one just seats the highlight) and wires the portal
|
|
79
|
+
* delegation on the list node (the body comments hold the rules).
|
|
80
|
+
*/
|
|
81
|
+
connect() {
|
|
82
|
+
// Reconcile-on-connect (Turbo morph/stream safe): a non-empty input
|
|
83
|
+
// re-derives visibility + highlight silently (no events for state the
|
|
84
|
+
// server already declared); an empty input just seats the highlight
|
|
85
|
+
// (a server-rendered data-highlighted item wins, else first enabled).
|
|
86
|
+
if ((this.#input()?.value ?? "") !== "") {
|
|
87
|
+
this.#pass({ silent: true })
|
|
88
|
+
} else {
|
|
89
|
+
this.#seat({ silent: true })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Portal delegation: Stimulus scopes
|
|
93
|
+
// data-actions to the controller's subtree, so the per-item
|
|
94
|
+
// activate/pointerHighlight actions go DEAD when the popup portals
|
|
95
|
+
// out of it (Combobox multiple mounts this engine on the ROOT while
|
|
96
|
+
// the listbox moves to body). The listeners ride the LIST node - they
|
|
97
|
+
// travel with the portal; in-scope items remain the actions' job
|
|
98
|
+
// (#delegatedItem guards the double-fire).
|
|
99
|
+
const list = this.#list()
|
|
100
|
+
|
|
101
|
+
if (list) {
|
|
102
|
+
list.addEventListener("click", this.#onListClick)
|
|
103
|
+
list.addEventListener("pointermove", this.#onListPointermove)
|
|
104
|
+
this.#delegatedList = list
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.#connected = true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Clears the timers and unwires the list delegation. */
|
|
111
|
+
disconnect() {
|
|
112
|
+
this.#connected = false
|
|
113
|
+
|
|
114
|
+
if (this.#passTimer !== null) window.clearTimeout(this.#passTimer)
|
|
115
|
+
if (this.#statusTimer !== null) window.clearTimeout(this.#statusTimer)
|
|
116
|
+
|
|
117
|
+
this.#passTimer = null
|
|
118
|
+
this.#statusTimer = null
|
|
119
|
+
|
|
120
|
+
if (this.#delegatedList) {
|
|
121
|
+
this.#delegatedList.removeEventListener("click", this.#onListClick)
|
|
122
|
+
this.#delegatedList.removeEventListener("pointermove", this.#onListPointermove)
|
|
123
|
+
this.#delegatedList = null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#onListClick = (event) => {
|
|
128
|
+
const item = this.#delegatedItem(event)
|
|
129
|
+
|
|
130
|
+
if (item && !this.#isDisabled(item)) this.#activate(item)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#onListPointermove = (event) => {
|
|
134
|
+
const item = this.#delegatedItem(event)
|
|
135
|
+
|
|
136
|
+
if (item && !this.#isDisabled(item) && !this.#isHidden(item)) this.#highlight(item, { scroll: false })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#delegatedItem(event) {
|
|
140
|
+
if (!(event.target instanceof Element)) return null
|
|
141
|
+
// In-scope items are the Stimulus actions' job - delegation exists
|
|
142
|
+
// only for items the portal moved OUT of this controller's subtree.
|
|
143
|
+
if (this.element.contains(event.target)) return null
|
|
144
|
+
|
|
145
|
+
return event.target.closest(ITEM_SELECTOR)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- the filter pass (input action) ---
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The input's input action: runs the filter pass (debounced when
|
|
152
|
+
* configured).
|
|
153
|
+
*/
|
|
154
|
+
filterInput() {
|
|
155
|
+
if (this.debounceValue <= 0) {
|
|
156
|
+
this.#pass()
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (this.#passTimer !== null) window.clearTimeout(this.#passTimer)
|
|
161
|
+
|
|
162
|
+
this.#passTimer = window.setTimeout(() => {
|
|
163
|
+
this.#passTimer = null
|
|
164
|
+
this.#pass()
|
|
165
|
+
}, this.debounceValue)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- the activedescendant keyboard map (input action) ---
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The input's keydown action: ArrowUp/Down move the highlight
|
|
172
|
+
* (Meta/Ctrl jumps first/last), Enter activates it.
|
|
173
|
+
* Home/End/ArrowLeft/ArrowRight fall through to the input (CARET
|
|
174
|
+
* movement - APG-correct for an editable field; a Home/End list-jump
|
|
175
|
+
* is deliberately not offered). Space TYPES a space, never
|
|
176
|
+
* activates (a text field - the family delta vs Select/menus). Esc/Tab
|
|
177
|
+
* are NOT handled - the hosting layer owns them (Dialog dismiss /
|
|
178
|
+
* Combobox close / natural tab-out).
|
|
179
|
+
*
|
|
180
|
+
* @param {KeyboardEvent} event
|
|
181
|
+
*/
|
|
182
|
+
keydown(event) {
|
|
183
|
+
switch (event.key) {
|
|
184
|
+
case "ArrowDown":
|
|
185
|
+
case "ArrowUp": {
|
|
186
|
+
event.preventDefault() // the caret never moves on vertical arrows
|
|
187
|
+
|
|
188
|
+
const items = this.#navigableItems()
|
|
189
|
+
|
|
190
|
+
if (items.length === 0) return
|
|
191
|
+
|
|
192
|
+
const down = event.key === "ArrowDown"
|
|
193
|
+
|
|
194
|
+
if (event.metaKey || event.ctrlKey) {
|
|
195
|
+
// Meta/Ctrl+ArrowDown/Up jump last / first.
|
|
196
|
+
this.#highlight(items[down ? items.length - 1 : 0])
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const index = items.indexOf(this.#highlighted())
|
|
201
|
+
let next
|
|
202
|
+
|
|
203
|
+
if (index === -1) {
|
|
204
|
+
next = down ? 0 : items.length - 1
|
|
205
|
+
} else {
|
|
206
|
+
next = index + (down ? 1 : -1)
|
|
207
|
+
|
|
208
|
+
if (this.loopValue) next = (next + items.length) % items.length
|
|
209
|
+
else next = Math.min(items.length - 1, Math.max(0, next))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.#highlight(items[next])
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
case "Enter": {
|
|
216
|
+
event.preventDefault() // never submit a form through the palette input
|
|
217
|
+
|
|
218
|
+
const item = this.#highlighted()
|
|
219
|
+
|
|
220
|
+
if (item && !this.#isDisabled(item) && !this.#isHidden(item)) this.#activate(item)
|
|
221
|
+
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
default:
|
|
225
|
+
// Everything else - printable keys, Space, Backspace, Home/End,
|
|
226
|
+
// Left/Right, Esc, Tab - belongs to the input or the host.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- activation (item click action) ---
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Each in-scope item's click action: dispatches the cancelable select
|
|
234
|
+
* event - and nothing further (the engine-purity contract).
|
|
235
|
+
*
|
|
236
|
+
* @param {MouseEvent} event
|
|
237
|
+
*/
|
|
238
|
+
activate(event) {
|
|
239
|
+
const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
|
|
240
|
+
const item = origin instanceof Element ? origin.closest(ITEM_SELECTOR) : null
|
|
241
|
+
|
|
242
|
+
if (!item || this.#isDisabled(item)) return
|
|
243
|
+
|
|
244
|
+
this.#activate(item)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- pointer highlight (item pointermove action) ---
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Each in-scope item's pointermove action: hovering highlights (no
|
|
251
|
+
* scroll chasing); pointerleave does NOT clear - the keyboard position
|
|
252
|
+
* survives mouse exit (contractual).
|
|
253
|
+
*
|
|
254
|
+
* @param {PointerEvent} event
|
|
255
|
+
*/
|
|
256
|
+
pointerHighlight(event) {
|
|
257
|
+
const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
|
|
258
|
+
const item = origin instanceof Element ? origin.closest(ITEM_SELECTOR) : null
|
|
259
|
+
|
|
260
|
+
if (!item || this.#isDisabled(item) || this.#isHidden(item)) return
|
|
261
|
+
|
|
262
|
+
this.#highlight(item, { scroll: false })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- the composition surface (engine-generic; Combobox calls these) ---
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The composition surface (Combobox calls it): moves the highlight to a
|
|
269
|
+
* given option (visible + enabled required).
|
|
270
|
+
*
|
|
271
|
+
* @param {Element} item
|
|
272
|
+
* @param {Object} [options]
|
|
273
|
+
* @param {boolean} [options.scroll=true] - scroll the option into view
|
|
274
|
+
*/
|
|
275
|
+
highlightItem(item, { scroll = true } = {}) {
|
|
276
|
+
if (!item || this.#isDisabled(item) || this.#isHidden(item)) return
|
|
277
|
+
|
|
278
|
+
this.#highlight(item, { scroll })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Clears the query and silently re-derives visibility + highlight (the
|
|
283
|
+
* clean-reopen reset an overlay host runs on close).
|
|
284
|
+
*/
|
|
285
|
+
reset() {
|
|
286
|
+
const input = this.#input()
|
|
287
|
+
|
|
288
|
+
if (!input) return
|
|
289
|
+
|
|
290
|
+
input.value = ""
|
|
291
|
+
this.#pass({ silent: true })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- the pass ---
|
|
295
|
+
|
|
296
|
+
// Synchronous, on every input event (and silently on connect/reset):
|
|
297
|
+
// 1. score every collection item; client mode hides score-0 (unless
|
|
298
|
+
// data-always-render) via hidden + data-hidden - NEVER reorders.
|
|
299
|
+
// 2. groups hide when ALL their items hide; separators hide whenever
|
|
300
|
+
// the query is non-empty. Skipped in filter:false.
|
|
301
|
+
// 3. re-seat the highlight: highest score among visible ∩ enabled,
|
|
302
|
+
// first-in-DOM tiebreak; zero visible clears both twin-writes and
|
|
303
|
+
// unhides the empty part.
|
|
304
|
+
// 4. dispatch poetry:command:filter {query, visible}; debounce the
|
|
305
|
+
// status live-region count.
|
|
306
|
+
#pass({ silent = false } = {}) {
|
|
307
|
+
const input = this.#input()
|
|
308
|
+
const list = this.#list()
|
|
309
|
+
|
|
310
|
+
if (!input || !list) return
|
|
311
|
+
|
|
312
|
+
const query = input.value ?? ""
|
|
313
|
+
const items = this.#items(list)
|
|
314
|
+
const scores = new Map()
|
|
315
|
+
|
|
316
|
+
for (const item of items) {
|
|
317
|
+
const score = scoreItem(item, query)
|
|
318
|
+
|
|
319
|
+
scores.set(item, score)
|
|
320
|
+
|
|
321
|
+
if (this.filterValue) {
|
|
322
|
+
const hide = score === 0 && !item.hasAttribute("data-always-render")
|
|
323
|
+
|
|
324
|
+
item.toggleAttribute("hidden", hide)
|
|
325
|
+
item.toggleAttribute("data-hidden", hide)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (this.filterValue) {
|
|
330
|
+
for (const group of list.querySelectorAll(GROUP_SELECTOR)) {
|
|
331
|
+
const members = this.#items(group)
|
|
332
|
+
const hide = !group.hasAttribute("data-always-render") &&
|
|
333
|
+
members.every((item) => item.hasAttribute("data-hidden"))
|
|
334
|
+
|
|
335
|
+
group.toggleAttribute("hidden", hide)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const separator of list.querySelectorAll(SEPARATOR_SELECTOR)) {
|
|
339
|
+
separator.toggleAttribute("hidden", query !== "")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const visibleItems = items.filter((item) => !this.#isHidden(item))
|
|
344
|
+
const visible = visibleItems.length
|
|
345
|
+
// List-scoped first: in Combobox's multiple mode this engine rides the
|
|
346
|
+
// ROOT while portal-on-open moves the popup (list + empty + status) to
|
|
347
|
+
// body - element scoping goes blind there; the list resolves by the
|
|
348
|
+
// input's aria-controls id, and the parts around it hang off it.
|
|
349
|
+
const empty = list.querySelector(EMPTY_SELECTOR) ?? this.element.querySelector(EMPTY_SELECTOR)
|
|
350
|
+
|
|
351
|
+
if (empty) empty.hidden = visible !== 0
|
|
352
|
+
|
|
353
|
+
// Re-seat: highest score among visible ∩ enabled, first-in-DOM
|
|
354
|
+
// tiebreak (strictly-greater replacement preserves DOM order as the
|
|
355
|
+
// within-band authority).
|
|
356
|
+
let best = null
|
|
357
|
+
let bestScore = -1
|
|
358
|
+
|
|
359
|
+
for (const item of visibleItems) {
|
|
360
|
+
if (this.#isDisabled(item)) continue
|
|
361
|
+
|
|
362
|
+
const score = scores.get(item) ?? 0
|
|
363
|
+
|
|
364
|
+
if (score > bestScore) {
|
|
365
|
+
best = item
|
|
366
|
+
bestScore = score
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (best) this.#highlight(best, { silent })
|
|
371
|
+
else this.#clearHighlight()
|
|
372
|
+
|
|
373
|
+
if (silent) return
|
|
374
|
+
|
|
375
|
+
this.dispatch("filter", { prefix: EVENT_PREFIX, detail: { query, visible } })
|
|
376
|
+
this.#announce(visible)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Initial seat (empty query): a server-rendered data-highlighted item
|
|
380
|
+
// wins (the value: option), else the first visible enabled item; the
|
|
381
|
+
// activedescendant twin is written either way.
|
|
382
|
+
#seat({ silent = false } = {}) {
|
|
383
|
+
const seeded = this.#highlighted()
|
|
384
|
+
|
|
385
|
+
if (seeded && !this.#isDisabled(seeded) && !this.#isHidden(seeded)) {
|
|
386
|
+
this.#highlight(seeded, { silent, force: true })
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const first = this.#navigableItems()[0]
|
|
391
|
+
|
|
392
|
+
if (first) this.#highlight(first, { silent })
|
|
393
|
+
else this.#clearHighlight()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- highlight (the twin-write: data-highlighted + activedescendant) ---
|
|
397
|
+
|
|
398
|
+
#highlight(item, { silent = false, scroll = true, force = false } = {}) {
|
|
399
|
+
const previous = this.#highlighted()
|
|
400
|
+
|
|
401
|
+
if (previous === item && !force) return
|
|
402
|
+
|
|
403
|
+
previous?.removeAttribute("data-highlighted")
|
|
404
|
+
item.setAttribute("data-highlighted", "")
|
|
405
|
+
this.#input()?.setAttribute("aria-activedescendant", item.id)
|
|
406
|
+
if (scroll) item.scrollIntoView?.({ block: "nearest" })
|
|
407
|
+
|
|
408
|
+
if (silent || previous === item) return
|
|
409
|
+
|
|
410
|
+
this.dispatch("highlight", {
|
|
411
|
+
prefix: EVENT_PREFIX,
|
|
412
|
+
target: item,
|
|
413
|
+
detail: { item, value: item.dataset.value ?? "" }
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#clearHighlight() {
|
|
418
|
+
this.#highlighted()?.removeAttribute("data-highlighted")
|
|
419
|
+
this.#input()?.removeAttribute("aria-activedescendant")
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- activation ---
|
|
423
|
+
|
|
424
|
+
// Cancelable select; NOT canceled -> Command does NOTHING FURTHER (no
|
|
425
|
+
// navigation, no close, no value write) - a palette engine, not an actor.
|
|
426
|
+
#activate(item) {
|
|
427
|
+
this.dispatch("select", {
|
|
428
|
+
prefix: EVENT_PREFIX,
|
|
429
|
+
target: item,
|
|
430
|
+
cancelable: true,
|
|
431
|
+
detail: { item, value: item.dataset.value ?? "", label: this.#labelOf(item) }
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#labelOf(item) {
|
|
436
|
+
return (item.querySelector(ITEM_TEXT_SELECTOR)?.textContent ?? item.textContent ?? "").trim()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- the status live region ---
|
|
440
|
+
|
|
441
|
+
// Debounced result counts into the component-rendered polite region;
|
|
442
|
+
// the localized templates ride data attributes on the region (data-zero
|
|
443
|
+
// / data-one / data-other with a %{count} placeholder) so the engine
|
|
444
|
+
// stays i18n-free.
|
|
445
|
+
#announce(visible) {
|
|
446
|
+
// Element scope first (the bare palette), then beside the resolved
|
|
447
|
+
// list - Combobox multiple portals the popup (status included) out of
|
|
448
|
+
// this engine's root subtree.
|
|
449
|
+
const status = this.element.querySelector(STATUS_SELECTOR) ??
|
|
450
|
+
this.#list()?.parentElement?.querySelector(STATUS_SELECTOR)
|
|
451
|
+
|
|
452
|
+
if (!status) return
|
|
453
|
+
|
|
454
|
+
if (this.#statusTimer !== null) window.clearTimeout(this.#statusTimer)
|
|
455
|
+
|
|
456
|
+
this.#statusTimer = window.setTimeout(() => {
|
|
457
|
+
this.#statusTimer = null
|
|
458
|
+
|
|
459
|
+
const template = (visible === 0 && status.dataset.zero) ||
|
|
460
|
+
(visible === 1 && status.dataset.one) ||
|
|
461
|
+
status.dataset.other || "%{count} results"
|
|
462
|
+
|
|
463
|
+
status.textContent = template.replace("%{count}", String(visible))
|
|
464
|
+
}, STATUS_DEBOUNCE)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --- structural resolution (data-slots, portal/stream-safe) ---
|
|
468
|
+
|
|
469
|
+
#input() {
|
|
470
|
+
return this.element.querySelector(INPUT_SELECTOR)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#list() {
|
|
474
|
+
const id = this.#input()?.getAttribute("aria-controls")
|
|
475
|
+
|
|
476
|
+
return (id && document.getElementById(id)) || this.element.querySelector(LIST_SELECTOR)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#items(root = this.#list()) {
|
|
480
|
+
return root ? collectionItems(root, ITEM_SELECTOR) : []
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#navigableItems() {
|
|
484
|
+
return this.#items().filter((item) => !this.#isHidden(item) && !this.#isDisabled(item))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#highlighted() {
|
|
488
|
+
return this.#list()?.querySelector("[data-highlighted]") ?? null
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#isDisabled(item) {
|
|
492
|
+
return item.hasAttribute("data-disabled") || item.getAttribute("aria-disabled") === "true"
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// An item is hidden when itself or any ancestor UP TO THE LIST (its
|
|
496
|
+
// group, a server-hidden frame) carries the hidden attribute. The walk
|
|
497
|
+
// stops at the list ON PURPOSE: the close-time reset() runs after the
|
|
498
|
+
// host hid the whole popup, and counting that ancestor made every item
|
|
499
|
+
// "hidden" - zero visible - so the pass UNHID the empty part and every
|
|
500
|
+
// reopen flashed "No results found." until the first keystroke.
|
|
501
|
+
#isHidden(item) {
|
|
502
|
+
const list = this.#list()
|
|
503
|
+
|
|
504
|
+
for (let node = item; node && node !== list; node = node.parentElement) {
|
|
505
|
+
if (node.hidden) return true
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return false
|
|
509
|
+
}
|
|
510
|
+
}
|