@djangocfg/ui-core 2.1.411 → 2.1.413
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/package.json +4 -4
- package/src/components/data/avatar-group/index.tsx +224 -0
- package/src/components/data/badge-overflow/index.tsx +259 -0
- package/src/components/data/circular-progress/index.tsx +358 -0
- package/src/components/data/relative-time-card/index.tsx +191 -0
- package/src/components/data/stat/index.tsx +140 -0
- package/src/components/data/status/index.tsx +80 -0
- package/src/components/effects/GlowBackground.tsx +9 -1
- package/src/components/effects/swap/index.tsx +289 -0
- package/src/components/feedback/banner/index.tsx +693 -0
- package/src/components/forms/checkbox-group/index.tsx +243 -0
- package/src/components/forms/editable/index.tsx +420 -0
- package/src/components/forms/input-otp/index.tsx +12 -3
- package/src/components/forms/mask-input/index.tsx +466 -0
- package/src/components/forms/otp/index.tsx +12 -8
- package/src/components/forms/segmented-input/index.tsx +319 -0
- package/src/components/forms/tags-input/index.tsx +896 -0
- package/src/components/forms/time-picker/index.tsx +285 -0
- package/src/components/index.ts +51 -0
- package/src/components/layout/key-value/index.tsx +884 -0
- package/src/components/layout/stack/index.tsx +349 -0
- package/src/components/navigation/context-menu/index.tsx +9 -6
- package/src/components/navigation/stepper/index.tsx +1307 -0
- package/src/components/select/multi-select-pro-async.tsx +11 -2
- package/src/components/select/multi-select-pro.tsx +11 -2
- package/src/components/select/select.tsx +13 -3
- package/src/components/specialized/presence/index.tsx +181 -0
- package/src/components/specialized/primitive/index.tsx +83 -0
- package/src/components/specialized/visually-hidden/index.tsx +19 -0
- package/src/components/specialized/visually-hidden-input/index.tsx +99 -0
- package/src/hooks/dom/index.ts +4 -0
- package/src/hooks/dom/useFormReset.ts +49 -0
- package/src/hooks/dom/useLayoutEffect.ts +16 -0
- package/src/hooks/dom/useSize.ts +57 -0
- package/src/hooks/state/index.ts +4 -0
- package/src/hooks/state/useCallbackRef.ts +25 -0
- package/src/hooks/state/usePrevious.ts +20 -0
- package/src/hooks/state/useStateMachine.ts +29 -0
- package/src/lib/compose-event-handlers.ts +22 -0
- package/src/lib/compose-refs.ts +65 -0
- package/src/lib/create-context.tsx +62 -0
- package/src/lib/get-element-ref.ts +33 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/styles.ts +103 -0
- package/src/styles/README.md +43 -0
- package/src/styles/palette/utils.ts +15 -5
- package/src/styles/utilities/animations.css +135 -0
- package/src/styles/utilities/display.css +62 -0
- package/src/styles/utilities/glass.css +57 -0
- package/src/styles/utilities/marquee.css +69 -0
- package/src/styles/utilities/step.css +25 -0
- package/src/styles/utilities.css +6 -259
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../../lib/utils"
|
|
7
|
+
|
|
8
|
+
const CIRCULAR_PROGRESS_NAME = "CircularProgress"
|
|
9
|
+
const INDICATOR_NAME = "CircularProgressIndicator"
|
|
10
|
+
const TRACK_NAME = "CircularProgressTrack"
|
|
11
|
+
const RANGE_NAME = "CircularProgressRange"
|
|
12
|
+
const VALUE_TEXT_NAME = "CircularProgressValueText"
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX = 100
|
|
15
|
+
|
|
16
|
+
type ProgressState = "indeterminate" | "complete" | "loading"
|
|
17
|
+
|
|
18
|
+
function getProgressState(
|
|
19
|
+
value: number | undefined | null,
|
|
20
|
+
maxValue: number,
|
|
21
|
+
): ProgressState {
|
|
22
|
+
return value == null
|
|
23
|
+
? "indeterminate"
|
|
24
|
+
: value === maxValue
|
|
25
|
+
? "complete"
|
|
26
|
+
: "loading"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getIsValidNumber(value: unknown): value is number {
|
|
30
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getIsValidMaxNumber(max: unknown): max is number {
|
|
34
|
+
return getIsValidNumber(max) && max > 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getIsValidValueNumber(
|
|
38
|
+
value: unknown,
|
|
39
|
+
min: number,
|
|
40
|
+
max: number,
|
|
41
|
+
): value is number {
|
|
42
|
+
return getIsValidNumber(value) && value <= max && value >= min
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getDefaultValueText(value: number, min: number, max: number): string {
|
|
46
|
+
const percentage = max === min ? 100 : ((value - min) / (max - min)) * 100
|
|
47
|
+
return `${Math.round(percentage)}%`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getInvalidValueError(
|
|
51
|
+
propValue: string,
|
|
52
|
+
componentName: string,
|
|
53
|
+
): string {
|
|
54
|
+
return `Invalid prop \`value\` of value \`${propValue}\` supplied to \`${componentName}\`. The \`value\` prop must be a number between \`min\` and \`max\` (inclusive), or \`null\`/\`undefined\` for indeterminate progress. The value will be clamped to the valid range.`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getInvalidMaxError(propValue: string, componentName: string): string {
|
|
58
|
+
return `Invalid prop \`max\` of value \`${propValue}\` supplied to \`${componentName}\`. Only numbers greater than 0 are valid. Defaulting to ${DEFAULT_MAX}.`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface CircularProgressContextValue {
|
|
62
|
+
value: number | null
|
|
63
|
+
valueText: string | undefined
|
|
64
|
+
max: number
|
|
65
|
+
min: number
|
|
66
|
+
state: ProgressState
|
|
67
|
+
radius: number
|
|
68
|
+
thickness: number
|
|
69
|
+
size: number
|
|
70
|
+
center: number
|
|
71
|
+
circumference: number
|
|
72
|
+
percentage: number | null
|
|
73
|
+
valueTextId?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const CircularProgressContext =
|
|
77
|
+
React.createContext<CircularProgressContextValue | null>(null)
|
|
78
|
+
|
|
79
|
+
function useCircularProgressContext(consumerName: string) {
|
|
80
|
+
const context = React.useContext(CircularProgressContext)
|
|
81
|
+
if (!context) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`\`${consumerName}\` must be used within \`${CIRCULAR_PROGRESS_NAME}\``,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return context
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface CircularProgressProps extends React.ComponentProps<"div"> {
|
|
90
|
+
value?: number | null | undefined
|
|
91
|
+
getValueText?(value: number, min: number, max: number): string
|
|
92
|
+
min?: number
|
|
93
|
+
max?: number
|
|
94
|
+
size?: number
|
|
95
|
+
thickness?: number
|
|
96
|
+
label?: string
|
|
97
|
+
asChild?: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function CircularProgress(props: CircularProgressProps) {
|
|
101
|
+
const {
|
|
102
|
+
value: valueProp = null,
|
|
103
|
+
getValueText = getDefaultValueText,
|
|
104
|
+
min: minProp = 0,
|
|
105
|
+
max: maxProp,
|
|
106
|
+
size = 48,
|
|
107
|
+
thickness = 4,
|
|
108
|
+
label,
|
|
109
|
+
asChild,
|
|
110
|
+
className,
|
|
111
|
+
children,
|
|
112
|
+
...rootProps
|
|
113
|
+
} = props
|
|
114
|
+
|
|
115
|
+
if ((maxProp || maxProp === 0) && !getIsValidMaxNumber(maxProp)) {
|
|
116
|
+
if (process.env.NODE_ENV !== "production") {
|
|
117
|
+
console.error(getInvalidMaxError(`${maxProp}`, CIRCULAR_PROGRESS_NAME))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rawMax = getIsValidMaxNumber(maxProp) ? maxProp : DEFAULT_MAX
|
|
122
|
+
const min = getIsValidNumber(minProp) ? minProp : 0
|
|
123
|
+
const max = rawMax <= min ? min + 1 : rawMax
|
|
124
|
+
|
|
125
|
+
if (process.env.NODE_ENV !== "production" && thickness >= size) {
|
|
126
|
+
console.warn(
|
|
127
|
+
`CircularProgress: thickness (${thickness}) should be less than size (${size}) for proper rendering.`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (valueProp !== null && !getIsValidValueNumber(valueProp, min, max)) {
|
|
132
|
+
if (process.env.NODE_ENV !== "production") {
|
|
133
|
+
console.error(
|
|
134
|
+
getInvalidValueError(`${valueProp}`, CIRCULAR_PROGRESS_NAME),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const value = getIsValidValueNumber(valueProp, min, max)
|
|
140
|
+
? valueProp
|
|
141
|
+
: getIsValidNumber(valueProp) && valueProp > max
|
|
142
|
+
? max
|
|
143
|
+
: getIsValidNumber(valueProp) && valueProp < min
|
|
144
|
+
? min
|
|
145
|
+
: null
|
|
146
|
+
|
|
147
|
+
const valueText = getIsValidNumber(value)
|
|
148
|
+
? getValueText(value, min, max)
|
|
149
|
+
: undefined
|
|
150
|
+
const state = getProgressState(value, max)
|
|
151
|
+
const radius = Math.max(0, (size - thickness) / 2)
|
|
152
|
+
const center = size / 2
|
|
153
|
+
const circumference = 2 * Math.PI * radius
|
|
154
|
+
|
|
155
|
+
const percentage = getIsValidNumber(value)
|
|
156
|
+
? max === min
|
|
157
|
+
? 1
|
|
158
|
+
: (value - min) / (max - min)
|
|
159
|
+
: null
|
|
160
|
+
|
|
161
|
+
const labelId = React.useId()
|
|
162
|
+
const valueTextId = React.useId()
|
|
163
|
+
|
|
164
|
+
const contextValue = React.useMemo<CircularProgressContextValue>(
|
|
165
|
+
() => ({
|
|
166
|
+
value,
|
|
167
|
+
valueText,
|
|
168
|
+
max,
|
|
169
|
+
min,
|
|
170
|
+
state,
|
|
171
|
+
radius,
|
|
172
|
+
thickness,
|
|
173
|
+
size,
|
|
174
|
+
center,
|
|
175
|
+
circumference,
|
|
176
|
+
percentage,
|
|
177
|
+
valueTextId,
|
|
178
|
+
}),
|
|
179
|
+
[
|
|
180
|
+
value,
|
|
181
|
+
valueText,
|
|
182
|
+
max,
|
|
183
|
+
min,
|
|
184
|
+
state,
|
|
185
|
+
radius,
|
|
186
|
+
thickness,
|
|
187
|
+
size,
|
|
188
|
+
center,
|
|
189
|
+
circumference,
|
|
190
|
+
percentage,
|
|
191
|
+
valueTextId,
|
|
192
|
+
],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const RootPrimitive = asChild ? Slot : "div"
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<CircularProgressContext.Provider value={contextValue}>
|
|
199
|
+
<RootPrimitive
|
|
200
|
+
role="progressbar"
|
|
201
|
+
aria-describedby={valueText ? valueTextId : undefined}
|
|
202
|
+
aria-labelledby={label ? labelId : undefined}
|
|
203
|
+
aria-valuemax={max}
|
|
204
|
+
aria-valuemin={min}
|
|
205
|
+
aria-valuenow={getIsValidNumber(value) ? value : undefined}
|
|
206
|
+
aria-valuetext={valueText}
|
|
207
|
+
data-state={state}
|
|
208
|
+
data-value={value ?? undefined}
|
|
209
|
+
data-max={max}
|
|
210
|
+
data-min={min}
|
|
211
|
+
data-percentage={percentage}
|
|
212
|
+
{...rootProps}
|
|
213
|
+
className={cn(
|
|
214
|
+
"relative inline-flex w-fit items-center justify-center",
|
|
215
|
+
className,
|
|
216
|
+
)}
|
|
217
|
+
>
|
|
218
|
+
{children}
|
|
219
|
+
{label && <div id={labelId}>{label}</div>}
|
|
220
|
+
</RootPrimitive>
|
|
221
|
+
</CircularProgressContext.Provider>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function CircularProgressIndicator(props: React.ComponentProps<"svg">) {
|
|
226
|
+
const { className, ...indicatorProps } = props
|
|
227
|
+
|
|
228
|
+
const context = useCircularProgressContext(INDICATOR_NAME)
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<svg
|
|
232
|
+
aria-hidden="true"
|
|
233
|
+
focusable="false"
|
|
234
|
+
viewBox={`0 0 ${context.size} ${context.size}`}
|
|
235
|
+
data-state={context.state}
|
|
236
|
+
data-value={context.value ?? undefined}
|
|
237
|
+
data-max={context.max}
|
|
238
|
+
data-min={context.min}
|
|
239
|
+
data-percentage={context.percentage}
|
|
240
|
+
width={context.size}
|
|
241
|
+
height={context.size}
|
|
242
|
+
{...indicatorProps}
|
|
243
|
+
className={cn("-rotate-90 transform", className)}
|
|
244
|
+
/>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
CircularProgressIndicator.displayName = INDICATOR_NAME
|
|
249
|
+
|
|
250
|
+
function CircularProgressTrack(props: React.ComponentProps<"circle">) {
|
|
251
|
+
const { className, ...trackProps } = props
|
|
252
|
+
|
|
253
|
+
const context = useCircularProgressContext(TRACK_NAME)
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<circle
|
|
257
|
+
data-state={context.state}
|
|
258
|
+
cx={context.center}
|
|
259
|
+
cy={context.center}
|
|
260
|
+
r={context.radius}
|
|
261
|
+
fill="none"
|
|
262
|
+
stroke="currentColor"
|
|
263
|
+
strokeWidth={context.thickness}
|
|
264
|
+
strokeLinecap="round"
|
|
265
|
+
vectorEffect="non-scaling-stroke"
|
|
266
|
+
{...trackProps}
|
|
267
|
+
className={cn("text-muted-foreground/20", className)}
|
|
268
|
+
/>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function CircularProgressRange(props: React.ComponentProps<"circle">) {
|
|
273
|
+
const { className, ...rangeProps } = props
|
|
274
|
+
|
|
275
|
+
const context = useCircularProgressContext(RANGE_NAME)
|
|
276
|
+
|
|
277
|
+
const strokeDasharray = context.circumference
|
|
278
|
+
const strokeDashoffset =
|
|
279
|
+
context.state === "indeterminate"
|
|
280
|
+
? context.circumference * 0.75
|
|
281
|
+
: context.percentage !== null
|
|
282
|
+
? context.circumference - context.percentage * context.circumference
|
|
283
|
+
: context.circumference
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<circle
|
|
287
|
+
data-state={context.state}
|
|
288
|
+
data-value={context.value ?? undefined}
|
|
289
|
+
data-max={context.max}
|
|
290
|
+
data-min={context.min}
|
|
291
|
+
cx={context.center}
|
|
292
|
+
cy={context.center}
|
|
293
|
+
r={context.radius}
|
|
294
|
+
fill="none"
|
|
295
|
+
stroke="currentColor"
|
|
296
|
+
strokeWidth={context.thickness}
|
|
297
|
+
strokeLinecap="round"
|
|
298
|
+
strokeDasharray={strokeDasharray}
|
|
299
|
+
strokeDashoffset={strokeDashoffset}
|
|
300
|
+
vectorEffect="non-scaling-stroke"
|
|
301
|
+
{...rangeProps}
|
|
302
|
+
className={cn(
|
|
303
|
+
"origin-center text-primary transition-all duration-300 ease-in-out",
|
|
304
|
+
context.state === "indeterminate" &&
|
|
305
|
+
"motion-reduce:animate-none motion-safe:[animation:var(--animate-spin-around)]",
|
|
306
|
+
className,
|
|
307
|
+
)}
|
|
308
|
+
/>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface CircularProgressValueTextProps extends React.ComponentProps<"span"> {
|
|
313
|
+
asChild?: boolean
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function CircularProgressValueText(props: CircularProgressValueTextProps) {
|
|
317
|
+
const { asChild, className, children, ...valueTextProps } = props
|
|
318
|
+
|
|
319
|
+
const context = useCircularProgressContext(VALUE_TEXT_NAME)
|
|
320
|
+
|
|
321
|
+
const ValueTextPrimitive = asChild ? Slot : "span"
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<ValueTextPrimitive
|
|
325
|
+
id={context.valueTextId}
|
|
326
|
+
data-state={context.state}
|
|
327
|
+
{...valueTextProps}
|
|
328
|
+
className={cn(
|
|
329
|
+
"absolute inset-0 flex items-center justify-center font-medium text-sm",
|
|
330
|
+
className,
|
|
331
|
+
)}
|
|
332
|
+
>
|
|
333
|
+
{children ?? context.valueText}
|
|
334
|
+
</ValueTextPrimitive>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function CircularProgressCombined(props: CircularProgressProps) {
|
|
339
|
+
return (
|
|
340
|
+
<CircularProgress {...props}>
|
|
341
|
+
<CircularProgressIndicator>
|
|
342
|
+
<CircularProgressTrack />
|
|
343
|
+
<CircularProgressRange />
|
|
344
|
+
</CircularProgressIndicator>
|
|
345
|
+
<CircularProgressValueText />
|
|
346
|
+
</CircularProgress>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export {
|
|
351
|
+
CircularProgress,
|
|
352
|
+
CircularProgressCombined,
|
|
353
|
+
CircularProgressIndicator,
|
|
354
|
+
CircularProgressRange,
|
|
355
|
+
CircularProgressTrack,
|
|
356
|
+
CircularProgressValueText,
|
|
357
|
+
}
|
|
358
|
+
export type { CircularProgressProps }
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import moment from "moment";
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
6
|
+
import { cn } from "../../../lib/utils";
|
|
7
|
+
|
|
8
|
+
const relativeTimeCardVariants = cva(
|
|
9
|
+
"inline-flex items-center gap-2 rounded-md px-2.5 py-1 text-sm font-medium",
|
|
10
|
+
{
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
default: "bg-muted text-muted-foreground",
|
|
14
|
+
subtle: "bg-transparent text-muted-foreground",
|
|
15
|
+
outline: "border border-border text-foreground",
|
|
16
|
+
accent: "bg-accent text-accent-foreground",
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
default: "text-sm px-2.5 py-1",
|
|
20
|
+
sm: "text-xs px-2 py-0.5",
|
|
21
|
+
lg: "text-base px-3 py-1.5",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "default",
|
|
26
|
+
size: "default",
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
interface RelativeTimeCardProps
|
|
32
|
+
extends React.HTMLAttributes<HTMLSpanElement>,
|
|
33
|
+
VariantProps<typeof relativeTimeCardVariants> {
|
|
34
|
+
/** Date value — accepts Date, string in any format, number (timestamp), or Moment */
|
|
35
|
+
date: Date | string | number | moment.Moment;
|
|
36
|
+
/** Input format hint if date is a string (e.g. "DD.MM.YYYY HH:mm"). Auto-detected if omitted. */
|
|
37
|
+
inputFormat?: string;
|
|
38
|
+
/** Display format: relative | auto | datetime | custom */
|
|
39
|
+
format?: "relative" | "auto" | "datetime" | "custom";
|
|
40
|
+
/** Custom output format string (moment format). Used when format="custom" */
|
|
41
|
+
outputFormat?: string;
|
|
42
|
+
/** Update interval in ms for relative format. @default 60000 */
|
|
43
|
+
updateInterval?: number;
|
|
44
|
+
/** Text before the time */
|
|
45
|
+
prefix?: string;
|
|
46
|
+
/** Text after the time */
|
|
47
|
+
suffix?: string;
|
|
48
|
+
/** Locale for display. @default "en" */
|
|
49
|
+
locale?: string;
|
|
50
|
+
/** Use UTC for all calculations. @default true */
|
|
51
|
+
utc?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseDate(
|
|
55
|
+
value: Date | string | number | moment.Moment,
|
|
56
|
+
inputFormat?: string,
|
|
57
|
+
useUtc: boolean = true
|
|
58
|
+
): moment.Moment {
|
|
59
|
+
if (moment.isMoment(value)) {
|
|
60
|
+
return useUtc ? value.utc() : value.local();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const m = useUtc ? moment.utc : moment;
|
|
64
|
+
|
|
65
|
+
if (value instanceof Date) {
|
|
66
|
+
return m(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof value === "number") {
|
|
70
|
+
return m(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
if (inputFormat) {
|
|
75
|
+
return m(value, inputFormat, true);
|
|
76
|
+
}
|
|
77
|
+
return m(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return m();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getRelativeTime(date: moment.Moment, locale: string = "en"): string {
|
|
84
|
+
moment.locale(locale);
|
|
85
|
+
return date.fromNow();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getAutoFormat(date: moment.Moment, locale: string = "en"): string {
|
|
89
|
+
moment.locale(locale);
|
|
90
|
+
const now = moment();
|
|
91
|
+
const diffHours = now.diff(date, "hours");
|
|
92
|
+
const diffDays = now.diff(date, "days");
|
|
93
|
+
|
|
94
|
+
if (diffHours < 24) {
|
|
95
|
+
return date.format("HH:mm");
|
|
96
|
+
}
|
|
97
|
+
if (diffDays < 7) {
|
|
98
|
+
return date.format("ddd");
|
|
99
|
+
}
|
|
100
|
+
if (diffDays < 365) {
|
|
101
|
+
return date.format("MMM D");
|
|
102
|
+
}
|
|
103
|
+
return date.format("MMM D, YYYY");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getDateTimeFormat(date: moment.Moment, locale: string = "en"): string {
|
|
107
|
+
moment.locale(locale);
|
|
108
|
+
return date.format("MMM D, YYYY HH:mm");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatDate(
|
|
112
|
+
date: moment.Moment,
|
|
113
|
+
format: "relative" | "auto" | "datetime" | "custom",
|
|
114
|
+
locale: string,
|
|
115
|
+
outputFormat?: string
|
|
116
|
+
): string {
|
|
117
|
+
switch (format) {
|
|
118
|
+
case "relative":
|
|
119
|
+
return getRelativeTime(date, locale);
|
|
120
|
+
case "auto":
|
|
121
|
+
return getAutoFormat(date, locale);
|
|
122
|
+
case "datetime":
|
|
123
|
+
return getDateTimeFormat(date, locale);
|
|
124
|
+
case "custom":
|
|
125
|
+
moment.locale(locale);
|
|
126
|
+
return date.format(outputFormat || "YYYY-MM-DD HH:mm");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const RelativeTimeCard = React.forwardRef<
|
|
131
|
+
HTMLTimeElement,
|
|
132
|
+
RelativeTimeCardProps
|
|
133
|
+
>(
|
|
134
|
+
(
|
|
135
|
+
{
|
|
136
|
+
date,
|
|
137
|
+
inputFormat,
|
|
138
|
+
format = "relative",
|
|
139
|
+
outputFormat,
|
|
140
|
+
updateInterval = 60000,
|
|
141
|
+
prefix,
|
|
142
|
+
suffix,
|
|
143
|
+
locale = "en",
|
|
144
|
+
utc = true,
|
|
145
|
+
className,
|
|
146
|
+
variant,
|
|
147
|
+
size,
|
|
148
|
+
...props
|
|
149
|
+
},
|
|
150
|
+
ref
|
|
151
|
+
) => {
|
|
152
|
+
const dateObj = React.useMemo(
|
|
153
|
+
() => parseDate(date, inputFormat, utc),
|
|
154
|
+
[date, inputFormat, utc]
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const [display, setDisplay] = React.useState(() =>
|
|
158
|
+
formatDate(dateObj, format, locale, outputFormat)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
React.useEffect(() => {
|
|
162
|
+
setDisplay(formatDate(dateObj, format, locale, outputFormat));
|
|
163
|
+
if (format !== "relative") return;
|
|
164
|
+
const id = setInterval(() => {
|
|
165
|
+
setDisplay(formatDate(dateObj, format, locale, outputFormat));
|
|
166
|
+
}, updateInterval);
|
|
167
|
+
return () => clearInterval(id);
|
|
168
|
+
}, [dateObj, format, locale, outputFormat, updateInterval]);
|
|
169
|
+
|
|
170
|
+
const isoString = dateObj.toISOString();
|
|
171
|
+
const title = dateObj.format("LLLL");
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<time
|
|
175
|
+
ref={ref}
|
|
176
|
+
dateTime={isoString}
|
|
177
|
+
className={cn(relativeTimeCardVariants({ variant, size }), className)}
|
|
178
|
+
title={title}
|
|
179
|
+
{...props}
|
|
180
|
+
>
|
|
181
|
+
{prefix}
|
|
182
|
+
{display}
|
|
183
|
+
{suffix}
|
|
184
|
+
</time>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
RelativeTimeCard.displayName = "RelativeTimeCard";
|
|
189
|
+
|
|
190
|
+
export { RelativeTimeCard, relativeTimeCardVariants };
|
|
191
|
+
export type { RelativeTimeCardProps };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../../lib/utils"
|
|
5
|
+
import { Separator } from "../../layout/separator"
|
|
6
|
+
|
|
7
|
+
function Stat({ className, ...props }: React.ComponentProps<"div">) {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
data-slot="stat"
|
|
11
|
+
className={cn(
|
|
12
|
+
"grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 rounded-lg border bg-card p-4 text-card-foreground shadow-sm",
|
|
13
|
+
"**:data-[slot=stat-label]:col-span-1 **:data-[slot=stat-value]:col-span-1",
|
|
14
|
+
"**:data-[slot=stat-indicator]:col-start-2 **:data-[slot=stat-indicator]:row-span-2 **:data-[slot=stat-indicator]:row-start-1 **:data-[slot=stat-indicator]:self-start",
|
|
15
|
+
"**:data-[slot=stat-description]:col-span-2 **:data-[slot=stat-separator]:col-span-2 **:data-[slot=stat-trend]:col-span-2",
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function StatLabel({ className, ...props }: React.ComponentProps<"div">) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-slot="stat-label"
|
|
27
|
+
className={cn("font-medium text-muted-foreground text-sm", className)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const statIndicatorVariants = cva(
|
|
34
|
+
"flex shrink-0 items-center justify-center [&_svg]:pointer-events-none",
|
|
35
|
+
{
|
|
36
|
+
variants: {
|
|
37
|
+
variant: {
|
|
38
|
+
default: "text-muted-foreground [&_svg:not([class*='size-'])]:size-5",
|
|
39
|
+
icon: "size-8 rounded-md border [&_svg:not([class*='size-'])]:size-3.5",
|
|
40
|
+
badge:
|
|
41
|
+
"h-6 min-w-6 rounded-sm border px-1.5 font-medium text-xs [&_svg:not([class*='size-'])]:size-3",
|
|
42
|
+
action:
|
|
43
|
+
"size-8 cursor-pointer rounded-md transition-colors hover:bg-muted/50 [&_svg:not([class*='size-'])]:size-4",
|
|
44
|
+
},
|
|
45
|
+
color: {
|
|
46
|
+
default: "bg-muted text-muted-foreground",
|
|
47
|
+
success:
|
|
48
|
+
"border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400",
|
|
49
|
+
info: "border-blue-500/20 bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
|
50
|
+
warning:
|
|
51
|
+
"border-orange-500/20 bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
|
52
|
+
error: "border-destructive/20 bg-destructive/10 text-destructive",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
defaultVariants: {
|
|
56
|
+
variant: "default",
|
|
57
|
+
color: "default",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
interface StatIndicatorProps
|
|
63
|
+
extends Omit<React.ComponentProps<"div">, "color">,
|
|
64
|
+
VariantProps<typeof statIndicatorVariants> {}
|
|
65
|
+
|
|
66
|
+
function StatIndicator({
|
|
67
|
+
className,
|
|
68
|
+
variant = "default",
|
|
69
|
+
color = "default",
|
|
70
|
+
...props
|
|
71
|
+
}: StatIndicatorProps) {
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
data-slot="stat-indicator"
|
|
75
|
+
data-variant={variant}
|
|
76
|
+
data-color={color}
|
|
77
|
+
className={cn(statIndicatorVariants({ variant, color, className }))}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function StatValue({ className, ...props }: React.ComponentProps<"div">) {
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
data-slot="stat-value"
|
|
87
|
+
className={cn("font-semibold text-2xl tracking-tight", className)}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function StatTrend({
|
|
94
|
+
className,
|
|
95
|
+
trend,
|
|
96
|
+
...props
|
|
97
|
+
}: React.ComponentProps<"div"> & { trend?: "up" | "down" | "neutral" }) {
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
data-slot="stat-trend"
|
|
101
|
+
data-trend={trend}
|
|
102
|
+
className={cn(
|
|
103
|
+
"inline-flex items-center gap-1 font-medium text-xs [&_svg:not([class*='size-'])]:size-3 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
104
|
+
{
|
|
105
|
+
"text-green-600 dark:text-green-400": trend === "up",
|
|
106
|
+
"text-red-600 dark:text-red-400": trend === "down",
|
|
107
|
+
"text-muted-foreground": trend === "neutral" || !trend,
|
|
108
|
+
},
|
|
109
|
+
className,
|
|
110
|
+
)}
|
|
111
|
+
{...props}
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function StatSeparator({ ...props }: React.ComponentProps<typeof Separator>) {
|
|
117
|
+
return <Separator data-slot="stat-separator" className="my-2" {...props} />
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function StatDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
data-slot="stat-description"
|
|
124
|
+
className={cn("text-muted-foreground text-xs", className)}
|
|
125
|
+
{...props}
|
|
126
|
+
/>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export {
|
|
131
|
+
Stat,
|
|
132
|
+
StatDescription,
|
|
133
|
+
StatIndicator,
|
|
134
|
+
StatLabel,
|
|
135
|
+
StatSeparator,
|
|
136
|
+
StatTrend,
|
|
137
|
+
StatValue,
|
|
138
|
+
statIndicatorVariants,
|
|
139
|
+
}
|
|
140
|
+
export type { StatIndicatorProps }
|