@petrarca/sonnet-core 0.5.0 → 0.6.0

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.
@@ -28,8 +28,18 @@ import React__default from 'react';
28
28
  */
29
29
  interface SonnetComponentDefaults {
30
30
  }
31
- /** Cross-cutting global settings. Extend via declaration merging as needed. */
31
+ /**
32
+ * Cross-cutting global settings. Extend via declaration merging as needed.
33
+ * Empty by default; packages add fields without editing this file.
34
+ */
32
35
  interface SonnetGlobalConfig {
36
+ /**
37
+ * BCP-47 locale (e.g. `"en-US"`, `"de-DE"`) used for display and input
38
+ * parsing of locale-sensitive components (dates, numbers). Values exchanged
39
+ * with consumers stay locale-independent (ISO). When unset, components fall
40
+ * back to the browser locale (`navigator.language`), then `"en"`.
41
+ */
42
+ locale?: string;
33
43
  }
34
44
  /** The full app-wide configuration object held by SonnetConfigProvider. */
35
45
  interface SonnetConfig {
@@ -75,4 +85,16 @@ declare const SonnetConfigContext: React.Context<SonnetConfig>;
75
85
  */
76
86
  declare function useComponentDefaults<Props extends Record<string, unknown>>(componentName: string, builtinDefaults: Partial<Props>, props: Props): Props;
77
87
 
78
- export { type SonnetComponentDefaults, type SonnetConfig, SonnetConfigContext, SonnetConfigProvider, type SonnetConfigProviderProps, type SonnetGlobalConfig, useComponentDefaults };
88
+ /**
89
+ * useLocale -- resolve the active BCP-47 locale.
90
+ *
91
+ * Resolution order: explicit override > provider global (SonnetConfigProvider)
92
+ * > browser (`navigator.language`) > `"en"`. Locale-sensitive components use
93
+ * this for display and input parsing only; values exchanged with consumers
94
+ * remain locale-independent (ISO).
95
+ */
96
+ /** The browser locale, or "en" when unavailable (e.g. SSR). */
97
+ declare function getBrowserLocale(): string;
98
+ declare function useLocale(override?: string): string;
99
+
100
+ export { type SonnetComponentDefaults, type SonnetConfig, SonnetConfigContext, SonnetConfigProvider, type SonnetConfigProviderProps, type SonnetGlobalConfig, getBrowserLocale, useComponentDefaults, useLocale };
@@ -55,9 +55,24 @@ function useComponentDefaults(componentName, builtinDefaults, props) {
55
55
  return resolved;
56
56
  }, [builtinDefaults, providerDefaults, props]);
57
57
  }
58
+
59
+ // src/config/useLocale.ts
60
+ import { useContext as useContext3 } from "react";
61
+ function getBrowserLocale() {
62
+ if (typeof navigator !== "undefined" && navigator.language) {
63
+ return navigator.language;
64
+ }
65
+ return "en";
66
+ }
67
+ function useLocale(override) {
68
+ const config = useContext3(SonnetConfigContext);
69
+ return override ?? config.globals?.locale ?? getBrowserLocale();
70
+ }
58
71
  export {
59
72
  SonnetConfigContext,
60
73
  SonnetConfigProvider,
61
- useComponentDefaults
74
+ getBrowserLocale,
75
+ useComponentDefaults,
76
+ useLocale
62
77
  };
