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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,65 @@
1
+ // The composed-DOM duplicate-id tripwire: the ONLY
2
+ // check that sees the page as actually composed - static lints can't see
3
+ // across templates, frames, streams, or cached fragments, so this scans
4
+ // the live document for duplicate [id] values after every composition
5
+ // event and reports what it finds. Development tooling: install via
6
+ // poetry_id_integrity_script (dev layouts); never wired in production.
7
+ //
8
+ // Duplicate ids are always a bug regardless of source (ARIA IDREFs
9
+ // resolve to the first match only), and under StableId they are the
10
+ // signature of the two documented hazards: the same key rendered twice,
11
+ // or sequence-mode collisions across frames/cached fragments.
12
+
13
+ /**
14
+ * Scans the live (composed) document for duplicate [id] values.
15
+ *
16
+ * @param {ParentNode} [root=document]
17
+ * @returns {string[]} the duplicated ids, each listed once ("" ignored)
18
+ */
19
+ export function scanForDuplicateIds(root = document) {
20
+ const seen = new Set()
21
+ const dups = new Set()
22
+ root.querySelectorAll("[id]").forEach((el) => {
23
+ if (el.id === "") return
24
+ if (seen.has(el.id)) dups.add(el.id)
25
+ seen.add(el.id)
26
+ })
27
+ return [...dups]
28
+ }
29
+
30
+ /**
31
+ * Installs the scanner on the page's composition events (initial load,
32
+ * Turbo loads, frame loads, morphs, stream insertions - each checked one
33
+ * frame after the event so the DOM has settled).
34
+ *
35
+ * @param {Object} [options]
36
+ * @param {(dups: string[]) => void} [options.report] - overrides the
37
+ * console warning
38
+ * @returns {() => string[]} the scan trigger, for manual use
39
+ */
40
+ export function installPoetryIdIntegrityCheck({ report } = {}) {
41
+ const notify = report || ((dups) => {
42
+ console.warn(
43
+ `[poetry] duplicate DOM ids in the composed page: ${dups.join(", ")} - ` +
44
+ "ARIA references resolve to the first match only. Same key: twice, " +
45
+ "or sequence-mode ids colliding across frames/cached fragments?"
46
+ )
47
+ })
48
+ const scan = () => {
49
+ const dups = scanForDuplicateIds()
50
+ if (dups.length > 0) notify(dups)
51
+ return dups
52
+ }
53
+ const settle = () => requestAnimationFrame(scan)
54
+
55
+ if (document.readyState === "loading") {
56
+ document.addEventListener("DOMContentLoaded", settle, { once: true })
57
+ } else {
58
+ settle()
59
+ }
60
+ ;["turbo:load", "turbo:frame-load", "turbo:morph", "turbo:before-stream-render"].forEach((event) => {
61
+ document.addEventListener(event, settle)
62
+ })
63
+
64
+ return scan
65
+ }
@@ -0,0 +1,328 @@
1
+ // The nullable-segment date/time value (the IncompleteDate model, adapted
2
+ // from an Apache-2.0-licensed source - source and license in
3
+ // THIRD_PARTY_NOTICES.md): segments
4
+ // are stored RAW so a user can edit day before
5
+ // month - the object can hold February 31st and only commit constrains.
6
+ // Hour is stored in the LOCALE'S HOUR CYCLE with a separate dayPeriod bit
7
+ // (0 = AM, 1 = PM), so am/pm edits are independent and "12 means 0" lives
8
+ // in exactly one place. Gregorian-only by design (the calendar seam is
9
+ // clean if that ever changes - limits() is the only calendar knowledge).
10
+
11
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
12
+
13
+ /** PageUp/PageDown step per segment type. */
14
+ export const PAGE_STEP = { year: 5, month: 2, day: 7, hour: 2, minute: 15, second: 15 }
15
+
16
+ /**
17
+ * Days in a Gregorian month, leap Februaries included.
18
+ *
19
+ * @param {number} year
20
+ * @param {number} month - 1-12 (out-of-range months report 31)
21
+ * @returns {number}
22
+ */
23
+ export function daysInMonth(year, month) {
24
+ if (month === 2 && year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) return 29
25
+
26
+ return DAYS_IN_MONTH[month - 1] ?? 31
27
+ }
28
+
29
+ function pad(number, width = 2) {
30
+ return String(number).padStart(width, "0")
31
+ }
32
+
33
+ /**
34
+ * A date/time value whose segments may each be null (the module header
35
+ * holds the storage rules). One instance backs one date-field.
36
+ */
37
+ export class IncompleteDate {
38
+ /**
39
+ * @param {"h11" | "h12" | "h23" | "h24"} [hourCycle="h23"] - the
40
+ * field's RESOLVED cycle ({@link resolveHourCycle})
41
+ */
42
+ constructor(hourCycle = "h23") {
43
+ this.hourCycle = hourCycle
44
+ this.year = null
45
+ this.month = null
46
+ this.day = null
47
+ this.hour = null // stored in the display cycle, NOT h23
48
+ this.minute = null
49
+ this.second = null
50
+ this.dayPeriod = null // 0 | 1 | null; only meaningful under h11/h12
51
+ }
52
+
53
+ /** @returns {boolean} whether the cycle carries a dayPeriod (h11/h12) */
54
+ get twelveHour() {
55
+ return this.hourCycle === "h11" || this.hourCycle === "h12"
56
+ }
57
+
58
+ /**
59
+ * Segment limits in DISPLAY terms. Day deliberately allows the calendar
60
+ * maximum (31) regardless of the month segment - editing order must not
61
+ * trap the user; constrain() clamps at commit.
62
+ *
63
+ * @param {string} type - "year" | "month" | "day" | "hour" | "minute" |
64
+ * "second" | "dayPeriod"
65
+ * @returns {{ min: number, max: number }}
66
+ */
67
+ limits(type) {
68
+ switch (type) {
69
+ case "year": return { min: 1, max: 9999 }
70
+ case "month": return { min: 1, max: 12 }
71
+ case "day": return { min: 1, max: 31 }
72
+ case "minute":
73
+ case "second": return { min: 0, max: 59 }
74
+ case "dayPeriod": return { min: 0, max: 1 }
75
+ case "hour":
76
+ switch (this.hourCycle) {
77
+ case "h11": return { min: 0, max: 11 }
78
+ case "h12": return { min: 1, max: 12 }
79
+ case "h24": return { min: 1, max: 24 }
80
+ default: return { min: 0, max: 23 }
81
+ }
82
+ default: return { min: 0, max: 0 }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Arrow/page steps a segment. The first press on an EMPTY segment lands
88
+ * on the placeholder value, the second one moves it (the cycle
89
+ * contract); values wrap within the segment's limits (the spinbutton
90
+ * contract).
91
+ *
92
+ * @param {string} type - the segment
93
+ * @param {number} amount - signed step
94
+ * @param {number} placeholderValue - what an empty segment lands on
95
+ * @param {Object} [options]
96
+ * @param {boolean} [options.round=false] - PageUp/Down snap to the next
97
+ * multiple of amount instead of adding it
98
+ */
99
+ cycle(type, amount, placeholderValue, { round = false } = {}) {
100
+ const { min, max } = this.limits(type)
101
+ const current = this[type]
102
+
103
+ if (current === null) {
104
+ this[type] = clampToLimits(placeholderValue, min, max)
105
+ return
106
+ }
107
+
108
+ if (type === "dayPeriod") {
109
+ this.dayPeriod = current === 0 ? 1 : 0
110
+ return
111
+ }
112
+
113
+ let next
114
+
115
+ if (round) {
116
+ const step = Math.abs(amount)
117
+
118
+ next = amount > 0
119
+ ? Math.floor(current / step) * step + step
120
+ : Math.ceil(current / step) * step - step
121
+ } else {
122
+ next = current + amount
123
+ }
124
+
125
+ // Wrap (the spinbutton contract), keeping the span inclusive.
126
+ const span = max - min + 1
127
+ next = ((next - min) % span + span) % span + min
128
+
129
+ this[type] = next
130
+ }
131
+
132
+ /**
133
+ * Sets a segment, clamped to its limits.
134
+ *
135
+ * @param {string} type
136
+ * @param {number | null} value - null clears
137
+ */
138
+ set(type, value) {
139
+ const { min, max } = this.limits(type)
140
+
141
+ this[type] = value === null ? null : clampToLimits(value, min, max)
142
+ }
143
+
144
+ /**
145
+ * Empties a segment.
146
+ * @param {string} type
147
+ */
148
+ clear(type) {
149
+ this[type] = null
150
+ }
151
+
152
+ /**
153
+ * Whether every listed segment holds a value.
154
+ *
155
+ * @param {string[]} types
156
+ * @returns {boolean}
157
+ */
158
+ isComplete(types) {
159
+ return types.every((type) => this[type] !== null)
160
+ }
161
+
162
+ /**
163
+ * A complete date can still be invalid (February 31st): valid means the
164
+ * day exists in the month.
165
+ *
166
+ * @returns {boolean} false while year/month/day are incomplete
167
+ */
168
+ isValidDate() {
169
+ if (this.year === null || this.month === null || this.day === null) return false
170
+
171
+ return this.day <= daysInMonth(this.year, this.month)
172
+ }
173
+
174
+ /**
175
+ * Commit-time clamp (blur): the raw day is pulled into the real month.
176
+ *
177
+ * @param {string[]} types - the field's segments; day is only clamped
178
+ * when listed
179
+ */
180
+ constrain(types) {
181
+ if (types.includes("day") && this.year !== null && this.month !== null && this.day !== null) {
182
+ this.day = Math.min(this.day, daysInMonth(this.year, this.month))
183
+ }
184
+ }
185
+
186
+ // --- hour-cycle conversion (the ONE place "12 means 0" lives) ---
187
+
188
+ /**
189
+ * The stored hour converted to h23. Under h11/h12 an unset dayPeriod
190
+ * reads as AM.
191
+ *
192
+ * @returns {number | null} null while the hour is unset
193
+ */
194
+ hourInH23() {
195
+ if (this.hour === null) return null
196
+
197
+ switch (this.hourCycle) {
198
+ case "h11":
199
+ return this.hour + (this.dayPeriod === 1 ? 12 : 0)
200
+ case "h12": {
201
+ const base = this.hour % 12
202
+ return base + (this.dayPeriod === 1 ? 12 : 0)
203
+ }
204
+ case "h24":
205
+ return this.hour % 24
206
+ default:
207
+ return this.hour
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Sets hour (and dayPeriod under h11/h12) from an h23 hour.
213
+ * @param {number} h23 - 0-23
214
+ */
215
+ setFromH23(h23) {
216
+ switch (this.hourCycle) {
217
+ case "h11":
218
+ this.hour = h23 % 12
219
+ this.dayPeriod = h23 < 12 ? 0 : 1
220
+ break
221
+ case "h12":
222
+ this.hour = h23 % 12 === 0 ? 12 : h23 % 12
223
+ this.dayPeriod = h23 < 12 ? 0 : 1
224
+ break
225
+ case "h24":
226
+ this.hour = h23 === 0 ? 24 : h23
227
+ break
228
+ default:
229
+ this.hour = h23
230
+ }
231
+ }
232
+
233
+ // --- ISO serialization (the native input's value format) ---
234
+
235
+ /** @returns {string | null} "YYYY-MM-DD", or null while incomplete */
236
+ toISODate() {
237
+ if (!this.isComplete(["year", "month", "day"])) return null
238
+
239
+ return `${pad(this.year, 4)}-${pad(this.month)}-${pad(this.day)}`
240
+ }
241
+
242
+ /**
243
+ * @param {boolean} [withSeconds=false]
244
+ * @returns {string | null} "HH:MM" or "HH:MM:SS" (h23 on the wire), or
245
+ * null while incomplete (a twelve-hour cycle needs dayPeriod too)
246
+ */
247
+ toISOTime(withSeconds = false) {
248
+ const types = withSeconds ? ["hour", "minute", "second"] : ["hour", "minute"]
249
+
250
+ if (this.twelveHour && this.dayPeriod === null) return null
251
+ if (!this.isComplete(types)) return null
252
+
253
+ const h23 = this.hourInH23()
254
+ const base = `${pad(h23)}:${pad(this.minute)}`
255
+
256
+ return withSeconds ? `${base}:${pad(this.second)}` : base
257
+ }
258
+
259
+ /**
260
+ * Fills year/month/day from "YYYY-MM-DD".
261
+ *
262
+ * @param {string | null} iso
263
+ * @returns {boolean} false (nothing touched) on a non-matching string
264
+ */
265
+ setFromISODate(iso) {
266
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? "")
267
+
268
+ if (!match) return false
269
+
270
+ this.year = Number(match[1])
271
+ this.month = Number(match[2])
272
+ this.day = Number(match[3])
273
+ return true
274
+ }
275
+
276
+ /**
277
+ * Fills hour/minute(/second) from "HH:MM(:SS)" (h23 on the wire).
278
+ *
279
+ * @param {string | null} iso
280
+ * @returns {boolean} false (nothing touched) on a non-matching string
281
+ */
282
+ setFromISOTime(iso) {
283
+ const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(iso ?? "")
284
+
285
+ if (!match) return false
286
+
287
+ this.setFromH23(Number(match[1]))
288
+ this.minute = Number(match[2])
289
+ this.second = match[3] === undefined ? null : Number(match[3])
290
+ return true
291
+ }
292
+ }
293
+
294
+ function clampToLimits(value, min, max) {
295
+ return Math.min(max, Math.max(min, value))
296
+ }
297
+
298
+ /**
299
+ * The resolved hour cycle for a locale, with two known Intl bug
300
+ * detections built in: Chrome resolves `hour12: false` to the buggy h24
301
+ * per the ECMA-402 spec bug, and WebKit misreports
302
+ * resolvedOptions().hourCycle in some locales - so the cycle is INFERRED
303
+ * by formatting hour 0 and hour 23 and reading what comes out.
304
+ *
305
+ * @param {string | undefined} locale
306
+ * @param {"h11" | "h12" | "h23" | "h24" | null} [override=null] - wins
307
+ * outright when present
308
+ * @returns {"h11" | "h12" | "h23" | "h24"}
309
+ */
310
+ export function resolveHourCycle(locale, override = null) {
311
+ if (override) return override
312
+
313
+ const formatter = new Intl.DateTimeFormat(locale, { hour: "numeric" })
314
+ const hourAt = (hour) => {
315
+ const parts = formatter.formatToParts(new Date(2020, 0, 1, hour))
316
+
317
+ return {
318
+ value: Number(parts.find((part) => part.type === "hour")?.value ?? NaN),
319
+ dayPeriod: parts.some((part) => part.type === "dayPeriod")
320
+ }
321
+ }
322
+ const midnight = hourAt(0)
323
+ const evening = hourAt(23)
324
+
325
+ if (midnight.dayPeriod || evening.dayPeriod) return midnight.value === 0 ? "h11" : "h12"
326
+
327
+ return midnight.value === 0 || evening.value === 23 ? "h23" : "h24"
328
+ }
@@ -0,0 +1,283 @@
1
+ // The mask engine, adapted from an MIT-licensed source (source and
2
+ // license in THIRD_PARTY_NOTICES.md).
3
+ // Pure functions over a parsed slot list, zero DOM. A mask is a sequence of
4
+ // slots: token slots
5
+ // validate ONE character against a pattern, literal slots are fixed chrome
6
+ // ("/", "-", " ") the engine inserts and the user never types. Every value
7
+ // decision lives here so it stays exhaustively unit-testable; the caret
8
+ // math and events live in mask_controller.js.
9
+
10
+ /** The default token map: mask char -> the pattern one input char must match. */
11
+ export const DEFAULT_TOKENS = {
12
+ "9": /[0-9]/,
13
+ a: /[A-Za-z]/,
14
+ A: /[A-Z]/,
15
+ "*": /[A-Za-z0-9]/,
16
+ "#": /[-+0-9]/
17
+ }
18
+
19
+ /**
20
+ * Parses a mask into a slot list. String grammar: token chars from the
21
+ * (custom-over-default merged) token map, "\" escapes the next char to a
22
+ * literal, "?" is consumed and makes every LATER slot optional - the flag
23
+ * is STICKY, it never resets, so "(999) 999-9999? x9999" is complete
24
+ * without the extension. Array grammar: RegExp item = token slot, string
25
+ * item = literal.
26
+ *
27
+ * @param {string | Array<RegExp | string>} mask
28
+ * @param {Object<string, RegExp>} [tokens={}] - custom tokens, merged
29
+ * over {@link DEFAULT_TOKENS}
30
+ * @returns {Array<Object>} slots: { type: "token" | "literal", char,
31
+ * pattern?, optional? }
32
+ */
33
+ export function parseMask(mask, tokens = {}) {
34
+ if (Array.isArray(mask)) {
35
+ return mask.map((item) =>
36
+ item instanceof RegExp
37
+ ? { type: "token", char: "_", pattern: item }
38
+ : { type: "literal", char: item }
39
+ )
40
+ }
41
+
42
+ const map = { ...DEFAULT_TOKENS, ...tokens }
43
+ const slots = []
44
+ let optional = false
45
+
46
+ for (let i = 0; i < mask.length; i++) {
47
+ const char = mask[i]
48
+
49
+ if (char === "\\" && i + 1 < mask.length) {
50
+ i++
51
+ slots.push({ type: "literal", char: mask[i] })
52
+ continue
53
+ }
54
+
55
+ if (char === "?") {
56
+ optional = true
57
+ continue
58
+ }
59
+
60
+ if (map[char]) slots.push({ type: "token", char, pattern: map[char], optional })
61
+ else slots.push({ type: "literal", char })
62
+ }
63
+
64
+ return slots
65
+ }
66
+
67
+ /**
68
+ * Raw chars -> masked string. Literals append EAGERLY (raw "12" under
69
+ * "99/99" is "12/" - the separator paints the moment it is reachable); a
70
+ * token slot consumes the next raw char when it matches, else silently
71
+ * DROPS it and retries the SAME slot with the following char; transform
72
+ * (poetry's upcase knob) runs before validation.
73
+ *
74
+ * @param {string} raw
75
+ * @param {Array<Object>} slots - from {@link parseMask}
76
+ * @param {(char: string) => string} [transform]
77
+ * @returns {string} the masked value
78
+ */
79
+ export function applyMaskToRaw(raw, slots, transform) {
80
+ let result = ""
81
+ let rawIndex = 0
82
+
83
+ for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
84
+ const slot = slots[slotIndex]
85
+
86
+ if (slot.type === "literal") {
87
+ result += slot.char
88
+ continue
89
+ }
90
+
91
+ if (rawIndex >= raw.length) break
92
+
93
+ const char = transform ? transform(raw[rawIndex]) : raw[rawIndex]
94
+
95
+ if (slot.pattern.test(char)) {
96
+ result += char
97
+ rawIndex++
98
+ } else {
99
+ rawIndex++ // silent drop
100
+ slotIndex-- // retry the same slot
101
+ }
102
+ }
103
+
104
+ return result
105
+ }
106
+
107
+ /**
108
+ * Re-parses arbitrary display text (autofill, a server-rendered value,
109
+ * IME output): literals self-match or are inserted, token slots scan
110
+ * forward discarding non-matching chars, and the walk stops at the first
111
+ * slot the remaining text cannot fill.
112
+ *
113
+ * @param {string} text
114
+ * @param {Array<Object>} slots
115
+ * @returns {string} the re-masked value
116
+ */
117
+ export function processInput(text, slots) {
118
+ let result = ""
119
+ let index = 0
120
+
121
+ for (let slotIndex = 0; slotIndex < slots.length && index <= text.length; slotIndex++) {
122
+ const slot = slots[slotIndex]
123
+
124
+ if (slot.type === "literal") {
125
+ result += slot.char
126
+ if (index < text.length && text[index] === slot.char) index++
127
+ continue
128
+ }
129
+
130
+ if (index >= text.length) break
131
+
132
+ while (index < text.length) {
133
+ const char = text[index]
134
+
135
+ index++
136
+
137
+ if (slot.pattern.test(char)) {
138
+ result += char
139
+ break
140
+ }
141
+ }
142
+
143
+ if (result.length <= slotIndex) break // the slot went unfilled
144
+ }
145
+
146
+ return result
147
+ }
148
+
149
+ /**
150
+ * Chars at token positions - the value the form actually means.
151
+ *
152
+ * @param {string} masked
153
+ * @param {Array<Object>} slots
154
+ * @returns {string}
155
+ */
156
+ export function extractRaw(masked, slots) {
157
+ let raw = ""
158
+
159
+ for (let i = 0; i < masked.length && i < slots.length; i++) {
160
+ if (slots[i].type === "token") raw += masked[i]
161
+ }
162
+
163
+ return raw
164
+ }
165
+
166
+ /**
167
+ * Pads the masked value with the mask skeleton. slotChar "_" by default;
168
+ * a multi-char slotChar indexes per position ("dd/mm/yyyy" under a date
169
+ * mask) falling back to "_" past its end; null/"" disables padding (the
170
+ * display stops at the first empty token slot).
171
+ *
172
+ * @param {string} value - the masked value so far
173
+ * @param {Array<Object>} slots
174
+ * @param {string | null} [slotChar="_"]
175
+ * @param {boolean} [showSlots=true] - false returns `value` untouched
176
+ * @returns {string}
177
+ */
178
+ export function buildDisplayValue(value, slots, slotChar = "_", showSlots = true) {
179
+ if (!showSlots) return value
180
+
181
+ let display = value
182
+
183
+ for (let i = value.length; i < slots.length; i++) {
184
+ const slot = slots[i]
185
+
186
+ if (slot.type === "literal") {
187
+ display += slot.char
188
+ continue
189
+ }
190
+
191
+ const char = slotCharAt(slotChar, i)
192
+
193
+ if (!char) break
194
+
195
+ display += char
196
+ }
197
+
198
+ return display
199
+ }
200
+
201
+ function slotCharAt(slotChar, index) {
202
+ if (slotChar === null || slotChar === undefined || slotChar === "") return ""
203
+
204
+ return slotChar.length > 1 ? (slotChar[index] ?? "_") : slotChar
205
+ }
206
+
207
+ /**
208
+ * Complete = every non-optional token position is filled and
209
+ * pattern-valid.
210
+ *
211
+ * @param {string} masked
212
+ * @param {Array<Object>} slots
213
+ * @returns {boolean}
214
+ */
215
+ export function checkComplete(masked, slots) {
216
+ return slots.every((slot, i) =>
217
+ slot.type !== "token" || slot.optional || (i < masked.length && slot.pattern.test(masked[i]))
218
+ )
219
+ }
220
+
221
+ /**
222
+ * Regex source for the HTML pattern attribute: "full" wraps each token in
223
+ * a capture group, "full-inexact" doesn't; optional tokens get a trailing
224
+ * "?"; literals are regex-escaped.
225
+ *
226
+ * @param {Array<Object>} slots
227
+ * @param {"full" | "full-inexact"} [kind="full"]
228
+ * @returns {string}
229
+ */
230
+ export function generatePattern(slots, kind = "full") {
231
+ return slots.map((slot) => {
232
+ if (slot.type === "literal") return slot.char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
233
+
234
+ const source = kind === "full-inexact" ? slot.pattern.source : `(${slot.pattern.source})`
235
+
236
+ return slot.optional ? `${source}?` : source
237
+ }).join("")
238
+ }
239
+
240
+ /**
241
+ * Skips a literal run rightward from `pos`, bounded by the filled region
242
+ * - the caret never lands inside chrome or out in the skeleton.
243
+ *
244
+ * @param {number} pos
245
+ * @param {Array<Object>} slots
246
+ * @param {number} filledLength
247
+ * @returns {number}
248
+ */
249
+ export function findNextEditablePosition(pos, slots, filledLength) {
250
+ while (pos < slots.length && pos < filledLength && slots[pos].type === "literal") pos++
251
+
252
+ return pos
253
+ }
254
+
255
+ /**
256
+ * First token position at or after `from`.
257
+ *
258
+ * @param {Array<Object>} slots
259
+ * @param {number} [from=0]
260
+ * @returns {number} slots.length when none
261
+ */
262
+ export function nextTokenPosition(slots, from = 0) {
263
+ for (let i = from; i < slots.length; i++) {
264
+ if (slots[i].type === "token") return i
265
+ }
266
+
267
+ return slots.length
268
+ }
269
+
270
+ /**
271
+ * Last token position at or before `from`.
272
+ *
273
+ * @param {Array<Object>} slots
274
+ * @param {number} from
275
+ * @returns {number} -1 when none
276
+ */
277
+ export function prevTokenPosition(slots, from) {
278
+ for (let i = from; i >= 0; i--) {
279
+ if (slots[i].type === "token") return i
280
+ }
281
+
282
+ return -1
283
+ }