@torch-ui/solid 0.1.3

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 (118) hide show
  1. package/README.md +166 -0
  2. package/package.json +67 -0
  3. package/src/components/actions/Button.tsx +612 -0
  4. package/src/components/actions/ButtonGroup.tsx +728 -0
  5. package/src/components/actions/Copy.tsx +98 -0
  6. package/src/components/actions/DarkModeToggle.tsx +80 -0
  7. package/src/components/actions/Link.tsx +37 -0
  8. package/src/components/actions/index.ts +19 -0
  9. package/src/components/actions/useCopyToClipboard.ts +90 -0
  10. package/src/components/charts/Chart.tsx +331 -0
  11. package/src/components/charts/Sparkline.tsx +156 -0
  12. package/src/components/charts/index.ts +13 -0
  13. package/src/components/data-display/Avatar.tsx +208 -0
  14. package/src/components/data-display/AvatarGroup.tsx +228 -0
  15. package/src/components/data-display/Badge.tsx +70 -0
  16. package/src/components/data-display/Carousel.tsx +214 -0
  17. package/src/components/data-display/ColorSwatch.tsx +56 -0
  18. package/src/components/data-display/DataTable.tsx +886 -0
  19. package/src/components/data-display/EmptyState.tsx +61 -0
  20. package/src/components/data-display/Image.tsx +277 -0
  21. package/src/components/data-display/Kbd.tsx +114 -0
  22. package/src/components/data-display/Persona.tsx +78 -0
  23. package/src/components/data-display/StatCard.tsx +338 -0
  24. package/src/components/data-display/Table.tsx +147 -0
  25. package/src/components/data-display/Tag.tsx +91 -0
  26. package/src/components/data-display/Timeline.tsx +200 -0
  27. package/src/components/data-display/TreeView.tsx +172 -0
  28. package/src/components/data-display/Video.tsx +95 -0
  29. package/src/components/data-display/avatar-utils.ts +32 -0
  30. package/src/components/data-display/index.ts +81 -0
  31. package/src/components/feedback/Loading.tsx +159 -0
  32. package/src/components/feedback/Progress.tsx +321 -0
  33. package/src/components/feedback/Skeleton.tsx +62 -0
  34. package/src/components/feedback/SkeletonBlocks.tsx +222 -0
  35. package/src/components/feedback/Toast.tsx +648 -0
  36. package/src/components/feedback/index.ts +44 -0
  37. package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
  38. package/src/components/feedback/password/password-strength.ts +115 -0
  39. package/src/components/feedback/password/password-validation-data.ts +66 -0
  40. package/src/components/feedback/password/password-validation.ts +93 -0
  41. package/src/components/forms/Autocomplete.tsx +268 -0
  42. package/src/components/forms/Checkbox.tsx +155 -0
  43. package/src/components/forms/CodeInput.tsx +237 -0
  44. package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
  45. package/src/components/forms/ColorPicker/color-utils.ts +75 -0
  46. package/src/components/forms/ColorPicker/index.ts +2 -0
  47. package/src/components/forms/DatePicker.tsx +516 -0
  48. package/src/components/forms/DateRangePicker.tsx +464 -0
  49. package/src/components/forms/FieldPicker.tsx +64 -0
  50. package/src/components/forms/FileUpload.tsx +614 -0
  51. package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
  52. package/src/components/forms/FilterBuilder.tsx +16 -0
  53. package/src/components/forms/FilterRuleRow.tsx +68 -0
  54. package/src/components/forms/Input.tsx +200 -0
  55. package/src/components/forms/MultiSelect.tsx +361 -0
  56. package/src/components/forms/NumberField.tsx +145 -0
  57. package/src/components/forms/RadioGroup.tsx +135 -0
  58. package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
  59. package/src/components/forms/ReorderableList.tsx +163 -0
  60. package/src/components/forms/Select.tsx +268 -0
  61. package/src/components/forms/Slider.tsx +260 -0
  62. package/src/components/forms/Switch.tsx +135 -0
  63. package/src/components/forms/TextArea.tsx +202 -0
  64. package/src/components/forms/ViewCustomizer.tsx +44 -0
  65. package/src/components/forms/index.ts +43 -0
  66. package/src/components/layout/Accordion.tsx +110 -0
  67. package/src/components/layout/Alert.tsx +156 -0
  68. package/src/components/layout/BlockQuote.tsx +70 -0
  69. package/src/components/layout/Card.tsx +166 -0
  70. package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
  71. package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
  72. package/src/components/layout/CodeBlock/prism.ts +81 -0
  73. package/src/components/layout/Collapsible.tsx +84 -0
  74. package/src/components/layout/Container.tsx +55 -0
  75. package/src/components/layout/Divider.tsx +64 -0
  76. package/src/components/layout/Form.tsx +39 -0
  77. package/src/components/layout/FormActions.tsx +50 -0
  78. package/src/components/layout/Grid.tsx +53 -0
  79. package/src/components/layout/PageHeading.tsx +46 -0
  80. package/src/components/layout/PromptWithAction.tsx +49 -0
  81. package/src/components/layout/Section.tsx +60 -0
  82. package/src/components/layout/TablePanel.tsx +24 -0
  83. package/src/components/layout/TableView/TableView.tsx +1018 -0
  84. package/src/components/layout/TableView/index.ts +3 -0
  85. package/src/components/layout/TableView/types.ts +51 -0
  86. package/src/components/layout/WizardStep.tsx +40 -0
  87. package/src/components/layout/WizardStepper.tsx +173 -0
  88. package/src/components/layout/index.ts +96 -0
  89. package/src/components/navigation/Breadcrumbs.tsx +66 -0
  90. package/src/components/navigation/DropdownMenu.tsx +86 -0
  91. package/src/components/navigation/MegaMenu.tsx +480 -0
  92. package/src/components/navigation/NavigationMenu.tsx +305 -0
  93. package/src/components/navigation/Pagination.tsx +298 -0
  94. package/src/components/navigation/Sidebar.tsx +280 -0
  95. package/src/components/navigation/Tabs.tsx +122 -0
  96. package/src/components/navigation/ViewSwitcher.tsx +314 -0
  97. package/src/components/navigation/index.ts +66 -0
  98. package/src/components/overlays/AlertDialog.tsx +174 -0
  99. package/src/components/overlays/ContextMenu.tsx +65 -0
  100. package/src/components/overlays/Dialog.tsx +279 -0
  101. package/src/components/overlays/Drawer.tsx +370 -0
  102. package/src/components/overlays/HoverCard.tsx +107 -0
  103. package/src/components/overlays/Popover.tsx +73 -0
  104. package/src/components/overlays/Tooltip.tsx +31 -0
  105. package/src/components/overlays/index.ts +71 -0
  106. package/src/components/typography/Code.tsx +72 -0
  107. package/src/components/typography/Icon.tsx +36 -0
  108. package/src/components/typography/index.ts +10 -0
  109. package/src/env.d.ts +9 -0
  110. package/src/index.ts +13 -0
  111. package/src/styles/theme.css +226 -0
  112. package/src/types/avatar-types.ts +11 -0
  113. package/src/types/filter-types.ts +35 -0
  114. package/src/utilities/classNames.ts +6 -0
  115. package/src/utilities/componentSize.ts +46 -0
  116. package/src/utilities/i18n.tsx +60 -0
  117. package/src/utilities/mergeRefs.ts +12 -0
  118. package/src/utilities/relativeDateDefault.ts +14 -0