63
78
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/config/SonnetConfigProvider.tsx","../../src/config/SonnetConfigContext.ts","../../src/config/useComponentDefaults.ts"],"sourcesContent":["/**\n * SonnetConfigProvider -- sets app-wide component defaults.\n *\n * Place once at the app root to define default props for components (and\n * cross-cutting globals). Individual instances still override via props.\n * Nested providers merge into their parent, so a subtree can refine or\n * override outer defaults.\n *\n * Resolution order for any prop (see useComponentDefaults):\n * instance prop > provider default > built-in default.\n */\n\nimport React, { useContext, useMemo } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\nimport type { SonnetConfig } from \"./types\";\n\nexport interface SonnetConfigProviderProps {\n /** The configuration to apply. Merged onto any parent provider's config. */\n value: SonnetConfig;\n children: React.ReactNode;\n}\n\n// Merge child config onto parent. Globals shallow-merge; component defaults\n// merge per component name, with each component's defaultProps shallow-merged\n// (child wins). One level of nesting is sufficient for the defaultProps shape.\nfunction mergeConfig(parent: SonnetConfig, child: SonnetConfig): SonnetConfig {\n const components: Record<string, { defaultProps?: Record<string, unknown> }> =\n {\n ...(parent.components as Record<\n string,\n { defaultProps?: Record<string, unknown> }\n >),\n };\n const childComponents = child.components as\n | Record<string, { defaultProps?: Record<string, unknown> }>\n | undefined;\n if (childComponents) {\n for (const name of Object.keys(childComponents)) {\n components[name] = {\n ...components[name],\n ...childComponents[name],\n defaultProps: {\n ...components[name]?.defaultProps,\n ...childComponents[name]?.defaultProps,\n },\n };\n }\n }\n return {\n globals: { ...parent.globals, ...child.globals },\n components: components as SonnetConfig[\"components\"],\n };\n}\n\nexport function SonnetConfigProvider({\n value,\n children,\n}: SonnetConfigProviderProps): React.ReactElement {\n const parent = useContext(SonnetConfigContext);\n const merged = useMemo(() => mergeConfig(parent, value), [parent, value]);\n return (\n <SonnetConfigContext.Provider value={merged}>\n {children}\n </SonnetConfigContext.Provider>\n );\n}\n","/**\n * React context holding the resolved app-wide SonnetConfig.\n *\n * Nested providers merge into their parent (see SonnetConfigProvider), so a\n * provider deeper in the tree can refine or override an outer one for its\n * subtree. The default is an empty config -- with no provider, components fall\n * back to their built-in defaults, preserving existing behavior.\n */\n\nimport { createContext } from \"react\";\nimport type { SonnetConfig } from \"./types\";\n\nexport const SonnetConfigContext = createContext<SonnetConfig>({});\n","/**\n * useComponentDefaults -- resolve a component's props against app-wide config.\n *\n * Resolution order, per prop: instance prop > provider default > built-in.\n * A component calls this at the top of its body:\n *\n * const resolved = useComponentDefaults(\"EntityTable\", BUILTIN, props);\n *\n * where BUILTIN is a stable (module-level) object of the component's built-in\n * defaults. With no provider in the tree, the result is BUILTIN merged with\n * props -- identical to not using the config at all, so existing behavior is\n * preserved.\n */\n\nimport { useContext, useMemo } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\n\nexport function useComponentDefaults<Props extends Record<string, unknown>>(\n componentName: string,\n builtinDefaults: Partial<Props>,\n props: Props,\n): Props {\n const config = useContext(SonnetConfigContext);\n const components = config.components as\n | Record<string, { defaultProps?: Partial<Props> } | undefined>\n | undefined;\n const providerDefaults = components?.[componentName]?.defaultProps;\n\n return useMemo(() => {\n // Spread order encodes precedence: builtin < provider < instance props.\n // Only undefined instance props fall through to provider/builtin, so an\n // explicit prop always wins.\n const resolved: Record<string, unknown> = {\n ...builtinDefaults,\n ...providerDefaults,\n };\n for (const key of Object.keys(props)) {\n if (props[key] !== undefined) resolved[key] = props[key];\n }\n return resolved as Props;\n }, [builtinDefaults, providerDefaults, props]);\n}\n"],"mappings":";AAYA,SAAgB,YAAY,eAAe;;;ACH3C,SAAS,qBAAqB;AAGvB,IAAM,sBAAsB,cAA4B,CAAC,CAAC;;;ADiD7D;AApCJ,SAAS,YAAY,QAAsB,OAAmC;AAC5E,QAAM,aACJ;AAAA,IACE,GAAI,OAAO;AAAA,EAIb;AACF,QAAM,kBAAkB,MAAM;AAG9B,MAAI,iBAAiB;AACnB,eAAW,QAAQ,OAAO,KAAK,eAAe,GAAG;AAC/C,iBAAW,IAAI,IAAI;AAAA,QACjB,GAAG,WAAW,IAAI;AAAA,QAClB,GAAG,gBAAgB,IAAI;AAAA,QACvB,cAAc;AAAA,UACZ,GAAG,WAAW,IAAI,GAAG;AAAA,UACrB,GAAG,gBAAgB,IAAI,GAAG;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AAAA,IACL,SAAS,EAAE,GAAG,OAAO,SAAS,GAAG,MAAM,QAAQ;AAAA,IAC/C;AAAA,EACF;AACF;AAEO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AACF,GAAkD;AAChD,QAAM,SAAS,WAAW,mBAAmB;AAC7C,QAAM,SAAS,QAAQ,MAAM,YAAY,QAAQ,KAAK,GAAG,CAAC,QAAQ,KAAK,CAAC;AACxE,SACE,oBAAC,oBAAoB,UAApB,EAA6B,OAAO,QAClC,UACH;AAEJ;;;AEnDA,SAAS,cAAAA,aAAY,WAAAC,gBAAe;AAG7B,SAAS,qBACd,eACA,iBACA,OACO;AACP,QAAM,SAASC,YAAW,mBAAmB;AAC7C,QAAM,aAAa,OAAO;AAG1B,QAAM,mBAAmB,aAAa,aAAa,GAAG;AAEtD,SAAOC,SAAQ,MAAM;AAInB,UAAM,WAAoC;AAAA,MACxC,GAAG;AAAA,MACH,GAAG;AAAA,IACL;AACA,eAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,UAAI,MAAM,GAAG,MAAM,OAAW,UAAS,GAAG,IAAI,MAAM,GAAG;AAAA,IACzD;AACA,WAAO;AAAA,EACT,GAAG,CAAC,iBAAiB,kBAAkB,KAAK,CAAC;AAC/C;","names":["useContext","useMemo","useContext","useMemo"]}
1
+ {"version":3,"sources":["../../src/config/SonnetConfigProvider.tsx","../../src/config/SonnetConfigContext.ts","../../src/config/useComponentDefaults.ts","../../src/config/useLocale.ts"],"sourcesContent":["/**\n * SonnetConfigProvider -- sets app-wide component defaults.\n *\n * Place once at the app root to define default props for components (and\n * cross-cutting globals). Individual instances still override via props.\n * Nested providers merge into their parent, so a subtree can refine or\n * override outer defaults.\n *\n * Resolution order for any prop (see useComponentDefaults):\n * instance prop > provider default > built-in default.\n */\n\nimport React, { useContext, useMemo } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\nimport type { SonnetConfig } from \"./types\";\n\nexport interface SonnetConfigProviderProps {\n /** The configuration to apply. Merged onto any parent provider's config. */\n value: SonnetConfig;\n children: React.ReactNode;\n}\n\n// Merge child config onto parent. Globals shallow-merge; component defaults\n// merge per component name, with each component's defaultProps shallow-merged\n// (child wins). One level of nesting is sufficient for the defaultProps shape.\nfunction mergeConfig(parent: SonnetConfig, child: SonnetConfig): SonnetConfig {\n const components: Record<string, { defaultProps?: Record<string, unknown> }> =\n {\n ...(parent.components as Record<\n string,\n { defaultProps?: Record<string, unknown> }\n >),\n };\n const childComponents = child.components as\n | Record<string, { defaultProps?: Record<string, unknown> }>\n | undefined;\n if (childComponents) {\n for (const name of Object.keys(childComponents)) {\n components[name] = {\n ...components[name],\n ...childComponents[name],\n defaultProps: {\n ...components[name]?.defaultProps,\n ...childComponents[name]?.defaultProps,\n },\n };\n }\n }\n return {\n globals: { ...parent.globals, ...child.globals },\n components: components as SonnetConfig[\"components\"],\n };\n}\n\nexport function SonnetConfigProvider({\n value,\n children,\n}: SonnetConfigProviderProps): React.ReactElement {\n const parent = useContext(SonnetConfigContext);\n const merged = useMemo(() => mergeConfig(parent, value), [parent, value]);\n return (\n <SonnetConfigContext.Provider value={merged}>\n {children}\n </SonnetConfigContext.Provider>\n );\n}\n","/**\n * React context holding the resolved app-wide SonnetConfig.\n *\n * Nested providers merge into their parent (see SonnetConfigProvider), so a\n * provider deeper in the tree can refine or override an outer one for its\n * subtree. The default is an empty config -- with no provider, components fall\n * back to their built-in defaults, preserving existing behavior.\n */\n\nimport { createContext } from \"react\";\nimport type { SonnetConfig } from \"./types\";\n\nexport const SonnetConfigContext = createContext<SonnetConfig>({});\n","/**\n * useComponentDefaults -- resolve a component's props against app-wide config.\n *\n * Resolution order, per prop: instance prop > provider default > built-in.\n * A component calls this at the top of its body:\n *\n * const resolved = useComponentDefaults(\"EntityTable\", BUILTIN, props);\n *\n * where BUILTIN is a stable (module-level) object of the component's built-in\n * defaults. With no provider in the tree, the result is BUILTIN merged with\n * props -- identical to not using the config at all, so existing behavior is\n * preserved.\n */\n\nimport { useContext, useMemo } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\n\nexport function useComponentDefaults<Props extends Record<string, unknown>>(\n componentName: string,\n builtinDefaults: Partial<Props>,\n props: Props,\n): Props {\n const config = useContext(SonnetConfigContext);\n const components = config.components as\n | Record<string, { defaultProps?: Partial<Props> } | undefined>\n | undefined;\n const providerDefaults = components?.[componentName]?.defaultProps;\n\n return useMemo(() => {\n // Spread order encodes precedence: builtin < provider < instance props.\n // Only undefined instance props fall through to provider/builtin, so an\n // explicit prop always wins.\n const resolved: Record<string, unknown> = {\n ...builtinDefaults,\n ...providerDefaults,\n };\n for (const key of Object.keys(props)) {\n if (props[key] !== undefined) resolved[key] = props[key];\n }\n return resolved as Props;\n }, [builtinDefaults, providerDefaults, props]);\n}\n","/**\n * useLocale -- resolve the active BCP-47 locale.\n *\n * Resolution order: explicit override > provider global (SonnetConfigProvider)\n * > browser (`navigator.language`) > `\"en\"`. Locale-sensitive components use\n * this for display and input parsing only; values exchanged with consumers\n * remain locale-independent (ISO).\n */\n\nimport { useContext } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\n\n/** The browser locale, or \"en\" when unavailable (e.g. SSR). */\nexport function getBrowserLocale(): string {\n if (typeof navigator !== \"undefined\" && navigator.language) {\n return navigator.language;\n }\n return \"en\";\n}\n\nexport function useLocale(override?: string): string {\n const config = useContext(SonnetConfigContext);\n return override ?? config.globals?.locale ?? getBrowserLocale();\n}\n"],"mappings":";AAYA,SAAgB,YAAY,eAAe;;;ACH3C,SAAS,qBAAqB;AAGvB,IAAM,sBAAsB,cAA4B,CAAC,CAAC;;;ADiD7D;AApCJ,SAAS,YAAY,QAAsB,OAAmC;AAC5E,QAAM,aACJ;AAAA,IACE,GAAI,OAAO;AAAA,EAIb;AACF,QAAM,kBAAkB,MAAM;AAG9B,MAAI,iBAAiB;AACnB,eAAW,QAAQ,OAAO,KAAK,eAAe,GAAG;AAC/C,iBAAW,IAAI,IAAI;AAAA,QACjB,GAAG,WAAW,IAAI;AAAA,QAClB,GAAG,gBAAgB,IAAI;AAAA,QACvB,cAAc;AAAA,UACZ,GAAG,WAAW,IAAI,GAAG;AAAA,UACrB,GAAG,gBAAgB,IAAI,GAAG;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AAAA,IACL,SAAS,EAAE,GAAG,OAAO,SAAS,GAAG,MAAM,QAAQ;AAAA,IAC/C;AAAA,EACF;AACF;AAEO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AACF,GAAkD;AAChD,QAAM,SAAS,WAAW,mBAAmB;AAC7C,QAAM,SAAS,QAAQ,MAAM,YAAY,QAAQ,KAAK,GAAG,CAAC,QAAQ,KAAK,CAAC;AACxE,SACE,oBAAC,oBAAoB,UAApB,EAA6B,OAAO,QAClC,UACH;AAEJ;;;AEnDA,SAAS,cAAAA,aAAY,WAAAC,gBAAe;AAG7B,SAAS,qBACd,eACA,iBACA,OACO;AACP,QAAM,SAASC,YAAW,mBAAmB;AAC7C,QAAM,aAAa,OAAO;AAG1B,QAAM,mBAAmB,aAAa,aAAa,GAAG;AAEtD,SAAOC,SAAQ,MAAM;AAInB,UAAM,WAAoC;AAAA,MACxC,GAAG;AAAA,MACH,GAAG;AAAA,IACL;AACA,eAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,UAAI,MAAM,GAAG,MAAM,OAAW,UAAS,GAAG,IAAI,MAAM,GAAG;AAAA,IACzD;AACA,WAAO;AAAA,EACT,GAAG,CAAC,iBAAiB,kBAAkB,KAAK,CAAC;AAC/C;;;AChCA,SAAS,cAAAC,mBAAkB;AAIpB,SAAS,mBAA2B;AACzC,MAAI,OAAO,cAAc,eAAe,UAAU,UAAU;AAC1D,WAAO,UAAU;AAAA,EACnB;AACA,SAAO;AACT;AAEO,SAAS,UAAU,UAA2B;AACnD,QAAM,SAASC,YAAW,mBAAmB;AAC7C,SAAO,YAAY,OAAO,SAAS,UAAU,iBAAiB;AAChE;","names":["useContext","useMemo","useContext","useMemo","useContext","useContext"]}
package/dist/index.d.ts CHANGED
@@ -34,6 +34,189 @@ declare function formatDateTimeTz(iso: string | null | undefined): string;
34
34
  /** Return "{count} {word}" with the word pluralized when count != 1. */
