@djangocfg/ui-core 2.1.412 → 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.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/components/data/avatar-group/index.tsx +224 -0
  3. package/src/components/data/badge-overflow/index.tsx +259 -0
  4. package/src/components/data/circular-progress/index.tsx +358 -0
  5. package/src/components/data/relative-time-card/index.tsx +191 -0
  6. package/src/components/data/stat/index.tsx +140 -0
  7. package/src/components/data/status/index.tsx +80 -0
  8. package/src/components/effects/GlowBackground.tsx +9 -1
  9. package/src/components/effects/swap/index.tsx +289 -0
  10. package/src/components/feedback/banner/index.tsx +693 -0
  11. package/src/components/forms/checkbox-group/index.tsx +243 -0
  12. package/src/components/forms/editable/index.tsx +420 -0
  13. package/src/components/forms/input-otp/index.tsx +12 -3
  14. package/src/components/forms/mask-input/index.tsx +466 -0
  15. package/src/components/forms/otp/index.tsx +12 -8
  16. package/src/components/forms/segmented-input/index.tsx +319 -0
  17. package/src/components/forms/tags-input/index.tsx +896 -0
  18. package/src/components/forms/time-picker/index.tsx +285 -0
  19. package/src/components/index.ts +51 -0
  20. package/src/components/layout/key-value/index.tsx +884 -0
  21. package/src/components/layout/stack/index.tsx +349 -0
  22. package/src/components/navigation/context-menu/index.tsx +9 -6
  23. package/src/components/navigation/stepper/index.tsx +1307 -0
  24. package/src/components/select/multi-select-pro-async.tsx +11 -2
  25. package/src/components/select/multi-select-pro.tsx +11 -2
  26. package/src/components/specialized/presence/index.tsx +181 -0
  27. package/src/components/specialized/primitive/index.tsx +83 -0
  28. package/src/components/specialized/visually-hidden/index.tsx +19 -0
  29. package/src/components/specialized/visually-hidden-input/index.tsx +99 -0
  30. package/src/hooks/dom/index.ts +4 -0
  31. package/src/hooks/dom/useFormReset.ts +49 -0
  32. package/src/hooks/dom/useLayoutEffect.ts +16 -0
  33. package/src/hooks/dom/useSize.ts +57 -0
  34. package/src/hooks/state/index.ts +4 -0
  35. package/src/hooks/state/useCallbackRef.ts +25 -0
  36. package/src/hooks/state/usePrevious.ts +20 -0
  37. package/src/hooks/state/useStateMachine.ts +29 -0
  38. package/src/lib/compose-event-handlers.ts +22 -0
  39. package/src/lib/compose-refs.ts +65 -0
  40. package/src/lib/create-context.tsx +62 -0
  41. package/src/lib/get-element-ref.ts +33 -0
  42. package/src/lib/index.ts +5 -0
  43. package/src/lib/styles.ts +103 -0
  44. package/src/styles/README.md +43 -0
  45. package/src/styles/palette/utils.ts +15 -5
  46. package/src/styles/utilities/animations.css +135 -0
  47. package/src/styles/utilities/display.css +62 -0
  48. package/src/styles/utilities/glass.css +57 -0
  49. package/src/styles/utilities/marquee.css +69 -0
  50. package/src/styles/utilities/step.css +25 -0
  51. package/src/styles/utilities.css +6 -259
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.412",
3
+ "version": "2.1.413",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -95,7 +95,7 @@
95
95
  "check": "tsc --noEmit"
96
96
  },
97
97
  "peerDependencies": {
98
- "@djangocfg/i18n": "^2.1.412",
98
+ "@djangocfg/i18n": "^2.1.413",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
101
101
  "moment": "^2.30.1",
@@ -166,8 +166,8 @@
166
166
  "vaul": "1.1.2"
167
167
  },
