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,422 @@
|
|
|
1
|
+
// Scroll geometry for the message-scroller, adapted from an MIT-licensed
|
|
2
|
+
// source (source and license in THIRD_PARTY_NOTICES.md): pure functions
|
|
3
|
+
// of {viewport, content, spacer, rects} - no Stimulus, no state. This is
|
|
4
|
+
// the jsdom-testable half; the controller owns the policy that decides
|
|
5
|
+
// when to call these. The constants encode deliberate fixes - treat them
|
|
6
|
+
// as pinned values, not tunables.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Two fractional scrollTop values within this range are treated as equal,
|
|
10
|
+
* to absorb zoom and HiDPI rounding drift.
|
|
11
|
+
*/
|
|
12
|
+
export const SCROLL_POSITION_EPSILON = 0.5
|
|
13
|
+
|
|
14
|
+
/** Initial scrollable snapshot before measurement. */
|
|
15
|
+
export const EMPTY_MESSAGE_SCROLLER_SCROLLABLE = Object.freeze({
|
|
16
|
+
start: false,
|
|
17
|
+
end: false
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** Initial visibility snapshot: nothing tracked, no current anchor. */
|
|
21
|
+
export const EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE = Object.freeze({
|
|
22
|
+
currentAnchorId: null,
|
|
23
|
+
visibleMessageIds: Object.freeze([])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether the scroller has scroll room past each edge, beyond the
|
|
28
|
+
* configured threshold.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} args
|
|
31
|
+
* @param {HTMLElement | null} args.content
|
|
32
|
+
* @param {number} args.scrollEdgeThreshold - px of slack that still
|
|
33
|
+
* counts as "at the edge"
|
|
34
|
+
* @param {HTMLElement | null} args.spacer
|
|
35
|
+
* @param {HTMLElement | null} args.viewport
|
|
36
|
+
* @returns {{ start: boolean, end: boolean }}
|
|
37
|
+
*/
|
|
38
|
+
export function getMessageScrollerScrollable({ content, scrollEdgeThreshold, spacer, viewport }) {
|
|
39
|
+
if (!viewport || !content) return EMPTY_MESSAGE_SCROLLER_SCROLLABLE
|
|
40
|
+
|
|
41
|
+
const contentBottom = getContentBottom({ content, spacer, viewport })
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
start: viewport.scrollTop > scrollEdgeThreshold,
|
|
45
|
+
end: contentBottom - viewport.scrollTop - viewport.clientHeight > scrollEdgeThreshold
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The visible message ids (top-to-bottom) and the current anchor - the
|
|
51
|
+
* last anchor row to have reached the reading line (the comments inside
|
|
52
|
+
* hold the line rules).
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} args
|
|
55
|
+
* @param {HTMLElement | null} args.content
|
|
56
|
+
* @param {number} args.scrollMargin
|
|
57
|
+
* @param {number} args.scrollPreviousItemPeek
|
|
58
|
+
* @param {HTMLElement | null} args.spacer
|
|
59
|
+
* @param {HTMLElement | null} args.viewport
|
|
60
|
+
* @param {Set<string>} args.visibleMessageIds - the observer's current
|
|
61
|
+
* set (consulted when IntersectionObserver exists)
|
|
62
|
+
* @returns {{ currentAnchorId: string | null, visibleMessageIds: string[] }}
|
|
63
|
+
*/
|
|
64
|
+
export function getMessageScrollerVisibilityState({
|
|
65
|
+
content,
|
|
66
|
+
scrollMargin,
|
|
67
|
+
scrollPreviousItemPeek,
|
|
68
|
+
spacer,
|
|
69
|
+
viewport,
|
|
70
|
+
visibleMessageIds
|
|
71
|
+
}) {
|
|
72
|
+
if (!content || !viewport) return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
|
|
73
|
+
|
|
74
|
+
const viewportRect = viewport.getBoundingClientRect()
|
|
75
|
+
// The reading line sits scrollPreviousItemPeek below scrollMargin: anchored
|
|
76
|
+
// turns land there with the previous turn peeking above. A row only peeking
|
|
77
|
+
// in that band has not been read down to yet, so it counts as neither
|
|
78
|
+
// visible nor current.
|
|
79
|
+
const lineTop = viewportRect.top + scrollMargin + scrollPreviousItemPeek
|
|
80
|
+
const trackByLayout = typeof IntersectionObserver === "undefined"
|
|
81
|
+
|
|
82
|
+
const visible = []
|
|
83
|
+
let currentAnchorId = null
|
|
84
|
+
|
|
85
|
+
// Walk rows in document order so visible ids come out top-to-bottom.
|
|
86
|
+
for (const item of getMessageScrollerItems(content, spacer)) {
|
|
87
|
+
const messageId = item.dataset.messageId
|
|
88
|
+
if (!messageId) continue
|
|
89
|
+
|
|
90
|
+
const isAnchor = item.dataset.scrollAnchor === "true"
|
|
91
|
+
// Anchors need a rect to place the current line; non-anchors lean on the
|
|
92
|
+
// observer set (or a rect in the no-observer fallback).
|
|
93
|
+
const rect = isAnchor || trackByLayout ? item.getBoundingClientRect() : null
|
|
94
|
+
|
|
95
|
+
const isVisible = trackByLayout && rect
|
|
96
|
+
? rect.bottom > lineTop && rect.top < viewportRect.bottom
|
|
97
|
+
: visibleMessageIds.has(messageId)
|
|
98
|
+
|
|
99
|
+
if (isVisible) visible.push(messageId)
|
|
100
|
+
|
|
101
|
+
// Current is the last anchor to have reached the reading line: the turn
|
|
102
|
+
// you scrolled to (placed at the line) wins over newer turns lower down,
|
|
103
|
+
// the previous turn peeking above the line has been passed, and it stays
|
|
104
|
+
// current even after its header scrolls above the viewport.
|
|
105
|
+
if (isAnchor && rect && rect.top <= lineTop + SCROLL_POSITION_EPSILON) {
|
|
106
|
+
currentAnchorId = messageId
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (visible.length === 0 && currentAnchorId === null) {
|
|
111
|
+
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { currentAnchorId, visibleMessageIds: visible }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The collection is DOM order: rows are content's element children minus
|
|
119
|
+
* the tail spacer - membership is a filter over live children, never a
|
|
120
|
+
* registration step.
|
|
121
|
+
*
|
|
122
|
+
* @param {HTMLElement} content
|
|
123
|
+
* @param {HTMLElement | null} spacer
|
|
124
|
+
* @returns {HTMLElement[]}
|
|
125
|
+
*/
|
|
126
|
+
export function getMessageScrollerItems(content, spacer) {
|
|
127
|
+
return Array.from(content.children).filter(
|
|
128
|
+
(child) => child instanceof HTMLElement && child !== spacer
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The first anchor row appended after `previousItemCount` items existed.
|
|
134
|
+
*
|
|
135
|
+
* @param {HTMLElement[]} items
|
|
136
|
+
* @param {number} previousItemCount
|
|
137
|
+
* @returns {HTMLElement | null}
|
|
138
|
+
*/
|
|
139
|
+
export function getNewScrollAnchor(items, previousItemCount) {
|
|
140
|
+
for (let index = previousItemCount; index < items.length; index++) {
|
|
141
|
+
const item = items[index]
|
|
142
|
+
if (item?.dataset.scrollAnchor === "true") return item
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The first anchor row not yet in `handledAnchors`.
|
|
149
|
+
*
|
|
150
|
+
* @param {HTMLElement[]} items
|
|
151
|
+
* @param {Set<HTMLElement>} handledAnchors
|
|
152
|
+
* @returns {HTMLElement | null}
|
|
153
|
+
*/
|
|
154
|
+
export function getUnanchoredScrollAnchor(items, handledAnchors) {
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
if (item.dataset.scrollAnchor === "true" && !handledAnchors.has(item)) return item
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether more than one anchor row arrived after `previousItemCount`
|
|
163
|
+
* items existed (a multi-turn batch).
|
|
164
|
+
*
|
|
165
|
+
* @param {HTMLElement[]} items
|
|
166
|
+
* @param {number} previousItemCount
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function hasMultipleNewScrollAnchors(items, previousItemCount) {
|
|
170
|
+
let count = 0
|
|
171
|
+
|
|
172
|
+
for (let index = previousItemCount; index < items.length; index++) {
|
|
173
|
+
const item = items[index]
|
|
174
|
+
if (item?.dataset.scrollAnchor !== "true") continue
|
|
175
|
+
|
|
176
|
+
count += 1
|
|
177
|
+
if (count > 1) return true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The last anchor row in the collection.
|
|
185
|
+
*
|
|
186
|
+
* @param {HTMLElement[]} items
|
|
187
|
+
* @returns {HTMLElement | null}
|
|
188
|
+
*/
|
|
189
|
+
export function getLastScrollAnchor(items) {
|
|
190
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
191
|
+
const item = items[index]
|
|
192
|
+
if (item?.dataset.scrollAnchor === "true") return item
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The topmost message row intersecting the viewport.
|
|
199
|
+
*
|
|
200
|
+
* @param {Object} args
|
|
201
|
+
* @param {HTMLElement} args.content
|
|
202
|
+
* @param {HTMLElement | null} args.spacer
|
|
203
|
+
* @param {HTMLElement} args.viewport
|
|
204
|
+
* @returns {HTMLElement | null}
|
|
205
|
+
*/
|
|
206
|
+
export function getFirstVisibleMessageItem({ content, spacer, viewport }) {
|
|
207
|
+
const viewportRect = viewport.getBoundingClientRect()
|
|
208
|
+
|
|
209
|
+
for (const item of getMessageScrollerItems(content, spacer)) {
|
|
210
|
+
if (!item.dataset.messageId) continue
|
|
211
|
+
|
|
212
|
+
const rect = item.getBoundingClientRect()
|
|
213
|
+
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) return item
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Target scrollTop that aligns `element` to the viewport inset (content
|
|
221
|
+
* block padding respected).
|
|
222
|
+
*
|
|
223
|
+
* @param {Object} args
|
|
224
|
+
* @param {"start" | "center" | "end" | "nearest"} args.align
|
|
225
|
+
* @param {HTMLElement} args.element
|
|
226
|
+
* @param {number} args.scrollMargin
|
|
227
|
+
* @param {HTMLElement | null} args.spacer
|
|
228
|
+
* @param {HTMLElement} args.viewport
|
|
229
|
+
* @returns {number} the target scrollTop (the caller clamps)
|
|
230
|
+
*/
|
|
231
|
+
export function getElementScrollTop({ align, element, scrollMargin, spacer, viewport }) {
|
|
232
|
+
const elementTop = getElementTop(element, viewport)
|
|
233
|
+
const elementHeight = element.getBoundingClientRect().height
|
|
234
|
+
const contentPadding = getContentBlockPadding(spacer)
|
|
235
|
+
|
|
236
|
+
if (align === "center") {
|
|
237
|
+
const insetHeight = Math.max(
|
|
238
|
+
0,
|
|
239
|
+
viewport.clientHeight - contentPadding.start - contentPadding.end
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return elementTop - contentPadding.start - (insetHeight - elementHeight) / 2 - scrollMargin
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (align === "end") {
|
|
246
|
+
return elementTop - viewport.clientHeight + elementHeight + contentPadding.end + scrollMargin
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (align === "nearest") {
|
|
250
|
+
const elementBottom = elementTop + elementHeight
|
|
251
|
+
const viewportTop = viewport.scrollTop + contentPadding.start
|
|
252
|
+
const viewportBottom = viewport.scrollTop + viewport.clientHeight - contentPadding.end
|
|
253
|
+
|
|
254
|
+
if (elementTop >= viewportTop && elementBottom <= viewportBottom) {
|
|
255
|
+
return viewport.scrollTop
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (elementTop < viewportTop) {
|
|
259
|
+
return elementTop - contentPadding.start - scrollMargin
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return elementBottom - viewport.clientHeight + contentPadding.end + scrollMargin
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return elementTop - contentPadding.start - scrollMargin
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `element`'s top in the viewport's content space (independent of the
|
|
270
|
+
* current scroll position).
|
|
271
|
+
*
|
|
272
|
+
* @param {HTMLElement} element
|
|
273
|
+
* @param {HTMLElement} viewport
|
|
274
|
+
* @returns {number} px
|
|
275
|
+
*/
|
|
276
|
+
export function getElementTop(element, viewport) {
|
|
277
|
+
const elementRect = element.getBoundingClientRect()
|
|
278
|
+
const viewportRect = viewport.getBoundingClientRect()
|
|
279
|
+
|
|
280
|
+
return elementRect.top - viewportRect.top + viewport.scrollTop
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `element`'s top relative to the viewport's current visual top.
|
|
285
|
+
*
|
|
286
|
+
* @param {HTMLElement} element
|
|
287
|
+
* @param {HTMLElement} viewport
|
|
288
|
+
* @returns {number} px
|
|
289
|
+
*/
|
|
290
|
+
export function getElementViewportTop(element, viewport) {
|
|
291
|
+
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Scroll room the tail spacer must fake below the last row so the
|
|
296
|
+
* requested scrollTop is reachable. Caller clamps/ceils (see the
|
|
297
|
+
* controller).
|
|
298
|
+
*
|
|
299
|
+
* @param {Object} args
|
|
300
|
+
* @param {HTMLElement} args.content
|
|
301
|
+
* @param {number} args.scrollTop - the position being made reachable
|
|
302
|
+
* @param {HTMLElement | null} args.spacer
|
|
303
|
+
* @param {HTMLElement} args.viewport
|
|
304
|
+
* @returns {number} px (possibly negative - the caller clamps)
|
|
305
|
+
*/
|
|
306
|
+
export function getTailSpacerHeight({ content, scrollTop, spacer, viewport }) {
|
|
307
|
+
const contentBottom = getContentBottom({ content, spacer, viewport })
|
|
308
|
+
|
|
309
|
+
return scrollTop + viewport.clientHeight - contentBottom
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Lowest row bottom in content space plus block padding, EXCLUDING the
|
|
314
|
+
* tail spacer - the spacer must never make the jump button appear.
|
|
315
|
+
*
|
|
316
|
+
* @param {Object} args
|
|
317
|
+
* @param {HTMLElement} args.content
|
|
318
|
+
* @param {HTMLElement | null} args.spacer
|
|
319
|
+
* @param {HTMLElement} args.viewport
|
|
320
|
+
* @returns {number} px
|
|
321
|
+
*/
|
|
322
|
+
export function getContentBottom({ content, spacer, viewport }) {
|
|
323
|
+
const items = getMessageScrollerItems(content, spacer)
|
|
324
|
+
const padding = getBlockPadding(content)
|
|
325
|
+
const viewportRect = viewport.getBoundingClientRect()
|
|
326
|
+
const scrollTop = viewport.scrollTop
|
|
327
|
+
let contentBottom = padding.start + padding.end
|
|
328
|
+
|
|
329
|
+
for (const item of items) {
|
|
330
|
+
const rect = item.getBoundingClientRect()
|
|
331
|
+
|
|
332
|
+
contentBottom = Math.max(
|
|
333
|
+
contentBottom,
|
|
334
|
+
rect.bottom - viewportRect.top + scrollTop + padding.end
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return contentBottom
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The viewport's maximum scrollTop (never negative).
|
|
343
|
+
*
|
|
344
|
+
* @param {HTMLElement} viewport
|
|
345
|
+
* @returns {number} px
|
|
346
|
+
*/
|
|
347
|
+
export function getMaxScrollTop(viewport) {
|
|
348
|
+
return Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function getBlockPadding(element) {
|
|
352
|
+
const style = window.getComputedStyle(element)
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
end: readCssPixel(style.paddingBlockEnd || style.paddingBottom),
|
|
356
|
+
start: readCssPixel(style.paddingBlockStart || style.paddingTop)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Block padding of the spacer's parent (the content element); zeros
|
|
362
|
+
* without a spacer.
|
|
363
|
+
*
|
|
364
|
+
* @param {HTMLElement | null} spacer
|
|
365
|
+
* @returns {{ start: number, end: number }} px
|
|
366
|
+
*/
|
|
367
|
+
export function getContentBlockPadding(spacer) {
|
|
368
|
+
const content = spacer?.parentElement
|
|
369
|
+
if (!content) return { end: 0, start: 0 }
|
|
370
|
+
|
|
371
|
+
return getBlockPadding(content)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The element's effective row gap (0 for "normal" or no element).
|
|
376
|
+
*
|
|
377
|
+
* @param {HTMLElement | null} element
|
|
378
|
+
* @returns {number} px
|
|
379
|
+
*/
|
|
380
|
+
export function getFlexGap(element) {
|
|
381
|
+
if (!element) return 0
|
|
382
|
+
|
|
383
|
+
const style = window.getComputedStyle(element)
|
|
384
|
+
const gap = style.rowGap === "normal" ? style.gap : style.rowGap
|
|
385
|
+
|
|
386
|
+
return readCssPixel(gap)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function readCssPixel(value) {
|
|
390
|
+
if (!value) return 0
|
|
391
|
+
|
|
392
|
+
const number = Number.parseFloat(value)
|
|
393
|
+
|
|
394
|
+
return Number.isFinite(number) ? number : 0
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Value equality for two scrollable snapshots.
|
|
399
|
+
*
|
|
400
|
+
* @param {{ start: boolean, end: boolean }} current
|
|
401
|
+
* @param {{ start: boolean, end: boolean }} next
|
|
402
|
+
* @returns {boolean}
|
|
403
|
+
*/
|
|
404
|
+
export function areScrollStatesEqual(current, next) {
|
|
405
|
+
return current.start === next.start && current.end === next.end
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Value equality for two visibility snapshots (anchor + ordered ids).
|
|
410
|
+
*
|
|
411
|
+
* @param {{ currentAnchorId: string | null, visibleMessageIds: string[] }} current
|
|
412
|
+
* @param {{ currentAnchorId: string | null, visibleMessageIds: string[] }} next
|
|
413
|
+
* @returns {boolean}
|
|
414
|
+
*/
|
|
415
|
+
export function areVisibilityStatesEqual(current, next) {
|
|
416
|
+
if (current.currentAnchorId !== next.currentAnchorId) return false
|
|
417
|
+
if (current.visibleMessageIds.length !== next.visibleMessageIds.length) return false
|
|
418
|
+
|
|
419
|
+
return current.visibleMessageIds.every(
|
|
420
|
+
(messageId, index) => messageId === next.visibleMessageIds[index]
|
|
421
|
+
)
|
|
422
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The controllable-state convention (the DOM is the store): runtime
|
|
2
|
+
// state is a set of presence-boolean data attributes - written here,
|
|
3
|
+
// styled by CSS variants (data-open:..., via the vendored bridge
|
|
4
|
+
// variants that also match the older data-state="open|closed" value
|
|
5
|
+
// form), owned by whichever layer set it (a Stimulus Value default, the
|
|
6
|
+
// DOM, the URL, or a server re-render).
|
|
7
|
+
//
|
|
8
|
+
// Keys are PAIRS (or triples): setting one member writes its attribute
|
|
9
|
+
// and removes its counterparts. The negative popup/panel/pressed/selected
|
|
10
|
+
// keys only remove, deliberately: there is no data-popup-closed -
|
|
11
|
+
// absence IS the state.
|
|
12
|
+
/** Vocabulary key -> its writes: { add: attribute | null, remove: [attributes] }. */
|
|
13
|
+
export const VOCABULARY = {
|
|
14
|
+
open: { add: "data-open", remove: ["data-closed"] },
|
|
15
|
+
closed: { add: "data-closed", remove: ["data-open"] },
|
|
16
|
+
"popup-open": { add: "data-popup-open", remove: [] },
|
|
17
|
+
"popup-closed": { add: null, remove: ["data-popup-open"] },
|
|
18
|
+
"panel-open": { add: "data-panel-open", remove: [] },
|
|
19
|
+
"panel-closed": { add: null, remove: ["data-panel-open"] },
|
|
20
|
+
checked: { add: "data-checked", remove: ["data-unchecked", "data-indeterminate"] },
|
|
21
|
+
unchecked: { add: "data-unchecked", remove: ["data-checked", "data-indeterminate"] },
|
|
22
|
+
indeterminate: { add: "data-indeterminate", remove: ["data-checked", "data-unchecked"] },
|
|
23
|
+
pressed: { add: "data-pressed", remove: [] },
|
|
24
|
+
active: { add: "data-active", remove: [] },
|
|
25
|
+
inactive: { add: null, remove: ["data-active"] },
|
|
26
|
+
unpressed: { add: null, remove: ["data-pressed"] },
|
|
27
|
+
selected: { add: "data-selected", remove: [] },
|
|
28
|
+
unselected: { add: null, remove: ["data-selected"] }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Derivation order: the open/closed pair first (the common ask), then the
|
|
32
|
+
// checked family, then the bare booleans. Attribute-only "closed" states
|
|
33
|
+
// (popup/panel/pressed/selected negatives) derive as undefined - callers
|
|
34
|
+
// that care test the attribute directly (hasAttribute("data-popup-open")).
|
|
35
|
+
const DERIVATION = [
|
|
36
|
+
["data-open", "open"], ["data-closed", "closed"],
|
|
37
|
+
["data-checked", "checked"], ["data-unchecked", "unchecked"],
|
|
38
|
+
["data-indeterminate", "indeterminate"],
|
|
39
|
+
["data-popup-open", "popup-open"], ["data-panel-open", "panel-open"],
|
|
40
|
+
["data-pressed", "pressed"], ["data-selected", "selected"], ["data-active", "active"]
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The vocabulary key `element` currently wears, per the derivation order
|
|
45
|
+
* above. Attribute-only negative states (the popup/panel/pressed/selected
|
|
46
|
+
* negatives) have no positive attribute to find and derive as undefined -
|
|
47
|
+
* callers that care test the attribute directly.
|
|
48
|
+
*
|
|
49
|
+
* @param {Element} element
|
|
50
|
+
* @returns {string | undefined}
|
|
51
|
+
*/
|
|
52
|
+
export function stateOf(element) {
|
|
53
|
+
for (const [attribute, key] of DERIVATION) {
|
|
54
|
+
if (element.hasAttribute(attribute)) return key
|
|
55
|
+
}
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Writes vocabulary key `key` onto `element`: sets its add-attribute (when
|
|
61
|
+
* the key has one), removes its counterparts, and announces the flip as a
|
|
62
|
+
* bubbling `poetry:state-change` CustomEvent carrying `{ state: key }`.
|
|
63
|
+
*
|
|
64
|
+
* @param {Element} element
|
|
65
|
+
* @param {string} key - a {@link VOCABULARY} key
|
|
66
|
+
* @returns {string} the key that was written
|
|
67
|
+
* @throws {Error} on a key outside the vocabulary
|
|
68
|
+
*/
|
|
69
|
+
export function setState(element, key) {
|
|
70
|
+
const writes = VOCABULARY[key]
|
|
71
|
+
if (!writes) throw new Error(`poetry state: unknown vocabulary key ${key}`)
|
|
72
|
+
|
|
73
|
+
if (writes.add) element.setAttribute(writes.add, "")
|
|
74
|
+
for (const attribute of writes.remove) element.removeAttribute(attribute)
|
|
75
|
+
|
|
76
|
+
element.dispatchEvent(
|
|
77
|
+
new CustomEvent("poetry:state-change", { detail: { state: key }, bubbles: true })
|
|
78
|
+
)
|
|
79
|
+
return key
|
|
80
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Tabbable-candidate walk: the shared filter behind focus-scope and
|
|
2
|
+
// the Dialog trap. Candidates in DOM order, minus disabled / hidden /
|
|
3
|
+
// tabindex=-1 / inert-subtree elements.
|
|
4
|
+
|
|
5
|
+
const CANDIDATE_SELECTOR = [
|
|
6
|
+
"a[href]",
|
|
7
|
+
"button",
|
|
8
|
+
"input",
|
|
9
|
+
"select",
|
|
10
|
+
"textarea",
|
|
11
|
+
"[tabindex]",
|
|
12
|
+
'[contenteditable="true"]',
|
|
13
|
+
"audio[controls]",
|
|
14
|
+
"video[controls]"
|
|
15
|
+
].join(", ")
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The tabbable elements under `container`, in DOM order: candidate
|
|
19
|
+
* tags/tabindexes minus disabled / hidden / tabindex=-1 / inert-subtree /
|
|
20
|
+
* type=hidden elements, with each radio group collapsed to its single
|
|
21
|
+
* real tab stop (see below).
|
|
22
|
+
*
|
|
23
|
+
* @param {Element} container
|
|
24
|
+
* @returns {Element[]}
|
|
25
|
+
*/
|
|
26
|
+
export function tabbableWithin(container) {
|
|
27
|
+
const candidates = Array.from(container.querySelectorAll(CANDIDATE_SELECTOR)).filter(
|
|
28
|
+
(element) =>
|
|
29
|
+
!element.disabled &&
|
|
30
|
+
!element.hidden &&
|
|
31
|
+
element.getAttribute("tabindex") !== "-1" &&
|
|
32
|
+
element.closest("[inert]") === null &&
|
|
33
|
+
element.type !== "hidden"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return candidates.filter((element) => isRadioTabStop(element, candidates))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A radio GROUP is one tab stop, not one per radio - the platform's own
|
|
40
|
+
// Tab rule: the checked radio represents the group; an all-unchecked group is
|
|
41
|
+
// represented by its first radio. Without this, a dialog trap treats every
|
|
42
|
+
// radio as an edge candidate and Shift+Tab at the "first" tabbable is
|
|
43
|
+
// wrong whenever a radio group sits at either end of the scope.
|
|
44
|
+
function isRadioTabStop(element, candidates) {
|
|
45
|
+
if (element.type !== "radio" || !element.name) return true
|
|
46
|
+
|
|
47
|
+
const group = candidates.filter(
|
|
48
|
+
(candidate) =>
|
|
49
|
+
candidate.type === "radio" &&
|
|
50
|
+
candidate.name === element.name &&
|
|
51
|
+
candidate.form === element.form
|
|
52
|
+
)
|
|
53
|
+
const checked = group.find((candidate) => candidate.checked)
|
|
54
|
+
|
|
55
|
+
return checked ? element === checked : element === group[0]
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Turbo snapshots the page BEFORE caching it (turbo:before-cache), and an
|
|
2
|
+
// overlay still open at that moment serializes INTO the snapshot: the
|
|
3
|
+
// restoration visit then renders a de-modalized zombie dialog (the open
|
|
4
|
+
// attribute survives HTML serialization, top-layer/modal state does not)
|
|
5
|
+
// over a body whose inline scroll-lock / pointer-events-scrim styles came
|
|
6
|
+
// back frozen - a page that can never scroll, or never be clicked, again
|
|
7
|
+
// (both reproduced live against the docs app). Overlay controllers
|
|
8
|
+
// subscribe their synchronous teardown here; no-op when Turbo is absent.
|
|
9
|
+
/**
|
|
10
|
+
* Subscribes `callback` to Turbo's before-cache moment - the last
|
|
11
|
+
* synchronous chance to tear an overlay down before the snapshot is
|
|
12
|
+
* taken. A page without Turbo simply never fires it.
|
|
13
|
+
*
|
|
14
|
+
* @param {(event: Event) => void} callback - must finish synchronously;
|
|
15
|
+
* the snapshot is taken right after the event dispatch completes
|
|
16
|
+
* @returns {() => void} unsubscribe
|
|
17
|
+
*/
|
|
18
|
+
export function onBeforeCache(callback) {
|
|
19
|
+
document.addEventListener("turbo:before-cache", callback)
|
|
20
|
+
return () => document.removeEventListener("turbo:before-cache", callback)
|
|
21
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The APG typeahead buffer, shared: printable keys accumulate into a
|
|
2
|
+
// search buffer that resets after a timeout (1s default), matching wraps
|
|
3
|
+
// from the current item, and a repeated same-letter buffer cycles matches.
|
|
4
|
+
// Extracted VERBATIM from menu_controller.js so the menu family and the
|
|
5
|
+
// Select listbox run the identical algorithm - the buffer/timer state
|
|
6
|
+
// lives in the instance this factory returns, one per consuming
|
|
7
|
+
// controller.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The label an item types against: data-text-value overrides textContent
|
|
11
|
+
* (icon-rich content declares its typed label explicitly).
|
|
12
|
+
*
|
|
13
|
+
* @param {Element} item
|
|
14
|
+
* @returns {string} the trimmed label ("" when neither source exists)
|
|
15
|
+
*/
|
|
16
|
+
export function typeaheadLabel(item) {
|
|
17
|
+
return (item.dataset.textValue ?? item.textContent ?? "").trim()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One typeahead instance for a consuming controller - the buffer/timer
|
|
22
|
+
* state lives in the returned object (the module header holds the
|
|
23
|
+
* algorithm).
|
|
24
|
+
*
|
|
25
|
+
* @returns {{ pending: () => boolean, reset: () => void,
|
|
26
|
+
* search: (key: string, items: Element[], options?: Object) => Element | null }}
|
|
27
|
+
*/
|
|
28
|
+
export function createTypeahead() {
|
|
29
|
+
let buffer = ""
|
|
30
|
+
let timer = null
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
/**
|
|
34
|
+
* A live buffer means Space extends the search instead of activating.
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
pending() {
|
|
38
|
+
return buffer !== ""
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/** Clears the buffer and cancels its reset timer. */
|
|
42
|
+
reset() {
|
|
43
|
+
window.clearTimeout(timer)
|
|
44
|
+
timer = null
|
|
45
|
+
buffer = ""
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The match walk: a repeated same-letter buffer cycles matches;
|
|
50
|
+
* a growing buffer keeps the current item first so continued typing
|
|
51
|
+
* stays put while it still matches; single-letter search excludes the
|
|
52
|
+
* current item so it always advances. The consumer decides what a
|
|
53
|
+
* match means (menus focus it; a closed Select trigger commits it).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} key - the printable key just typed
|
|
56
|
+
* @param {Element[]} items - ENABLED items only (disabled filtering
|
|
57
|
+
* is the consumer's collection contract)
|
|
58
|
+
* @param {Object} [options]
|
|
59
|
+
* @param {Element | null} [options.active=null] - the item the walk
|
|
60
|
+
* starts from
|
|
61
|
+
* @param {number} [options.timeout=1000] - ms before the buffer resets
|
|
62
|
+
* @param {(item: Element) => string} [options.labelOf=typeaheadLabel]
|
|
63
|
+
* @returns {Element | null} the matched item
|
|
64
|
+
*/
|
|
65
|
+
search(key, items, { active = null, timeout = 1000, labelOf = typeaheadLabel } = {}) {
|
|
66
|
+
window.clearTimeout(timer)
|
|
67
|
+
timer = window.setTimeout(() => { buffer = "" }, timeout)
|
|
68
|
+
buffer += key
|
|
69
|
+
|
|
70
|
+
if (items.length === 0) return null
|
|
71
|
+
|
|
72
|
+
const repeated = buffer.length > 1 && Array.from(buffer).every((char) => char === buffer[0])
|
|
73
|
+
const search = (repeated ? buffer[0] : buffer).toLowerCase()
|
|
74
|
+
const currentIndex = Math.max(items.indexOf(active), 0)
|
|
75
|
+
|
|
76
|
+
let ordered = items.map((_, offset) => (currentIndex + offset) % items.length)
|
|
77
|
+
|
|
78
|
+
if (search.length === 1) ordered = ordered.filter((index) => items[index] !== active)
|
|
79
|
+
|
|
80
|
+
const match = ordered.find((index) => labelOf(items[index]).toLowerCase().startsWith(search))
|
|
81
|
+
|
|
82
|
+
return match === undefined ? null : items[match]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|