@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.
- package/README.md +166 -0
- package/package.json +67 -0
- package/src/components/actions/Button.tsx +612 -0
- package/src/components/actions/ButtonGroup.tsx +728 -0
- package/src/components/actions/Copy.tsx +98 -0
- package/src/components/actions/DarkModeToggle.tsx +80 -0
- package/src/components/actions/Link.tsx +37 -0
- package/src/components/actions/index.ts +19 -0
- package/src/components/actions/useCopyToClipboard.ts +90 -0
- package/src/components/charts/Chart.tsx +331 -0
- package/src/components/charts/Sparkline.tsx +156 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/data-display/Avatar.tsx +208 -0
- package/src/components/data-display/AvatarGroup.tsx +228 -0
- package/src/components/data-display/Badge.tsx +70 -0
- package/src/components/data-display/Carousel.tsx +214 -0
- package/src/components/data-display/ColorSwatch.tsx +56 -0
- package/src/components/data-display/DataTable.tsx +886 -0
- package/src/components/data-display/EmptyState.tsx +61 -0
- package/src/components/data-display/Image.tsx +277 -0
- package/src/components/data-display/Kbd.tsx +114 -0
- package/src/components/data-display/Persona.tsx +78 -0
- package/src/components/data-display/StatCard.tsx +338 -0
- package/src/components/data-display/Table.tsx +147 -0
- package/src/components/data-display/Tag.tsx +91 -0
- package/src/components/data-display/Timeline.tsx +200 -0
- package/src/components/data-display/TreeView.tsx +172 -0
- package/src/components/data-display/Video.tsx +95 -0
- package/src/components/data-display/avatar-utils.ts +32 -0
- package/src/components/data-display/index.ts +81 -0
- package/src/components/feedback/Loading.tsx +159 -0
- package/src/components/feedback/Progress.tsx +321 -0
- package/src/components/feedback/Skeleton.tsx +62 -0
- package/src/components/feedback/SkeletonBlocks.tsx +222 -0
- package/src/components/feedback/Toast.tsx +648 -0
- package/src/components/feedback/index.ts +44 -0
- package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
- package/src/components/feedback/password/password-strength.ts +115 -0
- package/src/components/feedback/password/password-validation-data.ts +66 -0
- package/src/components/feedback/password/password-validation.ts +93 -0
- package/src/components/forms/Autocomplete.tsx +268 -0
- package/src/components/forms/Checkbox.tsx +155 -0
- package/src/components/forms/CodeInput.tsx +237 -0
- package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
- package/src/components/forms/ColorPicker/color-utils.ts +75 -0
- package/src/components/forms/ColorPicker/index.ts +2 -0
- package/src/components/forms/DatePicker.tsx +516 -0
- package/src/components/forms/DateRangePicker.tsx +464 -0
- package/src/components/forms/FieldPicker.tsx +64 -0
- package/src/components/forms/FileUpload.tsx +614 -0
- package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
- package/src/components/forms/FilterBuilder.tsx +16 -0
- package/src/components/forms/FilterRuleRow.tsx +68 -0
- package/src/components/forms/Input.tsx +200 -0
- package/src/components/forms/MultiSelect.tsx +361 -0
- package/src/components/forms/NumberField.tsx +145 -0
- package/src/components/forms/RadioGroup.tsx +135 -0
- package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
- package/src/components/forms/ReorderableList.tsx +163 -0
- package/src/components/forms/Select.tsx +268 -0
- package/src/components/forms/Slider.tsx +260 -0
- package/src/components/forms/Switch.tsx +135 -0
- package/src/components/forms/TextArea.tsx +202 -0
- package/src/components/forms/ViewCustomizer.tsx +44 -0
- package/src/components/forms/index.ts +43 -0
- package/src/components/layout/Accordion.tsx +110 -0
- package/src/components/layout/Alert.tsx +156 -0
- package/src/components/layout/BlockQuote.tsx +70 -0
- package/src/components/layout/Card.tsx +166 -0
- package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
- package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
- package/src/components/layout/CodeBlock/prism.ts +81 -0
- package/src/components/layout/Collapsible.tsx +84 -0
- package/src/components/layout/Container.tsx +55 -0
- package/src/components/layout/Divider.tsx +64 -0
- package/src/components/layout/Form.tsx +39 -0
- package/src/components/layout/FormActions.tsx +50 -0
- package/src/components/layout/Grid.tsx +53 -0
- package/src/components/layout/PageHeading.tsx +46 -0
- package/src/components/layout/PromptWithAction.tsx +49 -0
- package/src/components/layout/Section.tsx +60 -0
- package/src/components/layout/TablePanel.tsx +24 -0
- package/src/components/layout/TableView/TableView.tsx +1018 -0
- package/src/components/layout/TableView/index.ts +3 -0
- package/src/components/layout/TableView/types.ts +51 -0
- package/src/components/layout/WizardStep.tsx +40 -0
- package/src/components/layout/WizardStepper.tsx +173 -0
- package/src/components/layout/index.ts +96 -0
- package/src/components/navigation/Breadcrumbs.tsx +66 -0
- package/src/components/navigation/DropdownMenu.tsx +86 -0
- package/src/components/navigation/MegaMenu.tsx +480 -0
- package/src/components/navigation/NavigationMenu.tsx +305 -0
- package/src/components/navigation/Pagination.tsx +298 -0
- package/src/components/navigation/Sidebar.tsx +280 -0
- package/src/components/navigation/Tabs.tsx +122 -0
- package/src/components/navigation/ViewSwitcher.tsx +314 -0
- package/src/components/navigation/index.ts +66 -0
- package/src/components/overlays/AlertDialog.tsx +174 -0
- package/src/components/overlays/ContextMenu.tsx +65 -0
- package/src/components/overlays/Dialog.tsx +279 -0
- package/src/components/overlays/Drawer.tsx +370 -0
- package/src/components/overlays/HoverCard.tsx +107 -0
- package/src/components/overlays/Popover.tsx +73 -0
- package/src/components/overlays/Tooltip.tsx +31 -0
- package/src/components/overlays/index.ts +71 -0
- package/src/components/typography/Code.tsx +72 -0
- package/src/components/typography/Icon.tsx +36 -0
- package/src/components/typography/index.ts +10 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +13 -0
- package/src/styles/theme.css +226 -0
- package/src/types/avatar-types.ts +11 -0
- package/src/types/filter-types.ts +35 -0
- package/src/utilities/classNames.ts +6 -0
- package/src/utilities/componentSize.ts +46 -0
- package/src/utilities/i18n.tsx +60 -0
- package/src/utilities/mergeRefs.ts +12 -0
- 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
|
+
}
|