@godxjp/ui 5.0.2 → 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.
Files changed (298) hide show
  1. package/README.md +101 -142
  2. package/package.json +124 -128
  3. package/scripts/ui-audit.mjs +179 -0
  4. package/src/app/__tests__/app-provider.test.tsx +232 -0
  5. package/src/app/__tests__/date-format-labels.test.ts +36 -0
  6. package/src/app/__tests__/date-formats.test.ts +44 -0
  7. package/src/app/__tests__/timezones.test.ts +65 -0
  8. package/src/app/app-provider.tsx +227 -0
  9. package/src/app/date-format-labels.ts +21 -0
  10. package/src/app/date-formats.ts +30 -0
  11. package/src/app/index.ts +40 -0
  12. package/src/app/locales.ts +32 -0
  13. package/src/app/request-headers.ts +31 -0
  14. package/src/app/storage.ts +44 -0
  15. package/src/app/time-format-labels.ts +19 -0
  16. package/src/app/time-formats.ts +15 -0
  17. package/src/app/timezones.ts +208 -0
  18. package/src/app/types.ts +39 -0
  19. package/src/app/use-formatting.ts +47 -0
  20. package/src/components/__tests__/accessibility-primitives.test.tsx +65 -0
  21. package/src/components/__tests__/docs-parity.test.ts +41 -0
  22. package/src/components/__tests__/shadcn-release-guardrails.test.ts +71 -0
  23. package/src/components/__tests__/theme-axes-integration.test.tsx +242 -0
  24. package/src/components/admin/index.ts +76 -0
  25. package/src/components/data-display/__tests__/card-table.test.tsx +328 -0
  26. package/src/components/data-display/__tests__/data-display.test.tsx +73 -0
  27. package/src/components/data-display/__tests__/data-table.test.tsx +84 -0
  28. package/src/components/data-display/__tests__/popover.test.tsx +92 -0
  29. package/src/components/data-display/__tests__/scroll-area-collapsible.test.tsx +66 -0
  30. package/src/components/data-display/badge.tsx +27 -0
  31. package/src/components/data-display/card.tsx +194 -0
  32. package/src/components/data-display/code-badge.tsx +28 -0
  33. package/src/components/data-display/collapsible.tsx +5 -0
  34. package/src/components/data-display/data-table.tsx +476 -0
  35. package/src/components/data-display/empty-state.tsx +22 -0
  36. package/src/components/data-display/index.ts +41 -0
  37. package/src/components/data-display/key-value-grid.tsx +46 -0
  38. package/src/components/data-display/popover.tsx +62 -0
  39. package/src/components/data-display/progress-meter.tsx +20 -0
  40. package/src/components/data-display/scan-panel.tsx +16 -0
  41. package/src/components/data-display/scroll-area.tsx +42 -0
  42. package/src/components/data-display/status-badge.tsx +83 -0
  43. package/src/components/data-display/table.tsx +59 -0
  44. package/src/components/data-display/timeline.tsx +42 -0
  45. package/src/components/data-display/tree-list.tsx +42 -0
  46. package/src/components/data-entry/__fixtures__/tree-options.ts +80 -0
  47. package/src/components/data-entry/__tests__/cascader-tree-transfer.test.tsx +417 -0
  48. package/src/components/data-entry/__tests__/checkbox-group.test.tsx +40 -0
  49. package/src/components/data-entry/__tests__/checkbox.test.tsx +20 -0
  50. package/src/components/data-entry/__tests__/date-autocomplete.test.tsx +94 -0
  51. package/src/components/data-entry/__tests__/form-field.test.tsx +49 -0
  52. package/src/components/data-entry/__tests__/input-textarea.test.tsx +38 -0
  53. package/src/components/data-entry/__tests__/label-select.test.tsx +62 -0
  54. package/src/components/data-entry/__tests__/pickers.test.tsx +74 -0
  55. package/src/components/data-entry/__tests__/radio.test.tsx +46 -0
  56. package/src/components/data-entry/__tests__/search-input.test.tsx +32 -0
  57. package/src/components/data-entry/__tests__/switch-field.test.tsx +52 -0
  58. package/src/components/data-entry/__tests__/upload.test.tsx +125 -0
  59. package/src/components/data-entry/autocomplete.tsx +91 -0
  60. package/src/components/data-entry/calendar.tsx +90 -0
  61. package/src/components/data-entry/cascader.tsx +305 -0
  62. package/src/components/data-entry/checkbox-group.tsx +90 -0
  63. package/src/components/data-entry/checkbox.tsx +30 -0
  64. package/src/components/data-entry/choice-field.tsx +27 -0
  65. package/src/components/data-entry/choice-option.ts +20 -0
  66. package/src/components/data-entry/color-picker.tsx +75 -0
  67. package/src/components/data-entry/command.tsx +56 -0
  68. package/src/components/data-entry/country-select.tsx +88 -0
  69. package/src/components/data-entry/date-picker.tsx +69 -0
  70. package/src/components/data-entry/date-range-picker.tsx +75 -0
  71. package/src/components/data-entry/form-field.tsx +59 -0
  72. package/src/components/data-entry/index.ts +62 -0
  73. package/src/components/data-entry/input.tsx +26 -0
  74. package/src/components/data-entry/label.tsx +25 -0
  75. package/src/components/data-entry/radio.tsx +109 -0
  76. package/src/components/data-entry/search-input.tsx +103 -0
  77. package/src/components/data-entry/select.tsx +149 -0
  78. package/src/components/data-entry/slider.tsx +38 -0
  79. package/src/components/data-entry/switch-field.tsx +91 -0
  80. package/src/components/data-entry/switch.tsx +24 -0
  81. package/src/components/data-entry/textarea.tsx +12 -0
  82. package/src/components/data-entry/time-picker.tsx +214 -0
  83. package/src/components/data-entry/transfer.tsx +231 -0
  84. package/src/components/data-entry/tree-select-strategy.ts +6 -0
  85. package/src/components/data-entry/tree-select.tsx +279 -0
  86. package/src/components/data-entry/tree-utils.ts +221 -0
  87. package/src/components/data-entry/upload-crop-dialog.tsx +109 -0
  88. package/src/components/data-entry/upload-types.ts +86 -0
  89. package/src/components/data-entry/upload.tsx +498 -0
  90. package/src/components/data-entry/use-upload-draft.ts +93 -0
  91. package/src/components/feedback/__tests__/alert.test.tsx +127 -0
  92. package/src/components/feedback/__tests__/dialog.test.tsx +290 -0
  93. package/src/components/feedback/__tests__/sheet.test.tsx +94 -0
  94. package/src/components/feedback/__tests__/skeleton.test.tsx +25 -0
  95. package/src/components/feedback/__tests__/toast.test.tsx +52 -0
  96. package/src/components/feedback/alert.tsx +167 -0
  97. package/src/components/feedback/dialog.tsx +325 -0
  98. package/src/components/feedback/index.ts +53 -0
  99. package/src/components/feedback/sheet.tsx +130 -0
  100. package/src/components/feedback/skeleton.tsx +95 -0
  101. package/src/components/feedback/sonner.tsx +54 -0
  102. package/src/components/feedback/toaster.tsx +1 -0
  103. package/src/components/feedback/use-toast.ts +62 -0
  104. package/src/components/general/__tests__/button.test.tsx +71 -0
  105. package/src/components/general/button.tsx +61 -0
  106. package/src/components/general/index.ts +2 -0
  107. package/src/components/layout/__tests__/page-container.test.tsx +69 -0
  108. package/src/components/layout/__tests__/page-inset.test.tsx +14 -0
  109. package/src/components/layout/__tests__/stack-inline.test.tsx +39 -0
  110. package/src/components/layout/app-shell.tsx +42 -0
  111. package/src/components/layout/breadcrumb.tsx +35 -0
  112. package/src/components/layout/index.ts +31 -0
  113. package/src/components/layout/inline.tsx +13 -0
  114. package/src/components/layout/menu.tsx +34 -0
  115. package/src/components/layout/mobile-frame.tsx +57 -0
  116. package/src/components/layout/page-container.tsx +81 -0
  117. package/src/components/layout/page-inset.tsx +16 -0
  118. package/src/components/layout/responsive-grid.tsx +14 -0
  119. package/src/components/layout/shell-app.tsx +30 -0
  120. package/src/components/layout/sidebar.tsx +98 -0
  121. package/src/components/layout/split-pane.tsx +16 -0
  122. package/src/components/layout/stack.tsx +13 -0
  123. package/src/components/layout/topbar.tsx +108 -0
  124. package/src/components/navigation/__tests__/app-pickers.test.tsx +118 -0
  125. package/src/components/navigation/__tests__/dropdown-menu.test.tsx +104 -0
  126. package/src/components/navigation/__tests__/navigation.test.tsx +61 -0
  127. package/src/components/navigation/__tests__/pagination-steps-tabs.test.tsx +76 -0
  128. package/src/components/navigation/date-format-picker.tsx +55 -0
  129. package/src/components/navigation/dropdown-menu.tsx +190 -0
  130. package/src/components/navigation/filter-bar.tsx +38 -0
  131. package/src/components/navigation/index.ts +28 -0
  132. package/src/components/navigation/locale-picker.tsx +49 -0
  133. package/src/components/navigation/page-header.tsx +50 -0
  134. package/src/components/navigation/pagination-utils.ts +35 -0
  135. package/src/components/navigation/pagination.tsx +168 -0
  136. package/src/components/navigation/steps.tsx +163 -0
  137. package/src/components/navigation/tabs-items.tsx +69 -0
  138. package/src/components/navigation/tabs.tsx +67 -0
  139. package/src/components/navigation/time-format-picker.tsx +55 -0
  140. package/src/components/navigation/timezone-picker.tsx +63 -0
  141. package/src/components/query/__tests__/data-state.test.tsx +214 -0
  142. package/src/components/query/__tests__/infinite-prefetch.test.tsx +105 -0
  143. package/src/components/query/__tests__/query-helpers.test.tsx +61 -0
  144. package/src/components/query/data-state.tsx +58 -0
  145. package/src/components/query/index.ts +10 -0
  146. package/src/components/query/infinite-query-state.tsx +99 -0
  147. package/src/components/query/mutation-feedback.tsx +31 -0
  148. package/src/components/query/prefetch-link.tsx +45 -0
  149. package/src/components/query/query-refetch-button.tsx +41 -0
  150. package/src/components/ui/alert-dialog.tsx +1 -0
  151. package/src/components/ui/alert.tsx +1 -0
  152. package/src/components/ui/autocomplete.tsx +1 -0
  153. package/src/components/ui/badge.tsx +1 -0
  154. package/src/components/ui/button.tsx +1 -0
  155. package/src/components/ui/calendar.tsx +1 -0
  156. package/src/components/ui/card.tsx +1 -0
  157. package/src/components/ui/checkbox.tsx +1 -0
  158. package/src/components/ui/color-picker.tsx +1 -0
  159. package/src/components/ui/command.tsx +1 -0
  160. package/src/components/ui/date-picker.tsx +1 -0
  161. package/src/components/ui/date-range-picker.tsx +1 -0
  162. package/src/components/ui/dialog.tsx +1 -0
  163. package/src/components/ui/dropdown-menu.tsx +1 -0
  164. package/src/components/ui/index.tsx +31 -0
  165. package/src/components/ui/input.tsx +1 -0
  166. package/src/components/ui/label.tsx +1 -0
  167. package/src/components/ui/pagination.tsx +1 -0
  168. package/src/components/ui/popover.tsx +1 -0
  169. package/src/components/ui/radio.tsx +1 -0
  170. package/src/components/ui/scroll-area.tsx +1 -0
  171. package/src/components/ui/select.tsx +1 -0
  172. package/src/components/ui/sheet.tsx +1 -0
  173. package/src/components/ui/slider.tsx +1 -0
  174. package/src/components/ui/sonner.tsx +1 -0
  175. package/src/components/ui/switch.tsx +1 -0
  176. package/src/components/ui/table.tsx +1 -0
  177. package/src/components/ui/tabs-items.tsx +1 -0
  178. package/src/components/ui/tabs.tsx +1 -0
  179. package/src/components/ui/textarea.tsx +1 -0
  180. package/src/components/ui/time-picker.tsx +1 -0
  181. package/src/components/ui/upload.tsx +1 -0
  182. package/src/form/__tests__/use-zod-form.test.tsx +97 -0
  183. package/src/form/form-field-control.tsx +44 -0
  184. package/src/form/form-root.tsx +29 -0
  185. package/src/form/index.ts +7 -0
  186. package/src/form/use-zod-form.ts +29 -0
  187. package/src/i18n/__tests__/translate.test.ts +23 -0
  188. package/src/i18n/index.ts +9 -0
  189. package/src/i18n/messages/en.json +171 -0
  190. package/src/i18n/messages/ja.json +171 -0
  191. package/src/i18n/messages/vi.json +171 -0
  192. package/src/i18n/translate.ts +74 -0
  193. package/src/i18n/use-translation.ts +53 -0
  194. package/src/index.ts +3 -0
  195. package/src/lib/__tests__/control-styles.test.ts +78 -0
  196. package/src/lib/__tests__/datetime.test.ts +77 -0
  197. package/src/lib/__tests__/format-date.test.ts +97 -0
  198. package/src/lib/__tests__/format.test.ts +62 -0
  199. package/src/lib/__tests__/theme-tokens-audit.test.ts +176 -0
  200. package/src/lib/__tests__/theme-tokens-css.test.ts +118 -0
  201. package/src/lib/__tests__/token-governance.test.ts +191 -0
  202. package/src/lib/__tests__/variants.test.ts +18 -0
  203. package/src/lib/control-styles.ts +33 -0
  204. package/src/lib/datetime/detect.ts +25 -0
  205. package/src/lib/datetime/format-date.ts +100 -0
  206. package/src/lib/datetime/format.ts +140 -0
  207. package/src/lib/datetime/index.ts +25 -0
  208. package/src/lib/datetime/parse.ts +51 -0
  209. package/src/lib/datetime/sync.ts +48 -0
  210. package/src/lib/format.ts +114 -0
  211. package/src/lib/hooks.ts +54 -0
  212. package/src/lib/utils.ts +6 -0
  213. package/src/lib/variants.ts +40 -0
  214. package/src/props/components/app.prop.ts +99 -0
  215. package/src/props/components/data-display.prop.ts +73 -0
  216. package/src/props/components/data-entry.prop.ts +334 -0
  217. package/src/props/components/feedback.prop.ts +80 -0
  218. package/src/props/components/form.prop.ts +46 -0
  219. package/src/props/components/general.prop.ts +18 -0
  220. package/src/props/components/index.ts +99 -0
  221. package/src/props/components/layout.prop.ts +130 -0
  222. package/src/props/components/navigation.prop.ts +88 -0
  223. package/src/props/components/query.prop.ts +94 -0
  224. package/src/props/index.ts +17 -0
  225. package/src/props/registry.ts +603 -0
  226. package/src/props/vocabulary/content.prop.ts +35 -0
  227. package/src/props/vocabulary/data.prop.ts +46 -0
  228. package/src/props/vocabulary/index.ts +73 -0
  229. package/src/props/vocabulary/interaction.prop.ts +42 -0
  230. package/src/props/vocabulary/layout.prop.ts +25 -0
  231. package/src/props/vocabulary/navigation.prop.ts +19 -0
  232. package/src/props/vocabulary/shared.prop.ts +59 -0
  233. package/src/styles/alert-layout.css +191 -0
  234. package/src/styles/badge-layout.css +22 -0
  235. package/src/styles/card-layout.css +373 -0
  236. package/src/styles/control.css +504 -0
  237. package/src/styles/data-display-layout.css +246 -0
  238. package/src/styles/density.css +43 -0
  239. package/src/styles/dialog-layout.css +84 -0
  240. package/src/styles/index.css +105 -0
  241. package/src/styles/layout.css +479 -0
  242. package/src/styles/shell-layout.css +604 -0
  243. package/src/styles/table-layout.css +109 -0
  244. package/src/test/__tests__/render-loop-guard.test.tsx +38 -0
  245. package/src/test/jest-dom.d.ts +4 -0
  246. package/src/test/render-loop-guard.tsx +50 -0
  247. package/src/test/render.tsx +29 -0
  248. package/src/test/theme-globals.test.ts +77 -0
  249. package/src/test/theme-globals.ts +134 -0
  250. package/src/test/theme-test-utils.tsx +67 -0
  251. package/src/theme/example.service.css +37 -0
  252. package/src/tokens/base.css +13 -0
  253. package/src/tokens/foundation.css +151 -0
  254. package/src/tokens/primitives/badge.css +13 -0
  255. package/src/tokens/primitives/card.css +29 -0
  256. package/src/tokens/primitives/control.css +55 -0
  257. package/src/tokens/primitives/feedback.css +17 -0
  258. package/src/tokens/primitives/layout.css +20 -0
  259. package/src/tokens/primitives/navigation.css +13 -0
  260. package/src/tokens/primitives/table.css +10 -0
  261. package/BRAND.md +0 -296
  262. package/CHANGELOG.md +0 -668
  263. package/config/eslint.js +0 -54
  264. package/config/prettier.cjs +0 -20
  265. package/config/tsconfig.base.json +0 -22
  266. package/config/vitest.base.ts +0 -26
  267. package/dist/MiniMonth-YAmPGEpC.d.ts +0 -143
  268. package/dist/Table.types-BbsxoIYE.d.ts +0 -352
  269. package/dist/color-DO0qqUAb.d.ts +0 -38
  270. package/dist/components/composites.d.ts +0 -963
  271. package/dist/components/composites.js +0 -7343
  272. package/dist/components/composites.js.map +0 -1
  273. package/dist/components/primitives.d.ts +0 -2744
  274. package/dist/components/primitives.js +0 -7356
  275. package/dist/components/primitives.js.map +0 -1
  276. package/dist/components/shell.d.ts +0 -182
  277. package/dist/components/shell.js +0 -774
  278. package/dist/components/shell.js.map +0 -1
  279. package/dist/hooks.d.ts +0 -100
  280. package/dist/hooks.js +0 -558
  281. package/dist/hooks.js.map +0 -1
  282. package/dist/i18n.d.ts +0 -61
  283. package/dist/i18n.js +0 -860
  284. package/dist/i18n.js.map +0 -1
  285. package/dist/index.d.ts +0 -33
  286. package/dist/index.js +0 -13062
  287. package/dist/index.js.map +0 -1
  288. package/dist/padding-DY0JV5Ja.d.ts +0 -16
  289. package/dist/preferences.d.ts +0 -132
  290. package/dist/preferences.js +0 -262
  291. package/dist/preferences.js.map +0 -1
  292. package/dist/props.d.ts +0 -86
  293. package/dist/props.js +0 -16
  294. package/dist/props.js.map +0 -1
  295. package/dist/size-CQwNvOWd.d.ts +0 -19
  296. package/dist/types-LTj-2bl-.d.ts +0 -30
  297. package/dist/useTableViews-D5NIAJ7h.d.ts +0 -154
  298. package/src/tokens/tailwind.css +0 -158