@@ -0,0 +1,156 @@
1
+ import { onMount, onCleanup, createEffect, createSignal, splitProps } from 'solid-js'
2
+ import type { ChartConfiguration } from 'chart.js'
3
+ import { cn } from '../../utilities/classNames'
4
+
5
+ export interface SparklineProps {
6
+ /** Data points for the line (e.g. [12, 19, 8, 15, 22, 18]). */
7
+ data: number[]
8
+ /** Line and fill color (CSS color, e.g. rgb(59, 130, 246) or #3b82f6). Default: primary blue. */
9
+ color?: string
10
+ /** Fill area under the line. Default true. */
11
+ fill?: boolean
12
+ /** Line tension (0 = straight, 0.4 = smooth). Default 0.3. */
13
+ tension?: number
14
+ /** Show a point at the last value. Default true. */
15
+ showPoint?: boolean
16
+ /** Accessible label for the sparkline. When provided, the chart is exposed to assistive tech. */
17
+ 'aria-label'?: string
18
+ /** ID of an element that labels the sparkline. When provided, the chart is exposed to assistive tech. */
19
+ 'aria-labelledby'?: string
20
+ class?: string
21
+ }
22
+
23
+ const DEFAULT_COLOR = 'rgb(59, 130, 246)' // blue-500
24
+ const DEFAULT_FILL = 'rgba(59, 130, 246, 0.2)'
25
+
26
+ /**
27
+ * Minimal line chart for inline use (e.g. stat cards, table cells). Wraps
28
+ * Chart.js with no axes, legend, or tooltip.
29
+ *
30
+ * Unlike {@link Chart}, Sparkline does not auto-adapt to dark mode — consumers
31
+ * are responsible for passing a theme-appropriate `color` prop.
32
+ */
33
+ export function Sparkline(props: SparklineProps) {
34
+ const [local] = splitProps(props, ['data', 'color', 'fill', 'tension', 'showPoint', 'aria-label', 'aria-labelledby', 'class'])
35
+ let canvasEl: HTMLCanvasElement | undefined
36
+ let chartInstance: import('chart.js').Chart | null = null
37
+ const [chartReady, setChartReady] = createSignal(false)
38
+
39
+ const color = () => local.color ?? DEFAULT_COLOR
40
+ const fill = () => local.fill !== false
41
+ /** Derive a 25% opacity fill from the line color. Supports rgb/rgba/hex; other formats fall back to default. */
42
+ const fillColor = () => {
43
+ const c = color()
44
+ if (c.startsWith('rgba')) {
45
+ const match = c.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)$/)
46
+ if (match) return `rgba(${match[1]}, ${match[2]}, ${match[3]}, 0.25)`
47
+ } else if (c.startsWith('rgb')) {
48
+ const match = c.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
49
+ if (match) return `rgba(${match[1]}, ${match[2]}, ${match[3]}, 0.25)`
50
+ } else if (c.startsWith('#')) {
51
+ const hex = c.slice(1)
52
+ if (hex.length === 3 || hex.length === 6) {
53
+ const r = hex.length === 3 ? parseInt(hex[0] + hex[0], 16) : parseInt(hex.slice(0, 2), 16)
54
+ const g = hex.length === 3 ? parseInt(hex[1] + hex[1], 16) : parseInt(hex.slice(2, 4), 16)
55
+ const b = hex.length === 3 ? parseInt(hex[2] + hex[2], 16) : parseInt(hex.slice(4, 6), 16)
56
+ if (!isNaN(r) && !isNaN(g) && !isNaN(b)) {
57
+ return `rgba(${r}, ${g}, ${b}, 0.25)`
58
+ }
59
+ }
60
+ }
61
+ return DEFAULT_FILL
62
+ }
63
+
64
+ let ChartCtor: typeof import('chart.js').Chart | null = null
65
+
66
+ onMount(async () => {
67
+ if (!canvasEl) return
68
+ const { Chart } = await import('chart.js/auto')
69
+ ChartCtor = Chart
70
+ const config: ChartConfiguration<'line'> = {
71
+ type: 'line',
72
+ data: {
73
+ labels: local.data.map((_, i) => i.toString()),
74
+ datasets: [
75
+ {
76
+ data: local.data,
77
+ borderColor: color(),
78
+ backgroundColor: fill() ? fillColor() : undefined,
79
+ fill: fill(),
80
+ tension: local.tension ?? 0.3,
81
+ borderWidth: 1.5,
82
+ pointRadius: local.showPoint !== false ? 2.5 : 0,
83
+ pointHoverRadius: 4,
84
+ pointBackgroundColor: color(),
85
+ pointBorderColor: color(),
86
+ },
87
+ ],
88
+ },
89
+ options: {
90
+ responsive: true,
91
+ maintainAspectRatio: false,
92
+ plugins: {
93
+ legend: { display: false },
94
+ tooltip: { enabled: false },
95
+ },
96
+ scales: {
97
+ x: { display: false },
98
+ y: {
99
+ display: false,
100
+ grace: '5%',
101
+ beginAtZero: false,
102
+ },
103
+ },
104
+ interaction: { intersect: false, mode: 'index' },
105
+ },
106
+ }
107
+ chartInstance = new ChartCtor(canvasEl, config)
108
+ setChartReady(true)
109
+ })
110
+
111
+ createEffect(() => {
112
+ if (!chartReady()) return
113
+ const ci = chartInstance!
114
+ if (!local.data.length) {
115
+ ci.data.labels = []
116
+ ci.data.datasets[0].data = []
117
+ ci.update('none')
118
+ return
119
+ }
120
+ const nextLen = local.data.length
121
+ if ((ci.data.labels?.length ?? 0) !== nextLen) {
122
+ ci.data.labels = Array.from({ length: nextLen }, (_, i) => String(i))
123
+ }
124
+ ci.data.datasets[0].data = local.data
125
+ ci.data.datasets[0].borderColor = color()
126
+ ci.data.datasets[0].backgroundColor = fill() ? fillColor() : undefined
127
+ // Chart.js base dataset type doesn't include line-specific props (fill, pointRadius);
128
+ // they exist at runtime on line datasets. Cast is necessary until Chart.js ships better generics.
129
+ const ds = ci.data.datasets[0] as { fill?: boolean; pointRadius?: number; tension?: number }
130
+ ds.fill = fill()
131
+ ds.pointRadius = local.showPoint !== false ? 2.5 : 0
132
+ ds.tension = local.tension ?? 0.3
133
+ ci.update('none')
134
+ })
135
+
136
+ onCleanup(() => {
137
+ if (chartInstance) {
138
+ chartInstance.destroy()
139
+ chartInstance = null
140
+ }
141
+ })
142
+
143
+ const hasAccessibleName = () => !!local['aria-label'] || !!local['aria-labelledby']
144
+
145
+ return (
146
+ <div
147
+ class={cn('h-full w-full min-h-[32px]', local.class)}
148
+ aria-hidden={hasAccessibleName() ? undefined : 'true'}
149
+ aria-label={local['aria-label']}
150
+ aria-labelledby={local['aria-labelledby']}
151
+ role={hasAccessibleName() ? 'img' : undefined}
152
+ >
153
+ <canvas ref={canvasEl}>{local['aria-label'] ?? 'Sparkline chart'}</canvas>
154
+ </div>
155
+ )
156
+ }
@@ -0,0 +1,13 @@
1
+ /** Charts: Chart, Sparkline */
2
+ export { Chart } from './Chart'
3
+ export type {
4
+ ChartProps,
5
+ ChartData,
6
+ ChartDataset,
7
+ ChartType,
8
+ ScatterPoint,
9
+ BubblePoint,
10
+ } from './Chart'
11
+
12
+ export { Sparkline } from './Sparkline'
13
+ export type { SparklineProps } from './Sparkline'
@@ -0,0 +1,208 @@
1
+ import { Show, type JSX, splitProps, createSignal, createEffect, createMemo } from 'solid-js'
2
+ import { cn } from '../../utilities/classNames'
3
+ import { Skeleton } from '../feedback/Skeleton'
4
+ import {
5
+ type AvatarShape,
6
+ type AvatarColor,
7
+ type SizeKey,
8
+ } from '../../types/avatar-types'
9
+ import {
10
+ shapeClasses,
11
+ avatarSizeClasses,
12
+ neutralColorClass,
13
+ getInitials
14
+ } from './avatar-utils'
15
+
16
+ export type { AvatarShape, AvatarColor, SizeKey }
17
+
18
+ export type AvatarRing = true | { color?: string; /** When false, ring sits on the avatar edge (no gap). Use for stacks. Default true. */ offset?: boolean }
19
+
20
+ export type AvatarBadgePlacement = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
21
+
22
+ export interface AvatarProps extends Omit<JSX.HTMLAttributes<HTMLSpanElement>, 'children'> {
23
+ /** Display name: used for title and for initials. Initials = first letter of first name + first letter of last name (last word = last name, rest = first name). Single word → one letter. */
24
+ name: string
25
+ /** Optional image URL; when set, shows image instead of initials. */
26
+ imageUrl?: string | null
27
+ /** Size: sm = 32px, md = 40px, lg = 48px. */
28
+ size?: SizeKey
29
+ /** Shape: circle (default), rounded (rounded-lg), or square. */
30
+ shape?: AvatarShape
31
+ /** Ring outline around the avatar. true = default ring with offset; or { color?, offset?: boolean } for custom or no-offset (e.g. ring={{ offset: false }} for stack cutout). */
32
+ ring?: AvatarRing
33
+ /** Background and text color when showing initials. Ignored when imageUrl is set. */
34
+ color?: AvatarColor
35
+ /** Optional badge (e.g. Badge) overlaid on a corner. Use badgePlacement to choose which corner (default bottom-right). */
36
+ badge?: JSX.Element
37
+ /** When true, badge receives pointer events (e.g. for clickable status menus). Default false (decorative). */
38
+ badgeInteractive?: boolean
39
+ /** Pass the same size as your Badge so placement is correct. Default: same as avatar size. */
40
+ badgeSize?: SizeKey
41
+ /** Dot = status dot only; content = icon or count badge (larger dimensions). Content uses a larger base offset for proper placement. Default: dot. */
42
+ badgeKind?: 'dot' | 'content'
43
+ /** Where the badge sits on the avatar. Default: bottom-right. */
44
+ badgePlacement?: AvatarBadgePlacement
45
+ /** When true, avatar is purely decorative: aria-hidden, no role/label. Use when visible name text is adjacent (e.g. inside Persona). */
46
+ decorative?: boolean
47
+ }
48
+
49
+ const colorClasses: Record<AvatarColor, string> = {
50
+ neutral: neutralColorClass,
51
+ primary: 'bg-primary-100 text-primary-700 dark:bg-primary-500/25 dark:text-primary-200',
52
+ success: 'bg-success-100 text-success-700 dark:bg-success-950 dark:text-success-200',
53
+ warning: 'bg-warning-100 text-warning-700 dark:bg-warning-950 dark:text-warning-200',
54
+ danger: 'bg-danger-100 text-danger-700 dark:bg-danger-950 dark:text-danger-200',
55
+ info: 'bg-info-100 text-info-700 dark:bg-info-950 dark:text-info-200',
56
+ }
57
+
58
+ const badgePlacementClasses: Record<AvatarBadgePlacement, string> = {
59
+ 'bottom-right': 'bottom-0 right-0',
60
+ 'bottom-left': 'bottom-0 left-0',
61
+ 'top-right': 'top-0 right-0',
62
+ 'top-left': 'top-0 left-0',
63
+ }
64
+
65
+ const SIZE_IDX: Record<SizeKey, number> = { sm: 0, md: 1, lg: 2 }
66
+
67
+ /** Sign multipliers per placement: translate pushes the badge outward from the
68
+ * avatar corner. x: +1 = right, −1 = left. y: +1 = down, −1 = up. */
69
+ const BADGE_SIGNS: Record<AvatarBadgePlacement, [x: number, y: number]> = {
70
+ 'bottom-right': [1, 1],
71
+ 'bottom-left': [-1, 1],
72
+ 'top-right': [1, -1],
73
+ 'top-left': [-1, -1],
74
+ }
75
+
76
+ /** Compute the badge translate as an inline CSS transform string.
77
+ * offset = (badgeSize − avatarSize) × 10 + base, applied with placement-dependent signs. */
78
+ function badgeTransform(
79
+ placement: AvatarBadgePlacement,
80
+ avatarSize: SizeKey,
81
+ badgeSize: SizeKey,
82
+ kind: 'dot' | 'content',
83
+ ): string {
84
+ const base = kind === 'content' ? 20 : 5
85
+ const offset = (SIZE_IDX[badgeSize] - SIZE_IDX[avatarSize]) * 10 + base
86
+ const [xSign, ySign] = BADGE_SIGNS[placement]
87
+ return `translate(${offset * xSign}%, ${offset * ySign}%)`
88
+ }
89
+
90
+ /**
91
+ * Avatar with initials or image, optional ring, badge overlay, and multiple shapes/colors.
92
+ * Use as a standalone avatar or compose with Badge for status indicators.
93
+ *
94
+ * <Avatar name="Jane" badge={<Badge variant="success" />} />
95
+ */
96
+ export function Avatar(props: AvatarProps) {
97
+ const [local, others] = splitProps(props, [
98
+ 'name',
99
+ 'imageUrl',
100
+ 'size',
101
+ 'shape',
102
+ 'ring',
103
+ 'color',
104
+ 'badge',
105
+ 'badgeSize',
106
+ 'badgeKind',
107
+ 'badgePlacement',
108
+ 'badgeInteractive',
109
+ 'decorative',
110
+ 'class',
111
+ 'style',
112
+ ])
113
+
114
+ const size = () => local.size ?? 'md'
115
+ const badgeSize = () => local.badgeSize ?? size()
116
+ const badgeKind = () => local.badgeKind ?? 'dot'
117
+ const shape = () => local.shape ?? 'circle'
118
+ const color = () => local.color ?? 'neutral'
119
+ const badgePlacement = () => local.badgePlacement ?? 'bottom-right'
120
+ const sizeClass = () => avatarSizeClasses[size()]
121
+ const ringClass = (): string => {
122
+ const r = local.ring
123
+ if (!r) return ''
124
+ const ringColor = r === true ? 'ring-white dark:ring-ink-800' : (r.color ?? 'ring-white dark:ring-ink-800')
125
+ const useOffset = r === true || (typeof r === 'object' && r.offset !== false)
126
+ return cn(
127
+ 'ring-2',
128
+ useOffset && 'ring-offset-2 ring-offset-surface-base',
129
+ ringColor,
130
+ )
131
+ }
132
+ const initials = () => getInitials(local.name)
133
+ const hasBadge = () => local.badge != null
134
+ const badgeIsInteractive = () => !local.decorative && local.badgeInteractive === true
135
+ const badgeTransformStyle = createMemo(() => ({
136
+ transform: badgeTransform(badgePlacement(), size(), badgeSize(), badgeKind()),
137
+ }))
138
+
139
+ const [imageError, setImageError] = createSignal(false)
140
+ const [imageLoaded, setImageLoaded] = createSignal(false)
141
+ const [imageStartedLoading, setImageStartedLoading] = createSignal(false)
142
+
143
+ // Reset states when imageUrl changes
144
+ createEffect(() => {
145
+ const url = local.imageUrl
146
+ setImageError(false)
147
+ setImageLoaded(false)
148
+ setImageStartedLoading(false)
149
+ })
150
+
151
+ return (
152
+ <span
153
+ {...others}
154
+ role={local.decorative ? undefined : 'img'}
155
+ aria-label={local.decorative ? undefined : local.name}
156
+ aria-hidden={local.decorative ? 'true' : undefined}
157
+ class={cn('inline-flex shrink-0', hasBadge() && 'relative overflow-visible', local.class)}
158
+ style={local.style}
159
+ >
160
+ <span
161
+ class={cn(
162
+ 'relative inline-flex items-center justify-center overflow-hidden font-medium',
163
+ colorClasses[color()],
164
+ shapeClasses[shape()],
165
+ sizeClass(),
166
+ ringClass(),
167
+ )}
168
+ title={local.decorative ? undefined : local.name}
169
+ >
170
+ <Show
171
+ when={local.imageUrl && !imageError()}
172
+ fallback={<span aria-hidden="true">{initials()}</span>}
173
+ >
174
+ <Show
175
+ when={imageLoaded()}
176
+ fallback={
177
+ <Skeleton
178
+ class="absolute inset-0"
179
+ round={shape() === 'circle' ? 'full' : shape() === 'square' ? 'none' : 'lg'}
180
+ />
181
+ }
182
+ >
183
+ <img
184
+ src={local.imageUrl!}
185
+ alt=""
186
+ class="h-full w-full object-cover"
187
+ onError={() => setImageError(true)}
188
+ onLoad={() => setImageLoaded(true)}
189
+ onLoadStart={() => setImageStartedLoading(true)}
190
+ />
191
+ </Show>
192
+ </Show>
193
+ </span>
194
+ {hasBadge() && (
195
+ <span
196
+ class={cn(
197
+ 'absolute z-10 flex',
198
+ !badgeIsInteractive() && 'pointer-events-none',
199
+ badgePlacementClasses[badgePlacement()],
200
+ )}
201
+ style={badgeTransformStyle()}
202
+ >
203
+ {local.badge}
204
+ </span>
205
+ )}
206
+ </span>
207
+ )
208
+ }
@@ -0,0 +1,228 @@
1
+ import { For, createMemo, type JSX, splitProps } from 'solid-js'
2
+
3
+ import { cn } from '../../utilities/classNames'
4
+
5
+ import { Avatar } from './Avatar'
6
+
7
+ import type { AvatarRing } from './Avatar'
8
+
9
+ import { avatarSizeClasses, shapeClasses, neutralColorClass } from './avatar-utils'
10
+
11
+ import type { SizeKey, AvatarShape } from '../../types/avatar-types'
12
+
13
+
14
+
15
+ export interface AvatarGroupItem {
16
+
17
+ name: string
18
+
19
+ imageUrl?: string | null
20
+
21
+ }
22
+
23
+
24
+
25
+ export type AvatarStacking = 'first-on-top' | 'last-on-top'
26
+
27
+
28
+
29
+ export interface AvatarGroupProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children'> {
30
+
31
+ /** List of avatars to show. When max is set, only this many are shown plus a "+N" overflow. */
32
+
33
+ avatars: AvatarGroupItem[]
34
+
35
+ /** Max avatars to show before showing "+N". Omit to show all. */
36
+
37
+ max?: number
38
+
39
+ /** Size passed to each Avatar. */
40
+
41
+ size?: SizeKey
42
+
43
+ /** Shape passed to each Avatar. */
44
+
45
+ shape?: AvatarShape
46
+
47
+ /** Overlap amount: sm = tighter stack, md = default, lg = more overlap. */
48
+
49
+ overlap?: 'sm' | 'md' | 'lg'
50
+
51
+ /** Z-index stacking order for overlapping avatars. Default: last-on-top (DOM-natural). */
52
+
53
+ stacking?: AvatarStacking
54
+
55
+ }
56
+
57
+
58
+
59
+ const overlapClasses = {
60
+
61
+ sm: '-space-x-1.5',
62
+
63
+ md: '-space-x-2',
64
+
65
+ lg: '-space-x-3',
66
+
67
+ }
68
+
69
+
70
+
71
+ const STACK_RING: AvatarRing = { offset: false }
72
+
73
+
74
+
75
+ /**
76
+
77
+ * Group of avatars stacked horizontally with overlap. Use max to show a "+N" overflow.
78
+
79
+ */
80
+
81
+ export function AvatarGroup(props: AvatarGroupProps) {
82
+
83
+ const [local, others] = splitProps(props, [
84
+
85
+ 'avatars',
86
+
87
+ 'max',
88
+
89
+ 'size',
90
+
91
+ 'shape',
92
+
93
+ 'overlap',
94
+
95
+ 'stacking',
96
+
97
+ 'class',
98
+
99
+ ])
100
+
101
+
102
+
103
+ const overlap = () => local.overlap ?? 'md'
104
+
105
+ const size = () => local.size ?? 'md'
106
+
107
+ const shape = () => local.shape ?? 'circle'
108
+
109
+ const stacking = () => local.stacking ?? 'last-on-top'
110
+
111
+
112
+
113
+ const displayed = createMemo(() => {
114
+
115
+ const list = [...local.avatars]
116
+
117
+ const m = local.max
118
+
119
+ if (m == null || list.length <= m) return { items: list, overflow: 0 }
120
+
121
+ return {
122
+
123
+ items: list.slice(0, m),
124
+
125
+ overflow: list.length - m,
126
+
127
+ }
128
+
129
+ })
130
+
131
+ const items = () => displayed().items
132
+
133
+ const overflow = () => displayed().overflow
134
+
135
+ const count = () => items().length
136
+
137
+
138
+
139
+ return (
140
+
141
+ <div
142
+
143
+ class={cn(
144
+
145
+ 'inline-flex flex-row items-center',
146
+
147
+ overlapClasses[overlap()],
148
+
149
+ local.class,
150
+
151
+ )}
152
+
153
+ {...others}
154
+
155
+ >
156
+
157
+ <For each={items()}>
158
+
159
+ {(item, idx) => {
160
+
161
+ const zIndex = () =>
162
+
163
+ stacking() === 'first-on-top' ? count() - idx() : idx() + 1
164
+
165
+ return (
166
+
167
+ <Avatar
168
+
169
+ name={item.name}
170
+
171
+ imageUrl={item.imageUrl}
172
+
173
+ size={size()}
174
+
175
+ shape={shape()}
176
+
177
+ ring={STACK_RING}
178
+
179
+ class="relative"
180
+
181
+ style={{ 'z-index': zIndex() }}
182
+
183
+ />
184
+
185
+ )
186
+
187
+ }}
188
+
189
+ </For>
190
+
191
+ {overflow() > 0 && (
192
+
193
+ <span
194
+
195
+ role="img"
196
+
197
+ aria-label={`${overflow()} more avatars`}
198
+
199
+ class={cn(
200
+
201
+ 'relative inline-flex shrink-0 items-center justify-center font-medium ring-2 ring-white dark:ring-ink-800',
202
+
203
+ neutralColorClass,
204
+
205
+ shapeClasses[shape()],
206
+
207
+ avatarSizeClasses[size()],
208
+
209
+ )}
210
+
211
+ style={{ 'z-index': stacking() === 'first-on-top' ? 0 : count() + 1 }}
212
+
213
+ title={`+${overflow()} more`}
214
+
215
+ >
216
+
217
+ +{overflow()}
218
+
219
+ </span>
220
+
221
+ )}
222
+
223
+ </div>
224
+
225
+ )
226
+
227
+ }
228
+
@@ -0,0 +1,70 @@
1
+ import { type JSX, splitProps } from 'solid-js'
2
+ import { cn } from '../../utilities/classNames'
3
+
4
+ export type BadgeVariant = 'neutral' | 'primary' | 'success' | 'warning' | 'danger' | 'info'
5
+
6
+ export type BadgeSize = 'sm' | 'md' | 'lg'
7
+
8
+ export interface BadgeProps extends Omit<JSX.HTMLAttributes<HTMLSpanElement>, 'children'> {
9
+ /** Color variant. Default: neutral. */
10
+ variant?: BadgeVariant
11
+ /** Badge size; scales dot and pill. Default: md. */
12
+ size?: BadgeSize
13
+ /** Optional icon (e.g. from lucide-solid) shown inside the badge. Use for icon-only badge. */
14
+ icon?: JSX.Element
15
+ /** Optional count or label shown inside the badge (e.g. "3"). When omitted and no icon, renders as a dot only. */
16
+ children?: JSX.Element
17
+ /** When true (default), badge is hidden from assistive tech. Set false for meaningful status/count badges. */
18
+ decorative?: boolean
19
+ }
20
+
21
+ const variantClasses: Record<BadgeVariant, string> = {
22
+ neutral: 'bg-ink-400',
23
+ primary: 'bg-primary-500',
24
+ success: 'bg-success-500',
25
+ warning: 'bg-warning-500',
26
+ danger: 'bg-danger-500',
27
+ info: 'bg-info-600',
28
+ }
29
+
30
+ const sizeClasses = {
31
+ sm: { dot: 'size-2.5', pill: 'h-4 min-w-4 px-0.5 text-[10px]', icon: 'h-4 min-w-4 [&>svg]:size-2.5' },
32
+ md: { dot: 'size-3', pill: 'h-5 min-w-5 px-1 text-xs', icon: 'h-5 min-w-5 [&>svg]:size-3' },
33
+ lg: { dot: 'size-4', pill: 'h-6 min-w-6 px-1.5 text-sm', icon: 'h-6 min-w-6 [&>svg]:size-4' },
34
+ } as const
35
+
36
+ /**
37
+ * Small badge for the corner of an avatar (e.g. online status dot or count).
38
+ * Use as the badge prop of Avatar: <Avatar name="Jane" badge={<Badge variant="success" />} />
39
+ */
40
+ export function Badge(props: BadgeProps) {
41
+ const [local, others] = splitProps(props, ['variant', 'size', 'icon', 'class', 'children', 'decorative'])
42
+ const variant = () => local.variant ?? 'neutral'
43
+ const size = () => local.size ?? 'md'
44
+ const hasIcon = () => local.icon != null
45
+ const hasContent = () => local.children != null
46
+ const usePill = () => hasIcon() || hasContent()
47
+ const isDecorative = () => local.decorative !== false
48
+ const hasA11yName = () =>
49
+ (others as Record<string, unknown>)['aria-label'] != null ||
50
+ (others as Record<string, unknown>)['aria-labelledby'] != null
51
+
52
+ return (
53
+ <span
54
+ aria-hidden={isDecorative() && !hasA11yName() ? 'true' : undefined}
55
+ class={cn(
56
+ 'inline-flex shrink-0 items-center justify-center rounded-full border-2 border-white font-medium leading-none text-white shadow-sm',
57
+ hasIcon() && hasContent() && 'gap-0.5',
58
+ variantClasses[variant()],
59
+ usePill()
60
+ ? (hasIcon() && !hasContent() ? sizeClasses[size()].icon : sizeClasses[size()].pill)
61
+ : sizeClasses[size()].dot,
62
+ local.class,
63
+ )}
64
+ {...others}
65
+ >
66
+ {local.icon}
67
+ {local.children}
68
+ </span>
69
+ )
70
+ }