35
35
  declare function pluralize(count: number, word: string): string;
36
36
 
37
+ /**
38
+ * Date parsing and formatting -- pure functional API.
39
+ *
40
+ * The public surface is a set of pure functions. A `CompiledDateFormat`
41
+ * pre-computes the format structure so repeated parse/format calls (e.g.
42
+ * on every keystroke) don't re-derive component positions each time. Call
43
+ * `compileDateFormat` once in a `useMemo` and pass the result to
44
+ * `parseDate` / `formatDateInput`.
45
+ *
46
+ * The parser operates on native `Date` values internally. Widgets and form
47
+ * data exchange locale-independent ISO 8601 strings -- use
48
+ * `formatIsoDate` / `parseIsoDate` at the boundary.
49
+ */
50
+ /**
51
+ * Pre-compiled date format. Opaque to callers; produced by
52
+ * {@link compileDateFormat} and consumed by {@link parseDate} and
53
+ * {@link formatDateInput}.
54
+ */
55
+ interface CompiledDateFormat {
56
+ readonly format: string;
57
+ readonly yearThreshold: number;
58
+ readonly dayIndex: number;
59
+ readonly monthIndex: number;
60
+ readonly yearIndex: number;
61
+ readonly has2DigitYear: boolean;
62
+ readonly validSeparators: readonly string[];
63
+ }
64
+ /** Options for {@link compileDateFormat}. */
65
+ interface DateFormatOptions {
66
+ /**
67
+ * Component order for parsing and display. Supported tokens: `DD` (day),
68
+ * `MM` (month), `YYYY` or `YY` (year). Default: `"DD/MM/YYYY"`.
69
+ */
70
+ format?: string;
71
+ /**
72
+ * Threshold for expanding 2-digit years. If a 2-digit year is greater than
73
+ * this value it is treated as the prior century, otherwise the current one.
74
+ * Special cases: `0` always current century, `-1` always prior century.
75
+ * Default: the current year modulo 100, captured at compile time.
76
+ */
77
+ yearThreshold?: number;
78
+ }
79
+ /** Result of {@link parseDate}. */
80
+ interface DateParseResult {
81
+ /** Normalized display string (formatted per the compiled format), or "" on failure. */
82
+ value: string;
83
+ /** Parsed `Date` object, or null on failure. Needed by calendar components. */
84
+ date: Date | null;
85
+ /** Error message on failure, or "" on success. */
86
+ error: string;
87
+ }
88
+ /**
89
+ * Precision of a partial ISO 8601 date:
90
+ * year -> "YYYY"
91
+ * month -> "YYYY-MM"
92
+ * day -> "YYYY-MM-DD"
93
+ */
94
+ type DatePrecision = "year" | "month" | "day";
95
+ /**
96
+ * Pre-compile a date format string into an opaque `CompiledDateFormat`.
97
+ *
98
+ * Call once per format string (e.g. in `useMemo`) and pass the result to
99
+ * {@link parseDate} and {@link formatDateInput}.
100
+ */
101
+ declare function compileDateFormat(options?: DateFormatOptions): CompiledDateFormat;
102
+ /**
103
+ * Parse a user-entered date string against a compiled format.
104
+ *
105
+ * Accepts colon/separator-split and compact numeric input. The shorthands
106
+ * `"."`, `"t"`, and `"today"` resolve to the current date. Empty input
107
+ * returns `{ value: "", date: null, error: "" }`.
108
+ */
109
+ declare function parseDate(input: string, fmt: CompiledDateFormat): DateParseResult;
110
+ /**
111
+ * Format a `Date` as a display string using the compiled format.
112
+ * Returns "" for null or invalid dates.
113
+ */
114
+ declare function formatDateInput(date: Date | null | undefined, fmt: CompiledDateFormat): string;
115
+ /** Whether two dates fall on the same calendar day. */
116
+ declare function isDateEqual(a: Date | null, b: Date | null): boolean;
117
+ /**
118
+ * Convert a local `Date` to a partial ISO date string at the given precision
119
+ * (default `"day"`), using local components (no timezone shift).
120
+ * Returns "" for null or invalid dates.
121
+ */
122
+ declare function formatIsoDate(date: Date | null | undefined, precision?: DatePrecision): string;
123
+ /**
124
+ * Parse a partial ISO date string (`YYYY`, `YYYY-MM`, or `YYYY-MM-DD`) into a
125
+ * local `Date`. Missing month/day default to January/1st. Returns null for
126
+ * empty or malformed input.
127
+ */
128
+ declare function parseIsoDate(iso: string | null | undefined): Date | null;
129
+ /**
130
+ * Detect the precision of a partial ISO date string.
131
+ * Returns `"day"` for empty, malformed, or full-date input.
132
+ */
133
+ declare function isoDatePrecision(iso: string | null | undefined): DatePrecision;
134
+
135
+ /**
136
+ * Time parsing and formatting -- pure functional API.
137
+ *
138
+ * A time-of-day is a zone-free wall-clock value (Temporal.PlainTime): it
139
+ * carries no date and no timezone. Values are normalized ISO time strings:
140
+ * `"HH:mm"` (minute granularity) or `"HH:mm:ss"` (second granularity).
141
+ *
142
+ * API conventions match dateParser.ts:
143
+ * parseTime(input, granularity) -> TimeParseResult { value, error }
144
+ * formatIsoTime(h, m, s, granularity) -> string
145
+ * normalizeTime(iso, granularity) -> string
146
+ * An empty or failed result is represented as `value: ""`, not null.
147
+ */
148
+ /** Granularity of a time value: minutes or seconds. */
149
+ type TimeGranularity = "minute" | "second";
150
+ /** Result of {@link parseTime}. */
151
+ interface TimeParseResult {
152
+ /** Normalized ISO time string, or "" when parsing failed or input is empty. */
153
+ value: string;
154
+ /** Error message on failure, or "" on success. */
155
+ error: string;
156
+ }
157
+ /**
158
+ * Format H/M/S integer components as a normalized ISO time string.
159
+ * This is an internal building block; consumers typically use {@link normalizeTime}.
160
+ */
161
+ declare function formatIsoTime(hour: number, minute: number, second: number, granularity?: TimeGranularity): string;
162
+ /**
163
+ * Parse a user-entered time string into a normalized ISO time value.
164
+ *
165
+ * Accepts colon-separated (`"9:30"`, `"21:05:00"`) and compact numeric
166
+ * (`"0930"`, `"213005"`) forms. Missing minutes/seconds default to zero.
167
+ * The shorthands `"."`, `"n"`, and `"now"` resolve to the current time.
168
+ * Empty input returns `{ value: "", error: "" }`.
169
+ */
170
+ declare function parseTime(input: string, granularity?: TimeGranularity): TimeParseResult;
171
+ /**
172
+ * Normalize an ISO time string (`"HH:mm"` or `"HH:mm:ss"`) to the requested
173
+ * granularity. Returns "" for empty or malformed input.
174
+ */
175
+ declare function normalizeTime(iso: string | null | undefined, granularity?: TimeGranularity): string;
176
+ /**
177
+ * Split a plain wall-clock datetime (`"YYYY-MM-DDTHH:mm[:ss]"`) into its date
178
+ * (`"YYYY-MM-DD"`) and time (`"HH:mm[:ss]"`) halves. Missing halves return "".
179
+ */
180
+ declare function splitDateTime(iso: string | null | undefined): {
181
+ date: string;
182
+ time: string;
183
+ };
184
+ /**
185
+ * Join a date (`"YYYY-MM-DD"`) and time (`"HH:mm[:ss]"`) into a plain
186
+ * wall-clock datetime. Returns "" when the date is missing; a missing time
187
+ * yields the date alone.
188
+ */
189
+ declare function joinDateTime(date: string, time: string): string;
190
+
191
+ /**
192
+ * Locale-derived date formatting metadata.
193
+ *
194
+ * Uses the Intl APIs (no dependency) to derive, for a BCP-47 locale:
195
+ * - the day/month/year order and separator, as a DateParser format string
196
+ * - localized short weekday labels
197
+ * - the first day of the week (0 = Sunday .. 6 = Saturday)
198
+ *
199
+ * These drive display and input parsing only; exchanged values stay ISO.
200
+ */
201
+
202
+ /** DateParser format string per precision, derived for a locale. */
203
+ interface LocaleDateFormats {
204
+ year: string;
205
+ month: string;
206
+ day: string;
207
+ }
208
+ /** Derive DateParser format strings for each precision in the given locale. */
209
+ declare function getLocaleDateFormats(locale: string): LocaleDateFormats;
210
+ /** Derive the DateParser format for a single precision in the given locale. */
211
+ declare function getLocaleDateFormat(locale: string, precision: DatePrecision): string;
212
+ /**
213
+ * Localized short weekday labels ordered from the locale's first day of week.
214
+ * Returns 7 entries, e.g. `["Mon","Tue",...]` or `["Sun","Mon",...]`.
215
+ */
216
+ declare function getWeekdayLabels(locale: string): string[];
217
+ /** First day of the week for the locale: 0 = Sunday .. 6 = Saturday. */
218
+ declare function getFirstDayOfWeek(locale: string): number;
219
+
37
220
  /**
38
221
  * Escapes HTML characters to prevent XSS and rendering issues
39
222
  */
