@shipfox/react-ui 0.0.1

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 (224) hide show
  1. package/.storybook/main.ts +18 -0
  2. package/.storybook/preview.tsx +48 -0
  3. package/.turbo/turbo-build.log +6 -0
  4. package/.turbo/turbo-check.log +6 -0
  5. package/.turbo/turbo-type.log +5 -0
  6. package/CHANGELOG.md +7 -0
  7. package/LICENSE +21 -0
  8. package/dist/colors.stories.conts.d.ts +33 -0
  9. package/dist/colors.stories.conts.d.ts.map +1 -0
  10. package/dist/colors.stories.conts.js +166 -0
  11. package/dist/colors.stories.conts.js.map +1 -0
  12. package/dist/colors.stories.js +61 -0
  13. package/dist/colors.stories.js.map +1 -0
  14. package/dist/components/button.d.ts +13 -0
  15. package/dist/components/button.d.ts.map +1 -0
  16. package/dist/components/button.js +51 -0
  17. package/dist/components/button.js.map +1 -0
  18. package/dist/components/button.stories.js +174 -0
  19. package/dist/components/button.stories.js.map +1 -0
  20. package/dist/components/icon/custom/badge.d.ts +4 -0
  21. package/dist/components/icon/custom/badge.d.ts.map +1 -0
  22. package/dist/components/icon/custom/badge.js +20 -0
  23. package/dist/components/icon/custom/badge.js.map +1 -0
  24. package/dist/components/icon/custom/check-circle-solid.d.ts +4 -0
  25. package/dist/components/icon/custom/check-circle-solid.d.ts.map +1 -0
  26. package/dist/components/icon/custom/check-circle-solid.js +34 -0
  27. package/dist/components/icon/custom/check-circle-solid.js.map +1 -0
  28. package/dist/components/icon/custom/circle-dotted-line.d.ts +4 -0
  29. package/dist/components/icon/custom/circle-dotted-line.d.ts.map +1 -0
  30. package/dist/components/icon/custom/circle-dotted-line.js +20 -0
  31. package/dist/components/icon/custom/circle-dotted-line.js.map +1 -0
  32. package/dist/components/icon/custom/component-fill.d.ts +4 -0
  33. package/dist/components/icon/custom/component-fill.d.ts.map +1 -0
  34. package/dist/components/icon/custom/component-fill.js +20 -0
  35. package/dist/components/icon/custom/component-fill.js.map +1 -0
  36. package/dist/components/icon/custom/component-line.d.ts +4 -0
  37. package/dist/components/icon/custom/component-line.d.ts.map +1 -0
  38. package/dist/components/icon/custom/component-line.js +20 -0
  39. package/dist/components/icon/custom/component-line.js.map +1 -0
  40. package/dist/components/icon/custom/ellipse-mini-solid.d.ts +4 -0
  41. package/dist/components/icon/custom/ellipse-mini-solid.d.ts.map +1 -0
  42. package/dist/components/icon/custom/ellipse-mini-solid.js +22 -0
  43. package/dist/components/icon/custom/ellipse-mini-solid.js.map +1 -0
  44. package/dist/components/icon/custom/index.d.ts +12 -0
  45. package/dist/components/icon/custom/index.d.ts.map +1 -0
  46. package/dist/components/icon/custom/index.js +13 -0
  47. package/dist/components/icon/custom/index.js.map +1 -0
  48. package/dist/components/icon/custom/info-tooltip-fill.d.ts +4 -0
  49. package/dist/components/icon/custom/info-tooltip-fill.d.ts.map +1 -0
  50. package/dist/components/icon/custom/info-tooltip-fill.js +22 -0
  51. package/dist/components/icon/custom/info-tooltip-fill.js.map +1 -0
  52. package/dist/components/icon/custom/resize.d.ts +4 -0
  53. package/dist/components/icon/custom/resize.d.ts.map +1 -0
  54. package/dist/components/icon/custom/resize.js +20 -0
  55. package/dist/components/icon/custom/resize.js.map +1 -0
  56. package/dist/components/icon/custom/spinner.d.ts +4 -0
  57. package/dist/components/icon/custom/spinner.d.ts.map +1 -0
  58. package/dist/components/icon/custom/spinner.js +145 -0
  59. package/dist/components/icon/custom/spinner.js.map +1 -0
  60. package/dist/components/icon/custom/thunder.d.ts +4 -0
  61. package/dist/components/icon/custom/thunder.d.ts.map +1 -0
  62. package/dist/components/icon/custom/thunder.js +20 -0
  63. package/dist/components/icon/custom/thunder.js.map +1 -0
  64. package/dist/components/icon/custom/x-circle-solid.d.ts +4 -0
  65. package/dist/components/icon/custom/x-circle-solid.d.ts.map +1 -0
  66. package/dist/components/icon/custom/x-circle-solid.js +34 -0
  67. package/dist/components/icon/custom/x-circle-solid.js.map +1 -0
  68. package/dist/components/icon/icon.d.ts +27 -0
  69. package/dist/components/icon/icon.d.ts.map +1 -0
  70. package/dist/components/icon/icon.js +27 -0
  71. package/dist/components/icon/icon.js.map +1 -0
  72. package/dist/components/icon/icon.stories.js +35 -0
  73. package/dist/components/icon/icon.stories.js.map +1 -0
  74. package/dist/components/icon/index.d.ts +2 -0
  75. package/dist/components/icon/index.d.ts.map +1 -0
  76. package/dist/components/icon/index.js +3 -0
  77. package/dist/components/icon/index.js.map +1 -0
  78. package/dist/components/index.d.ts +5 -0
  79. package/dist/components/index.d.ts.map +1 -0
  80. package/dist/components/index.js +6 -0
  81. package/dist/components/index.js.map +1 -0
  82. package/dist/components/theme-provider.d.ts +10 -0
  83. package/dist/components/theme-provider.d.ts.map +1 -0
  84. package/dist/components/theme-provider.js +32 -0
  85. package/dist/components/theme-provider.js.map +1 -0
  86. package/dist/components/typography/code.d.ts +11 -0
  87. package/dist/components/typography/code.d.ts.map +1 -0
  88. package/dist/components/typography/code.js +28 -0
  89. package/dist/components/typography/code.js.map +1 -0
  90. package/dist/components/typography/code.stories.js +54 -0
  91. package/dist/components/typography/code.stories.js.map +1 -0
  92. package/dist/components/typography/header.d.ts +10 -0
  93. package/dist/components/typography/header.d.ts.map +1 -0
  94. package/dist/components/typography/header.js +34 -0
  95. package/dist/components/typography/header.js.map +1 -0
  96. package/dist/components/typography/header.stories.js +34 -0
  97. package/dist/components/typography/header.stories.js.map +1 -0
  98. package/dist/components/typography/index.d.ts +4 -0
  99. package/dist/components/typography/index.d.ts.map +1 -0
  100. package/dist/components/typography/index.js +5 -0
  101. package/dist/components/typography/index.js.map +1 -0
  102. package/dist/components/typography/text.d.ts +12 -0
  103. package/dist/components/typography/text.d.ts.map +1 -0
  104. package/dist/components/typography/text.js +32 -0
  105. package/dist/components/typography/text.js.map +1 -0
  106. package/dist/components/typography/text.stories.js +105 -0
  107. package/dist/components/typography/text.stories.js.map +1 -0
  108. package/dist/hooks/index.d.ts +3 -0
  109. package/dist/hooks/index.d.ts.map +1 -0
  110. package/dist/hooks/index.js +4 -0
  111. package/dist/hooks/index.js.map +1 -0
  112. package/dist/hooks/useCopy.d.ts +1 -0
  113. package/dist/hooks/useCopy.d.ts.map +1 -0
  114. package/dist/hooks/useCopy.js +2 -0
  115. package/dist/hooks/useCopy.js.map +1 -0
  116. package/dist/hooks/useCopyToClipboard.d.ts +10 -0
  117. package/dist/hooks/useCopyToClipboard.d.ts.map +1 -0
  118. package/dist/hooks/useCopyToClipboard.js +16 -0
  119. package/dist/hooks/useCopyToClipboard.js.map +1 -0
  120. package/dist/hooks/useTheme.d.ts +2 -0
  121. package/dist/hooks/useTheme.d.ts.map +1 -0
  122. package/dist/hooks/useTheme.js +9 -0
  123. package/dist/hooks/useTheme.js.map +1 -0
  124. package/dist/index.d.ts +4 -0
  125. package/dist/index.d.ts.map +1 -0
  126. package/dist/index.js +5 -0
  127. package/dist/index.js.map +1 -0
  128. package/dist/state/theme.d.ts +7 -0
  129. package/dist/state/theme.d.ts.map +1 -0
  130. package/dist/state/theme.js +8 -0
  131. package/dist/state/theme.js.map +1 -0
  132. package/dist/utils/clipboard.d.ts +2 -0
  133. package/dist/utils/clipboard.d.ts.map +1 -0
  134. package/dist/utils/clipboard.js +6 -0
  135. package/dist/utils/clipboard.js.map +1 -0
  136. package/dist/utils/cn.d.ts +3 -0
  137. package/dist/utils/cn.d.ts.map +1 -0
  138. package/dist/utils/cn.js +7 -0
  139. package/dist/utils/cn.js.map +1 -0
  140. package/dist/utils/date.d.ts +16 -0
  141. package/dist/utils/date.d.ts.map +1 -0
  142. package/dist/utils/date.js +79 -0
  143. package/dist/utils/date.js.map +1 -0
  144. package/dist/utils/format/chart.d.ts +3 -0
  145. package/dist/utils/format/chart.d.ts.map +1 -0
  146. package/dist/utils/format/chart.js +14 -0
  147. package/dist/utils/format/chart.js.map +1 -0
  148. package/dist/utils/format/date.d.ts +10 -0
  149. package/dist/utils/format/date.d.ts.map +1 -0
  150. package/dist/utils/format/date.js +57 -0
  151. package/dist/utils/format/date.js.map +1 -0
  152. package/dist/utils/format/duration.d.ts +9 -0
  153. package/dist/utils/format/duration.d.ts.map +1 -0
  154. package/dist/utils/format/duration.js +71 -0
  155. package/dist/utils/format/duration.js.map +1 -0
  156. package/dist/utils/format/index.d.ts +5 -0
  157. package/dist/utils/format/index.d.ts.map +1 -0
  158. package/dist/utils/format/index.js +6 -0
  159. package/dist/utils/format/index.js.map +1 -0
  160. package/dist/utils/format/number.d.ts +7 -0
  161. package/dist/utils/format/number.d.ts.map +1 -0
  162. package/dist/utils/format/number.js +20 -0
  163. package/dist/utils/format/number.js.map +1 -0
  164. package/dist/utils/index.d.ts +5 -0
  165. package/dist/utils/index.d.ts.map +1 -0
  166. package/dist/utils/index.js +6 -0
  167. package/dist/utils/index.js.map +1 -0
  168. package/index.css +778 -0
  169. package/package.json +74 -0
  170. package/src/colors.stories.conts.ts +164 -0
  171. package/src/colors.stories.tsx +66 -0
  172. package/src/components/button.stories.tsx +126 -0
  173. package/src/components/button.tsx +63 -0
  174. package/src/components/icon/custom/badge.tsx +17 -0
  175. package/src/components/icon/custom/check-circle-solid.tsx +24 -0
  176. package/src/components/icon/custom/circle-dotted-line.tsx +17 -0
  177. package/src/components/icon/custom/component-fill.tsx +17 -0
  178. package/src/components/icon/custom/component-line.tsx +17 -0
  179. package/src/components/icon/custom/ellipse-mini-solid.tsx +17 -0
  180. package/src/components/icon/custom/index.ts +11 -0
  181. package/src/components/icon/custom/info-tooltip-fill.tsx +21 -0
  182. package/src/components/icon/custom/resize.tsx +17 -0
  183. package/src/components/icon/custom/spinner.tsx +98 -0
  184. package/src/components/icon/custom/thunder.tsx +17 -0
  185. package/src/components/icon/custom/x-circle-solid.tsx +24 -0
  186. package/src/components/icon/icon.stories.tsx +29 -0
  187. package/src/components/icon/icon.tsx +42 -0
  188. package/src/components/icon/index.ts +1 -0
  189. package/src/components/index.ts +4 -0
  190. package/src/components/renovate.json +23 -0
  191. package/src/components/theme-provider.tsx +50 -0
  192. package/src/components/typography/code.stories.tsx +36 -0
  193. package/src/components/typography/code.tsx +38 -0
  194. package/src/components/typography/header.stories.tsx +27 -0
  195. package/src/components/typography/header.tsx +41 -0
  196. package/src/components/typography/index.ts +3 -0
  197. package/src/components/typography/text.stories.tsx +67 -0
  198. package/src/components/typography/text.tsx +42 -0
  199. package/src/hooks/index.ts +2 -0
  200. package/src/hooks/useCopy.ts +0 -0
  201. package/src/hooks/useCopyToClipboard.ts +20 -0
  202. package/src/hooks/useTheme.ts +10 -0
  203. package/src/index.ts +3 -0
  204. package/src/state/theme.ts +15 -0
  205. package/src/utils/clipboard.ts +4 -0
  206. package/src/utils/cn.ts +6 -0
  207. package/src/utils/date.test.ts +119 -0
  208. package/src/utils/date.ts +99 -0
  209. package/src/utils/format/chart.ts +16 -0
  210. package/src/utils/format/date.test.ts +65 -0
  211. package/src/utils/format/date.ts +75 -0
  212. package/src/utils/format/duration.test.ts +58 -0
  213. package/src/utils/format/duration.ts +82 -0
  214. package/src/utils/format/index.ts +4 -0
  215. package/src/utils/format/number.test.ts +38 -0
  216. package/src/utils/format/number.ts +33 -0
  217. package/src/utils/index.ts +4 -0
  218. package/test/global.ts +3 -0
  219. package/test/setup.ts +9 -0
  220. package/tsconfig.build.json +13 -0
  221. package/tsconfig.json +11 -0
  222. package/tsconfig.test.json +12 -0
  223. package/vercel.json +8 -0
  224. package/vitest.config.ts +17 -0
