@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,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
|
+
}
|