168
168
  "devDependencies": {
169
- "@djangocfg/i18n": "^2.1.412",
170
- "@djangocfg/typescript-config": "^2.1.412",
169
+ "@djangocfg/i18n": "^2.1.413",
170
+ "@djangocfg/typescript-config": "^2.1.413",
171
171
  "@types/node": "^25.2.3",
172
172
  "@types/react": "^19.2.15",
173
173
  "@types/react-dom": "^19.2.3",
@@ -0,0 +1,224 @@
1
+ "use client"
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import * as React from "react"
5
+ import { Slot } from "@radix-ui/react-slot"
6
+
7
+ import { cn } from "../../../lib/utils"
8
+
9
+ const avatarGroupVariants = cva("flex items-center", {
10
+ variants: {
11
+ orientation: {
12
+ horizontal: "flex-row",
13
+ vertical: "flex-col",
14
+ },
15
+ dir: {
16
+ ltr: "",
17
+ rtl: "",
18
+ },
19
+ },
20
+ compoundVariants: [
21
+ {
22
+ orientation: "horizontal",
23
+ dir: "ltr",
24
+ className: "-space-x-1",
25
+ },
26
+ {
27
+ orientation: "horizontal",
28
+ dir: "rtl",
29
+ className: "flex-row-reverse -space-x-1 space-x-reverse",
30
+ },
31
+ {
32
+ orientation: "vertical",
33
+ dir: "ltr",
34
+ className: "-space-y-1",
35
+ },
36
+ {
37
+ orientation: "vertical",
38
+ dir: "rtl",
39
+ className: "flex-col-reverse -space-y-1 space-y-reverse",
40
+ },
41
+ ],
42
+ defaultVariants: {
43
+ orientation: "horizontal",
44
+ dir: "ltr",
45
+ },
46
+ })
47
+
48
+ interface AvatarGroupProps
49
+ extends Omit<React.ComponentProps<"div">, "dir">,
50
+ VariantProps<typeof avatarGroupVariants> {
51
+ size?: number
52
+ max?: number
53
+ asChild?: boolean
54
+ reverse?: boolean
55
+ renderOverflow?: (count: number) => React.ReactNode
56
+ }
57
+
58
+ function AvatarGroup(props: AvatarGroupProps) {
59
+ const {
60
+ orientation = "horizontal",
61
+ dir = "ltr",
62
+ size = 40,
63
+ max,
64
+ asChild,
65
+ reverse = false,
66
+ renderOverflow,
67
+ className,
68
+ children,
69
+ ...rootProps
70
+ } = props
71
+
72
+ const childrenArray = React.Children.toArray(children).filter(
73
+ React.isValidElement,
74
+ )
75
+ const itemCount = childrenArray.length
76
+ const shouldTruncate = max && itemCount > max
77
+ const visibleItems = shouldTruncate
78
+ ? childrenArray.slice(0, max - 1)
79
+ : childrenArray
80
+ const overflowCount = shouldTruncate ? itemCount - (max - 1) : 0
81
+ const totalRenderedItems = shouldTruncate ? max : itemCount
82
+
83
+ const RootPrimitive = asChild ? Slot : "div"
84
+
85
+ return (
86
+ <RootPrimitive
87
+ data-orientation={orientation}
88
+ data-slot="avatar-group"
89
+ {...rootProps}
90
+ className={cn(avatarGroupVariants({ orientation, dir }), className)}
91
+ >
92
+ {visibleItems.map((child, index) => (
93
+ <AvatarGroupItem
94
+ key={index}
95
+ child={child}
96
+ index={index}
97
+ itemCount={totalRenderedItems}
98
+ orientation={orientation}
99
+ dir={dir}
100
+ size={size}
101
+ reverse={reverse}
102
+ />
103
+ ))}
104
+ {shouldTruncate && (
105
+ <AvatarGroupItem
106
+ key="overflow"
107
+ child={
108
+ renderOverflow ? (
109
+ renderOverflow(overflowCount)
110
+ ) : (
111
+ <div className="inline-flex size-full items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs">
112
+ +{overflowCount}
113
+ </div>
114
+ )
115
+ }
116
+ index={visibleItems.length}
117
+ itemCount={totalRenderedItems}
118
+ orientation={orientation}
119
+ dir={dir}
120
+ size={size}
121
+ reverse={reverse}
122
+ />
123
+ )}
124
+ </RootPrimitive>
125
+ )
126
+ }
127
+
128
+ interface AvatarGroupItemProps
129
+ extends Omit<React.ComponentProps<"div">, "dir">,
130
+ VariantProps<typeof avatarGroupVariants> {
131
+ child: React.ReactNode
132
+ index: number
133
+ itemCount: number
134
+ size: number
135
+ reverse: boolean
136
+ }
137
+
138
+ function AvatarGroupItem(props: AvatarGroupItemProps) {
139
+ const {
140
+ child,
141
+ index,
142
+ size,
143
+ orientation,
144
+ dir = "ltr",
145
+ reverse = false,
146
+ itemCount,
147
+ className,
148
+ style,
149
+ ...itemProps
150
+ } = props
151
+
152
+ const maskStyle = React.useMemo<React.CSSProperties>(() => {
153
+ let maskImage = ""
154
+
155
+ let shouldMask = false
156
+
157
+ if (orientation === "vertical" && dir === "rtl" && reverse) {
158
+ shouldMask = index !== itemCount - 1
159
+ } else {
160
+ shouldMask = reverse ? index < itemCount - 1 : index > 0
161
+ }
162
+
163
+ if (shouldMask) {
164
+ const maskRadius = size / 2
165
+ const maskOffset = size / 4 + size / 10
166
+
167
+ if (orientation === "vertical") {
168
+ if (dir === "ltr") {
169
+ if (reverse) {
170
+ maskImage = `radial-gradient(circle ${maskRadius}px at 50% ${size + maskOffset}px, transparent 99%, white 100%)`
171
+ } else {
172
+ maskImage = `radial-gradient(circle ${maskRadius}px at 50% -${maskOffset}px, transparent 99%, white 100%)`
173
+ }
174
+ } else {
175
+ if (reverse) {
176
+ maskImage = `radial-gradient(circle ${maskRadius}px at 50% -${maskOffset}px, transparent 99%, white 100%)`
177
+ } else {
178
+ maskImage = `radial-gradient(circle ${maskRadius}px at 50% ${size + maskOffset}px, transparent 99%, white 100%)`
179
+ }
180
+ }
181
+ } else {
182
+ if (dir === "ltr") {
183
+ if (reverse) {
184
+ maskImage = `radial-gradient(circle ${maskRadius}px at ${size + maskOffset}px 50%, transparent 99%, white 100%)`
185
+ } else {
186
+ maskImage = `radial-gradient(circle ${maskRadius}px at -${maskOffset}px 50%, transparent 99%, white 100%)`
187
+ }
188
+ } else {
189
+ if (reverse) {
190
+ maskImage = `radial-gradient(circle ${maskRadius}px at -${maskOffset}px 50%, transparent 99%, white 100%)`
191
+ } else {
192
+ maskImage = `radial-gradient(circle ${maskRadius}px at ${size + maskOffset}px 50%, transparent 99%, white 100%)`
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ return {
199
+ width: size,
200
+ height: size,
201
+ maskImage,
202
+ }
203
+ }, [size, index, orientation, dir, reverse, itemCount])
204
+
205
+ return (
206
+ <div
207
+ data-slot="avatar-group-item"
208
+ className={cn(
209
+ "size-full shrink-0 overflow-hidden rounded-full [&_img]:size-full",
210
+ className,
211
+ )}
212
+ style={{
213
+ ...maskStyle,
214
+ ...style,
215
+ }}
216
+ {...itemProps}
217
+ >
218
+ {child}
219
+ </div>
220
+ )
221
+ }
222
+
223
+ export { AvatarGroup }
224
+ export type { AvatarGroupProps }
@@ -0,0 +1,259 @@
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
+ interface GetBadgeLabel<T> {
9
+ /**
10
+ * Callback that returns a label string for each badge item.
11
+ * Optional for primitive arrays (strings, numbers), required for object arrays.
12
+ * @example getBadgeLabel={(item) => item.name}
13
+ */
14
+ getBadgeLabel: (item: T) => string
15
+ }
16
+
17
+ type BadgeOverflowElement = HTMLDivElement
18
+
19
+ type BadgeOverflowProps<T = string> = React.ComponentProps<"div"> &
20
+ (T extends object ? GetBadgeLabel<T> : Partial<GetBadgeLabel<T>>) & {
21
+ items: T[]
22
+ lineCount?: number
23
+ renderBadge: (item: T, label: string) => React.ReactNode
24
+ renderOverflow?: (count: number) => React.ReactNode
25
+ asChild?: boolean
26
+ }
27
+
28
+ function BadgeOverflow<T = string>(props: BadgeOverflowProps<T>) {
29
+ const {
30
+ items,
31
+ getBadgeLabel: getBadgeLabelProp,
32
+ lineCount = 1,
33
+ renderBadge,
34
+ renderOverflow,
35
+ asChild,
36
+ className,
37
+ style,
38
+ ref,
39
+ ...rootProps
40
+ } = props
41
+
42
+ const getBadgeLabel = React.useCallback(
43
+ (item: T): string => {
44
+ if (typeof item === "object" && !getBadgeLabelProp) {
45
+ throw new Error(
46
+ "`getBadgeLabel` is required when using array of objects",
47
+ )
48
+ }
49
+ return getBadgeLabelProp ? getBadgeLabelProp(item) : (item as string)
50
+ },
51
+ [getBadgeLabelProp],
52
+ )
53
+
54
+ const rootRef = React.useRef<BadgeOverflowElement | null>(null)
55
+ const measureRef = React.useRef<HTMLDivElement | null>(null)
56
+ const [containerWidth, setContainerWidth] = React.useState(0)
57
+ const [badgeGap, setBadgeGap] = React.useState(4)
58
+ const [badgeHeight, setBadgeHeight] = React.useState(20)
59
+ const [overflowBadgeWidth, setOverflowBadgeWidth] = React.useState(40)
60
+ const [isMeasured, setIsMeasured] = React.useState(false)
61
+ const [badgeWidths, setBadgeWidths] = React.useState<Map<string, number>>(
62
+ new Map(),
63
+ )
64
+
65
+ React.useLayoutEffect(() => {
66
+ if (!rootRef.current || !measureRef.current) return
67
+
68
+ function measureContainer() {
69
+ if (!rootRef.current || !measureRef.current) return
70
+
71
+ const computedStyle = getComputedStyle(rootRef.current)
72
+
73
+ const gapValue = computedStyle.gap
74
+ const gap = gapValue ? parseFloat(gapValue) : 4
75
+ setBadgeGap(gap)
76
+
77
+ const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0
78
+ const paddingRight = parseFloat(computedStyle.paddingRight) || 0
79
+ const totalPadding = paddingLeft + paddingRight
80
+
81
+ const widthMap = new Map<string, number>()
82
+ const measureChildren = measureRef.current.children
83
+
84
+ for (let i = 0; i < items.length; i++) {
85
+ const child = measureChildren[i] as HTMLElement | undefined
86
+ if (child) {
87
+ const label = getBadgeLabel(items[i] as T)
88
+ widthMap.set(label, child.offsetWidth)
89
+ }
90
+ }
91
+ setBadgeWidths(widthMap)
92
+
93
+ const firstBadge = measureChildren[0] as HTMLElement | undefined
94
+ if (firstBadge) {
95
+ setBadgeHeight(firstBadge.offsetHeight || 20)
96
+ }
97
+
98
+ const overflowChild = measureChildren[items.length] as
99
+ | HTMLElement
100
+ | undefined
101
+
102
+ if (overflowChild) {
103
+ setOverflowBadgeWidth(overflowChild.offsetWidth || 40)
104
+ }
105
+
106
+ const width = rootRef.current.clientWidth - totalPadding
107
+ setContainerWidth(width)
108
+ setIsMeasured(true)
109
+ }
110
+
111
+ measureContainer()
112
+
113
+ const resizeObserver = new ResizeObserver(measureContainer)
114
+ resizeObserver.observe(rootRef.current)
115
+
116
+ return () => {
117
+ resizeObserver.disconnect()
118
+ }
119
+ }, [items, getBadgeLabel])
120
+
121
+ const placeholderHeight = React.useMemo(
122
+ () => badgeHeight * lineCount + badgeGap * (lineCount - 1),
123
+ [badgeHeight, badgeGap, lineCount],
124
+ )
125
+
126
+ const { visibleItems, hiddenCount } = React.useMemo(() => {
127
+ if (!containerWidth || items.length === 0 || badgeWidths.size === 0) {
128
+ return { visibleItems: items, hiddenCount: 0 }
129
+ }
130
+
131
+ let currentLineWidth = 0
132
+ let currentLine = 1
133
+ const visible: T[] = []
134
+
135
+ for (let i = 0; i < items.length; i++) {
136
+ const item = items[i]
137
+ if (!item) continue
138
+
139
+ const label = getBadgeLabel(item)
140
+ const badgeWidth = badgeWidths.get(label)
141
+
142
+ if (!badgeWidth) {
143
+ // Skip items that haven't been measured yet
144
+ continue
145
+ }
146
+
147
+ const widthWithGap = badgeWidth + badgeGap
148
+ const isLastLine = currentLine === lineCount
149
+ const hasMoreItems = i < items.length - 1
150
+
151
+ const availableWidth =
152
+ isLastLine && hasMoreItems
153
+ ? containerWidth - overflowBadgeWidth - badgeGap
154
+ : containerWidth
155
+
156
+ if (currentLineWidth + widthWithGap <= availableWidth) {
157
+ currentLineWidth += widthWithGap
158
+ visible.push(item)
159
+ } else if (currentLine < lineCount) {
160
+ currentLine++
161
+ currentLineWidth = widthWithGap
162
+ visible.push(item)
163
+ } else {
164
+ // We're on the last line and this badge doesn't fit
165
+ break
166
+ }
167
+ }
168
+
169
+ return {
170
+ visibleItems: visible,
171
+ hiddenCount: Math.max(0, items.length - visible.length),
172
+ }
173
+ }, [
174
+ items,
175
+ getBadgeLabel,
176
+ containerWidth,
177
+ lineCount,
178
+ badgeGap,
179
+ overflowBadgeWidth,
180
+ badgeWidths,
181
+ ])
182
+
183
+ const Comp = asChild ? Slot : "div"
184
+
185
+ return (
186
+ <>
187
+ <div
188
+ ref={measureRef}
189
+ className="pointer-events-none invisible absolute flex flex-wrap"
190
+ style={{ gap: badgeGap }}
191
+ >
192
+ {items.map((item, index) => (
193
+ <React.Fragment key={index}>
194
+ {renderBadge(item, getBadgeLabel(item))}
195
+ </React.Fragment>
196
+ ))}
197
+ {renderOverflow ? (
198
+ renderOverflow(99)
199
+ ) : (
200
+ <div className="inline-flex h-5 shrink-0 items-center rounded-md border px-1.5 font-semibold text-xs">
201
+ +99
202
+ </div>
203
+ )}
204
+ </div>
205
+ {isMeasured ? (
206
+ <Comp
207
+ data-slot="badge-overflow"
208
+ {...rootProps}
209
+ ref={ref}
210
+ className={cn("flex flex-wrap", className)}
211
+ style={{
212
+ gap: badgeGap,
213
+ ...style,
214
+ }}
215
+ >
216
+ {visibleItems.map((item, index) => (
217
+ <React.Fragment key={index}>
218
+ {renderBadge(item, getBadgeLabel(item))}
219
+ </React.Fragment>
220
+ ))}
221
+ {hiddenCount > 0 &&
222
+ (renderOverflow ? (
223
+ renderOverflow(hiddenCount)
224
+ ) : (
225
+ <div className="inline-flex h-5 shrink-0 items-center rounded-md border px-1.5 font-semibold text-xs">
226
+ +{hiddenCount}
227
+ </div>
228
+ ))}
229
+ </Comp>
230
+ ) : (
231
+ <Comp
232
+ data-slot="badge-overflow"
233
+ {...rootProps}
234
+ ref={ref}
235
+ className={cn("flex flex-wrap", className)}
236
+ style={{
237
+ gap: badgeGap,
238
+ minHeight: placeholderHeight,
239
+ ...style,
240
+ }}
241
+ >
242
+ {items
243
+ .slice(
244
+ 0,
245
+ Math.min(items.length, lineCount * 3 - (lineCount > 1 ? 1 : 0)),
246
+ )
247
+ .map((item, index) => (
248
+ <React.Fragment key={index}>
249
+ {renderBadge(item, getBadgeLabel(item))}
250
+ </React.Fragment>
251
+ ))}
252
+ </Comp>
253
+ )}
254
+ </>
255
+ )
256
+ }
257
+
258
+ export { BadgeOverflow }
259
+ export type { BadgeOverflowProps }