@magicvr/schema-ui-ui 0.1.1 → 0.1.3

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.
package/i18n/runtime.d.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  * - Exposes `t` / `formatDate` / `formatNumber` to components.
10
10
  */
11
11
  import { type ReactNode } from "react";
12
- import { type MessageParams } from "./catalog";
13
- import { type Locale, type LocalePreference } from "./locale";
14
- import { type TimezonePreference } from "./timezone";
12
+ import { type MessageParams } from "./catalog.js";
13
+ import { type Locale, type LocalePreference } from "./locale.js";
14
+ import { type TimezonePreference } from "./timezone.js";
15
15
  export declare const LOCALE_STORAGE_KEY = "schema-ui:locale";
16
16
  export declare function readStoredLocale(): string | null;
17
17
  export declare function writeStoredLocale(preference: LocalePreference): void;
@@ -0,0 +1,184 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * I18n React runtime (S1 · C4/C5).
4
+ *
5
+ * - Resolves the effective locale via `resolveLocale` (frozen priority).
6
+ * - Persists the user's explicit choice in localStorage["schema-ui:locale"]
7
+ * (single channel, same pattern as the theme mechanism; login/logout never
8
+ * clears it — D-002 §I-L10N-002).
9
+ * - Applies `document.documentElement.lang` on locale change.
10
+ * - Exposes `t` / `formatDate` / `formatNumber` to components.
11
+ */
12
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
13
+ import { createTranslator } from "./catalog.js";
14
+ import { formatDate as formatDateImpl, formatNumber as formatNumberImpl } from "./format.js";
15
+ import { defaultBrowserLanguages, DEFAULT_LOCALE, normalizePreference, resolveLocale, } from "./locale.js";
16
+ import { AUTO_TIMEZONE, detectBrowserTimezone, normalizeTimezonePreference, readStoredTimezone, resolveEffectiveTimezone, writeStoredTimezone, } from "./timezone.js";
17
+ export const LOCALE_STORAGE_KEY = "schema-ui:locale";
18
+ export function readStoredLocale() {
19
+ try {
20
+ if (typeof localStorage === "undefined") {
21
+ return null;
22
+ }
23
+ return localStorage.getItem(LOCALE_STORAGE_KEY);
24
+ }
25
+ catch {
26
+ // Privacy mode / disabled storage throws SecurityError — same posture as
27
+ // tokens.ts / theme.ts. Never let locale boot white-screen the tree.
28
+ return null;
29
+ }
30
+ }
31
+ export function writeStoredLocale(preference) {
32
+ try {
33
+ if (typeof localStorage === "undefined") {
34
+ return;
35
+ }
36
+ if (preference === "auto") {
37
+ localStorage.removeItem(LOCALE_STORAGE_KEY);
38
+ }
39
+ else {
40
+ localStorage.setItem(LOCALE_STORAGE_KEY, preference);
41
+ }
42
+ }
43
+ catch {
44
+ // Best-effort persist; the in-memory preference still applies this page.
45
+ }
46
+ }
47
+ /** Applies the effective locale to <html lang>. No-op outside a browser. */
48
+ export function applyLocaleToDocument(locale) {
49
+ if (typeof document !== "undefined") {
50
+ document.documentElement.lang = locale;
51
+ }
52
+ }
53
+ // ── active-locale registry ────────────────────────────────────────────────────
54
+ // Module-level current locale for non-React consumers (API fetchers attach it
55
+ // as Accept-Language so the server negotiates the same language — VP-007 S4).
56
+ let activeLocale = "en-US";
57
+ /** Returns the provider's currently effective locale (defaults en-US). */
58
+ export function getActiveLocale() {
59
+ return activeLocale;
60
+ }
61
+ /** Internal: keeps the registry in sync with the provider's effective locale. */
62
+ export function setActiveLocale(locale) {
63
+ activeLocale = locale;
64
+ }
65
+ const I18nContext = createContext(null);
66
+ export function I18nProvider({ children, systemDefault = null, stored, browserLanguages, siteTimezone = null, storedTimezone, detectTimezone, siteDefaultCurrency = null, systemDefaultUrl, }) {
67
+ const [preference, setPreferenceState] = useState(() => {
68
+ const raw = stored !== undefined ? stored : readStoredLocale();
69
+ return normalizePreference(raw);
70
+ });
71
+ const [fetchedSystemDefault, setFetchedSystemDefault] = useState(null);
72
+ const [fetchedSiteTimezone, setFetchedSiteTimezone] = useState(null);
73
+ const [fetchedSiteDefaultCurrency, setFetchedSiteDefaultCurrency] = useState(null);
74
+ const [timezonePreference, setTimezonePreferenceState] = useState(() => {
75
+ const raw = storedTimezone !== undefined ? storedTimezone : readStoredTimezone();
76
+ return normalizeTimezonePreference(raw);
77
+ });
78
+ const browserList = browserLanguages !== undefined ? browserLanguages : defaultBrowserLanguages();
79
+ useEffect(() => {
80
+ if (systemDefaultUrl === undefined) {
81
+ return;
82
+ }
83
+ let cancelled = false;
84
+ fetch(systemDefaultUrl)
85
+ .then((response) => (response.ok ? response.json() : null))
86
+ .then((body) => {
87
+ if (cancelled) {
88
+ return;
89
+ }
90
+ const record = body;
91
+ const locale = typeof record?.defaultLocale === "string" ? record.defaultLocale : null;
92
+ setFetchedSystemDefault(locale === "auto" ? null : locale);
93
+ const zone = typeof record?.siteTimezone === "string" ? record.siteTimezone : null;
94
+ setFetchedSiteTimezone(zone === "auto" ? null : zone);
95
+ const currency = typeof record?.defaultCurrency === "string" ? record.defaultCurrency.trim().toUpperCase() : null;
96
+ setFetchedSiteDefaultCurrency(currency === "" || currency === null ? null : currency);
97
+ })
98
+ .catch(() => {
99
+ if (!cancelled) {
100
+ setFetchedSystemDefault(null);
101
+ setFetchedSiteTimezone(null);
102
+ setFetchedSiteDefaultCurrency(null);
103
+ }
104
+ });
105
+ return () => {
106
+ cancelled = true;
107
+ };
108
+ }, [systemDefaultUrl]);
109
+ const effectiveSystemDefault = systemDefault ?? fetchedSystemDefault;
110
+ const effectiveSiteTimezone = siteTimezone ?? fetchedSiteTimezone;
111
+ const effectiveSiteDefaultCurrency = siteDefaultCurrency ?? fetchedSiteDefaultCurrency;
112
+ const locale = useMemo(() => resolveLocale({
113
+ stored: preference === "auto" ? null : preference,
114
+ systemDefault: effectiveSystemDefault,
115
+ browserLanguages: browserList,
116
+ }), [preference, effectiveSystemDefault, browserList]);
117
+ const timezone = useMemo(() => resolveEffectiveTimezone({
118
+ stored: timezonePreference === AUTO_TIMEZONE ? null : timezonePreference,
119
+ siteDefault: effectiveSiteTimezone,
120
+ detect: detectTimezone ?? detectBrowserTimezone,
121
+ }), [timezonePreference, effectiveSiteTimezone, detectTimezone]);
122
+ useEffect(() => {
123
+ applyLocaleToDocument(locale);
124
+ setActiveLocale(locale);
125
+ }, [locale]);
126
+ const setPreference = useCallback((next) => {
127
+ writeStoredLocale(next);
128
+ setPreferenceState(next);
129
+ }, []);
130
+ const setTimezonePreference = useCallback((next) => {
131
+ writeStoredTimezone(next);
132
+ setTimezonePreferenceState(next);
133
+ }, []);
134
+ const t = useMemo(() => createTranslator(locale), [locale]);
135
+ const formatDate = useCallback((value, options = {}) => {
136
+ const requested = options.timeZone !== undefined && options.timeZone !== "" ? options.timeZone : null;
137
+ const zone = requested ?? (timezone === AUTO_TIMEZONE ? null : timezone);
138
+ return formatDateImpl(value, locale, zone === null ? {} : { timeZone: zone });
139
+ }, [locale, timezone]);
140
+ const formatNumber = useCallback((value, options) => formatNumberImpl(value, locale, options), [locale]);
141
+ const value = useMemo(() => ({
142
+ locale,
143
+ preference,
144
+ setPreference,
145
+ timezone,
146
+ timezonePreference,
147
+ setTimezonePreference,
148
+ defaultCurrency: effectiveSiteDefaultCurrency ?? "",
149
+ t,
150
+ formatDate,
151
+ formatNumber,
152
+ }), [
153
+ locale,
154
+ preference,
155
+ setPreference,
156
+ timezone,
157
+ timezonePreference,
158
+ setTimezonePreference,
159
+ effectiveSiteDefaultCurrency,
160
+ t,
161
+ formatDate,
162
+ formatNumber,
163
+ ]);
164
+ return _jsx(I18nContext.Provider, { value: value, children: children });
165
+ }
166
+ export function useI18n() {
167
+ const value = useContext(I18nContext);
168
+ if (value === null) {
169
+ throw new Error("useI18n must be used within an I18nProvider");
170
+ }
171
+ return value;
172
+ }
173
+ /**
174
+ * Tolerant translator hook for deep renderer internals.
175
+ *
176
+ * Returns the provider's translator, or a safe default (en-US resolution +
177
+ * missing-key observable fallback) when no provider is mounted. Production
178
+ * always mounts I18nProvider; bare component tests and pre-provider surfaces
179
+ * degrade to the documented safe fallback instead of throwing.
180
+ */
181
+ export function useTranslate() {
182
+ const value = useContext(I18nContext);
183
+ return useMemo(() => value?.t ?? createTranslator(DEFAULT_LOCALE), [value]);
184
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Timezone pure-logic unit (workspace-020 · R2 · C1).
3
+ *
4
+ * `resolveEffectiveTimezone` computes the effective timezone from the user's
5
+ * explicit override, the session probe, and the site default, per contract
6
+ * GOAL-002 D-001 §2 (user-confirmed I-001, 2026-08-26):
7
+ *
8
+ * L1 user override (localStorage "schema-ui:timezone") → L2 session probe
9
+ * (Intl) → L3 site default (siteTimezone) → L4 "auto" fallback
10
+ *
11
+ * Keeping the decision logic in plain functions lets vitest exercise every
12
+ * branch; the probe is injectable so tests do not depend on the host zone.
13
+ */
14
+ export const TIMEZONE_STORAGE_KEY = "schema-ui:timezone";
15
+ export const AUTO_TIMEZONE = "auto";
16
+ /**
17
+ * Validates an IANA timezone name via Intl (RangeError on invalid names).
18
+ * Side-effect-free apart from the noexcept Intl probe; returns false for
19
+ * empty / non-string / unknown zones instead of throwing.
20
+ */
21
+ export function isValidIanaTimeZone(raw) {
22
+ if (typeof raw !== "string") {
23
+ return false;
24
+ }
25
+ const trimmed = raw.trim();
26
+ if (trimmed === "") {
27
+ return false;
28
+ }
29
+ try {
30
+ // The format call itself never renders; constructing with the option is
31
+ // the validation probe (RangeError for unknown "Foo/Bar" style names).
32
+ new Intl.DateTimeFormat("en-US", { timeZone: trimmed });
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * Normalizes a raw stored/site value into a TimezonePreference.
41
+ * Any value that is not a valid IANA name resolves to "auto".
42
+ */
43
+ export function normalizeTimezonePreference(raw) {
44
+ if (typeof raw !== "string") {
45
+ return AUTO_TIMEZONE;
46
+ }
47
+ const trimmed = raw.trim();
48
+ if (trimmed === "" || trimmed.toLowerCase() === AUTO_TIMEZONE) {
49
+ return AUTO_TIMEZONE;
50
+ }
51
+ return isValidIanaTimeZone(trimmed) ? trimmed : AUTO_TIMEZONE;
52
+ }
53
+ /** Reads the stored user override; best-effort (privacy mode → null). */
54
+ export function readStoredTimezone() {
55
+ try {
56
+ if (typeof localStorage === "undefined") {
57
+ return null;
58
+ }
59
+ return localStorage.getItem(TIMEZONE_STORAGE_KEY);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** Persists the user override; "auto" removes the key (single channel). */
66
+ export function writeStoredTimezone(preference) {
67
+ try {
68
+ if (typeof localStorage === "undefined") {
69
+ return;
70
+ }
71
+ if (preference === AUTO_TIMEZONE) {
72
+ localStorage.removeItem(TIMEZONE_STORAGE_KEY);
73
+ }
74
+ else {
75
+ localStorage.setItem(TIMEZONE_STORAGE_KEY, preference);
76
+ }
77
+ }
78
+ catch {
79
+ // Best-effort persist; the in-memory preference still applies this page.
80
+ }
81
+ }
82
+ /** Real session probe: the host zone from Intl.resolvedOptions(). */
83
+ export function detectBrowserTimezone() {
84
+ try {
85
+ const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
86
+ return typeof zone === "string" ? zone : "";
87
+ }
88
+ catch {
89
+ return "";
90
+ }
91
+ }
92
+ /**
93
+ * Resolves the effective timezone per contract §2 (L1 → L2 → L3 → L4).
94
+ * Returns an IANA name, or "auto" when nothing is configured/detectable —
95
+ * consumers then fall back to the locale's default zone.
96
+ */
97
+ export function resolveEffectiveTimezone(input) {
98
+ const stored = normalizeTimezonePreference(input.stored);
99
+ if (stored !== AUTO_TIMEZONE) {
100
+ return stored; // L1 · user override wins
101
+ }
102
+ let detected = "";
103
+ try {
104
+ detected = String(input.detect() ?? "");
105
+ }
106
+ catch {
107
+ detected = "";
108
+ }
109
+ if (isValidIanaTimeZone(detected)) {
110
+ return detected; // L2 · session probe
111
+ }
112
+ const site = normalizeTimezonePreference(input.siteDefault);
113
+ if (site !== AUTO_TIMEZONE) {
114
+ return site; // L3 · site default
115
+ }
116
+ return AUTO_TIMEZONE; // L4 · embedded default
117
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Display-time formatting (GOAL-011 table style): ISO-8601 timestamps are
3
+ * rendered in a human-readable local-time form ("2026-08-01 18:02") instead
4
+ * of the raw wire value ("2026-08-01T18:02:44.000Z").
5
+ *
6
+ * Returns null when the value is not a renderable timestamp so callers fall
7
+ * back to the raw text (numbers, booleans, plain strings, arbitrary dates
8
+ * with other shapes stay untouched).
9
+ */
10
+ /** Matches the ISO-8601 timestamps shipped by the API (UTC Z or ±hh:mm). */
11
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
12
+ function pad2(value) {
13
+ return String(value).padStart(2, "0");
14
+ }
15
+ /**
16
+ * Formats an ISO-8601 timestamp string to local "YYYY-MM-DD HH:mm". Returns
17
+ * null for non-timestamp values, malformed dates, or non-string input.
18
+ */
19
+ export function formatDisplayTime(value) {
20
+ if (typeof value !== "string" || !ISO_TIMESTAMP_PATTERN.test(value)) {
21
+ return null;
22
+ }
23
+ const date = new Date(value);
24
+ if (Number.isNaN(date.getTime())) {
25
+ return null;
26
+ }
27
+ return (date.getFullYear() +
28
+ "-" +
29
+ pad2(date.getMonth() + 1) +
30
+ "-" +
31
+ pad2(date.getDate()) +
32
+ " " +
33
+ pad2(date.getHours()) +
34
+ ":" +
35
+ pad2(date.getMinutes()));
36
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,5 @@
1
+ import { clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+ export function cn(...inputs) {
4
+ return twMerge(clsx(inputs));
5
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@magicvr/schema-ui-ui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
- "description": "schema-ui-core 包面(VP-023 R3 六包化)",
6
- "main": "index.js",
7
- "types": "./components/ui/index.d.ts",
5
+ "description": "schema-ui-core 包面(ui)",
6
+ "main": "components/ui/index.js",
7
+ "types": "components/ui/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./components/ui/index.d.ts",
10
+ "types": "components/ui/index.d.ts",
11
11
  "import": "./index.js"
12
12
  },
13
13
  "./*": "./*"