@@ -146,4 +329,4 @@ interface PageResult<T> {
146
329
  */
147
330
  declare function toPageResult<T>(rows: T[]): PageResult<T>;
148
331
 
149
- export { FORM_ITEM_ID_FIELD, JSON_CSS_VARIABLES, type NormalizedError, type OffsetRange, type PageMeta, type PageResult, cn, copyTextToClipboard, devLog, errorLog, escapeHtml, formatDate, formatDateTime, formatDateTimeTz, formatJsonValue, formatNumber, formatNumberCompact, generateId, infoLog, isEqual, normalizeError, pluralize, resolvePointerRanges, snakeCaseToTitleCase, toPageResult, useComposedRefs, warnLog };
332
+ export { type CompiledDateFormat, type DateFormatOptions, type DateParseResult, type DatePrecision, FORM_ITEM_ID_FIELD, JSON_CSS_VARIABLES, type LocaleDateFormats, type NormalizedError, type OffsetRange, type PageMeta, type PageResult, type TimeGranularity, type TimeParseResult, cn, compileDateFormat, copyTextToClipboard, devLog, errorLog, escapeHtml, formatDate, formatDateInput, formatDateTime, formatDateTimeTz, formatIsoDate, formatIsoTime, formatJsonValue, formatNumber, formatNumberCompact, generateId, getFirstDayOfWeek, getLocaleDateFormat, getLocaleDateFormats, getWeekdayLabels, infoLog, isDateEqual, isEqual, isoDatePrecision, joinDateTime, normalizeError, normalizeTime, parseDate, parseIsoDate, parseTime, pluralize, resolvePointerRanges, snakeCaseToTitleCase, splitDateTime, toPageResult, useComposedRefs, warnLog };
package/dist/index.js CHANGED
@@ -77,6 +77,306 @@ function pluralize(count, word) {
77
77
  return `${count} ${word}${count !== 1 ? "s" : ""}`;
78
78
  }
79
79
 
80
+ // src/dateParser.ts
81
+ var VALID_SEPARATORS = ["/", "-", "."];
82
+ function compileDateFormat(options = {}) {
83
+ const format = options.format ?? "DD/MM/YYYY";
84
+ const yearThreshold = options.yearThreshold ?? (/* @__PURE__ */ new Date()).getFullYear() % 100;
85
+ const parts = format.split(/[^A-Z]+/);
86
+ const dayIndex = parts.findIndex((p) => p === "DD");
87
+ const monthIndex = parts.findIndex((p) => p === "MM");
88
+ const yearIndex = parts.findIndex((p) => p === "YYYY" || p === "YY");
89
+ const has2DigitYear = yearIndex !== -1 && parts[yearIndex] === "YY";
90
+ if (dayIndex === -1 && monthIndex === -1 && yearIndex === -1) {
91
+ throw new Error(
92
+ "Invalid date format. Must contain at least one of DD, MM, or YYYY/YY."
93
+ );
94
+ }
95
+ return {
96
+ format,
97
+ yearThreshold,
98
+ dayIndex,
99
+ monthIndex,
100
+ yearIndex,
101
+ has2DigitYear,
102
+ validSeparators: VALID_SEPARATORS
103
+ };
104
+ }
105
+ function cleanInput(input, fmt) {
106
+ return input.replace(
107
+ new RegExp(
108
+ `[^0-9${fmt.validSeparators.map((s) => "\\" + s).join("")}+-]`,
109
+ "g"
110
+ ),
111
+ ""
112
+ );
113
+ }
114
+ function expandYear(yearStr, fmt) {
115
+ const year = parseInt(yearStr, 10);
116
+ if (yearStr.length >= 4) return year;
117
+ const refYear = fmt.yearThreshold + Math.floor((/* @__PURE__ */ new Date()).getFullYear() / 100) * 100;
118
+ const century = Math.floor(refYear / 100) * 100;
119
+ if (fmt.yearThreshold === 0) return century + year;
120
+ if (fmt.yearThreshold === -1) return century - 100 + year;
121
+ return year > fmt.yearThreshold ? century - 100 + year : century + year;
122
+ }
123
+ function parseWithSeparators(input, fmt) {
124
+ const parts = input.split(
125
+ new RegExp(`[${fmt.validSeparators.map((s) => "\\" + s).join("")}]`)
126
+ );
127
+ const result = {
128
+ day: null,
129
+ month: null,
130
+ year: null
131
+ };
132
+ for (let i = 0; i < parts.length; i++) {
133
+ if (!parts[i]) continue;
134
+ if (i === fmt.dayIndex) result.day = parseInt(parts[i], 10);
135
+ else if (i === fmt.monthIndex) result.month = parseInt(parts[i], 10);
136
+ else if (i === fmt.yearIndex) result.year = expandYear(parts[i], fmt);
137
+ }
138
+ return result;
139
+ }
140
+ function parseWithoutSeparators(input, fmt) {
141
+ const result = {
142
+ day: null,
143
+ month: null,
144
+ year: null
145
+ };
146
+ const yearLength = fmt.has2DigitYear ? 2 : 4;
147
+ const components = [
148
+ { type: "day", index: fmt.dayIndex, length: 2 },
149
+ { type: "month", index: fmt.monthIndex, length: 2 },
150
+ { type: "year", index: fmt.yearIndex, length: yearLength }
151
+ ].filter((c) => c.index !== -1).sort((a, b) => a.index - b.index);
152
+ let remaining = input;
153
+ for (const c of components) {
154
+ if (!remaining.length) break;
155
+ const len = Math.min(c.length, remaining.length);
156
+ const val = remaining.substring(0, len);
157
+ remaining = remaining.substring(len);
158
+ if (c.type === "day") result.day = parseInt(val, 10);
159
+ else if (c.type === "month") result.month = parseInt(val, 10);
160
+ else if (c.type === "year") result.year = expandYear(val, fmt);
161
+ }
162
+ return result;
163
+ }
164
+ function daysInMonth(month, year) {
165
+ if (month === 2) {
166
+ const leap = year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
167
+ return leap ? 29 : 28;
168
+ }
169
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
170
+ }
171
+ function isValidDate(day, month, year) {
172
+ if (month < 1 || month > 12) return false;
173
+ if (year < 1900 || year > 2100) return false;
174
+ if (day < 1 || day > daysInMonth(month, year)) return false;
175
+ return true;
176
+ }
177
+ function buildDateResult(parsed, fmt) {
178
+ const today = /* @__PURE__ */ new Date();
179
+ const day = parsed.day ?? today.getDate();
180
+ const month = parsed.month ?? today.getMonth() + 1;
181
+ const year = parsed.year ?? today.getFullYear();
182
+ if (!isValidDate(day, month, year)) {
183
+ return { value: "", date: null, error: "Invalid date" };
184
+ }
185
+ const date = new Date(year, month - 1, day);
186
+ return { value: formatDateInput(date, fmt), date, error: "" };
187
+ }
188
+ function parseDate(input, fmt) {
189
+ if (!input || !input.trim()) return { value: "", date: null, error: "" };
190
+ if ([".", "t", "today"].includes(input.trim().toLowerCase())) {
191
+ const today = /* @__PURE__ */ new Date();
192
+ return { value: formatDateInput(today, fmt), date: today, error: "" };
193
+ }
194
+ const cleaned = cleanInput(input, fmt);
195
+ if (!cleaned) {
196
+ return { value: "", date: null, error: "Invalid characters in date" };
197
+ }
198
+ const hasSeparators = fmt.validSeparators.some((s) => cleaned.includes(s));
199
+ try {
200
+ const parsed = hasSeparators ? parseWithSeparators(cleaned, fmt) : parseWithoutSeparators(cleaned, fmt);
201
+ return buildDateResult(parsed, fmt);
202
+ } catch (e) {
203
+ return {
204
+ value: "",
205
+ date: null,
206
+ error: e instanceof Error ? e.message : "Invalid date format"
207
+ };
208
+ }
209
+ }
210
+ function formatDateInput(date, fmt) {
211
+ if (!date || isNaN(date.getTime())) return "";
212
+ const tokens = {
213
+ DD: date.getDate().toString().padStart(2, "0"),
214
+ MM: (date.getMonth() + 1).toString().padStart(2, "0"),
215
+ YYYY: date.getFullYear().toString(),
216
+ YY: date.getFullYear().toString().slice(-2)
217
+ };
218
+ return fmt.format.replace(/DD|MM|YYYY|YY/g, (m) => tokens[m]);
219
+ }
220
+ function isDateEqual(a, b) {
221
+ if (!a || !b) return false;
222
+ return a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
223
+ }
224
+ function formatIsoDate(date, precision = "day") {
225
+ if (!date || isNaN(date.getTime())) return "";
226
+ const year = date.getFullYear().toString().padStart(4, "0");
227
+ if (precision === "year") return year;
228
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
229
+ if (precision === "month") return `${year}-${month}`;
230
+ const day = date.getDate().toString().padStart(2, "0");
231
+ return `${year}-${month}-${day}`;
232
+ }
233
+ function parseIsoDate(iso) {
234
+ if (!iso) return null;
235
+ const match = iso.match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/);
236
+ if (!match) return null;
237
+ const [, year, month, day] = match;
238
+ const date = new Date(
239
+ Number(year),
240
+ month ? Number(month) - 1 : 0,
241
+ day ? Number(day) : 1
242
+ );
243
+ return isNaN(date.getTime()) ? null : date;
244
+ }
245
+ function isoDatePrecision(iso) {
246
+ if (!iso) return "day";
247
+ if (/^\d{4}$/.test(iso)) return "year";
248
+ if (/^\d{4}-\d{2}$/.test(iso)) return "month";
249
+ return "day";
250
+ }
251
+
252
+ // src/timeParser.ts
253
+ function splitDigits(digits) {
254
+ if (digits.length <= 2) return [Number(digits)];
255
+ if (digits.length <= 4) {
256
+ return [
257
+ Number(digits.slice(0, digits.length - 2)),
258
+ Number(digits.slice(-2))
259
+ ];
260
+ }
261
+ return [
262
+ Number(digits.slice(0, digits.length - 4)),
263
+ Number(digits.slice(-4, -2)),
264
+ Number(digits.slice(-2))
265
+ ];
266
+ }
267
+ function extractParts(input) {
268
+ if (input.includes(":")) {
269
+ const parts = input.split(":");
270
+ if (parts.length > 3 || parts[parts.length - 1] === "") {
271
+ return [];
272
+ }
273
+ return parts.map((p) => Number(p));
274
+ }
275
+ return splitDigits(input);
276
+ }
277
+ function isValidTime(hour, minute, second) {
278
+ const inRange = (n, max) => Number.isInteger(n) && n >= 0 && n <= max;
279
+ return inRange(hour, 23) && inRange(minute, 59) && inRange(second, 59);
280
+ }
281
+ function formatIsoTime(hour, minute, second, granularity = "minute") {
282
+ const hh = hour.toString().padStart(2, "0");
283
+ const mm = minute.toString().padStart(2, "0");
284
+ if (granularity === "minute") return `${hh}:${mm}`;
285
+ const ss = second.toString().padStart(2, "0");
286
+ return `${hh}:${mm}:${ss}`;
287
+ }
288
+ function parseTime(input, granularity = "minute") {
289
+ if (!input || !input.trim()) return { value: "", error: "" };
290
+ if ([".", "n", "now"].includes(input.trim().toLowerCase())) {
291
+ const now = /* @__PURE__ */ new Date();
292
+ return {
293
+ value: formatIsoTime(
294
+ now.getHours(),
295
+ now.getMinutes(),
296
+ now.getSeconds(),
297
+ granularity
298
+ ),
299
+ error: ""
300
+ };
301
+ }
302
+ const cleaned = input.replace(/[^0-9:]/g, "");
303
+ if (!cleaned) return { value: "", error: "Invalid characters in time" };
304
+ return parseCleanedTime(cleaned, granularity);
305
+ }
306
+ function parseCleanedTime(cleaned, granularity) {
307
+ const parts = extractParts(cleaned);
308
+ if (!parts.length) return { value: "", error: "Invalid time format" };
309
+ const hour = parts[0] ?? 0;
310
+ const minute = parts[1] ?? 0;
311
+ const second = parts[2] ?? 0;
312
+ if (!isValidTime(hour, minute, second)) {
313
+ return { value: "", error: "Invalid time" };
314
+ }
315
+ return { value: formatIsoTime(hour, minute, second, granularity), error: "" };
316
+ }
317
+ function normalizeTime(iso, granularity = "minute") {
318
+ if (!iso) return "";
319
+ return parseTime(iso, granularity).value;
320
+ }
321
+ function splitDateTime(iso) {
322
+ if (!iso) return { date: "", time: "" };
323
+ const [date, time] = iso.split("T");
324
+ return { date: date ?? "", time: time ?? "" };
325
+ }
326
+ function joinDateTime(date, time) {
327
+ if (!date) return "";
328
+ return time ? `${date}T${time}` : date;
329
+ }
330
+
331
+ // src/localeFormat.ts
332
+ function dayFormatFromParts(locale) {
333
+ const parts = new Intl.DateTimeFormat(locale, {
334
+ year: "numeric",
335
+ month: "2-digit",
336
+ day: "2-digit"
337
+ }).formatToParts(new Date(2023, 0, 2));
338
+ const tokens = [];
339
+ let separator = "/";
340
+ for (const part of parts) {
341
+ if (part.type === "day") tokens.push("DD");
342
+ else if (part.type === "month") tokens.push("MM");
343
+ else if (part.type === "year") tokens.push("YYYY");
344
+ else if (part.type === "literal") {
345
+ const trimmed = part.value.trim();
346
+ if (trimmed) separator = trimmed[0];
347
+ }
348
+ }
349
+ if (tokens.length !== 3) return "DD/MM/YYYY";
350
+ return tokens.join(separator);
351
+ }
352
+ function getLocaleDateFormats(locale) {
353
+ const day = dayFormatFromParts(locale);
354
+ const month = day.replace(/DD.|.DD/, "");
355
+ return { year: "YYYY", month, day };
356
+ }
357
+ function getLocaleDateFormat(locale, precision) {
358
+ return getLocaleDateFormats(locale)[precision];
359
+ }
360
+ function getWeekdayLabels(locale) {
361
+ const first = getFirstDayOfWeek(locale);
362
+ const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
363
+ return Array.from(
364
+ { length: 7 },
365
+ (_, i) => formatter.format(new Date(2023, 0, 1 + (first + i) % 7))
366
+ );
367
+ }
368
+ function getFirstDayOfWeek(locale) {
369
+ try {
370
+ const loc = new Intl.Locale(locale);
371
+ const info = loc.getWeekInfo?.() ?? loc.weekInfo;
372
+ if (info?.firstDay !== void 0) {
373
+ return info.firstDay === 7 ? 0 : info.firstDay;
374
+ }
375
+ } catch {
376
+ }
377
+ return 1;
378
+ }
379
+
80
380
  // src/jsonUtils.ts
