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,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
|
+
}
|