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,924 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { portalContent, resolvePortalContainer, restoreContent } from "@poetry/controllers/helpers/portal"
|
|
3
|
+
import { collectionItems } from "@poetry/controllers/helpers/collection"
|
|
4
|
+
import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
|
|
5
|
+
import { setState, stateOf } from "@poetry/controllers/helpers/state"
|
|
6
|
+
import { createTypeahead, typeaheadLabel } from "@poetry/controllers/helpers/typeahead"
|
|
7
|
+
|
|
8
|
+
// The Select listbox controller - the APG select-only combobox on the menus
|
|
9
|
+
// MACHINERY (popper markup-owned + focus-scope/dismissable/roving-focus
|
|
10
|
+
// activated as layers + the shared typeahead helper) but deliberately NOT a
|
|
11
|
+
// mode of poetry--core--menu: everything menu-specific (submenus,
|
|
12
|
+
// close_on_select, checkbox/radio aria-checked, edge-navigate, Tab-closes,
|
|
13
|
+
// pointer-open-no-focus) is wrong for a listbox, and everything here is
|
|
14
|
+
// dead weight for menus.
|
|
15
|
+
//
|
|
16
|
+
// THE SYNC INVARIANT (the component): native_select.value, the value Value,
|
|
17
|
+
// aria-selected/data-selected on options, and the display text never diverge -
|
|
18
|
+
// every write path (commit, closed-trigger typeahead, autofill adoption,
|
|
19
|
+
// programmatic setValue) funnels through #apply, which writes the NATIVE
|
|
20
|
+
// SELECT FIRST (serialization truth is never behind the facade), dispatches
|
|
21
|
+
// real bubbling change/input on it (Turbo auto-submit and friends work
|
|
22
|
+
// unmodified), flips aria-selected + data-selected TOGETHER on every option,
|
|
23
|
+
// syncs the value display from the option's item-text (data-text-value
|
|
24
|
+
// override), toggles trigger[data-placeholder], then fires
|
|
25
|
+
// poetry:select:change.
|
|
26
|
+
//
|
|
27
|
+
// Three deltas vs the menu family, all deliberate and contractual:
|
|
28
|
+
// - Tab while open is INERT (a value picker resolves by commit or Esc);
|
|
29
|
+
// - open focuses the SELECTED option for EVERY reason, pointer included;
|
|
30
|
+
// - typeahead on the CLOSED trigger COMMITS the match without opening
|
|
31
|
+
// (native <select> behavior), while open typeahead only moves focus.
|
|
32
|
+
const TRIGGER_SELECTOR = '[data-slot="select-trigger"]'
|
|
33
|
+
const ITEM_SELECTOR = '[data-slot="select-item"]'
|
|
34
|
+
const ITEM_TEXT_SELECTOR = '[data-slot="select-item-text"]'
|
|
35
|
+
const NATIVE_SELECTOR = '[data-slot="select-native"]'
|
|
36
|
+
const VALUE_SELECTOR = '[data-slot="select-value"]'
|
|
37
|
+
const VIEWPORT_SELECTOR = '[data-slot="select-viewport"]'
|
|
38
|
+
const SCROLL_UP_SELECTOR = '[data-slot="select-scroll-up-button"]'
|
|
39
|
+
const SCROLL_DOWN_SELECTOR = '[data-slot="select-scroll-down-button"]'
|
|
40
|
+
|
|
41
|
+
const EVENT_PREFIX = "poetry:select"
|
|
42
|
+
|
|
43
|
+
const ROVING = "poetry--core--roving-focus"
|
|
44
|
+
const ROVING_ACTION = `keydown->${ROVING}#keydown`
|
|
45
|
+
const CONTENT_LAYER_CONTROLLERS = ["poetry--core--focus-scope", "poetry--core--dismissable", ROVING]
|
|
46
|
+
const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
|
|
47
|
+
|
|
48
|
+
const SCROLL_HOLD_STEP = 4 // px per frame while hovering a scroll button
|
|
49
|
+
|
|
50
|
+
export default class SelectController extends Controller {
|
|
51
|
+
// The events this controller dispatches (manifest surface;
|
|
52
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
53
|
+
static events = [
|
|
54
|
+
"poetry:select:change", "poetry:select:closed", "poetry:select:open",
|
|
55
|
+
"poetry:select:select"
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
static values = {
|
|
59
|
+
open: { type: Boolean, default: false },
|
|
60
|
+
value: { type: String, default: "" },
|
|
61
|
+
modal: { type: Boolean, default: true },
|
|
62
|
+
loop: { type: Boolean, default: false },
|
|
63
|
+
typeaheadTimeout: { type: Number, default: 1000 },
|
|
64
|
+
alignItemWithTrigger: { type: Boolean, default: false }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#connected = false
|
|
68
|
+
#wired = []
|
|
69
|
+
#claimed = new WeakSet()
|
|
70
|
+
#suppressRestore = false
|
|
71
|
+
#cancelExit = null
|
|
72
|
+
#typeahead = createTypeahead()
|
|
73
|
+
#applied = ""
|
|
74
|
+
#placeholder = ""
|
|
75
|
+
#scrollFrame = null
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Captures the placeholder, reconciles silently from the native select
|
|
79
|
+
* (the serialization truth) or the given Value, wires the content
|
|
80
|
+
* listeners, and catches a server-open listbox up (layers + the late
|
|
81
|
+
* portal).
|
|
82
|
+
*/
|
|
83
|
+
connect() {
|
|
84
|
+
const content = this.#content()
|
|
85
|
+
|
|
86
|
+
if (content) this.#wireContent(content)
|
|
87
|
+
|
|
88
|
+
// The placeholder: the value display's data-placeholder attribute when
|
|
89
|
+
// given (survives a valued server render), else the empty-value display
|
|
90
|
+
// text - captured so a later clear can restore it.
|
|
91
|
+
const display = this.#display()
|
|
92
|
+
|
|
93
|
+
this.#placeholder = display?.dataset.placeholder ??
|
|
94
|
+
(this.#trigger()?.hasAttribute("data-placeholder") ? (display?.textContent ?? "").trim() : "")
|
|
95
|
+
|
|
96
|
+
// Reconcile-on-connect (Turbo Stream re-render safe): the native select
|
|
97
|
+
// is the serialization truth - adopt its value when the Value was not
|
|
98
|
+
// given, else normalize the DOM to the Value. Both paths are silent
|
|
99
|
+
// (no events for state the server already declared).
|
|
100
|
+
const serverValue = this.valueValue !== "" ? this.valueValue : (this.#native()?.value ?? "")
|
|
101
|
+
|
|
102
|
+
this.#applied = serverValue
|
|
103
|
+
this.#apply(serverValue, { silent: true, force: true })
|
|
104
|
+
|
|
105
|
+
this.#connected = true
|
|
106
|
+
|
|
107
|
+
if (this.#isOpen()) {
|
|
108
|
+
if (content) {
|
|
109
|
+
this.#activateLayers(content)
|
|
110
|
+
this.#portalPinned(content)
|
|
111
|
+
}
|
|
112
|
+
this.openValue = true
|
|
113
|
+
} else if (this.openValue) {
|
|
114
|
+
this.#show("trigger-press")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The reconcile path portals ONE FRAME LATE: connect order within a boot
|
|
119
|
+
// is unordered, and portaling before the sibling popper's connect would
|
|
120
|
+
// rob it of its content target before it could cache the node.
|
|
121
|
+
#portalPinned(content) {
|
|
122
|
+
window.requestAnimationFrame(() => {
|
|
123
|
+
if (!this.#connected || !this.#isOpen()) return
|
|
124
|
+
|
|
125
|
+
portalContent(content, { container: resolvePortalContainer(this.element) })
|
|
126
|
+
this.element.setAttribute(POPPER_STRATEGY, "absolute")
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Restores portaled content (drop-never-strand), unwires the content
|
|
132
|
+
* listeners, resets typeahead and the scroll hold, and abandons any
|
|
133
|
+
* exit.
|
|
134
|
+
*/
|
|
135
|
+
disconnect() {
|
|
136
|
+
this.#connected = false
|
|
137
|
+
|
|
138
|
+
// Never leave content stranded at the container (drop-never-strand).
|
|
139
|
+
const content = this.#content()
|
|
140
|
+
|
|
141
|
+
if (content) restoreContent(content)
|
|
142
|
+
|
|
143
|
+
for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
|
|
144
|
+
|
|
145
|
+
this.#wired = []
|
|
146
|
+
this.#typeahead.reset()
|
|
147
|
+
this.scrollHoldStop()
|
|
148
|
+
this.#cancelExit?.()
|
|
149
|
+
this.#cancelExit = null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- controllable state ---
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Stimulus value callback - controllable open state.
|
|
156
|
+
*
|
|
157
|
+
* @param {boolean} value
|
|
158
|
+
*/
|
|
159
|
+
openValueChanged(value) {
|
|
160
|
+
if (!this.#connected) return
|
|
161
|
+
|
|
162
|
+
if (value && !this.#isOpen()) this.#show("trigger-press")
|
|
163
|
+
else if (!value && this.#isOpen()) this.#hide("none")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Stimulus value callback - controllable value (no-ops on the applied
|
|
168
|
+
* echo).
|
|
169
|
+
*
|
|
170
|
+
* @param {string} value
|
|
171
|
+
*/
|
|
172
|
+
valueValueChanged(value) {
|
|
173
|
+
if (!this.#connected || value === this.#applied) return
|
|
174
|
+
|
|
175
|
+
this.#apply(value)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- trigger actions ---
|
|
179
|
+
|
|
180
|
+
/** The trigger's click action: open <-> close. */
|
|
181
|
+
toggle() {
|
|
182
|
+
if (this.#isOpen()) this.#hide("trigger-press")
|
|
183
|
+
else this.#show("trigger-press")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The trigger's keydown action: Enter / Space / ArrowDown / ArrowUp
|
|
188
|
+
* open and focus the SELECTED option (closed arrows never step
|
|
189
|
+
* the value). A printable key on the CLOSED trigger commits the
|
|
190
|
+
* typeahead match WITHOUT opening (native <select> behavior - the full
|
|
191
|
+
* commit pipeline minus open/close).
|
|
192
|
+
*
|
|
193
|
+
* @param {KeyboardEvent} event
|
|
194
|
+
*/
|
|
195
|
+
triggerKeydown(event) {
|
|
196
|
+
if (this.#isOpen()) return
|
|
197
|
+
|
|
198
|
+
if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
199
|
+
event.preventDefault()
|
|
200
|
+
this.#show("list-navigation", { seed: this.#applied === "" ? "first" : "selected" })
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
205
|
+
event.preventDefault()
|
|
206
|
+
|
|
207
|
+
const match = this.#search(event.key)
|
|
208
|
+
|
|
209
|
+
if (match) this.#commitItem(match, { close: false })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- programmatic API ---
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Programmatically opens.
|
|
217
|
+
*
|
|
218
|
+
* @param {string | Event} [reason="trigger-press"] - a family reason
|
|
219
|
+
* string (an Event, from data-action use, reads as trigger-press)
|
|
220
|
+
* @param {Object} [options]
|
|
221
|
+
* @param {"first" | "selected" | null} [options.seed=null] - the
|
|
222
|
+
* initial-focus seed stamped onto the content for this open
|
|
223
|
+
*/
|
|
224
|
+
open(reason = "trigger-press", { seed = null } = {}) {
|
|
225
|
+
if (reason instanceof Event) this.#show("trigger-press")
|
|
226
|
+
else this.#show(reason, { seed })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Programmatically closes.
|
|
231
|
+
*
|
|
232
|
+
* @param {string | Event} [reason="none"] - a family reason string; an
|
|
233
|
+
* Event reads as "none"
|
|
234
|
+
*/
|
|
235
|
+
close(reason = "none") {
|
|
236
|
+
this.#hide(reason instanceof Event ? "none" : reason)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The programmatic controllable-state surface: applies a value through
|
|
241
|
+
* the full sync pipeline.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} value - stringified; "" clears to the placeholder
|
|
244
|
+
*/
|
|
245
|
+
setValue(value) {
|
|
246
|
+
if (value === this.#applied) return
|
|
247
|
+
|
|
248
|
+
this.#apply(String(value ?? ""))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- option activation ---
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Each option's click action (markup-declared); the delegated content
|
|
255
|
+
* click claims first, so both paths never double-commit.
|
|
256
|
+
*
|
|
257
|
+
* @param {MouseEvent} event
|
|
258
|
+
*/
|
|
259
|
+
commit(event) {
|
|
260
|
+
if (this.#claim(event)) return
|
|
261
|
+
|
|
262
|
+
const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
|
|
263
|
+
const item = origin instanceof Element ? origin.closest(ITEM_SELECTOR) : null
|
|
264
|
+
|
|
265
|
+
if (!item || this.#isDisabled(item)) return
|
|
266
|
+
|
|
267
|
+
this.#commitItem(item)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- content keydown (commit keys, typeahead, the Tab-inert delta) ---
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The content's keydown route (also wired programmatically): Enter and
|
|
274
|
+
* Space commit the focused option (Space defers to a live typeahead),
|
|
275
|
+
* Tab is INERT while open (a value picker resolves by commit or Esc -
|
|
276
|
+
* contractual), Left/Right no-op (flat listbox), and printable
|
|
277
|
+
* keys move focus via typeahead - selection never follows focus.
|
|
278
|
+
*
|
|
279
|
+
* @param {KeyboardEvent} event
|
|
280
|
+
*/
|
|
281
|
+
keydown(event) {
|
|
282
|
+
if (this.#claim(event)) return
|
|
283
|
+
if (!this.#isOpen()) return
|
|
284
|
+
|
|
285
|
+
const target = event.target instanceof Element ? event.target : null
|
|
286
|
+
const item = target?.closest(ITEM_SELECTOR) ?? null
|
|
287
|
+
|
|
288
|
+
switch (event.key) {
|
|
289
|
+
case "Tab":
|
|
290
|
+
// THE delta vs the menu family: Tab is INERT while open - a value
|
|
291
|
+
// picker resolves by commit or Esc (contractual).
|
|
292
|
+
event.preventDefault()
|
|
293
|
+
event.stopImmediatePropagation()
|
|
294
|
+
return
|
|
295
|
+
case "Enter":
|
|
296
|
+
case " ":
|
|
297
|
+
if (event.key === " " && this.#typeahead.pending()) break // space extends a live search
|
|
298
|
+
|
|
299
|
+
event.preventDefault()
|
|
300
|
+
|
|
301
|
+
if (item && !this.#isDisabled(item)) this.#commitItem(item)
|
|
302
|
+
|
|
303
|
+
return
|
|
304
|
+
case "ArrowRight":
|
|
305
|
+
case "ArrowLeft":
|
|
306
|
+
return // flat listbox: no submenu meaning, no edge seam - no-ops
|
|
307
|
+
default:
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
312
|
+
event.preventDefault()
|
|
313
|
+
|
|
314
|
+
// Open typeahead moves focus (highlight) ONLY - selection does not
|
|
315
|
+
// follow focus; commit stays explicit.
|
|
316
|
+
this.#search(event.key, item)?.focus()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- autofill adoption ---
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The hidden native select's change action: browser autofill (or any
|
|
324
|
+
* programmatic write) fires change there - the UI adopts it without
|
|
325
|
+
* re-writing the native (no loop).
|
|
326
|
+
*/
|
|
327
|
+
nativeChanged() {
|
|
328
|
+
const value = this.#native()?.value ?? ""
|
|
329
|
+
|
|
330
|
+
if (value === this.#applied) return
|
|
331
|
+
|
|
332
|
+
this.#apply(value, { fromNative: true })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- scroll buttons ---
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The viewport's scroll action (also run on open): shows each scroll
|
|
339
|
+
* button only while scroll room remains on its side.
|
|
340
|
+
*/
|
|
341
|
+
syncScrollButtons() {
|
|
342
|
+
const content = this.#content()
|
|
343
|
+
const viewport = content?.querySelector(VIEWPORT_SELECTOR)
|
|
344
|
+
|
|
345
|
+
if (!content || !viewport) return
|
|
346
|
+
|
|
347
|
+
const up = content.querySelector(SCROLL_UP_SELECTOR)
|
|
348
|
+
const down = content.querySelector(SCROLL_DOWN_SELECTOR)
|
|
349
|
+
|
|
350
|
+
if (up) up.hidden = viewport.scrollTop <= 0
|
|
351
|
+
if (down) down.hidden = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The scroll buttons' pointerenter action: scrolls the viewport a few
|
|
356
|
+
* px per frame while hovered (hold-to-scroll).
|
|
357
|
+
*
|
|
358
|
+
* @param {PointerEvent} event
|
|
359
|
+
*/
|
|
360
|
+
scrollHoldStart(event) {
|
|
361
|
+
const origin = event.currentTarget instanceof Element ? event.currentTarget : null
|
|
362
|
+
const direction = origin?.matches(SCROLL_UP_SELECTOR) ? -1 : 1
|
|
363
|
+
const viewport = this.#content()?.querySelector(VIEWPORT_SELECTOR)
|
|
364
|
+
|
|
365
|
+
if (!viewport) return
|
|
366
|
+
|
|
367
|
+
this.scrollHoldStop()
|
|
368
|
+
|
|
369
|
+
const step = () => {
|
|
370
|
+
viewport.scrollTop += direction * SCROLL_HOLD_STEP
|
|
371
|
+
this.syncScrollButtons()
|
|
372
|
+
this.#scrollFrame = window.requestAnimationFrame(step)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.#scrollFrame = window.requestAnimationFrame(step)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The scroll buttons' pointerleave action: stops the held scroll. */
|
|
379
|
+
scrollHoldStop() {
|
|
380
|
+
if (this.#scrollFrame === null) return
|
|
381
|
+
|
|
382
|
+
window.cancelAnimationFrame(this.#scrollFrame)
|
|
383
|
+
this.#scrollFrame = null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --- open / close ---
|
|
387
|
+
|
|
388
|
+
#show(reason, { seed = null } = {}) {
|
|
389
|
+
const content = this.#content()
|
|
390
|
+
|
|
391
|
+
if (!content || this.#isOpen()) return
|
|
392
|
+
|
|
393
|
+
this.#cancelExit?.()
|
|
394
|
+
this.#cancelExit = null
|
|
395
|
+
this.#suppressRestore = false
|
|
396
|
+
|
|
397
|
+
const trigger = this.#trigger()
|
|
398
|
+
|
|
399
|
+
// Portal-on-open: move BEFORE the
|
|
400
|
+
// enter presence (reparenting mid-animation restarts it), re-anchor
|
|
401
|
+
// absolute - static under compositor scroll, transform-immune. In
|
|
402
|
+
// aligned mode the content fixed-positions ITSELF (viewport coords,
|
|
403
|
+
// location-independent - the math is written in viewport
|
|
404
|
+
// coordinates) and popper's writes stay bailed either way.
|
|
405
|
+
portalContent(content, { container: resolvePortalContainer(this.element) })
|
|
406
|
+
this.element.setAttribute(POPPER_STRATEGY, "absolute")
|
|
407
|
+
|
|
408
|
+
content.hidden = false
|
|
409
|
+
content.setAttribute("data-open-reason", reason)
|
|
410
|
+
if (seed) content.setAttribute("data-open-seed", seed)
|
|
411
|
+
else content.removeAttribute("data-open-seed")
|
|
412
|
+
trigger?.setAttribute("aria-expanded", "true")
|
|
413
|
+
if (trigger) setState(trigger, "popup-open")
|
|
414
|
+
enterPresence(content)
|
|
415
|
+
this.#activateLayers(content)
|
|
416
|
+
this.openValue = true
|
|
417
|
+
|
|
418
|
+
// The layer controllers connect on the attribute-mutation microtask;
|
|
419
|
+
// initial focus rides one microtask behind so focus-scope has already
|
|
420
|
+
// snapshotted the trigger (its focus-return target).
|
|
421
|
+
queueMicrotask(() => {
|
|
422
|
+
if (!this.#isOpen()) return
|
|
423
|
+
|
|
424
|
+
this.#focusSelected(content)
|
|
425
|
+
// AFTER #focusSelected: the aligned scroll write must win over the
|
|
426
|
+
// focus scroll-into-view.
|
|
427
|
+
if (this.#alignEligible()) this.#alignWithTrigger(content)
|
|
428
|
+
this.syncScrollButtons()
|
|
429
|
+
this.dispatch("open", { prefix: EVENT_PREFIX, detail: seed ? { reason, seed } : { reason } })
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// --- alignItemWithTrigger (the native-select-feel placement) ---
|
|
434
|
+
//
|
|
435
|
+
// The popup opens OVER the trigger with the selected item's TEXT center
|
|
436
|
+
// aligned to the trigger's value-text center (native <select> feel): the
|
|
437
|
+
// content stretches from the trigger line to the viewport bottom (or top,
|
|
438
|
+
// when the selection sits deep in a long list) and the LIST SCROLLS so
|
|
439
|
+
// the item lands on the trigger. Not a popper placement: the content is
|
|
440
|
+
// fixed-positioned by this routine and popper's writes are suppressed by
|
|
441
|
+
// the data-align-item-with-trigger attribute (its #update bails). The
|
|
442
|
+
// fallback rules: touch environments fall back (approximated by
|
|
443
|
+
// pointer:coarse), trigger within 20px of a viewport
|
|
444
|
+
// edge falls back, a popup squeezed under min(scrollHeight, 100px) falls
|
|
445
|
+
// back - all to plain popper positioning for THAT open. Skipped
|
|
446
|
+
// deliberately: WebKit pinch-zoom detection and scale normalization.
|
|
447
|
+
|
|
448
|
+
#alignEligible() {
|
|
449
|
+
return this.alignItemWithTriggerValue && !window.matchMedia?.("(pointer: coarse)")?.matches
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#alignWithTrigger(content) {
|
|
453
|
+
const trigger = this.#trigger()
|
|
454
|
+
|
|
455
|
+
if (!trigger) return
|
|
456
|
+
|
|
457
|
+
// Aligned mode has no side: kill popper's writes for this open (the
|
|
458
|
+
// attribute gates popper#update) and the data-side entrance variants.
|
|
459
|
+
content.setAttribute("data-align-item-with-trigger", "")
|
|
460
|
+
content.removeAttribute("data-side")
|
|
461
|
+
|
|
462
|
+
const placed = this.#placeAligned(content, trigger)
|
|
463
|
+
|
|
464
|
+
if (!placed) {
|
|
465
|
+
content.removeAttribute("data-align-item-with-trigger")
|
|
466
|
+
this.#clearAlignedStyles(content)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#placeAligned(content, trigger) {
|
|
471
|
+
const scroller = content.querySelector(VIEWPORT_SELECTOR) ?? content
|
|
472
|
+
const selected =
|
|
473
|
+
content.querySelector(`${ITEM_SELECTOR}[data-selected]`) ?? content.querySelector(ITEM_SELECTOR)
|
|
474
|
+
const textElement = selected?.querySelector(ITEM_TEXT_SELECTOR) ?? selected
|
|
475
|
+
const valueElement = trigger.querySelector(VALUE_SELECTOR)
|
|
476
|
+
|
|
477
|
+
if (!selected) return false
|
|
478
|
+
|
|
479
|
+
// Aligned mode ships NO entrance animation, and
|
|
480
|
+
// measuring demands it: the presence starting-style is a STATIC
|
|
481
|
+
// transform (scale) that animation/transition suppression cannot
|
|
482
|
+
// remove - it distorts every rect read this routine makes (caught
|
|
483
|
+
// live: the correction pass measured dy~0 through a scale(.95) and
|
|
484
|
+
// the popup settled 100px low). Suppress all three for the whole
|
|
485
|
+
// open; the fallback path restores them for popper mode, and
|
|
486
|
+
// #clearAlignedStyles clears them on close.
|
|
487
|
+
const previousAnimation = content.style.animation
|
|
488
|
+
const previousTransition = content.style.transition
|
|
489
|
+
const previousTransform = content.style.transform
|
|
490
|
+
content.style.animation = "none"
|
|
491
|
+
content.style.transition = "none"
|
|
492
|
+
content.style.transform = "none"
|
|
493
|
+
|
|
494
|
+
const styles = getComputedStyle(content)
|
|
495
|
+
const marginTop = parseFloat(styles.marginTop) || 10
|
|
496
|
+
const marginBottom = parseFloat(styles.marginBottom) || 10
|
|
497
|
+
const minHeight = parseFloat(styles.minHeight) || 100
|
|
498
|
+
const borderBottom = parseFloat(styles.borderBottomWidth) || 0
|
|
499
|
+
const collisionThreshold = 20
|
|
500
|
+
const paddingX = 5
|
|
501
|
+
|
|
502
|
+
const triggerRect = trigger.getBoundingClientRect()
|
|
503
|
+
const contentRect = content.getBoundingClientRect()
|
|
504
|
+
const viewportHeight = document.documentElement.clientHeight - marginTop - marginBottom
|
|
505
|
+
const viewportWidth = document.documentElement.clientWidth
|
|
506
|
+
const availableBeneathTrigger = viewportHeight - triggerRect.bottom + triggerRect.height
|
|
507
|
+
|
|
508
|
+
let alignedLeft = triggerRect.left
|
|
509
|
+
let offsetY = 0
|
|
510
|
+
|
|
511
|
+
if (textElement && valueElement) {
|
|
512
|
+
const valueRect = valueElement.getBoundingClientRect()
|
|
513
|
+
const textRect = textElement.getBoundingClientRect()
|
|
514
|
+
|
|
515
|
+
alignedLeft = contentRect.left + (valueRect.left - textRect.left)
|
|
516
|
+
const valueCenterFromTriggerTop = valueRect.top - triggerRect.top + valueRect.height / 2
|
|
517
|
+
const textCenterFromContentTop = textRect.top - contentRect.top + textRect.height / 2
|
|
518
|
+
|
|
519
|
+
offsetY = textCenterFromContentTop - valueCenterFromTriggerTop
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const idealHeight = availableBeneathTrigger + offsetY + marginBottom + borderBottom
|
|
523
|
+
let height = Math.min(viewportHeight, idealHeight)
|
|
524
|
+
const scrollTop = idealHeight - height
|
|
525
|
+
const maxScrollTop = scroller.scrollHeight - scroller.clientHeight
|
|
526
|
+
|
|
527
|
+
const fallback =
|
|
528
|
+
triggerRect.top < collisionThreshold ||
|
|
529
|
+
triggerRect.bottom > viewportHeight - collisionThreshold ||
|
|
530
|
+
Math.ceil(height) < Math.min(scroller.scrollHeight, minHeight)
|
|
531
|
+
|
|
532
|
+
if (fallback) {
|
|
533
|
+
content.style.animation = previousAnimation
|
|
534
|
+
content.style.transition = previousTransition
|
|
535
|
+
content.style.transform = previousTransform
|
|
536
|
+
return false
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// The dictionary's cn-select-viewport consumes these (the --radix-*
|
|
540
|
+
// names are a compatibility surface for ported CSS) - aligned mode
|
|
541
|
+
// is what they exist for.
|
|
542
|
+
content.style.setProperty("--radix-select-trigger-height", `${triggerRect.height}px`)
|
|
543
|
+
content.style.setProperty("--radix-select-trigger-width", `${triggerRect.width}px`)
|
|
544
|
+
|
|
545
|
+
content.style.position = "fixed"
|
|
546
|
+
content.style.left = `${Math.min(Math.max(alignedLeft, paddingX), viewportWidth - paddingX - contentRect.width)}px`
|
|
547
|
+
content.style.maxHeight = "none"
|
|
548
|
+
content.style.marginTop = `${marginTop}px`
|
|
549
|
+
content.style.marginBottom = `${marginBottom}px`
|
|
550
|
+
|
|
551
|
+
if (scrollTop >= maxScrollTop - 1) {
|
|
552
|
+
// Selection deep in the list: anchor to the top edge instead and pin
|
|
553
|
+
// the list at its end (the top-anchored branch).
|
|
554
|
+
const topOffset = Math.max(0, viewportHeight - idealHeight)
|
|
555
|
+
height = Math.min(viewportHeight, contentRect.height) - (scrollTop - maxScrollTop)
|
|
556
|
+
content.style.top = `${contentRect.height >= viewportHeight - marginTop ? 0 : topOffset}px`
|
|
557
|
+
content.style.bottom = ""
|
|
558
|
+
content.style.height = `${Math.max(minHeight, height)}px`
|
|
559
|
+
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight
|
|
560
|
+
} else {
|
|
561
|
+
content.style.top = ""
|
|
562
|
+
content.style.bottom = "0"
|
|
563
|
+
content.style.height = `${Math.max(minHeight, height)}px`
|
|
564
|
+
scroller.scrollTop = scrollTop
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Second pass: the placement math assumes fixed coordinates ARE
|
|
568
|
+
// viewport coordinates. poetry keeps the content in
|
|
569
|
+
// place, so a transformed/filtered ancestor becomes the fixed
|
|
570
|
+
// containing block and every coordinate above lands shifted by that
|
|
571
|
+
// box's offset (caught live: a docs example frame put the item 112px
|
|
572
|
+
// under the trigger). Measure the ACTUAL text/value deltas after
|
|
573
|
+
// placement and correct both axes - containing-block-proof by
|
|
574
|
+
// construction.
|
|
575
|
+
if (textElement && valueElement) {
|
|
576
|
+
const valueRect = valueElement.getBoundingClientRect()
|
|
577
|
+
const placedText = textElement.getBoundingClientRect()
|
|
578
|
+
const dy = (placedText.top + placedText.height / 2) - (valueRect.top + valueRect.height / 2)
|
|
579
|
+
const dx = placedText.left - valueRect.left
|
|
580
|
+
|
|
581
|
+
if (Math.abs(dy) > 0.5) {
|
|
582
|
+
if (content.style.bottom === "0px" || content.style.bottom === "0") {
|
|
583
|
+
content.style.bottom = `${parseFloat(content.style.bottom || "0") + dy}px`
|
|
584
|
+
} else {
|
|
585
|
+
content.style.top = `${parseFloat(content.style.top || "0") - dy}px`
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (Math.abs(dx) > 0.5) {
|
|
589
|
+
content.style.left = `${parseFloat(content.style.left || "0") - dx}px`
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return true
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
#clearAlignedStyles(content) {
|
|
597
|
+
for (const property of ["position", "left", "top", "bottom", "height", "max-height",
|
|
598
|
+
"margin-top", "margin-bottom", "animation", "transition", "transform"]) {
|
|
599
|
+
content.style.removeProperty(property)
|
|
600
|
+
}
|
|
601
|
+
content.style.removeProperty("--radix-select-trigger-height")
|
|
602
|
+
content.style.removeProperty("--radix-select-trigger-width")
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#hide(reason, { restoreFocus = true } = {}) {
|
|
606
|
+
const content = this.#content()
|
|
607
|
+
|
|
608
|
+
if (!content || !this.#isOpen()) return
|
|
609
|
+
|
|
610
|
+
this.#typeahead.reset()
|
|
611
|
+
this.scrollHoldStop()
|
|
612
|
+
this.#suppressRestore = !restoreFocus || (reason === "outside-press" && !this.modalValue)
|
|
613
|
+
|
|
614
|
+
const trigger = this.#trigger()
|
|
615
|
+
|
|
616
|
+
trigger?.setAttribute("aria-expanded", "false")
|
|
617
|
+
if (trigger) setState(trigger, "popup-closed")
|
|
618
|
+
content.removeAttribute("data-open-reason")
|
|
619
|
+
content.removeAttribute("data-open-seed")
|
|
620
|
+
if (content.hasAttribute("data-align-item-with-trigger")) {
|
|
621
|
+
content.removeAttribute("data-align-item-with-trigger")
|
|
622
|
+
this.#clearAlignedStyles(content)
|
|
623
|
+
}
|
|
624
|
+
this.openValue = false
|
|
625
|
+
|
|
626
|
+
this.#cancelExit = exitPresence(content, {
|
|
627
|
+
onRemove: () => {
|
|
628
|
+
this.#cancelExit = null
|
|
629
|
+
content.hidden = true
|
|
630
|
+
// Home AFTER the exit finished and hidden landed; focus
|
|
631
|
+
// return is focus-scope's ref-based job, indifferent to the move.
|
|
632
|
+
restoreContent(content)
|
|
633
|
+
this.element.setAttribute(POPPER_STRATEGY, "fixed")
|
|
634
|
+
this.#removeControllers(content, CONTENT_LAYER_CONTROLLERS)
|
|
635
|
+
this.dispatch("closed", { prefix: EVENT_PREFIX, detail: { reason } })
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Initial focus: the SELECTED option for EVERY reason, pointer included
|
|
641
|
+
// (the listbox exists to show you where you are);
|
|
642
|
+
// first enabled option when the value is empty.
|
|
643
|
+
#focusSelected(content) {
|
|
644
|
+
const items = this.#enabledItems(content)
|
|
645
|
+
const target = items.find((item) => item.getAttribute("aria-selected") === "true") ?? items[0]
|
|
646
|
+
|
|
647
|
+
if (!target) {
|
|
648
|
+
content.focus()
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
target.focus()
|
|
653
|
+
target.scrollIntoView?.({ block: "nearest" })
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// --- the commit pipeline ---
|
|
657
|
+
|
|
658
|
+
#commitItem(item, { close = true } = {}) {
|
|
659
|
+
const value = item.dataset.value ?? ""
|
|
660
|
+
|
|
661
|
+
// Cancelable BEFORE the value commits: preventDefault vetoes the commit
|
|
662
|
+
// and keeps the listbox open (the family select-event shape).
|
|
663
|
+
const select = this.dispatch("select", {
|
|
664
|
+
prefix: EVENT_PREFIX,
|
|
665
|
+
target: item,
|
|
666
|
+
cancelable: true,
|
|
667
|
+
detail: { item, value, label: this.#labelOf(item) }
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
if (select.defaultPrevented) return
|
|
671
|
+
|
|
672
|
+
if (value !== this.#applied) this.#apply(value)
|
|
673
|
+
if (close) this.#hide("item-press")
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// THE single sync path, native-first. silent skips events (reconcile);
|
|
677
|
+
// fromNative skips step 1's write-back (the value came FROM the native).
|
|
678
|
+
#apply(value, { silent = false, fromNative = false, force = false } = {}) {
|
|
679
|
+
if (!force && value === this.#applied) return
|
|
680
|
+
|
|
681
|
+
const previous = this.#applied
|
|
682
|
+
|
|
683
|
+
this.#applied = value
|
|
684
|
+
this.valueValue = value // the attribute mirror; the changed callback no-ops on #applied
|
|
685
|
+
|
|
686
|
+
// 1. The native select FIRST - serialization truth is never behind the
|
|
687
|
+
// display - with real bubbling change/input (Turbo/analytics fire).
|
|
688
|
+
const native = this.#native()
|
|
689
|
+
|
|
690
|
+
if (native && !fromNative) {
|
|
691
|
+
native.value = value
|
|
692
|
+
|
|
693
|
+
if (!silent) {
|
|
694
|
+
native.dispatchEvent(new Event("input", { bubbles: true }))
|
|
695
|
+
native.dispatchEvent(new Event("change", { bubbles: true }))
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// 2. aria-selected + data-selected flipped TOGETHER on every option.
|
|
700
|
+
let selected = null
|
|
701
|
+
|
|
702
|
+
for (const item of this.#items()) {
|
|
703
|
+
const match = value !== "" && (item.dataset.value ?? "") === value
|
|
704
|
+
|
|
705
|
+
item.setAttribute("aria-selected", String(match))
|
|
706
|
+
setState(item, match ? "selected" : "unselected")
|
|
707
|
+
|
|
708
|
+
if (match) selected = item
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// 3 + 4. The value display syncs from the option's item-text (or its
|
|
712
|
+
// data-text-value); an empty value restores the placeholder + the
|
|
713
|
+
// trigger's data-placeholder dimming.
|
|
714
|
+
const display = this.#display()
|
|
715
|
+
const label = selected ? this.#labelOf(selected) : null
|
|
716
|
+
|
|
717
|
+
if (display) display.textContent = label ?? this.#placeholder
|
|
718
|
+
|
|
719
|
+
this.#trigger()?.toggleAttribute("data-placeholder", !selected)
|
|
720
|
+
|
|
721
|
+
// 5. The poetry change event.
|
|
722
|
+
if (!silent) {
|
|
723
|
+
this.dispatch("change", { prefix: EVENT_PREFIX, detail: { value, label, previous } })
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// --- typeahead (the shared helper; anchor = focused option when open,
|
|
728
|
+
// the selected option when closed) ---
|
|
729
|
+
|
|
730
|
+
#search(key, activeItem = null) {
|
|
731
|
+
const content = this.#content()
|
|
732
|
+
|
|
733
|
+
if (!content) return null
|
|
734
|
+
|
|
735
|
+
const items = this.#enabledItems(content)
|
|
736
|
+
const active = activeItem ??
|
|
737
|
+
(this.#isOpen()
|
|
738
|
+
? (document.activeElement instanceof Element ? document.activeElement.closest(ITEM_SELECTOR) : null)
|
|
739
|
+
: items.find((item) => item.getAttribute("aria-selected") === "true") ?? null)
|
|
740
|
+
|
|
741
|
+
return this.#typeahead.search(key, items, { active, timeout: this.typeaheadTimeoutValue })
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
#labelOf(item) {
|
|
745
|
+
return (item.dataset.textValue ??
|
|
746
|
+
item.querySelector(ITEM_TEXT_SELECTOR)?.textContent ??
|
|
747
|
+
typeaheadLabel(item)).trim()
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// --- content wiring (programmatic: portal-safe) ---
|
|
751
|
+
|
|
752
|
+
#wireContent(content) {
|
|
753
|
+
this.#listen(content, "keydown", (event) => this.keydown(event))
|
|
754
|
+
this.#listen(content, "click", this.#onClick)
|
|
755
|
+
this.#listen(content, "scroll", () => this.syncScrollButtons())
|
|
756
|
+
this.#listen(content, "pointerover", this.#onPointerover)
|
|
757
|
+
this.#listen(content, "pointerout", this.#onPointerout)
|
|
758
|
+
this.#listen(content, "poetry--core--dismissable:dismiss", this.#onDismiss)
|
|
759
|
+
this.#listen(content, "poetry--core--dismissable:interact-outside", this.#onInteractOutside)
|
|
760
|
+
this.#listen(content, "poetry--core--focus-scope:mount-auto-focus", this.#onMountAutoFocus)
|
|
761
|
+
this.#listen(content, "poetry--core--focus-scope:unmount-auto-focus", this.#onUnmountAutoFocus)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Focus follows the pointer (the menu family's hover-highlight rule):
|
|
765
|
+
// themes style option highlight through focus:
|
|
766
|
+
// alone, so hover highlight IS this focus move. Disabled options clear
|
|
767
|
+
// it; touch pointers never hover-highlight.
|
|
768
|
+
#onPointerover = (event) => {
|
|
769
|
+
if (event.pointerType === "touch") return
|
|
770
|
+
|
|
771
|
+
const target = event.target instanceof Element ? event.target : null
|
|
772
|
+
const item = target?.closest(ITEM_SELECTOR)
|
|
773
|
+
|
|
774
|
+
if (!item) return
|
|
775
|
+
|
|
776
|
+
if (this.#isDisabled(item)) this.#content()?.focus()
|
|
777
|
+
else if (document.activeElement !== item) item.focus()
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Leaving an option for blank space hands focus back to the content so
|
|
781
|
+
// the highlight clears; option-to-option travel refocuses via pointerover.
|
|
782
|
+
#onPointerout = (event) => {
|
|
783
|
+
const target = event.target instanceof Element ? event.target : null
|
|
784
|
+
const item = target?.closest(ITEM_SELECTOR)
|
|
785
|
+
const related = event.relatedTarget instanceof Element ? event.relatedTarget : null
|
|
786
|
+
|
|
787
|
+
if (item && document.activeElement === item && (!related || !related.closest(ITEM_SELECTOR))) {
|
|
788
|
+
this.#content()?.focus()
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
#listen(target, type, listener) {
|
|
793
|
+
target.addEventListener(type, listener)
|
|
794
|
+
this.#wired.push([target, type, listener])
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
#onClick = (event) => {
|
|
798
|
+
if (this.#claim(event)) return
|
|
799
|
+
|
|
800
|
+
const item = event.target instanceof Element ? event.target.closest(ITEM_SELECTOR) : null
|
|
801
|
+
|
|
802
|
+
if (!item || this.#isDisabled(item)) return
|
|
803
|
+
|
|
804
|
+
this.#commitItem(item)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// A press on the select's OWN trigger is the toggle's job, not an
|
|
808
|
+
// outside dismissal: without the veto the pointerdown closes and the
|
|
809
|
+
// trailing click re-opens (the popover trigger-press rule).
|
|
810
|
+
#onInteractOutside = (event) => {
|
|
811
|
+
if (event.target !== this.#content()) return
|
|
812
|
+
|
|
813
|
+
const origin = event.detail?.originalEvent?.target
|
|
814
|
+
|
|
815
|
+
if (origin instanceof Element && this.#trigger()?.contains(origin)) event.preventDefault()
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Esc / outside press arrive as the dismissable layer's dismiss event.
|
|
819
|
+
// Esc NEVER commits - the value is untouched.
|
|
820
|
+
#onDismiss = (event) => {
|
|
821
|
+
if (event.target !== this.#content()) return
|
|
822
|
+
|
|
823
|
+
const escaped = event.detail?.originalEvent?.type === "keydown"
|
|
824
|
+
|
|
825
|
+
this.#hide(escaped ? "escape-key" : "outside-press")
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// The select owns initial focus (the selected option), not focus-scope.
|
|
829
|
+
#onMountAutoFocus = (event) => {
|
|
830
|
+
if (event.target === this.#content()) event.preventDefault()
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#onUnmountAutoFocus = (event) => {
|
|
834
|
+
if (event.target === this.#content() && this.#suppressRestore) event.preventDefault()
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// --- the layer stack (the menu controller's proven mechanism) ---
|
|
838
|
+
|
|
839
|
+
#activateLayers(content) {
|
|
840
|
+
content.setAttribute("data-poetry--core--focus-scope-trapped-value", String(this.modalValue))
|
|
841
|
+
content.setAttribute(
|
|
842
|
+
"data-poetry--core--dismissable-disable-outside-pointer-events-value", String(this.modalValue)
|
|
843
|
+
)
|
|
844
|
+
content.setAttribute(`data-${ROVING}-orientation-value`, "vertical")
|
|
845
|
+
content.setAttribute(`data-${ROVING}-manage-tabindex-value`, "true")
|
|
846
|
+
content.setAttribute(`data-${ROVING}-loop-value`, String(this.loopValue))
|
|
847
|
+
|
|
848
|
+
const action = content.getAttribute("data-action") ?? ""
|
|
849
|
+
|
|
850
|
+
if (!action.includes(ROVING_ACTION)) {
|
|
851
|
+
content.setAttribute("data-action", `${action} ${ROVING_ACTION}`.trim())
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
this.#addControllers(content, CONTENT_LAYER_CONTROLLERS)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
#addControllers(element, identifiers) {
|
|
858
|
+
const tokens = (element.getAttribute("data-controller") ?? "").split(/\s+/).filter(Boolean)
|
|
859
|
+
|
|
860
|
+
for (const identifier of identifiers) {
|
|
861
|
+
if (!tokens.includes(identifier)) tokens.push(identifier)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
element.setAttribute("data-controller", tokens.join(" "))
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
#removeControllers(element, identifiers) {
|
|
868
|
+
const tokens = (element.getAttribute("data-controller") ?? "")
|
|
869
|
+
.split(/\s+/)
|
|
870
|
+
.filter((token) => token && !identifiers.includes(token))
|
|
871
|
+
|
|
872
|
+
element.setAttribute("data-controller", tokens.join(" "))
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// --- structural resolution (ids are the seams; portal-safe) ---
|
|
876
|
+
|
|
877
|
+
#trigger() {
|
|
878
|
+
return this.element.querySelector(TRIGGER_SELECTOR)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
#content() {
|
|
882
|
+
const id = this.#trigger()?.getAttribute("aria-controls")
|
|
883
|
+
|
|
884
|
+
return id ? document.getElementById(id) : null
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
#native() {
|
|
888
|
+
return this.element.querySelector(NATIVE_SELECTOR)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
#display() {
|
|
892
|
+
return this.element.querySelector(VALUE_SELECTOR)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
#isOpen() {
|
|
896
|
+
const content = this.#content()
|
|
897
|
+
|
|
898
|
+
return Boolean(content) && stateOf(content) === "open"
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
#items() {
|
|
902
|
+
const content = this.#content()
|
|
903
|
+
|
|
904
|
+
return content ? collectionItems(content, ITEM_SELECTOR) : []
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
#enabledItems(content = this.#content()) {
|
|
908
|
+
if (!content) return []
|
|
909
|
+
|
|
910
|
+
return collectionItems(content, ITEM_SELECTOR).filter((item) => !this.#isDisabled(item))
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
#isDisabled(item) {
|
|
914
|
+
return item.hasAttribute("data-disabled") || item.getAttribute("aria-disabled") === "true"
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
#claim(event) {
|
|
918
|
+
if (this.#claimed.has(event)) return true
|
|
919
|
+
|
|
920
|
+
this.#claimed.add(event)
|
|
921
|
+
|
|
922
|
+
return false
|
|
923
|
+
}
|
|
924
|
+
}
|