@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,98 @@
1
+ import { type JSX, splitProps } from 'solid-js'
2
+ import { Copy as CopyIcon, Check } from 'lucide-solid'
3
+ import { Button } from './Button'
4
+ import type { ButtonVariant, ButtonSize } from './Button'
5
+ import { useCopyToClipboard } from './useCopyToClipboard'
6
+ import { cn } from '../../utilities/classNames'
7
+
8
+ export type CopyDisplay = 'text' | 'icon-and-text' | 'icon-only'
9
+
10
+ const filledVariants: ButtonVariant[] = ['primary', 'danger', 'success', 'warning']
11
+ const borderlessVariants: ButtonVariant[] = ['ghost', 'link', 'danger-link']
12
+
13
+ export interface CopyProps extends Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'href' | 'target' | 'rel'> {
14
+ /** Text to copy to clipboard. */
15
+ text: string
16
+ /** How to show the button: "Copy" only, icon + "Copy", or icon only. */
17
+ display?: CopyDisplay
18
+ /** Label when not copied. Default: "Copy" */
19
+ label?: string
20
+ /** Label after copy. Default: "Copied" */
21
+ copiedLabel?: string
22
+ /** Button visual variant. Default: outlined */
23
+ variant?: ButtonVariant
24
+ size?: ButtonSize
25
+ class?: string
26
+ /** Called after successful copy. */
27
+ onCopied?: () => void
28
+ }
29
+
30
+ /**
31
+ * Button that copies the given text to the clipboard and shows visual feedback when copied.
32
+ * Use display="text" | "icon-and-text" | "icon-only" for word only, icon + word, or icon only.
33
+ */
34
+ export function Copy(props: CopyProps) {
35
+ const [copy, copied] = useCopyToClipboard()
36
+ const [local, rest] = splitProps(props, [
37
+ 'text',
38
+ 'display',
39
+ 'label',
40
+ 'copiedLabel',
41
+ 'variant',
42
+ 'size',
43
+ 'class',
44
+ 'onCopied',
45
+ ])
46
+ const [, others] = splitProps(rest, ['onChange'])
47
+
48
+ async function handleClick() {
49
+ const ok = await copy(local.text)
50
+ if (ok) local.onCopied?.()
51
+ }
52
+
53
+ const display = () => local.display ?? 'icon-and-text'
54
+ const label = () => {
55
+ const v = (local.label ?? 'Copy').trim()
56
+ return v || 'Copy'
57
+ }
58
+ const copiedLabel = () => {
59
+ const v = (local.copiedLabel ?? 'Copied').trim()
60
+ return v || 'Copied'
61
+ }
62
+ const isIconOnly = () => display() === 'icon-only'
63
+ const showIcon = () => display() === 'icon-and-text' || display() === 'icon-only'
64
+
65
+ const checkIcon = () => (
66
+ <Check class="h-4 w-4 shrink-0" aria-hidden />
67
+ )
68
+ const copyIcon = () => <CopyIcon class="h-4 w-4 shrink-0" aria-hidden />
69
+ const currentVariant = () => local.variant ?? 'outlined'
70
+ const resolvedVariant = (): ButtonVariant =>
71
+ copied()
72
+ ? filledVariants.includes(currentVariant())
73
+ ? 'success'
74
+ : 'success-outline'
75
+ : currentVariant()
76
+ /** When copied, ghost/link stay borderless (success-outline has a border; we remove it). */
77
+ const copiedClass = () =>
78
+ copied() && borderlessVariants.includes(currentVariant())
79
+ ? '!border-0'
80
+ : ''
81
+
82
+ return (
83
+ <Button
84
+ type="button"
85
+ variant={resolvedVariant()}
86
+ size={local.size ?? 'sm'}
87
+ iconOnly={isIconOnly()}
88
+ icon={isIconOnly() ? (copied() ? checkIcon() : copyIcon()) : undefined}
89
+ startIcon={!isIconOnly() && showIcon() ? (copied() ? checkIcon() : copyIcon()) : undefined}
90
+ label={copied() ? copiedLabel() : label()}
91
+ class={cn('shrink-0', copiedClass(), local.class)}
92
+ title={isIconOnly() ? (copied() ? copiedLabel() : label()) : undefined}
93
+ aria-label={isIconOnly() ? (copied() ? copiedLabel() : label()) : undefined}
94
+ onClick={handleClick}
95
+ {...(others as Partial<import('./Button').ButtonProps>)}
96
+ />
97
+ )
98
+ }
@@ -0,0 +1,80 @@
1
+ import { createSignal, onMount, Show } from 'solid-js'
2
+ import { Sun, Moon } from 'lucide-solid'
3
+ import { cn } from '../../utilities/classNames'
4
+
5
+ export interface DarkModeToggleProps {
6
+ /** Visual style. 'icon' renders a button with sun/moon icon (default). 'switch' renders a pill toggle. */
7
+ variant?: 'icon' | 'switch'
8
+ /** Extra classes on the root element */
9
+ class?: string
10
+ /** Element to toggle the dark class on. Defaults to document.documentElement (the html element) */
11
+ target?: () => HTMLElement
12
+ }
13
+
14
+ export function DarkModeToggle(props: DarkModeToggleProps) {
15
+ const [dark, setDark] = createSignal(false)
16
+
17
+ onMount(() => {
18
+ const stored = localStorage.getItem('torch-theme')
19
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
20
+ const isDark = stored ? stored === 'dark' : prefersDark
21
+ setDark(isDark)
22
+ ;(props.target?.() ?? document.documentElement).classList.toggle('dark', isDark)
23
+ })
24
+
25
+ function toggle() {
26
+ const next = !dark()
27
+ setDark(next)
28
+ ;(props.target?.() ?? document.documentElement).classList.toggle('dark', next)
29
+ localStorage.setItem('torch-theme', next ? 'dark' : 'light')
30
+ }
31
+
32
+ const variant = () => props.variant ?? 'icon'
33
+
34
+ return (
35
+ <Show
36
+ when={variant() === 'switch'}
37
+ fallback={
38
+ <button
39
+ type="button"
40
+ onClick={toggle}
41
+ aria-label={dark() ? 'Switch to light mode' : 'Switch to dark mode'}
42
+ aria-pressed={dark()}
43
+ class={cn(
44
+ 'inline-flex items-center justify-center rounded-lg p-2 text-ink-500 transition-colors',
45
+ 'hover:bg-surface-overlay hover:text-ink-700',
46
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
47
+ props.class,
48
+ )}
49
+ >
50
+ {dark() ? <Sun class="h-4 w-4" /> : <Moon class="h-4 w-4" />}
51
+ </button>
52
+ }
53
+ >
54
+ <button
55
+ type="button"
56
+ role="switch"
57
+ onClick={toggle}
58
+ aria-checked={dark()}
59
+ aria-label={dark() ? 'Switch to light mode' : 'Switch to dark mode'}
60
+ class={cn(
61
+ 'relative inline-flex h-7 w-14 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200',
62
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 focus-visible:ring-offset-2',
63
+ dark() ? 'bg-primary-500' : 'bg-surface-dim',
64
+ props.class,
65
+ )}
66
+ >
67
+ <span
68
+ class={cn(
69
+ 'pointer-events-none flex h-5 w-5 items-center justify-center rounded-full bg-white shadow-sm ring-0 transition-transform duration-200',
70
+ dark() ? 'translate-x-7' : 'translate-x-0',
71
+ )}
72
+ >
73
+ {dark()
74
+ ? <Moon class="h-3 w-3 text-primary-600" />
75
+ : <Sun class="h-3 w-3 text-ink-400" />}
76
+ </span>
77
+ </button>
78
+ </Show>
79
+ )
80
+ }
@@ -0,0 +1,37 @@
1
+ import { type JSX, splitProps } from 'solid-js'
2
+ import { cn } from '../../utilities/classNames'
3
+
4
+ export type LinkVariant = 'primary' | 'muted'
5
+
6
+ export interface LinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {
7
+ /** Visual style. Default: "primary" */
8
+ variant?: LinkVariant
9
+ children?: JSX.Element
10
+ }
11
+
12
+ const linkVariants: Record<LinkVariant, string> = {
13
+ primary:
14
+ 'text-primary-500 font-medium hover:text-primary-600 hover:underline hover:underline-offset-4 focus-visible:ring-primary-500/50 dark:text-primary-400 dark:hover:text-primary-300',
15
+ muted:
16
+ 'text-ink-500 hover:text-ink-700 dark:hover:text-ink-300 hover:underline hover:underline-offset-4 focus-visible:ring-ink-300/50 dark:focus-visible:ring-ink-500/50',
17
+ }
18
+
19
+ /** Styled anchor link with primary and muted variants. */
20
+ export function Link(props: LinkProps) {
21
+ const [local, others] = splitProps(props, ['variant', 'class', 'children'])
22
+
23
+ const variant = () => local.variant ?? 'primary'
24
+
25
+ return (
26
+ <a
27
+ class={cn(
28
+ 'inline outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-surface-base rounded',
29
+ linkVariants[variant()],
30
+ local.class
31
+ )}
32
+ {...others}
33
+ >
34
+ {local.children}
35
+ </a>
36
+ )
37
+ }
@@ -0,0 +1,19 @@
1
+ /** Actions: Button, Button Group, Copy, Link, DarkModeToggle */
2
+ export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './Button'
3
+
4
+ export {
5
+ ButtonGroup,
6
+ type ButtonGroupProps,
7
+ type ButtonGroupMainProps,
8
+ type ButtonGroupMenuProps,
9
+ type ButtonGroupMenuSlot,
10
+ type ToggleGroupOption,
11
+ } from './ButtonGroup'
12
+
13
+ export { Copy, type CopyProps, type CopyDisplay } from './Copy'
14
+
15
+ export { useCopyToClipboard } from './useCopyToClipboard'
16
+
17
+ export { Link, type LinkProps, type LinkVariant } from './Link'
18
+
19
+ export { DarkModeToggle, type DarkModeToggleProps } from './DarkModeToggle'
@@ -0,0 +1,90 @@
1
+ import { createSignal, onCleanup } from 'solid-js'
2
+
3
+ const COPIED_RESET_MS = 2000
4
+
5
+ const hasAsyncClipboard = () =>
6
+ typeof navigator !== 'undefined' &&
7
+ !!navigator.clipboard &&
8
+ typeof navigator.clipboard.writeText === 'function'
9
+
10
+ /**
11
+ * Copy text to clipboard and show "copied" feedback for a short time.
12
+ * Returns [copy, copied, status] where status is 'idle' | 'copied' | 'error'.
13
+ */
14
+ export function useCopyToClipboard(): [
15
+ (text: string) => Promise<boolean>,
16
+ () => boolean,
17
+ () => 'idle' | 'copied' | 'error',
18
+ ] {
19
+ const [copied, setCopied] = createSignal(false)
20
+ const [status, setStatus] = createSignal<'idle' | 'copied' | 'error'>('idle')
21
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
22
+
23
+ function setCopiedWithReset() {
24
+ if (timeoutId) clearTimeout(timeoutId)
25
+ setCopied(true)
26
+ setStatus('copied')
27
+ timeoutId = setTimeout(() => {
28
+ setCopied(false)
29
+ setStatus('idle')
30
+ timeoutId = undefined
31
+ }, COPIED_RESET_MS)
32
+ }
33
+
34
+ function setError() {
35
+ if (timeoutId) clearTimeout(timeoutId)
36
+ setCopied(false)
37
+ setStatus('error')
38
+ timeoutId = setTimeout(() => {
39
+ setStatus('idle')
40
+ timeoutId = undefined
41
+ }, COPIED_RESET_MS)
42
+ }
43
+
44
+ onCleanup(() => {
45
+ if (timeoutId) clearTimeout(timeoutId)
46
+ })
47
+
48
+ /** execCommand fallback for older browsers / non-HTTPS / WebViews. */
49
+ function fallbackCopy(text: string): boolean {
50
+ if (typeof document === 'undefined' || !document.body) return false
51
+ const ta = document.createElement('textarea')
52
+ ta.value = text
53
+ ta.setAttribute('readonly', '')
54
+ ta.style.position = 'fixed'
55
+ ta.style.left = '-9999px'
56
+ ta.style.opacity = '0'
57
+ document.body.appendChild(ta)
58
+ try {
59
+ ta.focus()
60
+ ta.select()
61
+ ta.setSelectionRange(0, ta.value.length)
62
+ return document.execCommand('copy')
63
+ } catch {
64
+ return false
65
+ } finally {
66
+ ta.parentNode?.removeChild(ta)
67
+ }
68
+ }
69
+
70
+ async function copy(text: string): Promise<boolean> {
71
+ if (hasAsyncClipboard()) {
72
+ try {
73
+ await navigator.clipboard.writeText(text)
74
+ setCopiedWithReset()
75
+ return true
76
+ } catch {
77
+ /* Permission denied or gesture requirement — try fallback. */
78
+ }
79
+ }
80
+ const ok = fallbackCopy(text)
81
+ if (ok) {
82
+ setCopiedWithReset()
83
+ } else {
84
+ setError()
85
+ }
86
+ return ok
87
+ }
88
+
89
+ return [copy, copied, status]
90
+ }
@@ -0,0 +1,331 @@
1
+ import { onMount, onCleanup, createEffect, createSignal, createRoot, splitProps } from 'solid-js'
2
+ import type { ChartConfiguration } from 'chart.js'
3
+ import { cn } from '../../utilities/classNames'
4
+
5
+ export type ChartType = 'line' | 'bar' | 'doughnut' | 'pie' | 'radar' | 'polarArea' | 'scatter' | 'bubble'
6
+
7
+ /** Point for scatter charts (x, y) or bubble charts (x, y, r = radius). */
8
+ export type ScatterPoint = { x: number; y: number }
9
+ export type BubblePoint = { x: number; y: number; r: number }
10
+
11
+ export interface ChartDataset {
12
+ label: string
13
+ /** For line/bar/pie/doughnut/radar/polarArea: number[]. For scatter: ScatterPoint[]. For bubble: BubblePoint[]. */
14
+ data: number[] | ScatterPoint[] | BubblePoint[]
15
+ backgroundColor?: string | string[]
16
+ borderColor?: string | string[]
17
+ }
18
+
19
+ export interface ChartData {
20
+ /** For scatter/bubble can be empty []. For other types, one label per data point. */
21
+ labels: string[]
22
+ datasets: ChartDataset[]
23
+ }
24
+
25
+ export interface ChartProps {
26
+ /** Chart type: line, bar, doughnut, pie, radar, polarArea, scatter, or bubble */
27
+ type: ChartType
28
+ /** Chart data: labels and datasets */
29
+ data: ChartData
30
+ /** When type is 'line', fill area under the line. Default false. Same idea as Sparkline fill. */
31
+ fill?: boolean
32
+ /** Optional Chart.js options override (merged with defaults) */
33
+ options?: ChartConfiguration<ChartType>['options']
34
+ /** Accessible label for the chart wrapper. When provided, the chart is exposed to assistive tech. */
35
+ 'aria-label'?: string
36
+ /** ID of an element that labels the chart. When provided, the chart is exposed to assistive tech. */
37
+ 'aria-labelledby'?: string
38
+ class?: string
39
+ }
40
+
41
+ /** Reads current theme from body class. */
42
+ function isDark(): boolean {
43
+ if (typeof document === 'undefined') return false
44
+ return document.body.classList.contains('dark')
45
+ }
46
+
47
+ /** Shared dark-mode signal — one MutationObserver for all Chart instances.
48
+ * Created inside createRoot so the signal is ownerless/global and not tied
49
+ * to the reactive root of whichever component first calls getSharedDark(). */
50
+ let sharedDarkSignal: { dark: () => boolean; subscribe: () => void; unsubscribe: () => void } | null = null
51
+ let sharedDarkRefCount = 0
52
+
53
+ function getSharedDark() {
54
+ if (!sharedDarkSignal) {
55
+ createRoot(() => {
56
+ const [dark, setDark] = createSignal(isDark())
57
+ let observer: MutationObserver | null = null
58
+ sharedDarkSignal = {
59
+ dark,
60
+ subscribe() {
61
+ sharedDarkRefCount++
62
+ if (sharedDarkRefCount === 1 && typeof document !== 'undefined') {
63
+ observer = new MutationObserver(() => setDark(isDark()))
64
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class'] })
65
+ }
66
+ },
67
+ unsubscribe() {
68
+ sharedDarkRefCount--
69
+ if (sharedDarkRefCount <= 0 && observer) {
70
+ observer.disconnect()
71
+ observer = null
72
+ sharedDarkRefCount = 0
73
+ }
74
+ },
75
+ }
76
+ })
77
+ }
78
+ return sharedDarkSignal!
79
+ }
80
+
81
+ const THEME = {
82
+ gridColor: (dark: boolean) => (dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'),
83
+ tickColor: (dark: boolean) => (dark ? '#a1a1aa' : '#52525b'),
84
+ textColor: (dark: boolean) => (dark ? '#e4e4e7' : '#18181b'),
85
+ subtitleColor: (dark: boolean) => (dark ? '#a1a1aa' : '#52525b'),
86
+ tooltipBg: (dark: boolean) => (dark ? '#1e2328' : '#ffffff'),
87
+ tooltipBorder: (dark: boolean) => (dark ? '#3f3f46' : '#e4e4e7'),
88
+ }
89
+
90
+ /** Theme-aware options for plugins (legend, title, tooltip) and scale colors. Merged so charts read well in light/dark. */
91
+ function getThemeOptions(dark: boolean, type: ChartType): ChartConfiguration<ChartType>['options'] {
92
+ const textColor = THEME.textColor(dark)
93
+ const opts: ChartConfiguration<ChartType>['options'] = {
94
+ plugins: {
95
+ legend: { labels: { color: textColor } },
96
+ title: { color: textColor },
97
+ subtitle: { color: THEME.subtitleColor(dark) },
98
+ tooltip: {
99
+ titleColor: textColor,
100
+ bodyColor: textColor,
101
+ backgroundColor: THEME.tooltipBg(dark),
102
+ borderColor: THEME.tooltipBorder(dark),
103
+ borderWidth: 1,
104
+ },
105
+ },
106
+ }
107
+ const gridColor = THEME.gridColor(dark)
108
+ const tickColor = THEME.tickColor(dark)
109
+ if (type === 'line' || type === 'bar' || type === 'scatter' || type === 'bubble') {
110
+ opts.scales = {
111
+ x: { grid: { color: gridColor }, ticks: { color: tickColor } },
112
+ y: { grid: { color: gridColor }, ticks: { color: tickColor } },
113
+ }
114
+ }
115
+ if (type === 'radar' || type === 'polarArea') {
116
+ opts.scales = {
117
+ r: { grid: { color: gridColor }, ticks: { color: tickColor, backdropColor: 'transparent' } },
118
+ }
119
+ }
120
+ return opts
121
+ }
122
+
123
+ /** Segment border style for doughnut/pie so lines are visible in light and dark mode */
124
+ function getSegmentBorderForMode(dark: boolean): { borderColor: string; borderWidth: number } {
125
+ return dark
126
+ ? { borderColor: 'rgba(255,255,255,0.25)', borderWidth: 1 }
127
+ : { borderColor: 'rgba(0,0,0,0.08)', borderWidth: 1 }
128
+ }
129
+
130
+ function applySegmentBorders(
131
+ chartInstance: import('chart.js').Chart,
132
+ type: ChartType,
133
+ dark: boolean
134
+ ): void {
135
+ if (type !== 'doughnut' && type !== 'pie') return
136
+ const { borderColor, borderWidth } = getSegmentBorderForMode(dark)
137
+ chartInstance.data.datasets.forEach((ds) => {
138
+ const len = 'data' in ds && Array.isArray(ds.data) ? ds.data.length : 0
139
+ ;(ds as { borderColor?: string | string[]; borderWidth?: number }).borderColor = Array(len).fill(borderColor)
140
+ ;(ds as { borderWidth?: number }).borderWidth = borderWidth
141
+ })
142
+ chartInstance.update('none')
143
+ }
144
+
145
+ function buildConfig(
146
+ type: ChartType,
147
+ data: ChartData,
148
+ dark: boolean,
149
+ optionsOverride?: ChartConfiguration<ChartType>['options'],
150
+ lineFill?: boolean
151
+ ): ChartConfiguration<ChartType> {
152
+ const segmentBorder =
153
+ type === 'doughnut' || type === 'pie' ? getSegmentBorderForMode(dark) : null
154
+ const isLine = type === 'line'
155
+ const themeOpts = getThemeOptions(dark, type)
156
+ const gridColor = THEME.gridColor(dark)
157
+ const tickColor = THEME.tickColor(dark)
158
+ const isScatterOrBubble = type === 'scatter' || type === 'bubble'
159
+ // Chart.js generics are loose — ChartConfiguration<ChartType>['data']['datasets'] is a union of
160
+ // all chart-type dataset arrays. We infer the element type so individual dataset objects can be
161
+ // built without casting to the full union. The `as DatasetItem` casts below are safe because
162
+ // buildConfig is always called with a concrete type that matches the dataset shape.
163
+ type ChartDataConfig = ChartConfiguration<ChartType>['data']
164
+ type DatasetItem = NonNullable<ChartDataConfig>['datasets'] extends (infer D)[] ? D : never
165
+ const datasets: DatasetItem[] = data.datasets.map((ds) => {
166
+ if (isScatterOrBubble) {
167
+ return {
168
+ label: ds.label,
169
+ data: ds.data,
170
+ backgroundColor: ds.backgroundColor,
171
+ borderColor: ds.borderColor,
172
+ } as DatasetItem
173
+ }
174
+ const len = (ds.data as number[]).length
175
+ const out: Record<string, unknown> = {
176
+ label: ds.label,
177
+ data: ds.data,
178
+ backgroundColor: ds.backgroundColor,
179
+ borderColor: ds.borderColor,
180
+ borderWidth: isLine ? 2 : segmentBorder?.borderWidth ?? 1,
181
+ tension: isLine ? 0.3 : 0,
182
+ fill: isLine ? (lineFill ?? false) : undefined,
183
+ }
184
+ if (segmentBorder && !ds.borderColor) {
185
+ out.borderColor = Array(len).fill(segmentBorder.borderColor)
186
+ }
187
+ return out as unknown as DatasetItem
188
+ })
189
+ const base: ChartConfiguration<ChartType> = {
190
+ type,
191
+ data: {
192
+ labels: data.labels,
193
+ datasets,
194
+ },
195
+ options: {
196
+ responsive: true,
197
+ maintainAspectRatio: false,
198
+ plugins: {
199
+ ...(themeOpts?.plugins ?? {}),
200
+ legend: { position: 'bottom' as const, ...(themeOpts?.plugins?.legend ?? {}) },
201
+ },
202
+ ...(isLine || type === 'bar'
203
+ ? {
204
+ scales: {
205
+ x: { grid: { display: false, color: gridColor }, ticks: { color: tickColor } },
206
+ y: { beginAtZero: true, grace: '5%', grid: { color: gridColor }, ticks: { color: tickColor } },
207
+ },
208
+ }
209
+ : {}),
210
+ ...(isScatterOrBubble
211
+ ? {
212
+ scales: {
213
+ x: { type: 'linear' as const, grid: { color: gridColor }, ticks: { color: tickColor } },
214
+ y: { type: 'linear' as const, beginAtZero: true, grace: '5%', grid: { color: gridColor }, ticks: { color: tickColor } },
215
+ },
216
+ }
217
+ : {}),
218
+ ...(type === 'radar' || type === 'polarArea' ? { scales: themeOpts?.scales } : {}),
219
+ ...optionsOverride,
220
+ },
221
+ }
222
+ return base
223
+ }
224
+
225
+ /**
226
+ * Chart.js wrapper with automatic dark-mode theming. Renders to canvas which is
227
+ * inherently inaccessible — consumers should provide an accessible alternative
228
+ * (caption, summary text, or data table) when the chart conveys meaningful data.
229
+ */
230
+ export function Chart(props: ChartProps) {
231
+ const [local] = splitProps(props, ['type', 'data', 'fill', 'options', 'aria-label', 'aria-labelledby', 'class'])
232
+ let canvasEl: HTMLCanvasElement | undefined
233
+ let chartInstance: import('chart.js').Chart | null = null
234
+ let ChartConstructor: typeof import('chart.js').Chart | null = null
235
+ const [chartReady, setChartReady] = createSignal(false)
236
+ const sharedDark = getSharedDark()
237
+ const dark = sharedDark.dark
238
+ let currentType: ChartType = local.type
239
+
240
+ function destroyChart() {
241
+ if (chartInstance) {
242
+ chartInstance.destroy()
243
+ chartInstance = null
244
+ }
245
+ }
246
+
247
+ function createChart(type: ChartType) {
248
+ if (!canvasEl || !ChartConstructor) return
249
+ destroyChart()
250
+ const config = buildConfig(type, local.data, dark(), local.options, local.fill)
251
+ chartInstance = new ChartConstructor(canvasEl, config as ChartConfiguration<ChartType>)
252
+ if (type === 'doughnut' || type === 'pie') {
253
+ applySegmentBorders(chartInstance, type, dark())
254
+ }
255
+ currentType = type
256
+ }
257
+
258
+ onMount(async () => {
259
+ if (!canvasEl) return
260
+ const mod = await import('chart.js/auto')
261
+ ChartConstructor = mod.Chart
262
+ createChart(local.type)
263
+ sharedDark.subscribe()
264
+ setChartReady(true)
265
+ })
266
+
267
+ // Single update path for data, options, fill, type, and theme changes.
268
+ createEffect(() => {
269
+ if (!chartReady()) return
270
+ const currentDark = dark()
271
+ const opts = local.options
272
+ const fill = local.fill
273
+ const type = local.type
274
+
275
+ // Type changed — must destroy + recreate (Chart.js requirement).
276
+ if (type !== currentType) {
277
+ createChart(type)
278
+ return
279
+ }
280
+
281
+ const ci = chartInstance
282
+ if (!ci) return
283
+
284
+ // Empty datasets — clear the chart.
285
+ if (!local.data.datasets.length) {
286
+ ci.data.labels = []
287
+ ci.data.datasets = []
288
+ ci.update('none')
289
+ return
290
+ }
291
+
292
+ const config = buildConfig(type, local.data, currentDark, opts, fill)
293
+ ci.options = config.options!
294
+ ci.data.labels = local.data.labels
295
+ ci.data.datasets = local.data.datasets.map((ds, i) => {
296
+ const prev = ci.data.datasets[i] as unknown as Record<string, unknown> | undefined
297
+ return {
298
+ ...(prev ?? {}),
299
+ label: ds.label,
300
+ data: ds.data,
301
+ backgroundColor: ds.backgroundColor,
302
+ borderColor: ds.borderColor,
303
+ fill: type === 'line' ? (fill ?? false) : undefined,
304
+ }
305
+ })
306
+ if (type === 'doughnut' || type === 'pie') {
307
+ applySegmentBorders(ci, type, currentDark)
308
+ } else {
309
+ ci.update('none')
310
+ }
311
+ })
312
+
313
+ onCleanup(() => {
314
+ sharedDark.unsubscribe()
315
+ destroyChart()
316
+ })
317
+
318
+ const hasAccessibleName = () => !!local['aria-label'] || !!local['aria-labelledby']
319
+
320
+ return (
321
+ <div
322
+ class={cn('h-full w-full min-h-[200px]', local.class)}
323
+ aria-hidden={hasAccessibleName() ? undefined : 'true'}
324
+ aria-label={local['aria-label']}
325
+ aria-labelledby={local['aria-labelledby']}
326
+ role={hasAccessibleName() ? 'img' : undefined}
327
+ >
328
+ <canvas ref={canvasEl} />
329
+ </div>
330
+ )
331
+ }