@@ -0,0 +1,33 @@
1
+ /** Shared control sizing — reads `--control-height`, `--font-size-*` from density / theme. */
2
+ export const controlFieldClass =
3
+ "ui-control w-full rounded-lg border border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50";
4
+
5
+ export const controlMultilineClass =
6
+ "ui-control-multiline w-full rounded-lg border border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50";
7
+
8
+ export const controlTriggerClass =
9
+ "ui-control flex w-full items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-input bg-background shadow-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1";
10
+
11
+ export const controlIconClass = "size-[length:var(--control-height)] shrink-0";
12
+
13
+ export const controlIconSmClass = "size-[calc(var(--control-height)-0.5rem)] shrink-0";
14
+
15
+ /** Leading/affix icon inside an input row (search, command) — sized to `--control-icon-size`. */
16
+ export const controlIconLeadingClass = "size-[length:var(--control-icon-size)] shrink-0";
17
+
18
+ export const tableRowHeightClass = "h-[length:var(--table-row-height)]";
19
+
20
+ export const tableHeadHeightClass = "h-[length:var(--table-row-height)]";
21
+
22
+ export const tableCellPaddingClass = "py-[length:var(--table-cell-padding-y)]";
23
+
24
+ /** Semantic status / badge tones — always use tokens, never raw Tailwind palette. */
25
+ export const toneSuccessClass = "border-success/30 bg-success/10 text-success";
26
+
27
+ export const toneWarningClass = "border-warning/30 bg-warning/10 text-warning-foreground";
28
+
29
+ export const toneInfoClass = "border-info/30 bg-info/10 text-info";
30
+
31
+ export const toneDestructiveClass = "border-destructive/30 bg-destructive/10 text-destructive";
32
+
33
+ export const toneNeutralClass = "border-border bg-muted text-muted-foreground";
@@ -0,0 +1,25 @@
1
+ import { isValidHhmm } from "./parse";
2
+
3
+ export type FormatDateKind =
4
+ | "auto"
5
+ | "date"
6
+ | "datetime"
7
+ | "time"
8
+ | "long"
9
+ | "relative"
10
+ | "calendar";
11
+
12
+ const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
13
+
14
+ /** Infer display kind from raw value — ISO date, HH:mm, or instant. */
15
+ export function detectFormatDateKind(value: string | Date): Exclude<FormatDateKind, "auto"> {
16
+ if (value instanceof Date) return "datetime";
17
+ const trimmed = value.trim();
18
+ if (isValidHhmm(trimmed)) return "time";
19
+ if (DATE_ONLY_RE.test(trimmed)) return "date";
20
+ return "datetime";
21
+ }
22
+
23
+ export function isDateOnlyString(value: string): boolean {
24
+ return DATE_ONLY_RE.test(value.trim());
25
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ formatAppDate,
3
+ formatAppDateLong,
4
+ formatAppDateTime,
5
+ formatAppRelative,
6
+ formatAppTime,
7
+ formatCalendarDate,
8
+ formatTimeOfDay,
9
+ type FormatDatetimeOptions,
10
+ } from "./format";
11
+ import { detectFormatDateKind, type FormatDateKind } from "./detect";
12
+ import { isValidHhmm, parseDateInput } from "./parse";
13
+
14
+ const EMPTY = "—";
15
+
16
+ export type FormatDateOptions = FormatDatetimeOptions & {
17
+ /**
18
+ * Output preset. Default `auto` detects ISO date / HH:mm / instant.
19
+ * Locale, timezone, and 12h|24h fall back to AppProvider when omitted.
20
+ */
21
+ kind?: FormatDateKind;
22
+ /** Treat `Date` as calendar pick (react-day-picker) — not an instant. */
23
+ calendar?: boolean;
24
+ };
25
+
26
+ function resolveKind(
27
+ value: string | Date,
28
+ options?: FormatDateOptions,
29
+ ): Exclude<FormatDateKind, "auto"> {
30
+ if (options?.kind && options.kind !== "auto") return options.kind;
31
+ if (options?.calendar && value instanceof Date) return "calendar";
32
+ return detectFormatDateKind(value);
33
+ }
34
+
35
+ /**
36
+ * **Single entry point** for all date/time display in GX apps.
37
+ *
38
+ * - Defaults: locale, timezone, timeFormat from AppProvider (`syncDatetimeContext`)
39
+ * - Optional overrides: `{ timezone: "Asia/Tokyo" }` per call
40
+ * - Auto-detect: `"2026-05-01"` → date, `"14:30"` → time, ISO instant → datetime
41
+ * - International patterns: ISO 8601 date (`yyyy-MM-dd`) + ISO datetime (`yyyy-MM-dd HH:mm`)
42
+ */
43
+ export function formatDate(
44
+ value: string | Date | null | undefined,
45
+ options?: FormatDateOptions,
46
+ ): string {
47
+ if (value == null || value === "") return EMPTY;
48
+
49
+ if (typeof value === "string") {
50
+ const trimmed = value.trim();
51
+ if (!trimmed) return EMPTY;
52
+
53
+ const kind = resolveKind(trimmed, options);
54
+ switch (kind) {
55
+ case "time":
56
+ return isValidHhmm(trimmed)
57
+ ? formatTimeOfDay(trimmed, options)
58
+ : formatAppTime(trimmed, options);
59
+ case "date":
60
+ return formatAppDate(trimmed, options);
61
+ case "long":
62
+ return formatAppDateLong(trimmed, options);
63
+ case "relative":
64
+ return formatAppRelative(trimmed, options);
65
+ case "calendar":
66
+ case "datetime":
67
+ default:
68
+ return formatAppDateTime(trimmed, options);
69
+ }
70
+ }
71
+
72
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) return EMPTY;
73
+
74
+ const kind = resolveKind(value, options);
75
+ switch (kind) {
76
+ case "calendar":
77
+ return formatCalendarDate(value, options);
78
+ case "date":
79
+ return formatCalendarDate(value, options);
80
+ case "time":
81
+ return formatAppTime(value, options);
82
+ case "long":
83
+ return formatAppDateLong(value, options);
84
+ case "relative":
85
+ return formatAppRelative(value, options);
86
+ case "datetime":
87
+ default:
88
+ return formatAppDateTime(value, options);
89
+ }
90
+ }
91
+
92
+ /** True when value looks like a displayable date/time (string or valid Date). */
93
+ export function isFormatDateValue(value: unknown): value is string | Date {
94
+ if (value instanceof Date) return !Number.isNaN(value.getTime());
95
+ if (typeof value !== "string") return false;
96
+ const trimmed = value.trim();
97
+ if (!trimmed) return false;
98
+ if (isValidHhmm(trimmed)) return true;
99
+ return parseDateInput(trimmed) != null;
100
+ }
@@ -0,0 +1,140 @@
1
+ import { TZDate } from "@date-fns/tz";
2
+ import { format, formatDistanceToNow, type Locale } from "date-fns";
3
+ import { getDateFnsLocale } from "../../app/locales";
4
+ import { getDatePattern, getDateTimePattern } from "../../app/date-formats";
5
+ import { getTimePattern, type AppTimeFormat } from "../../app/time-formats";
6
+ import type { AppLocale, AppDateFormat } from "../../app/types";
7
+ import { calendarDateToTZDate, hhmmToTZDate, parseDateInput } from "./parse";
8
+ import { isDateOnlyString } from "./detect";
9
+ import { getDatetimeContext } from "./sync";
10
+
11
+ export type FormatDatetimeOptions = {
12
+ locale?: Locale | AppLocale;
13
+ timezone?: string;
14
+ timeFormat?: AppTimeFormat;
15
+ dateFormat?: AppDateFormat;
16
+ };
17
+
18
+ type ResolvedFormatOptions = {
19
+ locale: Locale;
20
+ timezone: string;
21
+ timeFormat: AppTimeFormat;
22
+ dateFormat: AppDateFormat;
23
+ };
24
+
25
+ function resolveLocale(locale?: Locale | AppLocale): Locale {
26
+ if (!locale) return getDatetimeContext().dateFnsLocale;
27
+ if (typeof locale === "string") return getDateFnsLocale(locale);
28
+ return locale;
29
+ }
30
+
31
+ function resolveOptions(options?: FormatDatetimeOptions): ResolvedFormatOptions {
32
+ const ctx = getDatetimeContext();
33
+ return {
34
+ locale: resolveLocale(options?.locale),
35
+ timezone: options?.timezone ?? ctx.timezone,
36
+ timeFormat: options?.timeFormat ?? ctx.timeFormat,
37
+ dateFormat: options?.dateFormat ?? ctx.dateFormat,
38
+ };
39
+ }
40
+
41
+ function formatTZDate(zoned: TZDate, pattern: string, options: ResolvedFormatOptions): string {
42
+ return format(zoned, pattern, { locale: options.locale });
43
+ }
44
+
45
+ function instantToTZDate(value: Date, timezone: string): TZDate {
46
+ return new TZDate(value, timezone);
47
+ }
48
+
49
+ const EMPTY = "—";
50
+
51
+ /** Date-only from calendar picker — uses Y/M/D in app timezone. */
52
+ export function formatCalendarDate(
53
+ value: Date | null | undefined,
54
+ options?: FormatDatetimeOptions,
55
+ ): string {
56
+ if (!value) return EMPTY;
57
+ const resolved = resolveOptions(options);
58
+ const zoned = calendarDateToTZDate(value, resolved.timezone);
59
+ return formatTZDate(zoned, getDatePattern(resolved.dateFormat), resolved);
60
+ }
61
+
62
+ /** Date-only from ISO instant or `yyyy-MM-dd` string — date part in app timezone. */
63
+ export function formatAppDate(
64
+ value: string | Date | null | undefined,
65
+ options?: FormatDatetimeOptions,
66
+ ): string {
67
+ const resolved = resolveOptions(options);
68
+ if (typeof value === "string" && isDateOnlyString(value)) {
69
+ const [year, month, day] = value.split("-").map(Number);
70
+ const zoned = new TZDate(year, month - 1, day, resolved.timezone);
71
+ return formatTZDate(zoned, getDatePattern(resolved.dateFormat), resolved);
72
+ }
73
+ const parsed = parseDateInput(value);
74
+ if (!parsed) return EMPTY;
75
+ const zoned = instantToTZDate(parsed, resolved.timezone);
76
+ return formatTZDate(zoned, getDatePattern(resolved.dateFormat), resolved);
77
+ }
78
+
79
+ /** Date + time in app timezone. */
80
+ export function formatAppDateTime(
81
+ value: string | Date | null | undefined,
82
+ options?: FormatDatetimeOptions,
83
+ ): string {
84
+ const parsed = parseDateInput(value);
85
+ if (!parsed) return EMPTY;
86
+ const resolved = resolveOptions(options);
87
+ const zoned = instantToTZDate(parsed, resolved.timezone);
88
+ return formatTZDate(
89
+ zoned,
90
+ getDateTimePattern(resolved.timeFormat, resolved.dateFormat),
91
+ resolved,
92
+ );
93
+ }
94
+
95
+ /** Time from ISO instant in app timezone. */
96
+ export function formatAppTime(
97
+ value: string | Date | null | undefined,
98
+ options?: FormatDatetimeOptions,
99
+ ): string {
100
+ const parsed = parseDateInput(value);
101
+ if (!parsed) return EMPTY;
102
+ const resolved = resolveOptions(options);
103
+ const zoned = instantToTZDate(parsed, resolved.timezone);
104
+ return formatTZDate(zoned, getTimePattern(resolved.timeFormat), resolved);
105
+ }
106
+
107
+ /** Long date (PPP) in app timezone. */
108
+ export function formatAppDateLong(
109
+ value: string | Date | null | undefined,
110
+ options?: FormatDatetimeOptions,
111
+ ): string {
112
+ const parsed = parseDateInput(value);
113
+ if (!parsed) return EMPTY;
114
+ const resolved = resolveOptions(options);
115
+ const zoned = instantToTZDate(parsed, resolved.timezone);
116
+ return formatTZDate(zoned, "PPP", resolved);
117
+ }
118
+
119
+ /** Relative time — instant-based; locale from context. */
120
+ export function formatAppRelative(
121
+ value: string | Date | null | undefined,
122
+ options?: Pick<FormatDatetimeOptions, "locale">,
123
+ ): string {
124
+ const parsed = parseDateInput(value);
125
+ if (!parsed) return EMPTY;
126
+ const locale = resolveLocale(options?.locale);
127
+ return formatDistanceToNow(parsed, { addSuffix: true, locale });
128
+ }
129
+
130
+ /** Format canonical HH:mm (24h storage) for display per timeFormat + locale. */
131
+ export function formatTimeOfDay(
132
+ hhmm: string | null | undefined,
133
+ options?: FormatDatetimeOptions,
134
+ ): string {
135
+ if (!hhmm) return EMPTY;
136
+ const resolved = resolveOptions(options);
137
+ const zoned = hhmmToTZDate(hhmm, resolved.timezone);
138
+ if (!zoned) return hhmm;
139
+ return formatTZDate(zoned, getTimePattern(resolved.timeFormat), resolved);
140
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ syncDatetimeContext,
3
+ getDatetimeContext,
4
+ resetDatetimeContextForTests,
5
+ type DatetimeContext,
6
+ } from "./sync";
7
+ export {
8
+ parseDateInput,
9
+ calendarDateToTZDate,
10
+ isValidHhmm,
11
+ normalizeHhmm,
12
+ hhmmToTZDate,
13
+ } from "./parse";
14
+ export {
15
+ formatCalendarDate,
16
+ formatAppDate,
17
+ formatAppDateTime,
18
+ formatAppTime,
19
+ formatAppDateLong,
20
+ formatAppRelative,
21
+ formatTimeOfDay,
22
+ type FormatDatetimeOptions,
23
+ } from "./format";
24
+ export { formatDate, isFormatDateValue, type FormatDateOptions } from "./format-date";
25
+ export { detectFormatDateKind, isDateOnlyString, type FormatDateKind } from "./detect";
@@ -0,0 +1,51 @@
1
+ import { TZDate } from "@date-fns/tz";
2
+ import { parseISO } from "date-fns";
3
+ import { isDateOnlyString } from "./detect";
4
+
5
+ /** Parse ISO string or Date — returns null for invalid values. */
6
+ export function parseDateInput(value: string | Date | null | undefined): Date | null {
7
+ if (value == null) return null;
8
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
9
+ const trimmed = value.trim();
10
+ if (isDateOnlyString(trimmed)) {
11
+ const [year, month, day] = trimmed.split("-").map(Number);
12
+ return new Date(year, month - 1, day);
13
+ }
14
+ const parsed = parseISO(trimmed);
15
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
16
+ }
17
+
18
+ /** Calendar date from react-day-picker — interpret Y/M/D in app timezone, not browser local. */
19
+ export function calendarDateToTZDate(date: Date, timezone: string): TZDate {
20
+ return new TZDate(date.getFullYear(), date.getMonth(), date.getDate(), timezone);
21
+ }
22
+
23
+ const HHMM_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;
24
+
25
+ export function isValidHhmm(value: string): boolean {
26
+ return HHMM_RE.test(value.trim());
27
+ }
28
+
29
+ /** Normalize loose input ("9:30") to canonical 24h "09:30". */
30
+ export function normalizeHhmm(value: string): string | null {
31
+ const trimmed = value.trim();
32
+ if (isValidHhmm(trimmed)) {
33
+ const [h, m] = trimmed.split(":");
34
+ return `${h.padStart(2, "0")}:${m}`;
35
+ }
36
+ const loose = /^(\d{1,2}):(\d{2})$/.exec(trimmed);
37
+ if (!loose) return null;
38
+ const hours = Number(loose[1]);
39
+ const minutes = Number(loose[2]);
40
+ if (hours > 23 || minutes > 59) return null;
41
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
42
+ }
43
+
44
+ /** Map canonical HH:mm to a TZDate on today's calendar date in the given timezone. */
45
+ export function hhmmToTZDate(hhmm: string, timezone: string): TZDate | null {
46
+ const normalized = normalizeHhmm(hhmm);
47
+ if (!normalized) return null;
48
+ const [h, m] = normalized.split(":").map(Number);
49
+ const today = TZDate.tz(timezone);
50
+ return new TZDate(today.getFullYear(), today.getMonth(), today.getDate(), h, m, 0, 0, timezone);
51
+ }
@@ -0,0 +1,48 @@
1
+ import type { Locale } from "date-fns";
2
+ import { getDateFnsLocale } from "../../app/locales";
3
+ import { resolveDefaultDateFormat } from "../../app/date-format-labels";
4
+ import type { AppLocale, AppTimeFormat, AppDateFormat } from "../../app/types";
5
+
6
+ export type DatetimeContext = {
7
+ locale: AppLocale;
8
+ dateFnsLocale: Locale;
9
+ timezone: string;
10
+ timeFormat: AppTimeFormat;
11
+ dateFormat: AppDateFormat;
12
+ };
13
+
14
+ const DEFAULT_LOCALE: AppLocale = "vi";
15
+
16
+ const defaultContext = (): DatetimeContext => ({
17
+ locale: DEFAULT_LOCALE,
18
+ dateFnsLocale: getDateFnsLocale(DEFAULT_LOCALE),
19
+ timezone: "Asia/Ho_Chi_Minh",
20
+ timeFormat: "24h",
21
+ dateFormat: resolveDefaultDateFormat(DEFAULT_LOCALE),
22
+ });
23
+
24
+ let syncedContext: DatetimeContext = defaultContext();
25
+
26
+ /** Sync module-level datetime prefs from AppProvider (mirrors syncI18nLocale). */
27
+ export function syncDatetimeContext(
28
+ partial: Pick<DatetimeContext, "locale" | "timezone" | "timeFormat" | "dateFormat"> & {
29
+ dateFnsLocale?: Locale;
30
+ },
31
+ ): void {
32
+ syncedContext = {
33
+ locale: partial.locale,
34
+ timezone: partial.timezone,
35
+ timeFormat: partial.timeFormat,
36
+ dateFormat: partial.dateFormat,
37
+ dateFnsLocale: partial.dateFnsLocale ?? getDateFnsLocale(partial.locale),
38
+ };
39
+ }
40
+
41
+ export function getDatetimeContext(): Readonly<DatetimeContext> {
42
+ return syncedContext;
43
+ }
44
+
45
+ /** Vitest only — reset to defaults between cases. */
46
+ export function resetDatetimeContextForTests(): void {
47
+ syncedContext = defaultContext();
48
+ }
@@ -0,0 +1,114 @@
1
+ // Shared formatting helpers — every admin component uses these instead of
2
+ // inline string templates so the platform speaks one language for dates,
3
+ // sizes, money, IDs.
4
+ import { formatDate, type FormatDateOptions, type FormatDatetimeOptions } from "./datetime";
5
+ import { translateCurrent } from "../i18n/translate";
6
+
7
+ export type FormatOptions = FormatDatetimeOptions & FormatDateOptions;
8
+
9
+ /** @deprecated Prefer `formatDate(value, { kind: "datetime" })` — delegates to unified formatter. */
10
+ export function formatTime(
11
+ value: string | Date | null | undefined,
12
+ options?: FormatOptions,
13
+ ): string {
14
+ return formatDate(value, { ...options, kind: "time" });
15
+ }
16
+
17
+ /** @deprecated Prefer `formatDate(value, { kind: "datetime" })`. */
18
+ export function formatDateTime(
19
+ value: string | Date | null | undefined,
20
+ options?: FormatOptions,
21
+ ): string {
22
+ return formatDate(value, { ...options, kind: "datetime" });
23
+ }
24
+
25
+ /** @deprecated Prefer `formatDate(value, { kind: "long" })`. */
26
+ export function formatDateLong(
27
+ value: string | Date | null | undefined,
28
+ options?: FormatOptions,
29
+ ): string {
30
+ return formatDate(value, { ...options, kind: "long" });
31
+ }
32
+
33
+ /** @deprecated Prefer `formatDate(value, { kind: "relative" })`. */
34
+ export function formatRelative(
35
+ value: string | Date | null | undefined,
36
+ options?: Pick<FormatOptions, "locale">,
37
+ ): string {
38
+ return formatDate(value, { ...options, kind: "relative" });
39
+ }
40
+
41
+ /** Bytes → "1.2 MB". */
42
+ export function formatBytes(n: number | null | undefined): string {
43
+ if (n == null) return "—";
44
+ if (n < 1024) return `${n} B`;
45
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
46
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
47
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
48
+ }
49
+
50
+ /** ISO 4217 minor units → display string. e.g. (1995, "USD") → "$19.95". */
51
+ export function formatCurrency(amountMinor: number | null | undefined, currency: string): string {
52
+ if (amountMinor == null || !currency) return "—";
53
+ // Most ISO 4217 currencies use 2 decimal places; JPY/VND/KRW use 0.
54
+ const zeroDecimal = [
55
+ "JPY",
56
+ "VND",
57
+ "KRW",
58
+ "CLP",
59
+ "ISK",
60
+ "BIF",
61
+ "DJF",
62
+ "GNF",
63
+ "KMF",
64
+ "RWF",
65
+ "XAF",
66
+ "XOF",
67
+ "XPF",
68
+ ];
69
+ const minorUnitDigits = zeroDecimal.includes(currency.toUpperCase()) ? 0 : 2;
70
+ const major = amountMinor / Math.pow(10, minorUnitDigits);
71
+ return new Intl.NumberFormat("en-US", {
72
+ style: "currency",
73
+ currency,
74
+ minimumFractionDigits: minorUnitDigits,
75
+ maximumFractionDigits: minorUnitDigits,
76
+ }).format(major);
77
+ }
78
+
79
+ /** UUIDv7 / UUIDv4 → first 8 chars + ellipsis. Pair with a Tooltip showing full. */
80
+ export function shortId(id: string | null | undefined): string {
81
+ if (!id) return "—";
82
+ if (id.length <= 12) return id;
83
+ return id.slice(0, 8) + "…";
84
+ }
85
+
86
+ /** Translate any backend error into something a user can act on. Falls back
87
+ * to a generic message for non-Error objects (avoids dumping stack traces). */
88
+ export function humanError(err: unknown): string {
89
+ if (err instanceof Error) {
90
+ const msg = err.message;
91
+ const cleaned = msg.replace(/^\d{3}[^:]*:\s*/, "");
92
+ if (cleaned && cleaned !== "(empty)") return cleaned;
93
+ return translateCurrent("feedback.genericError");
94
+ }
95
+ return translateCurrent("feedback.genericError");
96
+ }
97
+
98
+ // Re-export datetime utilities for apps that import from lib/format.
99
+ export {
100
+ formatDate,
101
+ isFormatDateValue,
102
+ formatCalendarDate,
103
+ formatAppDate,
104
+ formatAppDateTime,
105
+ formatAppTime,
106
+ formatAppDateLong,
107
+ formatAppRelative,
108
+ formatTimeOfDay,
109
+ parseDateInput,
110
+ normalizeHhmm,
111
+ isValidHhmm,
112
+ detectFormatDateKind,
113
+ } from "./datetime";
114
+ export type { FormatDateOptions, FormatDatetimeOptions, FormatDateKind } from "./datetime";
@@ -0,0 +1,54 @@
1
+ // Shared hooks for admin components.
2
+ import { useEffect, useState } from "react";
3
+
4
+ /**
5
+ * Returns a debounced view of `value`, updated only after `delay` ms of no
6
+ * change. Use for search inputs to avoid querying on every keystroke.
7
+ *
8
+ * setState runs only inside setTimeout (async) — compliant with
9
+ * react-hooks/set-state-in-effect.
10
+ */
11
+ export function useDebouncedValue<T>(value: T, delay = 250): T {
12
+ const [debounced, setDebounced] = useState(value);
13
+ useEffect(() => {
14
+ const t = setTimeout(() => {
15
+ setDebounced(value);
16
+ }, delay);
17
+ return () => {
18
+ clearTimeout(t);
19
+ };
20
+ }, [value, delay]);
21
+ return debounced;
22
+ }
23
+
24
+ /**
25
+ * Returns true while `ms` haven't elapsed since `signal` last flipped truthy.
26
+ * setState is scheduled asynchronously (setTimeout 0 / ms) — Rules of React safe.
27
+ */
28
+ export function useTimeoutFlag(signal: unknown, ms = 2_000): boolean {
29
+ const [active, setActive] = useState(false);
30
+
31
+ useEffect(() => {
32
+ if (!signal) {
33
+ const id = window.setTimeout(() => {
34
+ setActive(false);
35
+ }, 0);
36
+ return () => {
37
+ clearTimeout(id);
38
+ };
39
+ }
40
+
41
+ const showId = window.setTimeout(() => {
42
+ setActive(true);
43
+ }, 0);
44
+ const hideId = window.setTimeout(() => {
45
+ setActive(false);
46
+ }, ms);
47
+ return () => {
48
+ clearTimeout(showId);
49
+ clearTimeout(hideId);
50
+ };
51
+ }, [signal, ms]);
52
+
53
+ return Boolean(signal) && active;
54
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,40 @@
1
+ /** Prop → internal class maps. Apps use component props, never these classes directly. */
2
+ import type {
3
+ InlineGapProp,
4
+ PageContainerVariantProp,
5
+ PageDensityProp,
6
+ StackGapProp,
7
+ } from "../props/vocabulary";
8
+
9
+ export type Density = PageDensityProp;
10
+ export type StackGap = StackGapProp;
11
+ export type InlineGap = InlineGapProp;
12
+ export type PageContainerVariant = PageContainerVariantProp;
13
+
14
+ export const densityClass: Record<PageDensityProp, string> = {
15
+ compact: "ui-density-compact",
16
+ default: "ui-density-default",
17
+ comfortable: "ui-density-comfortable",
18
+ };
19
+
20
+ export const pageContainerVariantClass: Record<PageContainerVariantProp, string | undefined> = {
21
+ default: undefined,
22
+ narrow: "ui-page-container--narrow",
23
+ flush: "ui-page-container--flush",
24
+ ghost: "ui-page-container--ghost",
25
+ };
26
+
27
+ export const stackGapClass: Record<StackGapProp, string> = {
28
+ xs: "ui-stack-xs",
29
+ sm: "ui-stack-sm",
30
+ md: "ui-stack-md",
31
+ lg: "ui-stack-lg",
32
+ xl: "ui-stack-xl",
33
+ };
34
+
35
+ export const inlineGapClass: Record<InlineGapProp, string> = {
36
+ xs: "ui-inline-xs",
37
+ sm: "ui-inline-sm",
38
+ md: "ui-inline-md",
39
+ lg: "ui-inline-lg",
40
+ };