@@ -0,0 +1,119 @@
1
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
2
+ import {
3
+ generateDurationShortcut,
4
+ intervalToNowFromDuration,
5
+ parseTextDurationShortcut,
6
+ } from './date';
7
+
8
+ describe('date utils', () => {
9
+ const now = new Date('2024-03-20T17:45:00Z');
10
+
11
+ beforeEach(() => {
12
+ vi.useFakeTimers({now});
13
+ });
14
+
15
+ afterEach(() => {
16
+ vi.runOnlyPendingTimers();
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ describe('intervalToNowFromDuration', () => {
21
+ it('should return the interval for days', () => {
22
+ const result = intervalToNowFromDuration({days: 2});
23
+ expect(result).toEqual({
24
+ start: new Date('2024-03-18T17:45:00Z'),
25
+ end: new Date('2024-03-20T17:45:00Z'),
26
+ });
27
+ });
28
+
29
+ it('should return the interval for days rounded, when the options is given', () => {
30
+ const result = intervalToNowFromDuration({days: 2}, {attemptRounding: true});
31
+ expect(result).toEqual({
32
+ start: new Date('2024-03-18T00:00:00Z'),
33
+ end: new Date('2024-03-20T23:59:59.999Z'),
34
+ });
35
+ });
36
+
37
+ it('should not round the hours intervals, even when the options is given', () => {
38
+ const result = intervalToNowFromDuration({hours: 48}, {attemptRounding: true});
39
+ expect(result).toEqual({
40
+ start: new Date('2024-03-18T17:45:00Z'),
41
+ end: new Date('2024-03-20T17:45:00Z'),
42
+ });
43
+ });
44
+ });
45
+
46
+ describe('parseTextDurationShortcut', () => {
47
+ it('should parse a duration shortcut in seconds', () => {
48
+ const result = parseTextDurationShortcut('100s');
49
+ expect(result).toEqual({seconds: 100});
50
+ });
51
+
52
+ it('should parse a duration shortcut in minutes', () => {
53
+ const result = parseTextDurationShortcut('100m');
54
+ expect(result).toEqual({minutes: 100});
55
+ });
56
+
57
+ it('should parse a duration shortcut in hours', () => {
58
+ const result = parseTextDurationShortcut('100h');
59
+ expect(result).toEqual({hours: 100});
60
+ });
61
+
62
+ it('should parse a duration shortcut in days', () => {
63
+ const result = parseTextDurationShortcut('100d');
64
+ expect(result).toEqual({days: 100});
65
+ });
66
+
67
+ it('should parse a duration shortcut in weeks', () => {
68
+ const result = parseTextDurationShortcut('100w');
69
+ expect(result).toEqual({weeks: 100});
70
+ });
71
+
72
+ it('should parse a duration shortcut in months', () => {
73
+ const result = parseTextDurationShortcut('100mo');
74
+ expect(result).toEqual({months: 100});
75
+ });
76
+
77
+ it('should parse a duration shortcut in years', () => {
78
+ const result = parseTextDurationShortcut('100y');
79
+ expect(result).toEqual({years: 100});
80
+ });
81
+ });
82
+
83
+ describe('generateDurationShortcut', () => {
84
+ it('should generate a duration shortcut in seconds', () => {
85
+ const result = generateDurationShortcut({seconds: 100});
86
+ expect(result).toEqual('100s');
87
+ });
88
+
89
+ it('should generate a duration shortcut in minutes', () => {
90
+ const result = generateDurationShortcut({minutes: 100});
91
+ expect(result).toEqual('100m');
92
+ });
93
+
94
+ it('should generate a duration shortcut in hours', () => {
95
+ const result = generateDurationShortcut({hours: 100});
96
+ expect(result).toEqual('100h');
97
+ });
98
+
99
+ it('should generate a duration shortcut in days', () => {
100
+ const result = generateDurationShortcut({days: 100});
101
+ expect(result).toEqual('100d');
102
+ });
103
+
104
+ it('should generate a duration shortcut in weeks', () => {
105
+ const result = generateDurationShortcut({weeks: 100});
106
+ expect(result).toEqual('100w');
107
+ });
108
+
109
+ it('should generate a duration shortcut in months', () => {
110
+ const result = generateDurationShortcut({months: 100});
111
+ expect(result).toEqual('100mo');
112
+ });
113
+
114
+ it('should generate a duration shortcut in years', () => {
115
+ const result = generateDurationShortcut({years: 100});
116
+ expect(result).toEqual('100y');
117
+ });
118
+ });
119
+ });
@@ -0,0 +1,99 @@
1
+ import {
2
+ type Duration,
3
+ endOfDay,
4
+ formatDuration,
5
+ type NormalizedInterval,
6
+ startOfDay,
7
+ startOfMonth,
8
+ sub,
9
+ } from 'date-fns';
10
+
11
+ export function isStartOfDay(date: Date): boolean {
12
+ return date.getTime() === startOfDay(date).getTime();
13
+ }
14
+
15
+ export function isStartOfMonth(date: Date): boolean {
16
+ return date.getTime() === startOfMonth(date).getTime();
17
+ }
18
+
19
+ export function isEndOfDay(date: Date): boolean {
20
+ return date.getTime() === endOfDay(date).getTime();
21
+ }
22
+
23
+ export function humanizeDurationToNow(duration: Duration): string {
24
+ return `Past ${formatDuration(duration)}`;
25
+ }
26
+
27
+ export interface IntervalToNowFromDurationOptions {
28
+ /** When set, if an interval is given in a precise unit, it will be rounded to full days
29
+ * (ex: 1 day, will generate intervals from midnight (d-1) to midnight (d))
30
+ * Does not apply to units lower than day */
31
+ attemptRounding?: boolean;
32
+ }
33
+
34
+ export function intervalToNowFromDuration(
35
+ duration: Duration,
36
+ options?: IntervalToNowFromDurationOptions,
37
+ ): NormalizedInterval {
38
+ const now = new Date();
39
+ const interval = {
40
+ start: sub(now, duration),
41
+ end: now,
42
+ };
43
+ if (!options?.attemptRounding) return interval;
44
+ const units = Object.keys(duration);
45
+ if (units.length !== 1) return interval;
46
+ if (['hours', 'minutes', 'seconds'].includes(units[0])) return interval;
47
+ return {
48
+ start: startOfDay(interval.start),
49
+ end: endOfDay(interval.end),
50
+ };
51
+ }
52
+
53
+ const DURATION_SHORTCUTS: Record<keyof Duration, string> = {
54
+ years: 'y',
55
+ months: 'mo',
56
+ weeks: 'w',
57
+ days: 'd',
58
+ hours: 'h',
59
+ minutes: 'm',
60
+ seconds: 's',
61
+ };
62
+
63
+ const DURATION_SHORTCUTS_REVERSED: Record<string, keyof Duration> = Object.fromEntries(
64
+ Object.entries(DURATION_SHORTCUTS).map(([key, value]) => [value, key as keyof Duration]),
65
+ );
66
+
67
+ const DURATION_SHORTCUT_REGEX = new RegExp(
68
+ `^(\\d+)(${Object.keys(DURATION_SHORTCUTS_REVERSED).join('|')})$`,
69
+ );
70
+
71
+ export function generateDurationShortcut(duration: Duration): string {
72
+ const keys = Object.keys(duration) as (keyof Duration)[];
73
+ if (keys.length !== 1) return '';
74
+ const key = keys[0];
75
+ const value = duration[key];
76
+ return `${value}${DURATION_SHORTCUTS[key]}`;
77
+ }
78
+
79
+ export function parseTextDurationShortcut(text: string): Duration | undefined {
80
+ const match = text.match(DURATION_SHORTCUT_REGEX);
81
+ if (!match) return;
82
+ const [_, value, shortcut] = match;
83
+ const unit = DURATION_SHORTCUTS_REVERSED[shortcut];
84
+ return {[unit]: Number.parseInt(value, 10)};
85
+ }
86
+
87
+ const dateSplitterRefex = /[-\u2013]/;
88
+
89
+ export function parseTextInterval(text: string): NormalizedInterval | undefined {
90
+ const durationShortcut = parseTextDurationShortcut(text);
91
+ if (durationShortcut) return intervalToNowFromDuration(durationShortcut);
92
+ const textDates = text.split(dateSplitterRefex).map((token) => token.trim());
93
+ if (textDates.length !== 2) return;
94
+ const start = new Date(textDates[0]);
95
+ const end = new Date(textDates[1]);
96
+ if (Number.isNaN(start.getTime())) return;
97
+ if (Number.isNaN(end.getTime())) return;
98
+ return {start, end};
99
+ }
@@ -0,0 +1,16 @@
1
+ import {formatDateTime, formatTimeSeriesTick} from './date';
2
+ import {formatNumber, formatNumberCompact} from './number';
3
+
4
+ export function formatAxisTick(value: unknown) {
5
+ if (value instanceof Date) return formatTimeSeriesTick(value);
6
+ if (typeof value !== 'number')
7
+ throw new Error(`Expecting value to be a number, got ${typeof value}`);
8
+ return formatNumberCompact(value);
9
+ }
10
+
11
+ export function formatTooltipLabel(value: unknown) {
12
+ if (value instanceof Date) return formatDateTime(value);
13
+ if (typeof value !== 'number')
14
+ throw new Error(`Expecting value to be a number, got ${typeof value}`);
15
+ return formatNumber(value);
16
+ }
@@ -0,0 +1,65 @@
1
+ import {
2
+ addDays,
3
+ addYears,
4
+ endOfDay,
5
+ type NormalizedInterval,
6
+ startOfDay,
7
+ subDays,
8
+ subHours,
9
+ subYears,
10
+ } from 'date-fns';
11
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
12
+ import {formatDateTimeRange} from './date';
13
+
14
+ describe('Format - Date', () => {
15
+ describe('formatDateTimeRange', () => {
16
+ const now = new Date('2024-03-20T17:45:00Z');
17
+ let interval: NormalizedInterval;
18
+
19
+ beforeEach(() => {
20
+ interval = {
21
+ start: subHours(now, 1),
22
+ end: now,
23
+ };
24
+ vi.useFakeTimers({now});
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.runOnlyPendingTimers();
29
+ vi.useRealTimers();
30
+ });
31
+
32
+ it('displays only the hour of the second date if both on same day (en-US)', () => {
33
+ const result = formatDateTimeRange(interval);
34
+ expect(result).toEqual('Mar 20, 4:45\u2009\u2013\u20095:45\u202fPM');
35
+ });
36
+
37
+ it('displays the date for the second day if not on same day (en-US)', () => {
38
+ interval.end = addDays(interval.end, 1);
39
+ const result = formatDateTimeRange(interval);
40
+ expect(result).toEqual('Mar 20, 4:45\u202fPM\u2009\u2013\u2009Mar 21, 5:45\u202fPM');
41
+ });
42
+
43
+ it('displays the year for the second day if not on same day (en-US)', () => {
44
+ interval.end = addYears(interval.end, 1);
45
+ const result = formatDateTimeRange(interval);
46
+ expect(result).toEqual(
47
+ 'Mar 20, 2024, 4:45\u202fPM\u2009\u2013\u2009Mar 20, 2025, 5:45\u202fPM',
48
+ );
49
+ });
50
+
51
+ it('displays the year when the dates are both in another year (en-US)', () => {
52
+ interval.start = subYears(interval.start, 1);
53
+ interval.end = subYears(interval.end, 1);
54
+ const result = formatDateTimeRange(interval);
55
+ expect(result).toEqual('Mar 20, 2023, 4:45\u2009\u2013\u20095:45\u202fPM');
56
+ });
57
+
58
+ it('does not display time when interval if full days', () => {
59
+ interval.start = startOfDay(subDays(interval.start, 1));
60
+ interval.end = endOfDay(interval.end);
61
+ const result = formatDateTimeRange(interval);
62
+ expect(result).toEqual('Mar 19\u2009\u2013\u200920');
63
+ });
64
+ });
65
+ });
@@ -0,0 +1,75 @@
1
+ import {getYear, type NormalizedInterval} from 'date-fns';
2
+ import {isEndOfDay, isStartOfDay} from 'utils/date';
3
+
4
+ interface DateTimeFormatOptions extends Intl.DateTimeFormatOptions {
5
+ locale?: string;
6
+ }
7
+
8
+ const defaultOptions: DateTimeFormatOptions = {
9
+ year: 'numeric',
10
+ month: 'short',
11
+ day: 'numeric',
12
+ hour: 'numeric',
13
+ minute: '2-digit',
14
+ };
15
+
16
+ function getDateTimeFormatter({
17
+ locale,
18
+ ...options
19
+ }: DateTimeFormatOptions = {}): Intl.DateTimeFormat {
20
+ return new Intl.DateTimeFormat(locale, {
21
+ ...defaultOptions,
22
+ ...options,
23
+ });
24
+ }
25
+
26
+ function areCurrentYear({start, end}: NormalizedInterval): boolean {
27
+ const now = new Date();
28
+ return getYear(end) === getYear(now) && getYear(start) === getYear(now);
29
+ }
30
+
31
+ export function formatDateTime(date: Date, options?: DateTimeFormatOptions): string {
32
+ const formatter = getDateTimeFormatter(options);
33
+ return formatter.format(date);
34
+ }
35
+
36
+ export function formatDateTimeRelativeToInterval(
37
+ date: Date,
38
+ interval: NormalizedInterval,
39
+ options?: DateTimeFormatOptions,
40
+ ) {
41
+ const formatter = getDateTimeFormatter({
42
+ year: areCurrentYear(interval) ? undefined : defaultOptions.year,
43
+ ...options,
44
+ });
45
+ return formatter.format(date);
46
+ }
47
+
48
+ export function formatDateTimeRange(
49
+ interval: NormalizedInterval,
50
+ options?: DateTimeFormatOptions,
51
+ ): string {
52
+ const {start, end} = interval;
53
+ const areFullDays = isStartOfDay(start) && isEndOfDay(end);
54
+ const formatter = getDateTimeFormatter({
55
+ year: areCurrentYear(interval) ? undefined : defaultOptions.year,
56
+ hour: areFullDays ? undefined : defaultOptions.hour,
57
+ minute: areFullDays ? undefined : defaultOptions.minute,
58
+ ...options,
59
+ });
60
+ return formatter.formatRange(start, end);
61
+ }
62
+
63
+ export function formatTimeSeriesTick(
64
+ date: Date,
65
+ {locale, ...options}: DateTimeFormatOptions = {},
66
+ ): string {
67
+ const tickOptions: DateTimeFormatOptions = isStartOfDay(date)
68
+ ? {month: 'short', day: 'numeric'}
69
+ : {hour: 'numeric', minute: '2-digit'};
70
+ const formatter = new Intl.DateTimeFormat(locale, {
71
+ ...tickOptions,
72
+ ...options,
73
+ });
74
+ return formatter.format(date);
75
+ }
@@ -0,0 +1,58 @@
1
+ import {describe, expect, it} from 'vitest';
2
+ import {formatDuration} from './duration';
3
+
4
+ describe('formatDuration', () => {
5
+ it('returns a ns precision string', () => {
6
+ expect(formatDuration({nanoseconds: 100})).toBe('100 ns');
7
+ });
8
+
9
+ it('returns a μs precision string', () => {
10
+ expect(formatDuration({nanoseconds: 31000})).toBe('31 μs');
11
+ expect(formatDuration({microseconds: 31})).toBe('31 μs');
12
+ });
13
+
14
+ it('returns a ms precision string', () => {
15
+ expect(formatDuration({nanoseconds: 567000000})).toBe('567 ms');
16
+ expect(formatDuration({milliseconds: 567})).toBe('567 ms');
17
+ });
18
+
19
+ it('returns a s precision string', () => {
20
+ expect(formatDuration({nanoseconds: 43000000000})).toBe('43 s');
21
+ expect(formatDuration({seconds: 43})).toBe('43 s');
22
+ });
23
+
24
+ it('returns a min precision string', () => {
25
+ expect(formatDuration({nanoseconds: 180000000000})).toBe('3 min');
26
+ expect(formatDuration({minutes: 3})).toBe('3 min');
27
+ });
28
+
29
+ it('returns a h precision string', () => {
30
+ expect(formatDuration({nanoseconds: 3600000000000})).toBe('1 h');
31
+ expect(formatDuration({hours: 1})).toBe('1 h');
32
+ });
33
+
34
+ it('returns a d precision string', () => {
35
+ expect(formatDuration({nanoseconds: 86400000000000})).toBe('1 d');
36
+ expect(formatDuration({days: 1})).toBe('1 d');
37
+ });
38
+
39
+ it('returns a w precision string', () => {
40
+ expect(formatDuration({nanoseconds: 604800000000000})).toBe('1 w');
41
+ expect(formatDuration({weeks: 1})).toBe('1 w');
42
+ });
43
+
44
+ it('returns a mo precision string', () => {
45
+ expect(formatDuration({nanoseconds: 2592000000000000})).toBe('1 mo');
46
+ expect(formatDuration({months: 1})).toBe('1 mo');
47
+ });
48
+
49
+ it('returns a y precision string', () => {
50
+ expect(formatDuration({nanoseconds: 31536000000000000})).toBe('1 y');
51
+ expect(formatDuration({years: 1})).toBe('1 y');
52
+ });
53
+
54
+ it('rounds to one digit', () => {
55
+ expect(formatDuration({nanoseconds: 31567})).toBe('31.6 μs');
56
+ expect(formatDuration({microseconds: 31, nanoseconds: 567})).toBe('31.6 μs');
57
+ });
58
+ });
@@ -0,0 +1,82 @@
1
+ import type {Duration as BaseDuration} from 'date-fns';
2
+ import {formatNumber} from './number';
3
+
4
+ export interface Duration extends BaseDuration {
5
+ milliseconds?: number | bigint;
6
+ microseconds?: number | bigint;
7
+ nanoseconds?: number | bigint;
8
+ }
9
+
10
+ interface Unit {
11
+ key: keyof Duration;
12
+ symbol: string;
13
+ ns: number;
14
+ }
15
+
16
+ const units: Unit[] = [
17
+ {
18
+ key: 'years',
19
+ symbol: 'y',
20
+ ns: 365 * 24 * 60 * 60 * 1000 * 1000 * 1000,
21
+ },
22
+ {
23
+ key: 'months',
24
+ symbol: 'mo',
25
+ ns: 30 * 24 * 60 * 60 * 1000 * 1000 * 1000,
26
+ },
27
+ {
28
+ key: 'weeks',
29
+ symbol: 'w',
30
+ ns: 7 * 24 * 60 * 60 * 1000 * 1000 * 1000,
31
+ },
32
+ {
33
+ key: 'days',
34
+ symbol: 'd',
35
+ ns: 24 * 60 * 60 * 1000 * 1000 * 1000,
36
+ },
37
+ {
38
+ key: 'hours',
39
+ symbol: 'h',
40
+ ns: 60 * 60 * 1000 * 1000 * 1000,
41
+ },
42
+ {
43
+ key: 'minutes',
44
+ symbol: 'min',
45
+ ns: 60 * 1000 * 1000 * 1000,
46
+ },
47
+ {
48
+ key: 'seconds',
49
+ symbol: 's',
50
+ ns: 1000 * 1000 * 1000,
51
+ },
52
+ {
53
+ key: 'milliseconds',
54
+ symbol: 'ms',
55
+ ns: 1000 * 1000,
56
+ },
57
+ {
58
+ key: 'microseconds',
59
+ symbol: 'μs',
60
+ ns: 1000,
61
+ },
62
+ {
63
+ key: 'nanoseconds',
64
+ symbol: 'ns',
65
+ ns: 1,
66
+ },
67
+ ];
68
+
69
+ /** Format a duration in nanoseconds to a human readable string */
70
+ export function formatDuration(duration: Duration): string {
71
+ const nanoseconds = Object.entries(duration).reduce((acc, [key, value]) => {
72
+ const unit = units.find((u) => u.key === key);
73
+ if (!unit) throw new Error(`Received unknown duration unit: ${key}`);
74
+ return acc + Number(value) * unit.ns;
75
+ }, 0);
76
+
77
+ for (const unit of units) {
78
+ const value = nanoseconds / unit.ns;
79
+ if (value >= 1) return `${formatNumber(value, {maximumFractionDigits: 1})} ${unit.symbol}`;
80
+ }
81
+ return `${formatNumber(nanoseconds, {maximumFractionDigits: 1})} ns`;
82
+ }
@@ -0,0 +1,4 @@
1
+ export * from './chart';
2
+ export * from './date';
3
+ export * from './duration';
4
+ export * from './number';
@@ -0,0 +1,38 @@
1
+ import {describe, expect, it} from 'vitest';
2
+ import {formatNumber, formatNumberCompact, formatPercent} from './number';
3
+
4
+ describe('Format - Number', () => {
5
+ describe('formatPercent', () => {
6
+ it('formats 0.5 as 50% for en-US', () => {
7
+ expect(formatPercent(0.5, {locale: 'en-US'})).toBe('50%');
8
+ });
9
+ });
10
+
11
+ describe('formatNumber', () => {
12
+ it('formats 1_000 as 1,000 for en-US', () => {
13
+ expect(formatNumber(1_000, {locale: 'en-US'})).toBe('1,000');
14
+ });
15
+
16
+ it('formats 1_000 as 1 000 for fr-Fr', () => {
17
+ expect(formatNumber(1_000, {locale: 'fr-FR'})).toBe('1\u202f000');
18
+ });
19
+ });
20
+
21
+ describe('formatNumberCompact', () => {
22
+ it('formats 1_000 as 1K for en-US', () => {
23
+ expect(formatNumberCompact(1_000, {locale: 'en-US'})).toBe('1K');
24
+ });
25
+
26
+ it('formats 1_000_000 as 1M for en-US', () => {
27
+ expect(formatNumberCompact(1_000_000, {locale: 'en-US'})).toBe('1M');
28
+ });
29
+
30
+ it('formats 1_000_000_000 as 1B for en-US', () => {
31
+ expect(formatNumberCompact(1_000_000_000, {locale: 'en-US'})).toBe('1B');
32
+ });
33
+
34
+ it('format 10_000 as 1万 for zh-CN', () => {
35
+ expect(formatNumberCompact(10_000, {locale: 'zh-CN'})).toBe('1万');
36
+ });
37
+ });
38
+ });
@@ -0,0 +1,33 @@
1
+ export interface NumberFormatOptions extends Intl.NumberFormatOptions {
2
+ locale?: string;
3
+ }
4
+
5
+ export function formatNumber(
6
+ value: number | bigint,
7
+ {locale, ...options}: NumberFormatOptions = {},
8
+ ): string {
9
+ const formatter = new Intl.NumberFormat(locale, options);
10
+ return formatter.format(value);
11
+ }
12
+
13
+ export function formatNumberCompact(
14
+ value: number | bigint,
15
+ options: NumberFormatOptions = {},
16
+ ): string {
17
+ return formatNumber(value, {
18
+ ...options,
19
+ style: 'decimal',
20
+ notation: 'compact',
21
+ compactDisplay: 'short',
22
+ });
23
+ }
24
+
25
+ export function formatPercent(
26
+ value: number,
27
+ {locale, ...options}: NumberFormatOptions = {},
28
+ ): string {
29
+ return formatNumber(value, {
30
+ ...options,
31
+ style: 'percent',
32
+ });
33
+ }
@@ -0,0 +1,4 @@
1
+ export * from './clipboard';
2
+ export * from './cn';
3
+ export * from './date';
4
+ export * from './format';
package/test/global.ts ADDED
@@ -0,0 +1,3 @@
1
+ export const setup = () => {
2
+ process.env.TZ = 'UTC';
3
+ };
package/test/setup.ts ADDED
@@ -0,0 +1,9 @@
1
+ import * as extensions from '@testing-library/jest-dom/matchers';
2
+ import {cleanup} from '@testing-library/react';
3
+ import {afterEach, expect} from 'vitest';
4
+
5
+ expect.extend(extensions);
6
+
7
+ afterEach(() => {
8
+ cleanup();
9
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "@shipfox/ts-config",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "baseUrl": "src",
6
+ "outDir": "dist",
7
+ "lib": ["DOM", "ESNext"],
8
+ "jsx": "react-jsx",
9
+ "types": ["@shipfox/vite/client"]
10
+ },
11
+ "include": ["src"],
12
+ "exclude": ["**/*.test.tsx", "**/*.test.ts", "**/*.stories.tsx", "**/*.stories.ts"]
13
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "references": [
3
+ {
4
+ "path": "tsconfig.build.json"
5
+ },
6
+ {
7
+ "path": "tsconfig.test.json"
8
+ }
9
+ ],
10
+ "exclude": ["**/*.stories.ts", "**/*.stories.js", "**/*.stories.jsx", "**/*.stories.tsx"]
11
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "paths": {
6
+ "test": ["../test"],
7
+ "test/*": ["../test/*"]
8
+ }
9
+ },
10
+ "include": ["src", "test", "jest.config.ts", "vite.config.ts"],
11
+ "exclude": []
12
+ }
package/vercel.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "buildCommand": "turbo build && pnpm run storybook:build",
4
+ "devCommand": "pnpm run storybook",
5
+ "installCommand": "pnpm install",
6
+ "framework": null,
7
+ "outputDirectory": "./storybook-static"
8
+ }
@@ -0,0 +1,17 @@
1
+ import {defineConfig} from '@shipfox/vitest';
2
+ import tailwindcss from '@tailwindcss/vite';
3
+ import react from '@vitejs/plugin-react';
4
+
5
+ // https://vitejs.dev/config/
6
+ export default defineConfig(
7
+ {
8
+ plugins: [react(), tailwindcss()],
9
+ css: {},
10
+ test: {
11
+ environment: 'jsdom',
12
+ setupFiles: ['./test/setup.ts'],
13
+ globalSetup: './test/global.ts',
14
+ },
15
+ },
16
+ import.meta.url,
17
+ );