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,1095 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { collectionItems } from "@poetry/controllers/helpers/collection"
|
|
3
|
+
import { isImeKeydown } from "@poetry/controllers/helpers/escape"
|
|
4
|
+
import { isPortaled, portalContent, resolvePortalContainer, restoreContent } from "@poetry/controllers/helpers/portal"
|
|
5
|
+
import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
|
|
6
|
+
import { setState, stateOf } from "@poetry/controllers/helpers/state"
|
|
7
|
+
import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
|
|
8
|
+
|
|
9
|
+
// The Combobox ORCHESTRATOR: Select's shell
|
|
10
|
+
// x Command's engine, composed VIA THE EVENT CONTRACT ONLY - this thin
|
|
11
|
+
// controller owns open/close + the commit pipeline + autofill adoption and
|
|
12
|
+
// listens for the embedded engine's poetry:command:select; it contains NO
|
|
13
|
+
// filter/highlight/scoring code (Command owns those) and Command gained no
|
|
14
|
+
// combobox code (engine purity, fenced both directions by the conformance
|
|
15
|
+
// greps). Zero code is shared with SelectController - the PATTERNS are
|
|
16
|
+
// (the layer mechanism, the native-first 5-step pipeline, the adoption
|
|
17
|
+
// path), re-instantiated here because a facade extraction was explicitly
|
|
18
|
+
// declined (the Select contract's open question, answered by this build).
|
|
19
|
+
//
|
|
20
|
+
// THE THREE DELIBERATE DELTAS vs Select, each pinned by tests so neither
|
|
21
|
+
// sibling's rules leak:
|
|
22
|
+
// - open focuses the COMMAND INPUT for every reason (a combobox session is
|
|
23
|
+
// a TYPING session - APG editable combobox); the selected option gets the
|
|
24
|
+
// HIGHLIGHT (activedescendant, via the Command controller) + scroll, not
|
|
25
|
+
// DOM focus;
|
|
26
|
+
// - Tab while open CLOSES WITHOUT COMMIT and lets focus proceed (Popover
|
|
27
|
+
// semantics, modal:false default; modal:true restores the trap) - Select
|
|
28
|
+
// is Tab-inert;
|
|
29
|
+
// - a printable key on the CLOSED trigger OPENS and SEEDS the filter
|
|
30
|
+
// (typing filters, never blind-commits) - Select's closed-trigger
|
|
31
|
+
// typeahead-commit does NOT port.
|
|
32
|
+
//
|
|
33
|
+
// TWO MEANINGS, TWO ATTRIBUTES, ONE LIST: data-highlighted +
|
|
34
|
+
// aria-activedescendant = position (Command's, never aria-selected);
|
|
35
|
+
// aria-selected + data-selected + the indicator = the COMMITTED value
|
|
36
|
+
// (Select's twin-write, written only here, in the pipeline).
|
|
37
|
+
//
|
|
38
|
+
// THE SYNC INVARIANT (Select's, inherited): native_select.value, the value
|
|
39
|
+
// Value, the twin-write, and the display never diverge - every write path
|
|
40
|
+
// funnels through #apply, NATIVE FIRST (serialization truth is never
|
|
41
|
+
// behind the facade), with real bubbling change/input on the native so
|
|
42
|
+
// Turbo auto-submit and form listeners work unmodified.
|
|
43
|
+
//
|
|
44
|
+
// MULTIPLE (the input-inside layout): the value is a
|
|
45
|
+
// LIST (the value Value carries a JSON array over the same String seam),
|
|
46
|
+
// the native is a <select multiple> posting name[], and the trigger is
|
|
47
|
+
// replaced by the chips FIELD - one chip per committed value IN VALUE
|
|
48
|
+
// ORDER with the filter input inline after them (data-slot=combobox-chip-input,
|
|
49
|
+
// which the engine resolves too; the engine itself rides the ROOT).
|
|
50
|
+
// Selection TOGGLES and the popup STAYS OPEN; chips take REAL DOM focus
|
|
51
|
+
// (:focus-visible styles it - never data-highlighted) and focusing a chip
|
|
52
|
+
// closes the popup; Backspace on the empty input removes the last chip;
|
|
53
|
+
// Escape on the CLOSED popup clears the query and wipes the selection.
|
|
54
|
+
// The input NEVER mirrors selection text; single-mode paths are
|
|
55
|
+
// behavior-identical.
|
|
56
|
+
const TRIGGER_SELECTOR = '[data-slot="combobox-trigger"]'
|
|
57
|
+
const NATIVE_SELECTOR = '[data-slot="combobox-native"]'
|
|
58
|
+
const VALUE_SELECTOR = '[data-slot="combobox-value"]'
|
|
59
|
+
const CONTENT_SELECTOR = '[data-slot="combobox-content"]'
|
|
60
|
+
const CLEAR_SELECTOR = '[data-slot="combobox-clear"]'
|
|
61
|
+
const CHIPS_SELECTOR = '[data-slot="combobox-chips"]'
|
|
62
|
+
const CHIP_SELECTOR = '[data-slot="combobox-chip"]'
|
|
63
|
+
const CHIP_REMOVE_SELECTOR = '[data-slot="combobox-chip-remove"]'
|
|
64
|
+
const COMMAND_SELECTOR = '[data-slot="combobox-command"]'
|
|
65
|
+
const INPUT_SELECTOR = '[data-slot="combobox-input"], [data-slot="combobox-chip-input"]'
|
|
66
|
+
const ITEM_SELECTOR = '[data-slot="combobox-item"]'
|
|
67
|
+
const ITEM_TEXT_SELECTOR = '[data-slot="combobox-item-text"]'
|
|
68
|
+
|
|
69
|
+
const EVENT_PREFIX = "poetry:combobox"
|
|
70
|
+
const COMMAND_IDENTIFIER = "poetry--core--command"
|
|
71
|
+
|
|
72
|
+
// NO roving-focus (the popup is Command's activedescendant session - the
|
|
73
|
+
// family's first popup without it).
|
|
74
|
+
const CONTENT_LAYER_CONTROLLERS = ["poetry--core--focus-scope", "poetry--core--dismissable"]
|
|
75
|
+
const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
|
|
76
|
+
|
|
77
|
+
export default class ComboboxController extends Controller {
|
|
78
|
+
// The events this controller dispatches (manifest surface;
|
|
79
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
80
|
+
static events = [
|
|
81
|
+
"poetry:combobox:change", "poetry:combobox:closed", "poetry:combobox:open",
|
|
82
|
+
"poetry:combobox:select"
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
static values = {
|
|
86
|
+
open: { type: Boolean, default: false },
|
|
87
|
+
// Single: the committed value. multiple: a JSON array over the same
|
|
88
|
+
// String seam (parsed by #listValues, serialized by #applyValues).
|
|
89
|
+
value: { type: String, default: "" },
|
|
90
|
+
// DEFAULT FALSE - Popover semantics (Tab-out closes, no scrim); true
|
|
91
|
+
// restores the focus-scope trap for dialog-critical pickers.
|
|
92
|
+
modal: { type: Boolean, default: false },
|
|
93
|
+
// multiple: the value is a LIST, the trigger is the chips
|
|
94
|
+
// field, selection toggles, the popup stays open on select.
|
|
95
|
+
multiple: { type: Boolean, default: false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#connected = false
|
|
99
|
+
#wired = []
|
|
100
|
+
#suppressRestore = false
|
|
101
|
+
#cancelExit = null
|
|
102
|
+
#applied = ""
|
|
103
|
+
#placeholder = ""
|
|
104
|
+
#dismissedEvent = null
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wires the content (and chips-field) listeners, captures the
|
|
108
|
+
* placeholder, reconciles silently from the native select / the Value
|
|
109
|
+
* (multiple parses the JSON-array seam), and catches a server-open
|
|
110
|
+
* popup up (layers + the late portal).
|
|
111
|
+
*/
|
|
112
|
+
connect() {
|
|
113
|
+
const content = this.#content()
|
|
114
|
+
|
|
115
|
+
if (content) this.#wireContent(content)
|
|
116
|
+
|
|
117
|
+
const chips = this.#chips()
|
|
118
|
+
|
|
119
|
+
// The chips FIELD is a second wired keyboard surface in multiple
|
|
120
|
+
// (Tab-out closes from the inline input exactly like from the popup).
|
|
121
|
+
if (chips) this.#listen(chips, "keydown", this.#onKeydown)
|
|
122
|
+
|
|
123
|
+
const display = this.#display()
|
|
124
|
+
|
|
125
|
+
this.#placeholder = display?.dataset.placeholder ??
|
|
126
|
+
(this.#trigger()?.hasAttribute("data-placeholder") ? (display?.textContent ?? "").trim() : "")
|
|
127
|
+
|
|
128
|
+
// Reconcile-on-connect (Turbo Stream re-render safe): the native select
|
|
129
|
+
// is the serialization truth (Select-exact). multiple reads the value
|
|
130
|
+
// Value's JSON array, falling back to the native's selectedOptions.
|
|
131
|
+
const serverValue = this.multipleValue
|
|
132
|
+
? this.#reconciledValues()
|
|
133
|
+
: (this.valueValue !== "" ? this.valueValue : (this.#native()?.value ?? ""))
|
|
134
|
+
|
|
135
|
+
this.#applied = serverValue
|
|
136
|
+
this.#apply(serverValue, { silent: true, force: true })
|
|
137
|
+
|
|
138
|
+
this.#connected = true
|
|
139
|
+
|
|
140
|
+
if (this.#isOpen()) {
|
|
141
|
+
if (content) {
|
|
142
|
+
this.#activateLayers(content)
|
|
143
|
+
this.#portalPinned(content)
|
|
144
|
+
}
|
|
145
|
+
this.openValue = true
|
|
146
|
+
} else if (this.openValue) {
|
|
147
|
+
this.#show("trigger-press")
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The reconcile path portals ONE FRAME LATE: connect order within a boot
|
|
152
|
+
// is unordered, and portaling before the sibling popper's connect would
|
|
153
|
+
// rob it of its content target before it could cache the node.
|
|
154
|
+
#portalPinned(content) {
|
|
155
|
+
window.requestAnimationFrame(() => {
|
|
156
|
+
if (!this.#connected || !this.#isOpen()) return
|
|
157
|
+
|
|
158
|
+
portalContent(content, { container: resolvePortalContainer(this.element) })
|
|
159
|
+
this.element.setAttribute(POPPER_STRATEGY, "absolute")
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Restores portaled content (drop-never-strand) and unwires everything.
|
|
165
|
+
*/
|
|
166
|
+
disconnect() {
|
|
167
|
+
this.#connected = false
|
|
168
|
+
|
|
169
|
+
// Never leave content stranded at the container (drop-never-strand).
|
|
170
|
+
const content = this.#content()
|
|
171
|
+
|
|
172
|
+
if (content) restoreContent(content)
|
|
173
|
+
|
|
174
|
+
for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
|
|
175
|
+
|
|
176
|
+
this.#wired = []
|
|
177
|
+
this.#cancelExit?.()
|
|
178
|
+
this.#cancelExit = null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- controllable state ---
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Stimulus value callback - controllable open state.
|
|
185
|
+
*
|
|
186
|
+
* @param {boolean} value
|
|
187
|
+
*/
|
|
188
|
+
openValueChanged(value) {
|
|
189
|
+
if (!this.#connected) return
|
|
190
|
+
|
|
191
|
+
if (value && !this.#isOpen()) this.#show("trigger-press")
|
|
192
|
+
else if (!value && this.#isOpen()) this.#hide("none")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Stimulus value callback - controllable value (multiple parses the
|
|
197
|
+
* JSON-array seam; both modes no-op on the applied echo).
|
|
198
|
+
*
|
|
199
|
+
* @param {string} value
|
|
200
|
+
*/
|
|
201
|
+
valueValueChanged(value) {
|
|
202
|
+
if (!this.#connected) return
|
|
203
|
+
|
|
204
|
+
if (this.multipleValue) {
|
|
205
|
+
const values = this.#listValues(value)
|
|
206
|
+
|
|
207
|
+
if (this.#sameValues(values, this.#applied)) return
|
|
208
|
+
|
|
209
|
+
this.#apply(values)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (value === this.#applied) return
|
|
214
|
+
|
|
215
|
+
this.#apply(value)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- trigger actions ---
|
|
219
|
+
|
|
220
|
+
/** The trigger's click action: open <-> close. */
|
|
221
|
+
toggle() {
|
|
222
|
+
if (this.#isOpen()) this.#hide("trigger-press")
|
|
223
|
+
else this.#show("trigger-press")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The trigger's keydown action: Enter / Space / ArrowDown / ArrowUp
|
|
228
|
+
* open (reason: list-navigation). A PRINTABLE key opens AND seeds the
|
|
229
|
+
* filter input with the char (reason: keyboard + the char as
|
|
230
|
+
* data-open-seed, a poetry extension - the char is never lost, the
|
|
231
|
+
* filter pass runs immediately). There is deliberately NO
|
|
232
|
+
* closed-trigger typeahead-commit here.
|
|
233
|
+
*
|
|
234
|
+
* @param {KeyboardEvent} event
|
|
235
|
+
*/
|
|
236
|
+
triggerKeydown(event) {
|
|
237
|
+
if (this.#isOpen()) return
|
|
238
|
+
|
|
239
|
+
if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
240
|
+
event.preventDefault()
|
|
241
|
+
this.#show("list-navigation")
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
246
|
+
event.preventDefault()
|
|
247
|
+
this.#show("keyboard", { seed: event.key })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- the chips field (multiple) ---
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The chips frame's pointerdown action (multiple): a press anywhere in
|
|
255
|
+
* the FRAME focuses the input and opens the popup (the whole frame IS
|
|
256
|
+
* the field) - except a chip-remove press, which is a removal,
|
|
257
|
+
* never a chips-area press.
|
|
258
|
+
*
|
|
259
|
+
* @param {PointerEvent} event
|
|
260
|
+
*/
|
|
261
|
+
chipsPointerdown(event) {
|
|
262
|
+
const input = this.#input()
|
|
263
|
+
|
|
264
|
+
if (!input || input.disabled) return
|
|
265
|
+
if (event.target instanceof Element && event.target.closest(CHIP_REMOVE_SELECTOR)) return
|
|
266
|
+
|
|
267
|
+
// Keep the press from landing focus on the frame/chip; a press on the
|
|
268
|
+
// input itself keeps native caret placement.
|
|
269
|
+
if (event.target !== input) event.preventDefault()
|
|
270
|
+
|
|
271
|
+
input.focus()
|
|
272
|
+
|
|
273
|
+
if (!this.#isOpen()) this.#show("trigger-press")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The inline input's OWN keyboard map (multiple; the engine's map rides
|
|
278
|
+
* the same keydown): Backspace on an empty input removes the LAST chip
|
|
279
|
+
* (focus stays here), ArrowLeft at caret 0 walks into the chips,
|
|
280
|
+
* ArrowDown/Up reopen the popup, Enter with no highlight closes, and
|
|
281
|
+
* Escape on the CLOSED popup clears the query AND wipes the selection
|
|
282
|
+
* to [] (contractual; readOnly blocks every mutation).
|
|
283
|
+
*
|
|
284
|
+
* @param {KeyboardEvent} event
|
|
285
|
+
*/
|
|
286
|
+
inputKeydown(event) {
|
|
287
|
+
if (!this.multipleValue) return
|
|
288
|
+
|
|
289
|
+
const input = event.target
|
|
290
|
+
|
|
291
|
+
switch (event.key) {
|
|
292
|
+
case "Backspace": {
|
|
293
|
+
if (input.value !== "" || input.readOnly) return
|
|
294
|
+
if (this.#applied.length === 0) return
|
|
295
|
+
|
|
296
|
+
this.#apply(this.#applied.slice(0, -1))
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
case "ArrowLeft": {
|
|
300
|
+
if (input.selectionStart !== 0 || input.selectionEnd !== 0) return
|
|
301
|
+
|
|
302
|
+
const chips = this.#chipElements()
|
|
303
|
+
|
|
304
|
+
if (chips.length > 0) {
|
|
305
|
+
event.preventDefault()
|
|
306
|
+
this.#focusChip(chips[chips.length - 1])
|
|
307
|
+
}
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
case "ArrowDown":
|
|
311
|
+
case "ArrowUp":
|
|
312
|
+
if (!this.#isOpen()) this.#show("list-navigation")
|
|
313
|
+
return
|
|
314
|
+
case "Enter":
|
|
315
|
+
if (this.#isOpen() && !this.#highlightedOption()) this.#hide("none")
|
|
316
|
+
return
|
|
317
|
+
case "Escape": {
|
|
318
|
+
// The press that just dismissed the popup must not ALSO wipe - and
|
|
319
|
+
// neither may an IME composition-cancel Escape (the user is dropping
|
|
320
|
+
// a composition, not asking to clear the field).
|
|
321
|
+
if (event === this.#dismissedEvent || this.#isOpen() || input.readOnly) return
|
|
322
|
+
if (isImeKeydown(event)) return
|
|
323
|
+
|
|
324
|
+
this.#command()?.reset()
|
|
325
|
+
if (this.#applied.length > 0) this.#apply([])
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
default:
|
|
329
|
+
// Everything else belongs to the input or the engine's map.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A focused chip's keyboard map: Left/Right walk the chips
|
|
335
|
+
* (off either end -> back to the input), Backspace/Delete remove (next
|
|
336
|
+
* highlight: same index, step back at the tail, the input once
|
|
337
|
+
* emptied), Enter/Space are no-ops returning to the input, ArrowDown/Up
|
|
338
|
+
* reopen the popup, and a printable char resumes the typing session.
|
|
339
|
+
*
|
|
340
|
+
* @param {KeyboardEvent} event
|
|
341
|
+
*/
|
|
342
|
+
chipKeydown(event) {
|
|
343
|
+
const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
|
|
344
|
+
const chip = origin instanceof Element ? origin.closest(CHIP_SELECTOR) : null
|
|
345
|
+
|
|
346
|
+
if (!chip) return
|
|
347
|
+
|
|
348
|
+
const chips = this.#chipElements()
|
|
349
|
+
const index = chips.indexOf(chip)
|
|
350
|
+
|
|
351
|
+
switch (event.key) {
|
|
352
|
+
case "ArrowLeft":
|
|
353
|
+
case "ArrowRight": {
|
|
354
|
+
event.preventDefault()
|
|
355
|
+
|
|
356
|
+
const next = index + (event.key === "ArrowRight" ? 1 : -1)
|
|
357
|
+
|
|
358
|
+
if (next >= 0 && next < chips.length) this.#focusChip(chips[next])
|
|
359
|
+
else this.#input()?.focus()
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
case "Backspace":
|
|
363
|
+
case "Delete":
|
|
364
|
+
event.preventDefault()
|
|
365
|
+
|
|
366
|
+
if (this.#input()?.readOnly) return
|
|
367
|
+
|
|
368
|
+
this.#removeAt(index)
|
|
369
|
+
return
|
|
370
|
+
case "Enter":
|
|
371
|
+
case " ":
|
|
372
|
+
event.preventDefault()
|
|
373
|
+
this.#input()?.focus()
|
|
374
|
+
return
|
|
375
|
+
case "ArrowDown":
|
|
376
|
+
case "ArrowUp":
|
|
377
|
+
event.preventDefault()
|
|
378
|
+
this.#input()?.focus()
|
|
379
|
+
if (!this.#isOpen()) this.#show("list-navigation")
|
|
380
|
+
return
|
|
381
|
+
default:
|
|
382
|
+
// A printable char refocuses the input (the key lands there).
|
|
383
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
384
|
+
this.#input()?.focus()
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The chip-remove button's click action: removes the chip's value;
|
|
391
|
+
* focus returns to the input WITHOUT opening (not a chips-area press).
|
|
392
|
+
*
|
|
393
|
+
* @param {MouseEvent} event
|
|
394
|
+
*/
|
|
395
|
+
removeChip(event) {
|
|
396
|
+
if (this.#input()?.readOnly) return
|
|
397
|
+
|
|
398
|
+
const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
|
|
399
|
+
const chip = origin instanceof Element ? origin.closest(CHIP_SELECTOR) : null
|
|
400
|
+
const index = chip ? this.#chipElements().indexOf(chip) : -1
|
|
401
|
+
|
|
402
|
+
if (index === -1) return
|
|
403
|
+
|
|
404
|
+
const values = [...this.#applied]
|
|
405
|
+
|
|
406
|
+
values.splice(index, 1)
|
|
407
|
+
this.#apply(values)
|
|
408
|
+
this.#input()?.focus()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- the show_clear X (its click action) ---
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The show_clear X's click action - the trigger-side deselection
|
|
415
|
+
* surface: commits the blank value through the
|
|
416
|
+
* pipeline, then hands focus to the trigger (the X hides itself once
|
|
417
|
+
* the value empties, and a focused hidden button would drop focus to
|
|
418
|
+
* body). Single mode only (the component raises on multiple; the guard
|
|
419
|
+
* here is belt and braces).
|
|
420
|
+
*/
|
|
421
|
+
clear() {
|
|
422
|
+
if (this.multipleValue) return { value: [...this.#applied], changed: false }
|
|
423
|
+
|
|
424
|
+
const changed = this.#applied !== ""
|
|
425
|
+
|
|
426
|
+
if (changed) this.#apply("")
|
|
427
|
+
this.#trigger()?.focus()
|
|
428
|
+
|
|
429
|
+
return { value: "", changed }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// --- programmatic API ---
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Programmatically opens.
|
|
436
|
+
*
|
|
437
|
+
* @param {string | Event} [reason="trigger-press"] - a family reason
|
|
438
|
+
* string; an Event reads as trigger-press
|
|
439
|
+
* @param {Object} [options]
|
|
440
|
+
* @param {string} [options.seed=""] - a printable char pre-seeding the
|
|
441
|
+
* filter input
|
|
442
|
+
*/
|
|
443
|
+
open(reason = "trigger-press", { seed = "" } = {}) {
|
|
444
|
+
if (reason instanceof Event) this.#show("trigger-press")
|
|
445
|
+
else this.#show(reason, { seed })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Programmatically closes.
|
|
450
|
+
*
|
|
451
|
+
* @param {string | Event} [reason="none"]
|
|
452
|
+
*/
|
|
453
|
+
close(reason = "none") {
|
|
454
|
+
this.#hide(reason instanceof Event ? "none" : reason)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The programmatic controllable-state surface (multiple accepts an
|
|
459
|
+
* array). Returns the resulting state - what an agent tool reports
|
|
460
|
+
* back - so an unknown value (not committed) reads as changed: false.
|
|
461
|
+
*
|
|
462
|
+
* @param {string | string[] | null} value
|
|
463
|
+
* @returns {{ value: string | string[], changed: boolean }}
|
|
464
|
+
*/
|
|
465
|
+
setValue(value) {
|
|
466
|
+
if (this.multipleValue) {
|
|
467
|
+
const values = this.#listValues(value)
|
|
468
|
+
|
|
469
|
+
if (this.#sameValues(values, this.#applied)) return { value: [...this.#applied], changed: false }
|
|
470
|
+
|
|
471
|
+
this.#apply(values)
|
|
472
|
+
|
|
473
|
+
return { value: [...this.#applied], changed: true }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const next = String(value ?? "")
|
|
477
|
+
|
|
478
|
+
if (next === this.#applied) return { value: this.#applied, changed: false }
|
|
479
|
+
if (next !== "" && !this.#items().some((item) => (item.dataset.value ?? "") === next)) {
|
|
480
|
+
return { value: this.#applied, changed: false }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
this.#apply(next)
|
|
484
|
+
|
|
485
|
+
return { value: this.#applied, changed: true }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// --- autofill adoption (Select's path verbatim) ---
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The hidden native's change action: browser autofill (or any
|
|
492
|
+
* programmatic write) adopts - multiple compares MEMBERSHIP, not order
|
|
493
|
+
* (the body comment holds the reorder rule).
|
|
494
|
+
*/
|
|
495
|
+
nativeChanged() {
|
|
496
|
+
if (this.multipleValue) {
|
|
497
|
+
const values = this.#nativeValues()
|
|
498
|
+
|
|
499
|
+
// MEMBERSHIP comparison, not order: a <select multiple> only knows
|
|
500
|
+
// DOM order, so the pipeline's own bubbling change (value order
|
|
501
|
+
// preserved) must never round-trip into a reorder.
|
|
502
|
+
if (this.#sameMembers(values, this.#applied)) return
|
|
503
|
+
|
|
504
|
+
this.#apply(values, { fromNative: true })
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const value = this.#native()?.value ?? ""
|
|
509
|
+
|
|
510
|
+
if (value === this.#applied) return
|
|
511
|
+
|
|
512
|
+
this.#apply(value, { fromNative: true })
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --- open / close ---
|
|
516
|
+
|
|
517
|
+
#show(reason, { seed = "" } = {}) {
|
|
518
|
+
const content = this.#content()
|
|
519
|
+
|
|
520
|
+
if (!content || this.#isOpen()) return
|
|
521
|
+
|
|
522
|
+
this.#cancelExit?.()
|
|
523
|
+
this.#cancelExit = null
|
|
524
|
+
this.#suppressRestore = false
|
|
525
|
+
|
|
526
|
+
const expander = this.#expander()
|
|
527
|
+
|
|
528
|
+
// Portal-on-open: move BEFORE the
|
|
529
|
+
// enter presence (reparenting mid-animation restarts it), re-anchor
|
|
530
|
+
// absolute - static under compositor scroll, transform-immune. In
|
|
531
|
+
// multiple mode only the popup (listbox) moves; the chips field with
|
|
532
|
+
// its inline input stays home as the popper anchor.
|
|
533
|
+
portalContent(content, { container: resolvePortalContainer(this.element) })
|
|
534
|
+
this.element.setAttribute(POPPER_STRATEGY, "absolute")
|
|
535
|
+
|
|
536
|
+
content.hidden = false
|
|
537
|
+
content.setAttribute("data-open-reason", reason)
|
|
538
|
+
if (seed) content.setAttribute("data-open-seed", seed)
|
|
539
|
+
else content.removeAttribute("data-open-seed")
|
|
540
|
+
expander?.setAttribute("aria-expanded", "true")
|
|
541
|
+
if (expander) setState(expander, "popup-open")
|
|
542
|
+
enterPresence(content)
|
|
543
|
+
this.#activateLayers(content)
|
|
544
|
+
this.openValue = true
|
|
545
|
+
|
|
546
|
+
// Layer controllers connect on the attribute-mutation microtask; focus
|
|
547
|
+
// rides one behind so focus-scope has snapshotted the trigger.
|
|
548
|
+
queueMicrotask(() => {
|
|
549
|
+
if (!this.#isOpen()) return
|
|
550
|
+
|
|
551
|
+
const input = this.#input()
|
|
552
|
+
|
|
553
|
+
// FOCUS GOES TO THE INPUT for every reason (the typing-session call);
|
|
554
|
+
// the selected option is communicated by highlight, not focus.
|
|
555
|
+
input?.focus()
|
|
556
|
+
|
|
557
|
+
if (seed && input) {
|
|
558
|
+
input.value = seed
|
|
559
|
+
// The engine's own action runs the pass (and re-seats highlight).
|
|
560
|
+
input.dispatchEvent(new Event("input", { bubbles: true }))
|
|
561
|
+
} else {
|
|
562
|
+
// Seed the highlight on the COMMITTED option via the Command
|
|
563
|
+
// controller (activedescendant + scrollIntoView) - first enabled
|
|
564
|
+
// when no value (the engine cannot seat while the popup was
|
|
565
|
+
// hidden, so the shell seeds every open).
|
|
566
|
+
const items = this.#items()
|
|
567
|
+
const target = items.find((item) => item.getAttribute("aria-selected") === "true") ??
|
|
568
|
+
items.find((item) => !item.hasAttribute("data-disabled") && !item.closest("[hidden]"))
|
|
569
|
+
|
|
570
|
+
if (target) this.#command()?.highlightItem(target)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
this.dispatch("open", { prefix: EVENT_PREFIX, detail: seed ? { reason, seed } : { reason } })
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#hide(reason, { restoreFocus = true } = {}) {
|
|
578
|
+
const content = this.#content()
|
|
579
|
+
|
|
580
|
+
if (!content || !this.#isOpen()) return
|
|
581
|
+
|
|
582
|
+
this.#suppressRestore = !restoreFocus || (reason === "outside-press" && !this.modalValue)
|
|
583
|
+
|
|
584
|
+
const expander = this.#expander()
|
|
585
|
+
|
|
586
|
+
expander?.setAttribute("aria-expanded", "false")
|
|
587
|
+
if (expander) setState(expander, "popup-closed")
|
|
588
|
+
content.removeAttribute("data-open-reason")
|
|
589
|
+
content.removeAttribute("data-open-seed")
|
|
590
|
+
this.openValue = false
|
|
591
|
+
|
|
592
|
+
this.#cancelExit = exitPresence(content, {
|
|
593
|
+
onRemove: () => {
|
|
594
|
+
this.#cancelExit = null
|
|
595
|
+
content.hidden = true
|
|
596
|
+
// Home AFTER the exit finished and hidden landed; focus
|
|
597
|
+
// return is focus-scope's ref-based job, indifferent to the move.
|
|
598
|
+
restoreContent(content)
|
|
599
|
+
this.element.setAttribute(POPPER_STRATEGY, "fixed")
|
|
600
|
+
this.#removeControllers(content, CONTENT_LAYER_CONTROLLERS)
|
|
601
|
+
// Reset the query so reopen starts clean (a remount would get
|
|
602
|
+
// this for free; persistent DOM does it deliberately).
|
|
603
|
+
this.#command()?.reset()
|
|
604
|
+
this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// --- the commit pipeline ---
|
|
610
|
+
|
|
611
|
+
// Command's select event is the ONLY commit trigger (no click handling
|
|
612
|
+
// here - the composition boundary). Cancelable BEFORE the value commits;
|
|
613
|
+
// vetoing keeps the popup open. Committing the already-selected value is
|
|
614
|
+
// IDEMPOTENT: close, value unchanged, no change events (deselection is a
|
|
615
|
+
// form affordance - include_blank - not a hidden toggle gesture).
|
|
616
|
+
// multiple INVERTS both rules: selection TOGGLES membership
|
|
617
|
+
// (appended at the array END) and the popup STAYS OPEN; a typed query
|
|
618
|
+
// clears immediately so the full list is restored for the next pick.
|
|
619
|
+
#onCommandSelect = (event) => {
|
|
620
|
+
const { item, value = "", label = "" } = event.detail ?? {}
|
|
621
|
+
|
|
622
|
+
const select = this.dispatch("select", {
|
|
623
|
+
prefix: EVENT_PREFIX,
|
|
624
|
+
target: item instanceof Element ? item : this.element,
|
|
625
|
+
cancelable: true,
|
|
626
|
+
detail: { item, value, label }
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
if (select.defaultPrevented) return
|
|
630
|
+
|
|
631
|
+
if (this.multipleValue) {
|
|
632
|
+
const input = this.#input()
|
|
633
|
+
|
|
634
|
+
if (input?.readOnly) return
|
|
635
|
+
|
|
636
|
+
const values = [...this.#applied]
|
|
637
|
+
const index = values.indexOf(value)
|
|
638
|
+
|
|
639
|
+
if (index === -1) values.push(value)
|
|
640
|
+
else values.splice(index, 1)
|
|
641
|
+
|
|
642
|
+
this.#apply(values)
|
|
643
|
+
|
|
644
|
+
if (input && input.value !== "") {
|
|
645
|
+
this.#command()?.reset()
|
|
646
|
+
if (item instanceof Element) this.#command()?.highlightItem(item)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
input?.focus()
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (value !== this.#applied) this.#apply(value)
|
|
654
|
+
|
|
655
|
+
this.#hide("item-press")
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// THE single sync path, NATIVE FIRST (Select's 5 steps re-instantiated):
|
|
659
|
+
// 1. native_select.value + real bubbling change/input; 2. aria-selected
|
|
660
|
+
// + data-selected twin-flipped on every option; 3+4. display synced from
|
|
661
|
+
// the option's item-text (data-text-value override) + the trigger's
|
|
662
|
+
// data-placeholder; 5. poetry:combobox:change. multiple routes to the
|
|
663
|
+
// set-shaped twin below.
|
|
664
|
+
#apply(value, { silent = false, fromNative = false, force = false } = {}) {
|
|
665
|
+
if (this.multipleValue) {
|
|
666
|
+
this.#applyValues(this.#listValues(value), { silent, fromNative, force })
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!force && value === this.#applied) return
|
|
671
|
+
|
|
672
|
+
const previous = this.#applied
|
|
673
|
+
|
|
674
|
+
this.#applied = value
|
|
675
|
+
this.valueValue = value
|
|
676
|
+
|
|
677
|
+
const native = this.#native()
|
|
678
|
+
|
|
679
|
+
if (native && !fromNative) {
|
|
680
|
+
native.value = value
|
|
681
|
+
|
|
682
|
+
if (!silent) {
|
|
683
|
+
native.dispatchEvent(new Event("input", { bubbles: true }))
|
|
684
|
+
native.dispatchEvent(new Event("change", { bubbles: true }))
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
let selected = null
|
|
689
|
+
|
|
690
|
+
for (const item of this.#items()) {
|
|
691
|
+
const match = value !== "" && (item.dataset.value ?? "") === value
|
|
692
|
+
|
|
693
|
+
item.setAttribute("aria-selected", String(match))
|
|
694
|
+
setState(item, match ? "selected" : "unselected")
|
|
695
|
+
|
|
696
|
+
if (match) selected = item
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const display = this.#display()
|
|
700
|
+
const label = selected ? this.#labelOf(selected) : null
|
|
701
|
+
|
|
702
|
+
if (display) display.textContent = label ?? this.#placeholder
|
|
703
|
+
|
|
704
|
+
this.#trigger()?.toggleAttribute("data-placeholder", !selected)
|
|
705
|
+
// The show_clear X follows the VALUE (not the matched item - the async
|
|
706
|
+
// recipe can commit values whose item is not rendered); the chevron
|
|
707
|
+
// swap derives from this one flip in CSS.
|
|
708
|
+
this.#clearButton()?.toggleAttribute("hidden", value === "")
|
|
709
|
+
|
|
710
|
+
if (!silent) {
|
|
711
|
+
this.dispatch("change", { prefix: EVENT_PREFIX, detail: { value, label, previous } })
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
#labelOf(item) {
|
|
716
|
+
return (item.dataset.textValue ??
|
|
717
|
+
item.querySelector(ITEM_TEXT_SELECTOR)?.textContent ??
|
|
718
|
+
item.textContent ?? "").trim()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// The multiple twin of #apply, set-shaped (the same 5 steps over a value
|
|
722
|
+
// LIST): 1. the native <select multiple>'s selectedOptions + real
|
|
723
|
+
// bubbling change/input; 2. aria-selected + data-selected twin-flipped
|
|
724
|
+
// by ARRAY INCLUSION; 3+4. chips rebuilt IN VALUE ORDER + the frame's
|
|
725
|
+
// data-placeholder / role=toolbar flips (the input NEVER mirrors
|
|
726
|
+
// selection text); 5. poetry:combobox:change with array-shaped detail.
|
|
727
|
+
#applyValues(values, { silent = false, fromNative = false, force = false } = {}) {
|
|
728
|
+
if (!force && this.#sameValues(values, this.#applied)) return
|
|
729
|
+
|
|
730
|
+
const previous = this.#applied
|
|
731
|
+
|
|
732
|
+
this.#applied = values
|
|
733
|
+
this.valueValue = JSON.stringify(values)
|
|
734
|
+
|
|
735
|
+
const native = this.#native()
|
|
736
|
+
|
|
737
|
+
if (native && !fromNative) {
|
|
738
|
+
for (const option of this.#nativeOptions()) option.selected = values.includes(option.value)
|
|
739
|
+
|
|
740
|
+
if (!silent) {
|
|
741
|
+
native.dispatchEvent(new Event("input", { bubbles: true }))
|
|
742
|
+
native.dispatchEvent(new Event("change", { bubbles: true }))
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
for (const item of this.#items()) {
|
|
747
|
+
const match = values.includes(item.dataset.value ?? "")
|
|
748
|
+
|
|
749
|
+
item.setAttribute("aria-selected", String(match))
|
|
750
|
+
setState(item, match ? "selected" : "unselected")
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
this.#renderChips(values)
|
|
754
|
+
|
|
755
|
+
if (!silent) {
|
|
756
|
+
this.dispatch("change", {
|
|
757
|
+
prefix: EVENT_PREFIX,
|
|
758
|
+
detail: { value: values, label: values.map((value) => this.#labelForValue(value)), previous }
|
|
759
|
+
})
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Chips are SERVER markup: the frame's <template> skeleton (byte-what
|
|
764
|
+
// the server renders for a committed value) is cloned per value rather
|
|
765
|
+
// than composing DOM of this controller's own.
|
|
766
|
+
#renderChips(values) {
|
|
767
|
+
const chips = this.#chips()
|
|
768
|
+
|
|
769
|
+
if (!chips) return
|
|
770
|
+
|
|
771
|
+
for (const chip of this.#chipElements()) chip.remove()
|
|
772
|
+
|
|
773
|
+
const template = chips.querySelector("template")
|
|
774
|
+
const input = chips.querySelector(INPUT_SELECTOR)
|
|
775
|
+
const removeTemplate = chips.dataset.removeLabel ?? "Remove %{label}"
|
|
776
|
+
|
|
777
|
+
if (template?.content?.firstElementChild) {
|
|
778
|
+
for (const value of values) {
|
|
779
|
+
const chip = template.content.firstElementChild.cloneNode(true)
|
|
780
|
+
const label = this.#labelForValue(value)
|
|
781
|
+
|
|
782
|
+
chip.setAttribute("data-value", value)
|
|
783
|
+
chip.setAttribute("aria-label", label)
|
|
784
|
+
chip.querySelector(CHIP_REMOVE_SELECTOR)
|
|
785
|
+
?.setAttribute("aria-label", removeTemplate.replace("%{label}", label))
|
|
786
|
+
chip.insertBefore(document.createTextNode(label), chip.firstChild)
|
|
787
|
+
chips.insertBefore(chip, input)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// role=toolbar rides the frame ONLY while it holds chips;
|
|
792
|
+
// an empty selection wears the data-placeholder styling hook instead.
|
|
793
|
+
if (values.length > 0) chips.setAttribute("role", "toolbar")
|
|
794
|
+
else chips.removeAttribute("role")
|
|
795
|
+
chips.toggleAttribute("data-placeholder", values.length === 0)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Chip keyboard removal: next highlight lands on the SAME index, steps
|
|
799
|
+
// back at the tail, and returns to the input once the list empties.
|
|
800
|
+
#removeAt(index) {
|
|
801
|
+
const values = [...this.#applied]
|
|
802
|
+
|
|
803
|
+
if (index < 0 || index >= values.length) return
|
|
804
|
+
|
|
805
|
+
values.splice(index, 1)
|
|
806
|
+
this.#apply(values)
|
|
807
|
+
|
|
808
|
+
const chips = this.#chipElements()
|
|
809
|
+
|
|
810
|
+
if (chips.length === 0) this.#input()?.focus()
|
|
811
|
+
else this.#focusChip(chips[Math.min(index, chips.length - 1)])
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Real DOM focus IS the chip highlight (:focus-visible/:focus-within
|
|
815
|
+
// style it; chips never wear data-highlighted) - and focusing a chip
|
|
816
|
+
// CLOSES the popup (the typing session is suspended). disabled blocks
|
|
817
|
+
// chip focus entirely.
|
|
818
|
+
#focusChip(chip) {
|
|
819
|
+
if (!chip || chip.hasAttribute("data-disabled")) return
|
|
820
|
+
|
|
821
|
+
if (this.#isOpen()) this.#hide("chip-focus", { restoreFocus: false })
|
|
822
|
+
|
|
823
|
+
chip.focus()
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
#labelForValue(value) {
|
|
827
|
+
const item = this.#items().find((candidate) => (candidate.dataset.value ?? "") === value)
|
|
828
|
+
|
|
829
|
+
if (item) return this.#labelOf(item)
|
|
830
|
+
|
|
831
|
+
const option = this.#nativeOptions().find((candidate) => candidate.value === value)
|
|
832
|
+
|
|
833
|
+
return (option?.textContent ?? value).trim()
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// --- content wiring (programmatic: portal-safe) ---
|
|
837
|
+
|
|
838
|
+
#wireContent(content) {
|
|
839
|
+
this.#listen(content, "keydown", this.#onKeydown)
|
|
840
|
+
this.#listen(content, "poetry:command:select", this.#onCommandSelect)
|
|
841
|
+
this.#listen(content, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
|
|
842
|
+
this.#listen(content, "poetry--core--dismissable:dismiss", this.#onDismiss)
|
|
843
|
+
this.#listen(content, "poetry--core--focus-scope:mount-auto-focus", this.#onMountAutoFocus)
|
|
844
|
+
this.#listen(content, "poetry--core--focus-scope:unmount-auto-focus", this.#onUnmountAutoFocus)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
#listen(target, type, listener) {
|
|
848
|
+
target.addEventListener(type, listener)
|
|
849
|
+
this.#wired.push([target, type, listener])
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Tab while open CLOSES WITHOUT COMMIT and lets focus proceed (Popover
|
|
853
|
+
// semantics - THE delta vs Select's Tab-inert). modal:true leaves Tab to
|
|
854
|
+
// the focus-scope trap instead. Everything else inside the popup is
|
|
855
|
+
// Command's activedescendant map - no second keyboard map lives here.
|
|
856
|
+
//
|
|
857
|
+
// The Tab seam: a Tab originating INSIDE the portaled
|
|
858
|
+
// popup would proceed from body's end, not from the combobox - close
|
|
859
|
+
// (unchanged) and place focus where the un-portaled DOM would have
|
|
860
|
+
// landed it: the trigger on Shift+Tab, the next tabbable after it on
|
|
861
|
+
// Tab (the un-portaled tab-order rule). Multiple mode's typing surface is the
|
|
862
|
+
// chips input at HOME, so its Tab-out proceeds naturally, untouched.
|
|
863
|
+
#onKeydown = (event) => {
|
|
864
|
+
if (event.key !== "Tab" || this.modalValue || !this.#isOpen()) return
|
|
865
|
+
|
|
866
|
+
const content = this.#content()
|
|
867
|
+
const fromPortaled = Boolean(content) && isPortaled(content) &&
|
|
868
|
+
event.target instanceof Element && content.contains(event.target)
|
|
869
|
+
|
|
870
|
+
this.#hide("focus-out", { restoreFocus: false })
|
|
871
|
+
|
|
872
|
+
if (!fromPortaled) return
|
|
873
|
+
|
|
874
|
+
const anchor = this.#trigger()
|
|
875
|
+
|
|
876
|
+
if (!anchor) return
|
|
877
|
+
|
|
878
|
+
event.preventDefault()
|
|
879
|
+
|
|
880
|
+
if (event.shiftKey) {
|
|
881
|
+
anchor.focus()
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const tabbables = tabbableWithin(document.body)
|
|
886
|
+
const next = tabbables
|
|
887
|
+
.slice(tabbables.indexOf(anchor) + 1)
|
|
888
|
+
.find((element) => !content.contains(element))
|
|
889
|
+
|
|
890
|
+
;(next ?? anchor).focus()
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// A press on the combobox's OWN trigger is the toggle's job (the popover
|
|
894
|
+
// trigger-press rule: without the veto, pointerdown closes and the
|
|
895
|
+
// trailing click re-opens). In multiple, a press in the chips FIELD is
|
|
896
|
+
// an anchor press too - vetoing the layer keeps the popup open while
|
|
897
|
+
// chips mutate under the pointer.
|
|
898
|
+
#onInteractOutside = (event) => {
|
|
899
|
+
if (event.target !== this.#content()) return
|
|
900
|
+
|
|
901
|
+
const origin = event.detail?.originalEvent?.target
|
|
902
|
+
|
|
903
|
+
if (!(origin instanceof Element)) return
|
|
904
|
+
if (this.#trigger()?.contains(origin)) return event.preventDefault()
|
|
905
|
+
if (this.multipleValue && this.#chips()?.contains(origin)) event.preventDefault()
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Esc / outside press arrive as the dismissable layer's dismiss event.
|
|
909
|
+
// Neither EVER commits - the value is untouched (family rule).
|
|
910
|
+
#onDismiss = (event) => {
|
|
911
|
+
if (event.target !== this.#content()) return
|
|
912
|
+
|
|
913
|
+
const escaped = event.detail?.originalEvent?.type === "keydown"
|
|
914
|
+
|
|
915
|
+
// Remembered so the SAME keypress cannot double as the closed-popup
|
|
916
|
+
// Escape wipe in the multiple input map.
|
|
917
|
+
this.#dismissedEvent = event.detail?.originalEvent ?? null
|
|
918
|
+
this.#hide(escaped ? "escape-key" : "outside-press")
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// The combobox owns initial focus (the command input), not focus-scope.
|
|
922
|
+
#onMountAutoFocus = (event) => {
|
|
923
|
+
if (event.target === this.#content()) event.preventDefault()
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
#onUnmountAutoFocus = (event) => {
|
|
927
|
+
if (event.target === this.#content() && this.#suppressRestore) event.preventDefault()
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// --- the layer stack (Select's proven mechanism, MINUS roving-focus) ---
|
|
931
|
+
|
|
932
|
+
#activateLayers(content) {
|
|
933
|
+
content.setAttribute("data-poetry--core--focus-scope-trapped-value", String(this.modalValue))
|
|
934
|
+
// Non-modal Tab must DEPART (closing the popup - the Select delta);
|
|
935
|
+
// focus-scope's edge-loop default would swallow it. modal:true keeps
|
|
936
|
+
// the trap+loop pair.
|
|
937
|
+
content.setAttribute("data-poetry--core--focus-scope-loop-value", String(this.modalValue))
|
|
938
|
+
content.setAttribute(
|
|
939
|
+
"data-poetry--core--dismissable-disable-outside-pointer-events-value", String(this.modalValue)
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
this.#addControllers(content, CONTENT_LAYER_CONTROLLERS)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
#addControllers(element, identifiers) {
|
|
946
|
+
const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
|
|
947
|
+
|
|
948
|
+
for (const identifier of identifiers) {
|
|
949
|
+
if (!tokens.includes(identifier)) tokens.push(identifier)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
element.setAttribute("data-controller", tokens.join(" "))
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
#removeControllers(element, identifiers) {
|
|
956
|
+
const tokens = (element.getAttribute("data-controller") ?? "")
|
|
957
|
+
.split(/\s+/)
|
|
958
|
+
.filter((token) => token && !identifiers.includes(token))
|
|
959
|
+
|
|
960
|
+
element.setAttribute("data-controller", tokens.join(" "))
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// --- structural resolution (ids are the seams; portal/stream-safe) ---
|
|
964
|
+
|
|
965
|
+
#trigger() {
|
|
966
|
+
return this.element.querySelector(TRIGGER_SELECTOR)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// The trigger's aria-controls points at the LISTBOX (the a11y-true
|
|
970
|
+
// relationship); the popup container is its closest content wrapper.
|
|
971
|
+
// Multiple has no trigger - the chips frame's inline input carries the
|
|
972
|
+
// same aria-controls (and sits HOME, so this read survives the portal).
|
|
973
|
+
#listbox() {
|
|
974
|
+
const id = this.#trigger()?.getAttribute("aria-controls") ??
|
|
975
|
+
this.#chips()?.querySelector(INPUT_SELECTOR)?.getAttribute("aria-controls")
|
|
976
|
+
|
|
977
|
+
return id ? document.getElementById(id) : null
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
#content() {
|
|
981
|
+
return this.#listbox()?.closest(CONTENT_SELECTOR) ??
|
|
982
|
+
this.element.querySelector(CONTENT_SELECTOR)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
#command() {
|
|
986
|
+
// multiple mounts the engine on the ROOT (the input sits outside the
|
|
987
|
+
// popup); single keeps it on the popup's command part.
|
|
988
|
+
const root = this.#content()?.querySelector(COMMAND_SELECTOR) ??
|
|
989
|
+
(this.multipleValue ? this.element : null)
|
|
990
|
+
|
|
991
|
+
return root
|
|
992
|
+
? this.application.getControllerForElementAndIdentifier(root, COMMAND_IDENTIFIER)
|
|
993
|
+
: null
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
#input() {
|
|
997
|
+
// multiple: the ONE input lives inline in the chips frame (the
|
|
998
|
+
// input-inside layout); single keeps it in the popup.
|
|
999
|
+
return this.#content()?.querySelector(INPUT_SELECTOR) ??
|
|
1000
|
+
this.#chips()?.querySelector(INPUT_SELECTOR) ?? null
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
#native() {
|
|
1004
|
+
return this.element.querySelector(NATIVE_SELECTOR)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
#display() {
|
|
1008
|
+
return this.element.querySelector(VALUE_SELECTOR)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// The open-state carrier: the trigger button (single) or the inline
|
|
1012
|
+
// input (multiple stamps aria-expanded/data-popup-open there).
|
|
1013
|
+
#expander() {
|
|
1014
|
+
return this.#trigger() ?? (this.multipleValue ? this.#input() : null)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
#chips() {
|
|
1018
|
+
return this.element.querySelector(CHIPS_SELECTOR)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
#clearButton() {
|
|
1022
|
+
return this.element.querySelector(CLEAR_SELECTOR)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Live chips only - the <template> skeleton's content is inert and
|
|
1026
|
+
// never matches a querySelectorAll over the frame.
|
|
1027
|
+
#chipElements() {
|
|
1028
|
+
const chips = this.#chips()
|
|
1029
|
+
|
|
1030
|
+
return chips ? Array.from(chips.querySelectorAll(CHIP_SELECTOR)) : []
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
#highlightedOption() {
|
|
1034
|
+
return (this.#listbox() ?? this.#content())?.querySelector("[data-highlighted]") ?? null
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
#reconciledValues() {
|
|
1038
|
+
const declared = this.#listValues(this.valueValue)
|
|
1039
|
+
|
|
1040
|
+
return declared.length > 0 ? declared : this.#nativeValues()
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// selectedOptions derived by hand (options + the selected property):
|
|
1044
|
+
// identical semantics, and it holds in every DOM this runs against.
|
|
1045
|
+
#nativeValues() {
|
|
1046
|
+
return this.#nativeOptions()
|
|
1047
|
+
.filter((option) => option.selected)
|
|
1048
|
+
.map((option) => option.value)
|
|
1049
|
+
.filter((value) => value !== "")
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
#nativeOptions() {
|
|
1053
|
+
return Array.from(this.#native()?.querySelectorAll("option") ?? [])
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// The value Value stays a String seam in both modes; multiple carries a
|
|
1057
|
+
// JSON array through it (a bare scalar adopts as a one-element list).
|
|
1058
|
+
#listValues(raw) {
|
|
1059
|
+
if (Array.isArray(raw)) return raw.map(String)
|
|
1060
|
+
if (typeof raw !== "string" || raw === "") return []
|
|
1061
|
+
|
|
1062
|
+
try {
|
|
1063
|
+
const parsed = JSON.parse(raw)
|
|
1064
|
+
|
|
1065
|
+
return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]
|
|
1066
|
+
} catch {
|
|
1067
|
+
return [raw]
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Order-sensitive on purpose: chips render IN VALUE ORDER, so a reorder
|
|
1072
|
+
// IS a change.
|
|
1073
|
+
#sameValues(values, applied) {
|
|
1074
|
+
return Array.isArray(applied) && values.length === applied.length &&
|
|
1075
|
+
values.every((value, index) => value === applied[index])
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Order-blind twin for the native adoption seam only.
|
|
1079
|
+
#sameMembers(values, applied) {
|
|
1080
|
+
return Array.isArray(applied) && values.length === applied.length &&
|
|
1081
|
+
values.every((value) => applied.includes(value))
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
#isOpen() {
|
|
1085
|
+
const content = this.#content()
|
|
1086
|
+
|
|
1087
|
+
return Boolean(content) && stateOf(content) === "open"
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
#items() {
|
|
1091
|
+
const listbox = this.#listbox() ?? this.#content()
|
|
1092
|
+
|
|
1093
|
+
return listbox ? collectionItems(listbox, ITEM_SELECTOR) : []
|
|
1094
|
+
}
|
|
1095
|
+
}
|