81
381
  function escapeHtml(value) {
82
382
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -395,23 +695,39 @@ export {
395
695
  FORM_ITEM_ID_FIELD,
396
696
  JSON_CSS_VARIABLES,
397
697
  cn,
698
+ compileDateFormat,
398
699
  copyTextToClipboard,
399
700
  devLog,
400
701
  errorLog,
401
702
  escapeHtml,
402
703
  formatDate,
704
+ formatDateInput,
403
705
  formatDateTime,
404
706
  formatDateTimeTz,
707
+ formatIsoDate,
708
+ formatIsoTime,
405
709
  formatJsonValue,
406
710
  formatNumber,
407
711
  formatNumberCompact,
408
712
  generateId,
713
+ getFirstDayOfWeek,
714
+ getLocaleDateFormat,
715
+ getLocaleDateFormats,
716
+ getWeekdayLabels,
409
717
  infoLog,
718
+ isDateEqual,
410
719
  isEqual,
720
+ isoDatePrecision,
721
+ joinDateTime,
411
722
  normalizeError,
723
+ normalizeTime,
724
+ parseDate,
725
+ parseIsoDate,
726
+ parseTime,
412
727
  pluralize,
413
728
  resolvePointerRanges,
414
729
  snakeCaseToTitleCase_default as snakeCaseToTitleCase,
730
+ splitDateTime,
415
731
  toPageResult,
416
732
  useComposedRefs,
417
733
  warnLog