@godxjp/ui 5.0.1 → 6.0.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.
- package/README.md +101 -142
- package/package.json +124 -128
- package/scripts/ui-audit.mjs +179 -0
- package/src/app/__tests__/app-provider.test.tsx +232 -0
- package/src/app/__tests__/date-format-labels.test.ts +36 -0
- package/src/app/__tests__/date-formats.test.ts +44 -0
- package/src/app/__tests__/timezones.test.ts +65 -0
- package/src/app/app-provider.tsx +227 -0
- package/src/app/date-format-labels.ts +21 -0
- package/src/app/date-formats.ts +30 -0
- package/src/app/index.ts +40 -0
- package/src/app/locales.ts +32 -0
- package/src/app/request-headers.ts +31 -0
- package/src/app/storage.ts +44 -0
- package/src/app/time-format-labels.ts +19 -0
- package/src/app/time-formats.ts +15 -0
- package/src/app/timezones.ts +208 -0
- package/src/app/types.ts +39 -0
- package/src/app/use-formatting.ts +47 -0
- package/src/components/__tests__/accessibility-primitives.test.tsx +65 -0
- package/src/components/__tests__/docs-parity.test.ts +41 -0
- package/src/components/__tests__/shadcn-release-guardrails.test.ts +71 -0
- package/src/components/__tests__/theme-axes-integration.test.tsx +242 -0
- package/src/components/admin/index.ts +76 -0
- package/src/components/data-display/__tests__/card-table.test.tsx +328 -0
- package/src/components/data-display/__tests__/data-display.test.tsx +73 -0
- package/src/components/data-display/__tests__/data-table.test.tsx +84 -0
- package/src/components/data-display/__tests__/popover.test.tsx +92 -0
- package/src/components/data-display/__tests__/scroll-area-collapsible.test.tsx +66 -0
- package/src/components/data-display/badge.tsx +27 -0
- package/src/components/data-display/card.tsx +194 -0
- package/src/components/data-display/code-badge.tsx +28 -0
- package/src/components/data-display/collapsible.tsx +5 -0
- package/src/components/data-display/data-table.tsx +476 -0
- package/src/components/data-display/empty-state.tsx +22 -0
- package/src/components/data-display/index.ts +41 -0
- package/src/components/data-display/key-value-grid.tsx +46 -0
- package/src/components/data-display/popover.tsx +62 -0
- package/src/components/data-display/progress-meter.tsx +20 -0
- package/src/components/data-display/scan-panel.tsx +16 -0
- package/src/components/data-display/scroll-area.tsx +42 -0
- package/src/components/data-display/status-badge.tsx +83 -0
- package/src/components/data-display/table.tsx +59 -0
- package/src/components/data-display/timeline.tsx +42 -0
- package/src/components/data-display/tree-list.tsx +42 -0
- package/src/components/data-entry/__fixtures__/tree-options.ts +80 -0
- package/src/components/data-entry/__tests__/cascader-tree-transfer.test.tsx +417 -0
- package/src/components/data-entry/__tests__/checkbox-group.test.tsx +40 -0
- package/src/components/data-entry/__tests__/checkbox.test.tsx +20 -0
- package/src/components/data-entry/__tests__/date-autocomplete.test.tsx +94 -0
- package/src/components/data-entry/__tests__/form-field.test.tsx +49 -0
- package/src/components/data-entry/__tests__/input-textarea.test.tsx +38 -0
- package/src/components/data-entry/__tests__/label-select.test.tsx +62 -0
- package/src/components/data-entry/__tests__/pickers.test.tsx +74 -0
- package/src/components/data-entry/__tests__/radio.test.tsx +46 -0
- package/src/components/data-entry/__tests__/search-input.test.tsx +32 -0
- package/src/components/data-entry/__tests__/switch-field.test.tsx +52 -0
- package/src/components/data-entry/__tests__/upload.test.tsx +125 -0
- package/src/components/data-entry/autocomplete.tsx +91 -0
- package/src/components/data-entry/calendar.tsx +90 -0
- package/src/components/data-entry/cascader.tsx +305 -0
- package/src/components/data-entry/checkbox-group.tsx +90 -0
- package/src/components/data-entry/checkbox.tsx +30 -0
- package/src/components/data-entry/choice-field.tsx +27 -0
- package/src/components/data-entry/choice-option.ts +20 -0
- package/src/components/data-entry/color-picker.tsx +75 -0
- package/src/components/data-entry/command.tsx +56 -0
- package/src/components/data-entry/country-select.tsx +88 -0
- package/src/components/data-entry/date-picker.tsx +69 -0
- package/src/components/data-entry/date-range-picker.tsx +75 -0
- package/src/components/data-entry/form-field.tsx +59 -0
- package/src/components/data-entry/index.ts +62 -0
- package/src/components/data-entry/input.tsx +26 -0
- package/src/components/data-entry/label.tsx +25 -0
- package/src/components/data-entry/radio.tsx +109 -0
- package/src/components/data-entry/search-input.tsx +103 -0
- package/src/components/data-entry/select.tsx +149 -0
- package/src/components/data-entry/slider.tsx +38 -0
- package/src/components/data-entry/switch-field.tsx +91 -0
- package/src/components/data-entry/switch.tsx +24 -0
- package/src/components/data-entry/textarea.tsx +12 -0
- package/src/components/data-entry/time-picker.tsx +214 -0
- package/src/components/data-entry/transfer.tsx +231 -0
- package/src/components/data-entry/tree-select-strategy.ts +6 -0
- package/src/components/data-entry/tree-select.tsx +279 -0
- package/src/components/data-entry/tree-utils.ts +221 -0
- package/src/components/data-entry/upload-crop-dialog.tsx +109 -0
- package/src/components/data-entry/upload-types.ts +86 -0
- package/src/components/data-entry/upload.tsx +498 -0
- package/src/components/data-entry/use-upload-draft.ts +93 -0
- package/src/components/feedback/__tests__/alert.test.tsx +127 -0
- package/src/components/feedback/__tests__/dialog.test.tsx +290 -0
- package/src/components/feedback/__tests__/sheet.test.tsx +94 -0
- package/src/components/feedback/__tests__/skeleton.test.tsx +25 -0
- package/src/components/feedback/__tests__/toast.test.tsx +52 -0
- package/src/components/feedback/alert.tsx +167 -0
- package/src/components/feedback/dialog.tsx +325 -0
- package/src/components/feedback/index.ts +53 -0
- package/src/components/feedback/sheet.tsx +130 -0
- package/src/components/feedback/skeleton.tsx +95 -0
- package/src/components/feedback/sonner.tsx +54 -0
- package/src/components/feedback/toaster.tsx +1 -0
- package/src/components/feedback/use-toast.ts +62 -0
- package/src/components/general/__tests__/button.test.tsx +71 -0
- package/src/components/general/button.tsx +61 -0
- package/src/components/general/index.ts +2 -0
- package/src/components/layout/__tests__/page-container.test.tsx +69 -0
- package/src/components/layout/__tests__/page-inset.test.tsx +14 -0
- package/src/components/layout/__tests__/stack-inline.test.tsx +39 -0
- package/src/components/layout/app-shell.tsx +42 -0
- package/src/components/layout/breadcrumb.tsx +35 -0
- package/src/components/layout/index.ts +31 -0
- package/src/components/layout/inline.tsx +13 -0
- package/src/components/layout/menu.tsx +34 -0
- package/src/components/layout/mobile-frame.tsx +57 -0
- package/src/components/layout/page-container.tsx +81 -0
- package/src/components/layout/page-inset.tsx +16 -0
- package/src/components/layout/responsive-grid.tsx +14 -0
- package/src/components/layout/shell-app.tsx +30 -0
- package/src/components/layout/sidebar.tsx +98 -0
- package/src/components/layout/split-pane.tsx +16 -0
- package/src/components/layout/stack.tsx +13 -0
- package/src/components/layout/topbar.tsx +108 -0
- package/src/components/navigation/__tests__/app-pickers.test.tsx +118 -0
- package/src/components/navigation/__tests__/dropdown-menu.test.tsx +104 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +61 -0
- package/src/components/navigation/__tests__/pagination-steps-tabs.test.tsx +76 -0
- package/src/components/navigation/date-format-picker.tsx +55 -0
- package/src/components/navigation/dropdown-menu.tsx +190 -0
- package/src/components/navigation/filter-bar.tsx +38 -0
- package/src/components/navigation/index.ts +28 -0
- package/src/components/navigation/locale-picker.tsx +49 -0
- package/src/components/navigation/page-header.tsx +50 -0
- package/src/components/navigation/pagination-utils.ts +35 -0
- package/src/components/navigation/pagination.tsx +168 -0
- package/src/components/navigation/steps.tsx +163 -0
- package/src/components/navigation/tabs-items.tsx +69 -0
- package/src/components/navigation/tabs.tsx +67 -0
- package/src/components/navigation/time-format-picker.tsx +55 -0
- package/src/components/navigation/timezone-picker.tsx +63 -0
- package/src/components/query/__tests__/data-state.test.tsx +214 -0
- package/src/components/query/__tests__/infinite-prefetch.test.tsx +105 -0
- package/src/components/query/__tests__/query-helpers.test.tsx +61 -0
- package/src/components/query/data-state.tsx +58 -0
- package/src/components/query/index.ts +10 -0
- package/src/components/query/infinite-query-state.tsx +99 -0
- package/src/components/query/mutation-feedback.tsx +31 -0
- package/src/components/query/prefetch-link.tsx +45 -0
- package/src/components/query/query-refetch-button.tsx +41 -0
- package/src/components/ui/alert-dialog.tsx +1 -0
- package/src/components/ui/alert.tsx +1 -0
- package/src/components/ui/autocomplete.tsx +1 -0
- package/src/components/ui/badge.tsx +1 -0
- package/src/components/ui/button.tsx +1 -0
- package/src/components/ui/calendar.tsx +1 -0
- package/src/components/ui/card.tsx +1 -0
- package/src/components/ui/checkbox.tsx +1 -0
- package/src/components/ui/color-picker.tsx +1 -0
- package/src/components/ui/command.tsx +1 -0
- package/src/components/ui/date-picker.tsx +1 -0
- package/src/components/ui/date-range-picker.tsx +1 -0
- package/src/components/ui/dialog.tsx +1 -0
- package/src/components/ui/dropdown-menu.tsx +1 -0
- package/src/components/ui/index.tsx +31 -0
- package/src/components/ui/input.tsx +1 -0
- package/src/components/ui/label.tsx +1 -0
- package/src/components/ui/pagination.tsx +1 -0
- package/src/components/ui/popover.tsx +1 -0
- package/src/components/ui/radio.tsx +1 -0
- package/src/components/ui/scroll-area.tsx +1 -0
- package/src/components/ui/select.tsx +1 -0
- package/src/components/ui/sheet.tsx +1 -0
- package/src/components/ui/slider.tsx +1 -0
- package/src/components/ui/sonner.tsx +1 -0
- package/src/components/ui/switch.tsx +1 -0
- package/src/components/ui/table.tsx +1 -0
- package/src/components/ui/tabs-items.tsx +1 -0
- package/src/components/ui/tabs.tsx +1 -0
- package/src/components/ui/textarea.tsx +1 -0
- package/src/components/ui/time-picker.tsx +1 -0
- package/src/components/ui/upload.tsx +1 -0
- package/src/form/__tests__/use-zod-form.test.tsx +97 -0
- package/src/form/form-field-control.tsx +44 -0
- package/src/form/form-root.tsx +29 -0
- package/src/form/index.ts +7 -0
- package/src/form/use-zod-form.ts +29 -0
- package/src/i18n/__tests__/translate.test.ts +23 -0
- package/src/i18n/index.ts +9 -0
- package/src/i18n/messages/en.json +171 -0
- package/src/i18n/messages/ja.json +171 -0
- package/src/i18n/messages/vi.json +171 -0
- package/src/i18n/translate.ts +74 -0
- package/src/i18n/use-translation.ts +53 -0
- package/src/index.ts +3 -0
- package/src/lib/__tests__/control-styles.test.ts +78 -0
- package/src/lib/__tests__/datetime.test.ts +77 -0
- package/src/lib/__tests__/format-date.test.ts +97 -0
- package/src/lib/__tests__/format.test.ts +62 -0
- package/src/lib/__tests__/theme-tokens-audit.test.ts +176 -0
- package/src/lib/__tests__/theme-tokens-css.test.ts +118 -0
- package/src/lib/__tests__/token-governance.test.ts +191 -0
- package/src/lib/__tests__/variants.test.ts +18 -0
- package/src/lib/control-styles.ts +33 -0
- package/src/lib/datetime/detect.ts +25 -0
- package/src/lib/datetime/format-date.ts +100 -0
- package/src/lib/datetime/format.ts +140 -0
- package/src/lib/datetime/index.ts +25 -0
- package/src/lib/datetime/parse.ts +51 -0
- package/src/lib/datetime/sync.ts +48 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/hooks.ts +54 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/variants.ts +40 -0
- package/src/props/components/app.prop.ts +99 -0
- package/src/props/components/data-display.prop.ts +73 -0
- package/src/props/components/data-entry.prop.ts +334 -0
- package/src/props/components/feedback.prop.ts +80 -0
- package/src/props/components/form.prop.ts +46 -0
- package/src/props/components/general.prop.ts +18 -0
- package/src/props/components/index.ts +99 -0
- package/src/props/components/layout.prop.ts +130 -0
- package/src/props/components/navigation.prop.ts +88 -0
- package/src/props/components/query.prop.ts +94 -0
- package/src/props/index.ts +17 -0
- package/src/props/registry.ts +603 -0
- package/src/props/vocabulary/content.prop.ts +35 -0
- package/src/props/vocabulary/data.prop.ts +46 -0
- package/src/props/vocabulary/index.ts +73 -0
- package/src/props/vocabulary/interaction.prop.ts +42 -0
- package/src/props/vocabulary/layout.prop.ts +25 -0
- package/src/props/vocabulary/navigation.prop.ts +19 -0
- package/src/props/vocabulary/shared.prop.ts +59 -0
- package/src/styles/alert-layout.css +191 -0
- package/src/styles/badge-layout.css +22 -0
- package/src/styles/card-layout.css +373 -0
- package/src/styles/control.css +504 -0
- package/src/styles/data-display-layout.css +246 -0
- package/src/styles/density.css +43 -0
- package/src/styles/dialog-layout.css +84 -0
- package/src/styles/index.css +105 -0
- package/src/styles/layout.css +479 -0
- package/src/styles/shell-layout.css +604 -0
- package/src/styles/table-layout.css +109 -0
- package/src/test/__tests__/render-loop-guard.test.tsx +38 -0
- package/src/test/jest-dom.d.ts +4 -0
- package/src/test/render-loop-guard.tsx +50 -0
- package/src/test/render.tsx +29 -0
- package/src/test/theme-globals.test.ts +77 -0
- package/src/test/theme-globals.ts +134 -0
- package/src/test/theme-test-utils.tsx +67 -0
- package/src/theme/example.service.css +37 -0
- package/src/tokens/base.css +13 -0
- package/src/tokens/foundation.css +151 -0
- package/src/tokens/primitives/badge.css +13 -0
- package/src/tokens/primitives/card.css +29 -0
- package/src/tokens/primitives/control.css +55 -0
- package/src/tokens/primitives/feedback.css +17 -0
- package/src/tokens/primitives/layout.css +20 -0
- package/src/tokens/primitives/navigation.css +13 -0
- package/src/tokens/primitives/table.css +10 -0
- package/BRAND.md +0 -296
- package/CHANGELOG.md +0 -650
- package/config/eslint.js +0 -54
- package/config/prettier.cjs +0 -20
- package/config/tsconfig.base.json +0 -22
- package/config/vitest.base.ts +0 -26
- package/dist/MiniMonth-YAmPGEpC.d.ts +0 -143
- package/dist/Table.types-BbsxoIYE.d.ts +0 -352
- package/dist/color-DO0qqUAb.d.ts +0 -38
- package/dist/components/composites.d.ts +0 -963
- package/dist/components/composites.js +0 -7340
- package/dist/components/composites.js.map +0 -1
- package/dist/components/primitives.d.ts +0 -2736
- package/dist/components/primitives.js +0 -7353
- package/dist/components/primitives.js.map +0 -1
- package/dist/components/shell.d.ts +0 -182
- package/dist/components/shell.js +0 -774
- package/dist/components/shell.js.map +0 -1
- package/dist/hooks.d.ts +0 -100
- package/dist/hooks.js +0 -558
- package/dist/hooks.js.map +0 -1
- package/dist/i18n.d.ts +0 -61
- package/dist/i18n.js +0 -860
- package/dist/i18n.js.map +0 -1
- package/dist/index.d.ts +0 -33
- package/dist/index.js +0 -13059
- package/dist/index.js.map +0 -1
- package/dist/padding-DY0JV5Ja.d.ts +0 -16
- package/dist/preferences.d.ts +0 -132
- package/dist/preferences.js +0 -262
- package/dist/preferences.js.map +0 -1
- package/dist/props.d.ts +0 -86
- package/dist/props.js +0 -16
- package/dist/props.js.map +0 -1
- package/dist/size-CQwNvOWd.d.ts +0 -19
- package/dist/types-LTj-2bl-.d.ts +0 -30
- package/dist/useTableViews-D5NIAJ7h.d.ts +0 -154
- package/src/tokens/tailwind.css +0 -158
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { resolveDefaultDateFormat } from "./date-format-labels";
|
|
3
|
+
import { getDateFnsLocale, getDayPickerLocale } from "./locales";
|
|
4
|
+
import { syncAppRequestHeaders } from "./request-headers";
|
|
5
|
+
import { syncI18nLocale } from "../i18n/translate";
|
|
6
|
+
import { syncDatetimeContext } from "../lib/datetime";
|
|
7
|
+
import { DEFAULT_STORAGE_KEY, readStoredPreferences, writeStoredPreferences } from "./storage";
|
|
8
|
+
import { resolveDefaultTimeFormat } from "./time-format-labels";
|
|
9
|
+
import { resolveDefaultTimezone } from "./timezones";
|
|
10
|
+
import {
|
|
11
|
+
APP_REQUEST_HEADER_DATE_FORMAT,
|
|
12
|
+
APP_REQUEST_HEADER_LOCALE,
|
|
13
|
+
APP_REQUEST_HEADER_TIME_FORMAT,
|
|
14
|
+
APP_REQUEST_HEADER_TIMEZONE,
|
|
15
|
+
type AppDateFormat,
|
|
16
|
+
type AppLocale,
|
|
17
|
+
type AppRequestHeaders,
|
|
18
|
+
type AppTimeFormat,
|
|
19
|
+
type AppTimezone,
|
|
20
|
+
} from "./types";
|
|
21
|
+
import type { AppContextValue, AppProviderProp } from "../props/components/app.prop";
|
|
22
|
+
|
|
23
|
+
export type { AppProviderProp, AppContextValue } from "../props/components/app.prop";
|
|
24
|
+
|
|
25
|
+
const AppContext = React.createContext<AppContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
function resolveInitialTimeFormat(
|
|
28
|
+
stored: AppTimeFormat | undefined,
|
|
29
|
+
defaultTimeFormat: AppTimeFormat | "locale",
|
|
30
|
+
locale: AppLocale,
|
|
31
|
+
): AppTimeFormat {
|
|
32
|
+
if (stored) return stored;
|
|
33
|
+
if (defaultTimeFormat === "locale") return resolveDefaultTimeFormat(locale);
|
|
34
|
+
return defaultTimeFormat;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveInitialDateFormat(
|
|
38
|
+
stored: AppDateFormat | undefined,
|
|
39
|
+
defaultDateFormat: AppDateFormat | "locale",
|
|
40
|
+
locale: AppLocale,
|
|
41
|
+
): AppDateFormat {
|
|
42
|
+
if (stored) return stored;
|
|
43
|
+
if (defaultDateFormat === "locale") return resolveDefaultDateFormat(locale);
|
|
44
|
+
return defaultDateFormat;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildRequestHeaders(
|
|
48
|
+
locale: AppLocale,
|
|
49
|
+
timezone: AppTimezone,
|
|
50
|
+
timeFormat: AppTimeFormat,
|
|
51
|
+
dateFormat: AppDateFormat,
|
|
52
|
+
): AppRequestHeaders {
|
|
53
|
+
return {
|
|
54
|
+
[APP_REQUEST_HEADER_LOCALE]: locale,
|
|
55
|
+
[APP_REQUEST_HEADER_TIMEZONE]: timezone,
|
|
56
|
+
[APP_REQUEST_HEADER_TIME_FORMAT]: timeFormat,
|
|
57
|
+
[APP_REQUEST_HEADER_DATE_FORMAT]: dateFormat,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function AppProvider({
|
|
62
|
+
children,
|
|
63
|
+
defaultLocale = "vi",
|
|
64
|
+
fallbackLocale = "en",
|
|
65
|
+
defaultTimezone = "browser",
|
|
66
|
+
systemTimezone,
|
|
67
|
+
defaultTimeFormat = "locale",
|
|
68
|
+
defaultDateFormat = "locale",
|
|
69
|
+
timezoneOptions,
|
|
70
|
+
storageKey = DEFAULT_STORAGE_KEY,
|
|
71
|
+
persist = true,
|
|
72
|
+
onLocaleChange,
|
|
73
|
+
onTimezoneChange,
|
|
74
|
+
onTimeFormatChange,
|
|
75
|
+
onDateFormatChange,
|
|
76
|
+
}: AppProviderProp) {
|
|
77
|
+
const stored = React.useMemo(
|
|
78
|
+
() => (persist ? readStoredPreferences(storageKey) : {}),
|
|
79
|
+
[persist, storageKey],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const initialLocale = stored.locale ?? defaultLocale;
|
|
83
|
+
|
|
84
|
+
const [locale, setLocaleState] = React.useState<AppLocale>(initialLocale);
|
|
85
|
+
const [timezone, setTimezoneState] = React.useState<AppTimezone>(
|
|
86
|
+
stored.timezone ?? resolveDefaultTimezone(defaultTimezone, systemTimezone),
|
|
87
|
+
);
|
|
88
|
+
const [timeFormat, setTimeFormatState] = React.useState<AppTimeFormat>(() =>
|
|
89
|
+
resolveInitialTimeFormat(stored.timeFormat, defaultTimeFormat, initialLocale),
|
|
90
|
+
);
|
|
91
|
+
const [dateFormat, setDateFormatState] = React.useState<AppDateFormat>(() =>
|
|
92
|
+
resolveInitialDateFormat(stored.dateFormat, defaultDateFormat, initialLocale),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const prefsRef = React.useRef({ locale, timezone, timeFormat, dateFormat });
|
|
96
|
+
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
prefsRef.current = { locale, timezone, timeFormat, dateFormat };
|
|
99
|
+
}, [locale, timezone, timeFormat, dateFormat]);
|
|
100
|
+
|
|
101
|
+
const setLocale = React.useCallback(
|
|
102
|
+
(next: AppLocale) => {
|
|
103
|
+
prefsRef.current = { ...prefsRef.current, locale: next };
|
|
104
|
+
setLocaleState(next);
|
|
105
|
+
onLocaleChange?.(next);
|
|
106
|
+
if (persist) writeStoredPreferences(storageKey, prefsRef.current);
|
|
107
|
+
},
|
|
108
|
+
[onLocaleChange, persist, storageKey],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const setTimezone = React.useCallback(
|
|
112
|
+
(next: AppTimezone) => {
|
|
113
|
+
prefsRef.current = { ...prefsRef.current, timezone: next };
|
|
114
|
+
setTimezoneState(next);
|
|
115
|
+
onTimezoneChange?.(next);
|
|
116
|
+
if (persist) writeStoredPreferences(storageKey, prefsRef.current);
|
|
117
|
+
},
|
|
118
|
+
[onTimezoneChange, persist, storageKey],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const setTimeFormat = React.useCallback(
|
|
122
|
+
(next: AppTimeFormat) => {
|
|
123
|
+
prefsRef.current = { ...prefsRef.current, timeFormat: next };
|
|
124
|
+
setTimeFormatState(next);
|
|
125
|
+
onTimeFormatChange?.(next);
|
|
126
|
+
if (persist) writeStoredPreferences(storageKey, prefsRef.current);
|
|
127
|
+
},
|
|
128
|
+
[onTimeFormatChange, persist, storageKey],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const setDateFormat = React.useCallback(
|
|
132
|
+
(next: AppDateFormat) => {
|
|
133
|
+
prefsRef.current = { ...prefsRef.current, dateFormat: next };
|
|
134
|
+
setDateFormatState(next);
|
|
135
|
+
onDateFormatChange?.(next);
|
|
136
|
+
if (persist) writeStoredPreferences(storageKey, prefsRef.current);
|
|
137
|
+
},
|
|
138
|
+
[onDateFormatChange, persist, storageKey],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const requestHeaders = React.useMemo(
|
|
142
|
+
() => buildRequestHeaders(locale, timezone, timeFormat, dateFormat),
|
|
143
|
+
[locale, timezone, timeFormat, dateFormat],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
React.useEffect(() => {
|
|
147
|
+
syncAppRequestHeaders(requestHeaders);
|
|
148
|
+
syncI18nLocale(locale, fallbackLocale);
|
|
149
|
+
syncDatetimeContext({
|
|
150
|
+
locale,
|
|
151
|
+
timezone,
|
|
152
|
+
timeFormat,
|
|
153
|
+
dateFormat,
|
|
154
|
+
dateFnsLocale: getDateFnsLocale(locale),
|
|
155
|
+
});
|
|
156
|
+
}, [requestHeaders, locale, fallbackLocale, timezone, timeFormat, dateFormat]);
|
|
157
|
+
|
|
158
|
+
const value = React.useMemo<AppContextValue>(
|
|
159
|
+
() => ({
|
|
160
|
+
locale,
|
|
161
|
+
fallbackLocale,
|
|
162
|
+
timezone,
|
|
163
|
+
timeFormat,
|
|
164
|
+
dateFormat,
|
|
165
|
+
dateFnsLocale: getDateFnsLocale(locale),
|
|
166
|
+
dayPickerLocale: getDayPickerLocale(locale),
|
|
167
|
+
requestHeaders,
|
|
168
|
+
timezoneOptions,
|
|
169
|
+
setLocale,
|
|
170
|
+
setTimezone,
|
|
171
|
+
setTimeFormat,
|
|
172
|
+
setDateFormat,
|
|
173
|
+
}),
|
|
174
|
+
[
|
|
175
|
+
locale,
|
|
176
|
+
fallbackLocale,
|
|
177
|
+
timezone,
|
|
178
|
+
timeFormat,
|
|
179
|
+
dateFormat,
|
|
180
|
+
requestHeaders,
|
|
181
|
+
timezoneOptions,
|
|
182
|
+
setLocale,
|
|
183
|
+
setTimezone,
|
|
184
|
+
setTimeFormat,
|
|
185
|
+
setDateFormat,
|
|
186
|
+
],
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function useAppContext(): AppContextValue {
|
|
193
|
+
const ctx = React.useContext(AppContext);
|
|
194
|
+
if (!ctx) {
|
|
195
|
+
throw new Error("useAppContext must be used within <AppProvider>");
|
|
196
|
+
}
|
|
197
|
+
return ctx;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Returns null outside AppProvider — used by pickers for optional context. */
|
|
201
|
+
export function useOptionalAppContext(): AppContextValue | null {
|
|
202
|
+
return React.useContext(AppContext);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Shorthand for `{ locale, setLocale }`. */
|
|
206
|
+
export function useAppLocale() {
|
|
207
|
+
const { locale, fallbackLocale, setLocale, dateFnsLocale, dayPickerLocale } = useAppContext();
|
|
208
|
+
return { locale, fallbackLocale, setLocale, dateFnsLocale, dayPickerLocale };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Shorthand for `{ timezone, setTimezone }`. */
|
|
212
|
+
export function useAppTimezone() {
|
|
213
|
+
const { timezone, setTimezone } = useAppContext();
|
|
214
|
+
return { timezone, setTimezone };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Shorthand for `{ timeFormat, setTimeFormat }`. */
|
|
218
|
+
export function useAppTimeFormat() {
|
|
219
|
+
const { timeFormat, setTimeFormat } = useAppContext();
|
|
220
|
+
return { timeFormat, setTimeFormat };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Shorthand for `{ dateFormat, setDateFormat }`. */
|
|
224
|
+
export function useAppDateFormat() {
|
|
225
|
+
const { dateFormat, setDateFormat } = useAppContext();
|
|
226
|
+
return { dateFormat, setDateFormat };
|
|
227
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { APP_DATE_FORMATS } from "./date-formats";
|
|
2
|
+
import type { AppLocale } from "./types";
|
|
3
|
+
import type { AppDateFormat } from "./date-formats";
|
|
4
|
+
import { translate } from "../i18n/translate";
|
|
5
|
+
|
|
6
|
+
export const APP_DATE_FORMAT_OPTIONS = APP_DATE_FORMATS.map((value) => ({ value }));
|
|
7
|
+
|
|
8
|
+
export function getDateFormatLabel(
|
|
9
|
+
dateFormat: AppDateFormat,
|
|
10
|
+
locale: AppLocale,
|
|
11
|
+
fallbackLocale: AppLocale = "en",
|
|
12
|
+
): string {
|
|
13
|
+
return translate(locale, fallbackLocale, `dateFormat.${dateFormat}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Suggested default per locale — vi → dmy, ja → iso, en → mdy. */
|
|
17
|
+
export function resolveDefaultDateFormat(locale: AppLocale): AppDateFormat {
|
|
18
|
+
if (locale === "en") return "mdy";
|
|
19
|
+
if (locale === "ja") return "iso";
|
|
20
|
+
return "dmy";
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Date-only display preset — sent as `x-date-format` to backend. */
|
|
2
|
+
import { getTimePattern, type AppTimeFormat } from "./time-formats";
|
|
3
|
+
|
|
4
|
+
export type AppDateFormat = "iso" | "dmy" | "mdy";
|
|
5
|
+
|
|
6
|
+
export const APP_DATE_FORMATS = ["iso", "dmy", "mdy"] as const satisfies readonly AppDateFormat[];
|
|
7
|
+
|
|
8
|
+
export const APP_REQUEST_HEADER_DATE_FORMAT = "x-date-format" as const;
|
|
9
|
+
|
|
10
|
+
/** date-fns pattern for date-only display. */
|
|
11
|
+
export function getDatePattern(dateFormat: AppDateFormat): string {
|
|
12
|
+
switch (dateFormat) {
|
|
13
|
+
case "dmy":
|
|
14
|
+
return "dd/MM/yyyy";
|
|
15
|
+
case "mdy":
|
|
16
|
+
return "MM/dd/yyyy";
|
|
17
|
+
case "iso":
|
|
18
|
+
default:
|
|
19
|
+
return "yyyy-MM-dd";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** date-fns pattern for date + time (table cells). */
|
|
24
|
+
export function getDateTimePattern(timeFormat: AppTimeFormat, dateFormat: AppDateFormat): string {
|
|
25
|
+
return `${getDatePattern(dateFormat)} ${getTimePattern(timeFormat)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isAppDateFormat(value: string | null | undefined): value is AppDateFormat {
|
|
29
|
+
return value === "iso" || value === "dmy" || value === "mdy";
|
|
30
|
+
}
|
package/src/app/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export * from "./locales";
|
|
3
|
+
export * from "./timezones";
|
|
4
|
+
export * from "./time-formats";
|
|
5
|
+
export * from "./date-formats";
|
|
6
|
+
export * from "./time-format-labels";
|
|
7
|
+
export * from "./date-format-labels";
|
|
8
|
+
export * from "./storage";
|
|
9
|
+
export * from "./request-headers";
|
|
10
|
+
export {
|
|
11
|
+
AppProvider,
|
|
12
|
+
useAppContext,
|
|
13
|
+
useOptionalAppContext,
|
|
14
|
+
useAppLocale,
|
|
15
|
+
useAppTimezone,
|
|
16
|
+
useAppTimeFormat,
|
|
17
|
+
useAppDateFormat,
|
|
18
|
+
} from "./app-provider";
|
|
19
|
+
export { useFormatting, useDateTime } from "./use-formatting";
|
|
20
|
+
export {
|
|
21
|
+
syncDatetimeContext,
|
|
22
|
+
getDatetimeContext,
|
|
23
|
+
formatDate,
|
|
24
|
+
isFormatDateValue,
|
|
25
|
+
detectFormatDateKind,
|
|
26
|
+
formatAppDate,
|
|
27
|
+
formatAppDateTime,
|
|
28
|
+
formatAppTime,
|
|
29
|
+
formatAppDateLong,
|
|
30
|
+
formatAppRelative,
|
|
31
|
+
formatCalendarDate,
|
|
32
|
+
formatTimeOfDay,
|
|
33
|
+
parseDateInput,
|
|
34
|
+
normalizeHhmm,
|
|
35
|
+
isValidHhmm,
|
|
36
|
+
type FormatDateOptions,
|
|
37
|
+
type FormatDateKind,
|
|
38
|
+
type FormatDatetimeOptions,
|
|
39
|
+
} from "../lib/datetime";
|
|
40
|
+
export { useTranslation, usePickerLocales } from "../i18n/use-translation";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Locale } from "date-fns";
|
|
2
|
+
import { enUS, ja, vi } from "date-fns/locale";
|
|
3
|
+
import {
|
|
4
|
+
enUS as enUSDayPicker,
|
|
5
|
+
ja as jaDayPicker,
|
|
6
|
+
vi as viDayPicker,
|
|
7
|
+
} from "react-day-picker/locale";
|
|
8
|
+
import type { AppLocale } from "./types";
|
|
9
|
+
|
|
10
|
+
export type AppLocaleConfig = {
|
|
11
|
+
code: AppLocale;
|
|
12
|
+
dateFns: Locale;
|
|
13
|
+
dayPicker: typeof viDayPicker;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const APP_LOCALE_CONFIG: Record<AppLocale, AppLocaleConfig> = {
|
|
17
|
+
vi: { code: "vi", dateFns: vi, dayPicker: viDayPicker },
|
|
18
|
+
en: { code: "en", dateFns: enUS, dayPicker: enUSDayPicker },
|
|
19
|
+
ja: { code: "ja", dateFns: ja, dayPicker: jaDayPicker },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function isAppLocale(value: string | null | undefined): value is AppLocale {
|
|
23
|
+
return value === "vi" || value === "en" || value === "ja";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getDateFnsLocale(locale: AppLocale): Locale {
|
|
27
|
+
return APP_LOCALE_CONFIG[locale].dateFns;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getDayPickerLocale(locale: AppLocale) {
|
|
31
|
+
return APP_LOCALE_CONFIG[locale].dayPicker;
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AppRequestHeaders } from "./types";
|
|
2
|
+
import {
|
|
3
|
+
APP_REQUEST_HEADER_DATE_FORMAT,
|
|
4
|
+
APP_REQUEST_HEADER_LOCALE,
|
|
5
|
+
APP_REQUEST_HEADER_TIME_FORMAT,
|
|
6
|
+
APP_REQUEST_HEADER_TIMEZONE,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_HEADERS: AppRequestHeaders = {
|
|
10
|
+
[APP_REQUEST_HEADER_LOCALE]: "vi",
|
|
11
|
+
[APP_REQUEST_HEADER_TIMEZONE]: "Asia/Ho_Chi_Minh",
|
|
12
|
+
[APP_REQUEST_HEADER_TIME_FORMAT]: "24h",
|
|
13
|
+
[APP_REQUEST_HEADER_DATE_FORMAT]: "dmy",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let currentHeaders: AppRequestHeaders = { ...DEFAULT_HEADERS };
|
|
17
|
+
|
|
18
|
+
/** Sync locale/timezone into module state for HTTP clients (via `getAppRequestHeaders`). */
|
|
19
|
+
export function syncAppRequestHeaders(headers: Partial<AppRequestHeaders>): void {
|
|
20
|
+
currentHeaders = { ...currentHeaders, ...headers };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read current app preference headers — wire to API client `setAppHeaderProvider`. */
|
|
24
|
+
export function getAppRequestHeaders(): AppRequestHeaders {
|
|
25
|
+
return { ...currentHeaders };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reset to defaults — for tests only. */
|
|
29
|
+
export function resetAppRequestHeaders(): void {
|
|
30
|
+
currentHeaders = { ...DEFAULT_HEADERS };
|
|
31
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isAppLocale } from "./locales";
|
|
2
|
+
import { isAppDateFormat } from "./date-formats";
|
|
3
|
+
import { isAppTimeFormat } from "./time-formats";
|
|
4
|
+
import type { AppLocale, AppTimezone, AppTimeFormat, AppDateFormat } from "./types";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_STORAGE_KEY = "godxjp.app";
|
|
7
|
+
|
|
8
|
+
export type StoredAppPreferences = {
|
|
9
|
+
locale?: AppLocale;
|
|
10
|
+
timezone?: AppTimezone;
|
|
11
|
+
timeFormat?: AppTimeFormat;
|
|
12
|
+
dateFormat?: AppDateFormat;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function readStoredPreferences(storageKey: string): StoredAppPreferences {
|
|
16
|
+
if (typeof window === "undefined") return {};
|
|
17
|
+
try {
|
|
18
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
19
|
+
if (!raw) return {};
|
|
20
|
+
const parsed = JSON.parse(raw) as StoredAppPreferences;
|
|
21
|
+
return {
|
|
22
|
+
locale: isAppLocale(parsed.locale) ? parsed.locale : undefined,
|
|
23
|
+
timezone: typeof parsed.timezone === "string" ? parsed.timezone : undefined,
|
|
24
|
+
timeFormat: isAppTimeFormat(parsed.timeFormat) ? parsed.timeFormat : undefined,
|
|
25
|
+
dateFormat: isAppDateFormat(parsed.dateFormat) ? parsed.dateFormat : undefined,
|
|
26
|
+
};
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeStoredPreferences(
|
|
33
|
+
storageKey: string,
|
|
34
|
+
preferences: StoredAppPreferences,
|
|
35
|
+
): void {
|
|
36
|
+
if (typeof window === "undefined") return;
|
|
37
|
+
try {
|
|
38
|
+
window.localStorage.setItem(storageKey, JSON.stringify(preferences));
|
|
39
|
+
} catch {
|
|
40
|
+
// Quota or private mode — ignore.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { DEFAULT_STORAGE_KEY };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { APP_TIME_FORMATS } from "./time-formats";
|
|
2
|
+
import type { AppLocale } from "./types";
|
|
3
|
+
import type { AppTimeFormat } from "./time-formats";
|
|
4
|
+
import { translate } from "../i18n/translate";
|
|
5
|
+
|
|
6
|
+
export const APP_TIME_FORMAT_OPTIONS = APP_TIME_FORMATS.map((value) => ({ value }));
|
|
7
|
+
|
|
8
|
+
export function getTimeFormatLabel(
|
|
9
|
+
timeFormat: AppTimeFormat,
|
|
10
|
+
locale: AppLocale,
|
|
11
|
+
fallbackLocale: AppLocale = "en",
|
|
12
|
+
): string {
|
|
13
|
+
return translate(locale, fallbackLocale, `timeFormat.${timeFormat}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Suggested default per locale — vi/ja → 24h, en → 12h. */
|
|
17
|
+
export function resolveDefaultTimeFormat(locale: AppLocale): AppTimeFormat {
|
|
18
|
+
return locale === "en" ? "12h" : "24h";
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Clock display — sent as `x-time-format` to backend. */
|
|
2
|
+
export type AppTimeFormat = "24h" | "12h";
|
|
3
|
+
|
|
4
|
+
export const APP_TIME_FORMATS = ["24h", "12h"] as const satisfies readonly AppTimeFormat[];
|
|
5
|
+
|
|
6
|
+
export const APP_REQUEST_HEADER_TIME_FORMAT = "x-time-format" as const;
|
|
7
|
+
|
|
8
|
+
/** date-fns pattern for time-only display. */
|
|
9
|
+
export function getTimePattern(timeFormat: AppTimeFormat): string {
|
|
10
|
+
return timeFormat === "24h" ? "HH:mm" : "h:mm a";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isAppTimeFormat(value: string | null | undefined): value is AppTimeFormat {
|
|
14
|
+
return value === "24h" || value === "12h";
|
|
15
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { translate } from "../i18n/translate";
|
|
2
|
+
import type { AppLocale } from "./types";
|
|
3
|
+
|
|
4
|
+
/** Curated preset — pass to `<AppProvider timezoneOptions={APP_TIMEZONE_PRESET} />`. */
|
|
5
|
+
export const APP_TIMEZONE_PRESET = [
|
|
6
|
+
"UTC",
|
|
7
|
+
"Asia/Ho_Chi_Minh",
|
|
8
|
+
"Asia/Bangkok",
|
|
9
|
+
"Asia/Singapore",
|
|
10
|
+
"Asia/Jakarta",
|
|
11
|
+
"Asia/Manila",
|
|
12
|
+
"Asia/Kuala_Lumpur",
|
|
13
|
+
"Asia/Yangon",
|
|
14
|
+
"Asia/Phnom_Penh",
|
|
15
|
+
"Asia/Vientiane",
|
|
16
|
+
"Asia/Brunei",
|
|
17
|
+
"Asia/Tokyo",
|
|
18
|
+
"Asia/Seoul",
|
|
19
|
+
"Asia/Shanghai",
|
|
20
|
+
"Asia/Hong_Kong",
|
|
21
|
+
"Asia/Taipei",
|
|
22
|
+
"Asia/Ulaanbaatar",
|
|
23
|
+
"Asia/Kolkata",
|
|
24
|
+
"Asia/Karachi",
|
|
25
|
+
"Asia/Dhaka",
|
|
26
|
+
"Asia/Colombo",
|
|
27
|
+
"Asia/Dubai",
|
|
28
|
+
"Asia/Riyadh",
|
|
29
|
+
"Asia/Qatar",
|
|
30
|
+
"Asia/Tehran",
|
|
31
|
+
"Asia/Jerusalem",
|
|
32
|
+
"Europe/London",
|
|
33
|
+
"Europe/Paris",
|
|
34
|
+
"Europe/Berlin",
|
|
35
|
+
"Europe/Amsterdam",
|
|
36
|
+
"Europe/Rome",
|
|
37
|
+
"Europe/Madrid",
|
|
38
|
+
"Europe/Moscow",
|
|
39
|
+
"Europe/Istanbul",
|
|
40
|
+
"America/New_York",
|
|
41
|
+
"America/Chicago",
|
|
42
|
+
"America/Denver",
|
|
43
|
+
"America/Los_Angeles",
|
|
44
|
+
"America/Toronto",
|
|
45
|
+
"America/Vancouver",
|
|
46
|
+
"America/Mexico_City",
|
|
47
|
+
"America/Sao_Paulo",
|
|
48
|
+
"America/Buenos_Aires",
|
|
49
|
+
"Australia/Sydney",
|
|
50
|
+
"Australia/Melbourne",
|
|
51
|
+
"Australia/Perth",
|
|
52
|
+
"Pacific/Auckland",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/** @deprecated Use `APP_TIMEZONE_PRESET` or `getAllIanaTimezones()`. */
|
|
56
|
+
export const APP_TIMEZONE_OPTIONS = APP_TIMEZONE_PRESET;
|
|
57
|
+
|
|
58
|
+
export type AppTimezonePreset = (typeof APP_TIMEZONE_PRESET)[number];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Preferred IANA ids not returned by all runtimes (e.g. Node lists `Asia/Saigon` only).
|
|
62
|
+
* Keys are app-facing ids; values are Intl-canonical ids for offset lookup.
|
|
63
|
+
*/
|
|
64
|
+
export const TIMEZONE_ALIASES: Record<string, string> = {
|
|
65
|
+
"Asia/Ho_Chi_Minh": "Asia/Saigon",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const PREFERRED_TIMEZONE_IDS = Object.keys(TIMEZONE_ALIASES);
|
|
69
|
+
|
|
70
|
+
/** Map app id → Intl id for `DateTimeFormat` / validation. */
|
|
71
|
+
export function resolveTimezoneForIntl(timezone: string): string {
|
|
72
|
+
return TIMEZONE_ALIASES[timezone] ?? timezone;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Minimal fallback when `Intl.supportedValuesOf("timeZone")` is unavailable. */
|
|
76
|
+
const FALLBACK_IANA_TIMEZONES = [...APP_TIMEZONE_PRESET] as const;
|
|
77
|
+
|
|
78
|
+
let cachedAllTimezones: readonly string[] | null = null;
|
|
79
|
+
let cachedTimezoneSet: ReadonlySet<string> | null = null;
|
|
80
|
+
|
|
81
|
+
function enrichTimezoneList(base: readonly string[]): readonly string[] {
|
|
82
|
+
const set = new Set(base);
|
|
83
|
+
for (const preferred of PREFERRED_TIMEZONE_IDS) {
|
|
84
|
+
if (!set.has(preferred)) {
|
|
85
|
+
const canonical = TIMEZONE_ALIASES[preferred];
|
|
86
|
+
if (!canonical || set.has(canonical)) set.add(preferred);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...set].sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Full IANA list from runtime — sorted lexicographically. */
|
|
93
|
+
export function getAllIanaTimezones(): readonly string[] {
|
|
94
|
+
if (cachedAllTimezones) return cachedAllTimezones;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const intl = Intl as typeof Intl & { supportedValuesOf?: (key: string) => string[] };
|
|
98
|
+
if (typeof intl.supportedValuesOf === "function") {
|
|
99
|
+
cachedAllTimezones = enrichTimezoneList(intl.supportedValuesOf("timeZone").slice().sort());
|
|
100
|
+
return cachedAllTimezones;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
/* use fallback */
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
cachedAllTimezones = FALLBACK_IANA_TIMEZONES;
|
|
107
|
+
return cachedAllTimezones;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getIanaTimezoneSet(): ReadonlySet<string> {
|
|
111
|
+
cachedTimezoneSet ??= new Set(getAllIanaTimezones());
|
|
112
|
+
return cachedTimezoneSet;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isValidIanaTimezone(value: string): boolean {
|
|
116
|
+
const set = getIanaTimezoneSet();
|
|
117
|
+
if (set.has(value)) return true;
|
|
118
|
+
const canonical = TIMEZONE_ALIASES[value];
|
|
119
|
+
return canonical ? set.has(canonical) : false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @deprecated Use `isValidIanaTimezone`. */
|
|
123
|
+
export function isKnownAppTimezone(value: string): value is AppTimezonePreset {
|
|
124
|
+
return (APP_TIMEZONE_PRESET as readonly string[]).includes(value);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Options for TimezonePicker.
|
|
129
|
+
* - `configured` omitted/empty → full IANA list
|
|
130
|
+
* - `configured` set → only those ids (+ `current` if missing)
|
|
131
|
+
*/
|
|
132
|
+
export function resolveTimezonePickerOptions(
|
|
133
|
+
configured?: readonly string[] | null,
|
|
134
|
+
current?: string,
|
|
135
|
+
): readonly string[] {
|
|
136
|
+
const base = configured && configured.length > 0 ? [...configured] : [...getAllIanaTimezones()];
|
|
137
|
+
|
|
138
|
+
if (current && !base.includes(current)) {
|
|
139
|
+
return [current, ...base];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return base;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getBrowserTimezone(): string {
|
|
146
|
+
try {
|
|
147
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
148
|
+
} catch {
|
|
149
|
+
return "UTC";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** City/region segment from IANA id — `Asia/Ho_Chi_Minh` → `Ho Chi Minh`. */
|
|
154
|
+
export function getTimezoneCityName(timezone: string): string {
|
|
155
|
+
const segment = timezone.split("/").pop();
|
|
156
|
+
if (!segment) return timezone;
|
|
157
|
+
return segment.replace(/_/g, " ");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** UTC/GMT offset via Intl — e.g. `GMT+7`, `UTC`. */
|
|
161
|
+
export function getTimezoneOffsetLabel(timezone: string, locale: AppLocale = "en"): string {
|
|
162
|
+
if (timezone === "UTC") return "UTC";
|
|
163
|
+
try {
|
|
164
|
+
const parts = new Intl.DateTimeFormat(locale, {
|
|
165
|
+
timeZone: resolveTimezoneForIntl(timezone),
|
|
166
|
+
timeZoneName: "shortOffset",
|
|
167
|
+
}).formatToParts(new Date());
|
|
168
|
+
return parts.find((part) => part.type === "timeZoneName")?.value ?? "";
|
|
169
|
+
} catch {
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Intl fallback when no i18n override — `Ho Chi Minh (GMT+7)`. */
|
|
175
|
+
export function formatTimezoneDisplayLabel(timezone: string, locale: AppLocale = "en"): string {
|
|
176
|
+
const city = getTimezoneCityName(timezone);
|
|
177
|
+
const offset = getTimezoneOffsetLabel(timezone, locale);
|
|
178
|
+
return offset ? `${city} (${offset})` : city;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function getTimezoneLabel(
|
|
182
|
+
timezone: string,
|
|
183
|
+
locale: AppLocale,
|
|
184
|
+
fallbackLocale: AppLocale = "en",
|
|
185
|
+
): string {
|
|
186
|
+
const key = `timezone.${timezone}`;
|
|
187
|
+
const translated = translate(locale, fallbackLocale, key);
|
|
188
|
+
if (translated !== key) return translated;
|
|
189
|
+
|
|
190
|
+
const offset = getTimezoneOffsetLabel(timezone, locale);
|
|
191
|
+
const city = getTimezoneCityName(timezone);
|
|
192
|
+
return offset ? `${city} (${offset})` : city;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function resolveDefaultTimezone(
|
|
196
|
+
defaultTimezone: "browser" | "system" | (string & {}),
|
|
197
|
+
systemTimezone?: string,
|
|
198
|
+
): string {
|
|
199
|
+
if (defaultTimezone === "browser") return getBrowserTimezone();
|
|
200
|
+
if (defaultTimezone === "system") return systemTimezone ?? "UTC";
|
|
201
|
+
return defaultTimezone;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Vitest only — reset cached IANA list between cases. */
|
|
205
|
+
export function resetIanaTimezoneCacheForTests(): void {
|
|
206
|
+
cachedAllTimezones = null;
|
|
207
|
+
cachedTimezoneSet = null;
|
|
208
|
+
}
|