This commit is contained in:
2026-03-18 20:54:43 +01:00
parent b3c8b77f12
commit 9fe656b34c
8058 changed files with 912898 additions and 23 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
import { createLocalStorage } from '@solid-primitives/storage'
import { createMemo } from 'solid-js'
import { Devtools } from './Devtools'
import { getPreferredColorScheme } from './utils'
import { THEME_PREFERENCE } from './constants'
import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts'
import type { Theme } from './contexts'
import type { DevtoolsComponentType } from './Devtools'
const DevtoolsComponent: DevtoolsComponentType = (props) => {
const [localStore, setLocalStore] = createLocalStorage({
prefix: 'TanstackQueryDevtools',
})
const colorScheme = getPreferredColorScheme()
const theme = createMemo(() => {
const preference = (props.theme ||
localStore.theme_preference ||
THEME_PREFERENCE) as Theme
if (preference !== 'system') return preference
return colorScheme()
})
return (
<QueryDevtoolsContext.Provider value={props}>
<PiPProvider localStore={localStore} setLocalStore={setLocalStore}>
<ThemeContext.Provider value={theme}>
<Devtools localStore={localStore} setLocalStore={setLocalStore} />
</ThemeContext.Provider>
</PiPProvider>
</QueryDevtoolsContext.Provider>
)
}
export default DevtoolsComponent
@@ -0,0 +1,47 @@
import { createLocalStorage } from '@solid-primitives/storage'
import { createMemo } from 'solid-js'
import { ContentView, ParentPanel } from './Devtools'
import { getPreferredColorScheme } from './utils'
import { THEME_PREFERENCE } from './constants'
import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts'
import type { Theme } from './contexts'
import type { DevtoolsComponentType } from './Devtools'
const DevtoolsPanelComponent: DevtoolsComponentType = (props) => {
const [localStore, setLocalStore] = createLocalStorage({
prefix: 'TanstackQueryDevtools',
})
const colorScheme = getPreferredColorScheme()
const theme = createMemo(() => {
const preference = (props.theme ||
localStore.theme_preference ||
THEME_PREFERENCE) as Theme
if (preference !== 'system') return preference
return colorScheme()
})
return (
<QueryDevtoolsContext.Provider value={props}>
<PiPProvider
disabled
localStore={localStore}
setLocalStore={setLocalStore}
>
<ThemeContext.Provider value={theme}>
<ParentPanel>
<ContentView
localStore={localStore}
setLocalStore={setLocalStore}
onClose={props.onClose}
showPanelViewOnly
/>
</ParentPanel>
</ThemeContext.Provider>
</PiPProvider>
</QueryDevtoolsContext.Provider>
)
}
export default DevtoolsPanelComponent
+674
View File
@@ -0,0 +1,674 @@
import { serialize, stringify } from 'superjson'
import { clsx as cx } from 'clsx'
import {
Index,
Match,
Show,
Switch,
createMemo,
createSignal,
createUniqueId,
} from 'solid-js'
import { Key } from '@solid-primitives/keyed'
import * as goober from 'goober'
import { tokens } from './theme'
import {
deleteNestedDataByPath,
displayValue,
updateNestedDataByPath,
} from './utils'
import {
Check,
CopiedCopier,
Copier,
ErrorCopier,
List,
Pencil,
Trash,
} from './icons'
import { useQueryDevtoolsContext, useTheme } from './contexts'
import type { Query } from '@tanstack/query-core'
/**
* Chunk elements in the array by size
*
* when the array cannot be chunked evenly by size, the last chunk will be
* filled with the remaining elements
*
* @example
* chunkArray(['a','b', 'c', 'd', 'e'], 2) // returns [['a','b'], ['c', 'd'], ['e']]
*/
function chunkArray<T extends { label: string; value: unknown }>(
array: Array<T>,
size: number,
): Array<Array<T>> {
if (size < 1) return []
let i = 0
const result: Array<Array<T>> = []
while (i < array.length) {
result.push(array.slice(i, i + size))
i = i + size
}
return result
}
const Expander = (props: { expanded: boolean }) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
return (
<span
class={cx(
styles().expander,
css`
transform: rotate(${props.expanded ? 90 : 0}deg);
`,
props.expanded &&
css`
& svg {
top: -1px;
}
`,
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 12L10 8L6 4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
)
}
type CopyState = 'NoCopy' | 'SuccessCopy' | 'ErrorCopy'
const CopyButton = (props: { value: unknown }) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const [copyState, setCopyState] = createSignal<CopyState>('NoCopy')
return (
<button
class={styles().actionButton}
title="Copy object to clipboard"
aria-label={`${
copyState() === 'NoCopy'
? 'Copy object to clipboard'
: copyState() === 'SuccessCopy'
? 'Object copied to clipboard'
: 'Error copying object to clipboard'
}`}
onClick={
copyState() === 'NoCopy'
? () => {
navigator.clipboard.writeText(stringify(props.value)).then(
() => {
setCopyState('SuccessCopy')
setTimeout(() => {
setCopyState('NoCopy')
}, 1500)
},
(err) => {
console.error('Failed to copy: ', err)
setCopyState('ErrorCopy')
setTimeout(() => {
setCopyState('NoCopy')
}, 1500)
},
)
}
: undefined
}
>
<Switch>
<Match when={copyState() === 'NoCopy'}>
<Copier />
</Match>
<Match when={copyState() === 'SuccessCopy'}>
<CopiedCopier theme={theme()} />
</Match>
<Match when={copyState() === 'ErrorCopy'}>
<ErrorCopier />
</Match>
</Switch>
</button>
)
}
const ClearArrayButton = (props: {
dataPath: Array<string>
activeQuery: Query
}) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const queryClient = useQueryDevtoolsContext().client
return (
<button
class={styles().actionButton}
title={'Remove all items'}
aria-label={'Remove all items'}
onClick={() => {
const oldData = props.activeQuery.state.data
const newData = updateNestedDataByPath(oldData, props.dataPath, [])
queryClient.setQueryData(props.activeQuery.queryKey, newData)
}}
>
<List />
</button>
)
}
const DeleteItemButton = (props: {
dataPath: Array<string>
activeQuery: Query
}) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const queryClient = useQueryDevtoolsContext().client
return (
<button
class={cx(styles().actionButton)}
title={'Delete item'}
aria-label={'Delete item'}
onClick={() => {
const oldData = props.activeQuery.state.data
const newData = deleteNestedDataByPath(oldData, props.dataPath)
queryClient.setQueryData(props.activeQuery.queryKey, newData)
}}
>
<Trash />
</button>
)
}
const ToggleValueButton = (props: {
dataPath: Array<string>
activeQuery: Query
value: boolean
}) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const queryClient = useQueryDevtoolsContext().client
return (
<button
class={cx(
styles().actionButton,
css`
width: ${tokens.size[3.5]};
height: ${tokens.size[3.5]};
`,
)}
title={'Toggle value'}
aria-label={'Toggle value'}
onClick={() => {
const oldData = props.activeQuery.state.data
const newData = updateNestedDataByPath(
oldData,
props.dataPath,
!props.value,
)
queryClient.setQueryData(props.activeQuery.queryKey, newData)
}}
>
<Check theme={theme()} checked={props.value} />
</button>
)
}
type ExplorerProps = {
editable?: boolean
label: string
value: unknown
defaultExpanded?: Array<string>
dataPath?: Array<string>
activeQuery?: Query
itemsDeletable?: boolean
onEdit?: () => void
}
function isIterable(x: any): x is Iterable<unknown> {
return Symbol.iterator in x
}
export default function Explorer(props: ExplorerProps) {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const queryClient = useQueryDevtoolsContext().client
const [expanded, setExpanded] = createSignal(
(props.defaultExpanded || []).includes(props.label),
)
const toggleExpanded = () => setExpanded((old) => !old)
const [expandedPages, setExpandedPages] = createSignal<Array<number>>([])
const subEntries = createMemo(() => {
if (Array.isArray(props.value)) {
return props.value.map((d, i) => ({
label: i.toString(),
value: d,
}))
} else if (
props.value !== null &&
typeof props.value === 'object' &&
isIterable(props.value) &&
typeof props.value[Symbol.iterator] === 'function'
) {
if (props.value instanceof Map) {
return Array.from(props.value, ([key, val]) => ({
label: key,
value: val,
}))
}
return Array.from(props.value, (val, i) => ({
label: i.toString(),
value: val,
}))
} else if (typeof props.value === 'object' && props.value !== null) {
return Object.entries(props.value).map(([key, val]) => ({
label: key,
value: val,
}))
}
return []
})
const type = createMemo<string>(() => {
if (Array.isArray(props.value)) {
return 'array'
} else if (
props.value !== null &&
typeof props.value === 'object' &&
isIterable(props.value) &&
typeof props.value[Symbol.iterator] === 'function'
) {
return 'Iterable'
} else if (typeof props.value === 'object' && props.value !== null) {
return 'object'
}
return typeof props.value
})
const subEntryPages = createMemo(() => chunkArray(subEntries(), 100))
const currentDataPath = props.dataPath ?? []
const inputId = createUniqueId()
return (
<div class={styles().entry}>
<Show when={subEntryPages().length}>
<div class={styles().expanderButtonContainer}>
<button
class={styles().expanderButton}
onClick={() => toggleExpanded()}
aria-expanded={expanded() ? 'true' : 'false'}
>
<Expander expanded={expanded()} /> <span>{props.label}</span>{' '}
<span class={styles().info}>
{String(type()).toLowerCase() === 'iterable' ? '(Iterable) ' : ''}
{subEntries().length} {subEntries().length > 1 ? `items` : `item`}
</span>
</button>
<Show when={props.editable}>
<div class={styles().actions}>
<CopyButton value={props.value} />
<Show
when={props.itemsDeletable && props.activeQuery !== undefined}
>
<DeleteItemButton
activeQuery={props.activeQuery!}
dataPath={currentDataPath}
/>
</Show>
<Show
when={type() === 'array' && props.activeQuery !== undefined}
>
<ClearArrayButton
activeQuery={props.activeQuery!}
dataPath={currentDataPath}
/>
</Show>
<Show when={!!props.onEdit && !serialize(props.value).meta}>
<button
class={styles().actionButton}
title={'Bulk Edit Data'}
aria-label={'Bulk Edit Data'}
onClick={() => {
props.onEdit?.()
}}
>
<Pencil />
</button>
</Show>
</div>
</Show>
</div>
<Show when={expanded()}>
<Show when={subEntryPages().length === 1}>
<div class={styles().subEntry}>
<Key each={subEntries()} by={(item) => item.label}>
{(entry) => {
return (
<Explorer
defaultExpanded={props.defaultExpanded}
label={entry().label}
value={entry().value}
editable={props.editable}
dataPath={[...currentDataPath, entry().label]}
activeQuery={props.activeQuery}
itemsDeletable={
type() === 'array' ||
type() === 'Iterable' ||
type() === 'object'
}
/>
)
}}
</Key>
</div>
</Show>
<Show when={subEntryPages().length > 1}>
<div class={styles().subEntry}>
<Index each={subEntryPages()}>
{(entries, index) => (
<div>
<div class={styles().entry}>
<button
onClick={() =>
setExpandedPages((old) =>
old.includes(index)
? old.filter((d) => d !== index)
: [...old, index],
)
}
class={styles().expanderButton}
>
<Expander expanded={expandedPages().includes(index)} />{' '}
[{index * 100}...
{index * 100 + 100 - 1}]
</button>
<Show when={expandedPages().includes(index)}>
<div class={styles().subEntry}>
<Key each={entries()} by={(entry) => entry.label}>
{(entry) => (
<Explorer
defaultExpanded={props.defaultExpanded}
label={entry().label}
value={entry().value}
editable={props.editable}
dataPath={[...currentDataPath, entry().label]}
activeQuery={props.activeQuery}
/>
)}
</Key>
</div>
</Show>
</div>
</div>
)}
</Index>
</div>
</Show>
</Show>
</Show>
<Show when={subEntryPages().length === 0}>
<div class={styles().row}>
<label for={inputId} class={styles().label}>
{props.label}:
</label>
<Show
when={
props.editable &&
props.activeQuery !== undefined &&
(type() === 'string' ||
type() === 'number' ||
type() === 'boolean')
}
fallback={
<span class={styles().value}>{displayValue(props.value)}</span>
}
>
<Show
when={
props.editable &&
props.activeQuery !== undefined &&
(type() === 'string' || type() === 'number')
}
>
<input
id={inputId}
type={type() === 'number' ? 'number' : 'text'}
class={cx(styles().value, styles().editableInput)}
value={props.value as string | number}
onChange={(changeEvent) => {
const oldData = props.activeQuery!.state.data
const newData = updateNestedDataByPath(
oldData,
currentDataPath,
type() === 'number'
? changeEvent.target.valueAsNumber
: changeEvent.target.value,
)
queryClient.setQueryData(props.activeQuery!.queryKey, newData)
}}
/>
</Show>
<Show when={type() === 'boolean'}>
<span
class={cx(
styles().value,
styles().actions,
styles().editableInput,
)}
>
<ToggleValueButton
activeQuery={props.activeQuery!}
dataPath={currentDataPath}
value={props.value as boolean}
/>
{displayValue(props.value)}
</span>
</Show>
</Show>
<Show
when={
props.editable &&
props.itemsDeletable &&
props.activeQuery !== undefined
}
>
<DeleteItemButton
activeQuery={props.activeQuery!}
dataPath={currentDataPath}
/>
</Show>
</div>
</Show>
</div>
)
}
const stylesFactory = (
theme: 'light' | 'dark',
css: (typeof goober)['css'],
) => {
const { colors, font, size, border } = tokens
const t = (light: string, dark: string) => (theme === 'light' ? light : dark)
return {
entry: css`
& * {
font-size: ${font.size.xs};
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
position: relative;
outline: none;
word-break: break-word;
`,
subEntry: css`
margin: 0 0 0 0.5em;
padding-left: 0.75em;
border-left: 2px solid ${t(colors.gray[300], colors.darkGray[400])};
/* outline: 1px solid ${colors.teal[400]}; */
`,
expander: css`
& path {
stroke: ${colors.gray[400]};
}
& svg {
width: ${size[3]};
height: ${size[3]};
}
display: inline-flex;
align-items: center;
transition: all 0.1s ease;
/* outline: 1px solid ${colors.blue[400]}; */
`,
expanderButtonContainer: css`
display: flex;
align-items: center;
line-height: ${size[4]};
min-height: ${size[4]};
gap: ${size[2]};
`,
expanderButton: css`
cursor: pointer;
color: inherit;
font: inherit;
outline: inherit;
height: ${size[5]};
background: transparent;
border: none;
padding: 0;
display: inline-flex;
align-items: center;
gap: ${size[1]};
position: relative;
/* outline: 1px solid ${colors.green[400]}; */
&:focus-visible {
border-radius: ${border.radius.xs};
outline: 2px solid ${colors.blue[800]};
}
& svg {
position: relative;
left: 1px;
}
`,
info: css`
color: ${t(colors.gray[500], colors.gray[500])};
font-size: ${font.size.xs};
margin-left: ${size[1]};
/* outline: 1px solid ${colors.yellow[400]}; */
`,
label: css`
color: ${t(colors.gray[700], colors.gray[300])};
white-space: nowrap;
`,
value: css`
color: ${t(colors.purple[600], colors.purple[400])};
flex-grow: 1;
`,
actions: css`
display: inline-flex;
gap: ${size[2]};
align-items: center;
`,
row: css`
display: inline-flex;
gap: ${size[2]};
width: 100%;
margin: ${size[0.25]} 0px;
line-height: ${size[4.5]};
align-items: center;
`,
editableInput: css`
border: none;
padding: ${size[0.5]} ${size[1]} ${size[0.5]} ${size[1.5]};
flex-grow: 1;
border-radius: ${border.radius.xs};
background-color: ${t(colors.gray[200], colors.darkGray[500])};
&:hover {
background-color: ${t(colors.gray[300], colors.darkGray[600])};
}
`,
actionButton: css`
background-color: transparent;
color: ${t(colors.gray[500], colors.gray[500])};
border: none;
display: inline-flex;
padding: 0px;
align-items: center;
justify-content: center;
cursor: pointer;
width: ${size[3]};
height: ${size[3]};
position: relative;
z-index: 1;
&:hover svg {
color: ${t(colors.gray[600], colors.gray[400])};
}
&:focus-visible {
border-radius: ${border.radius.xs};
outline: 2px solid ${colors.blue[800]};
outline-offset: 2px;
}
`,
}
}
const lightStyles = (css: (typeof goober)['css']) => stylesFactory('light', css)
const darkStyles = (css: (typeof goober)['css']) => stylesFactory('dark', css)
@@ -0,0 +1,160 @@
import { render } from 'solid-js/web'
import { createSignal, lazy } from 'solid-js'
import { setupStyleSheet } from './utils'
import type {
QueryClient,
onlineManager as TOnlineManager,
} from '@tanstack/query-core'
import type { DevtoolsComponentType } from './Devtools'
import type {
DevtoolsButtonPosition,
DevtoolsErrorType,
DevtoolsPosition,
QueryDevtoolsProps,
Theme,
} from './contexts'
import type { Signal } from 'solid-js'
export interface TanstackQueryDevtoolsConfig extends QueryDevtoolsProps {
styleNonce?: string
shadowDOMTarget?: ShadowRoot
}
class TanstackQueryDevtools {
#client: Signal<QueryClient>
#onlineManager: typeof TOnlineManager
#queryFlavor: string
#version: string
#isMounted = false
#styleNonce?: string
#shadowDOMTarget?: ShadowRoot
#buttonPosition: Signal<DevtoolsButtonPosition | undefined>
#position: Signal<DevtoolsPosition | undefined>
#initialIsOpen: Signal<boolean | undefined>
#errorTypes: Signal<Array<DevtoolsErrorType> | undefined>
#hideDisabledQueries: Signal<boolean | undefined>
#Component: DevtoolsComponentType | undefined
#theme: Signal<Theme | undefined>
#dispose?: () => void
constructor(config: TanstackQueryDevtoolsConfig) {
const {
client,
queryFlavor,
version,
onlineManager,
buttonPosition,
position,
initialIsOpen,
errorTypes,
styleNonce,
shadowDOMTarget,
hideDisabledQueries,
theme,
} = config
this.#client = createSignal(client)
this.#queryFlavor = queryFlavor
this.#version = version
this.#onlineManager = onlineManager
this.#styleNonce = styleNonce
this.#shadowDOMTarget = shadowDOMTarget
this.#buttonPosition = createSignal(buttonPosition)
this.#position = createSignal(position)
this.#initialIsOpen = createSignal(initialIsOpen)
this.#errorTypes = createSignal(errorTypes)
this.#hideDisabledQueries = createSignal(hideDisabledQueries)
this.#theme = createSignal(theme)
}
setButtonPosition(position: DevtoolsButtonPosition) {
this.#buttonPosition[1](position)
}
setPosition(position: DevtoolsPosition) {
this.#position[1](position)
}
setInitialIsOpen(isOpen: boolean) {
this.#initialIsOpen[1](isOpen)
}
setErrorTypes(errorTypes: Array<DevtoolsErrorType>) {
this.#errorTypes[1](errorTypes)
}
setClient(client: QueryClient) {
this.#client[1](client)
}
setTheme(theme?: Theme) {
this.#theme[1](theme)
}
mount<T extends HTMLElement>(el: T) {
if (this.#isMounted) {
throw new Error('Devtools is already mounted')
}
const dispose = render(() => {
const [btnPosition] = this.#buttonPosition
const [pos] = this.#position
const [isOpen] = this.#initialIsOpen
const [errors] = this.#errorTypes
const [hideDisabledQueries] = this.#hideDisabledQueries
const [queryClient] = this.#client
const [theme] = this.#theme
let Devtools: DevtoolsComponentType
if (this.#Component) {
Devtools = this.#Component
} else {
Devtools = lazy(() => import('./DevtoolsComponent'))
this.#Component = Devtools
}
setupStyleSheet(this.#styleNonce, this.#shadowDOMTarget)
return (
<Devtools
queryFlavor={this.#queryFlavor}
version={this.#version}
onlineManager={this.#onlineManager}
shadowDOMTarget={this.#shadowDOMTarget}
{...{
get client() {
return queryClient()
},
get buttonPosition() {
return btnPosition()
},
get position() {
return pos()
},
get initialIsOpen() {
return isOpen()
},
get errorTypes() {
return errors()
},
get hideDisabledQueries() {
return hideDisabledQueries()
},
get theme() {
return theme()
},
}}
/>
)
}, el)
this.#isMounted = true
this.#dispose = dispose
}
unmount() {
if (!this.#isMounted) {
throw new Error('Devtools is not mounted')
}
this.#dispose?.()
this.#isMounted = false
}
}
export { TanstackQueryDevtools }
@@ -0,0 +1,172 @@
import { render } from 'solid-js/web'
import { createSignal, lazy } from 'solid-js'
import { setupStyleSheet } from './utils'
import type {
QueryClient,
onlineManager as TOnlineManager,
} from '@tanstack/query-core'
import type { DevtoolsComponentType } from './Devtools'
import type {
DevtoolsButtonPosition,
DevtoolsErrorType,
DevtoolsPosition,
QueryDevtoolsProps,
Theme,
} from './contexts'
import type { Signal } from 'solid-js'
export interface TanstackQueryDevtoolsPanelConfig extends QueryDevtoolsProps {
styleNonce?: string
shadowDOMTarget?: ShadowRoot
onClose?: () => unknown
}
class TanstackQueryDevtoolsPanel {
#client: Signal<QueryClient>
#onlineManager: typeof TOnlineManager
#queryFlavor: string
#version: string
#isMounted = false
#styleNonce?: string
#shadowDOMTarget?: ShadowRoot
#buttonPosition: Signal<DevtoolsButtonPosition | undefined>
#position: Signal<DevtoolsPosition | undefined>
#initialIsOpen: Signal<boolean | undefined>
#errorTypes: Signal<Array<DevtoolsErrorType> | undefined>
#hideDisabledQueries: Signal<boolean | undefined>
#onClose: Signal<(() => unknown) | undefined>
#Component: DevtoolsComponentType | undefined
#theme: Signal<Theme | undefined>
#dispose?: () => void
constructor(config: TanstackQueryDevtoolsPanelConfig) {
const {
client,
queryFlavor,
version,
onlineManager,
buttonPosition,
position,
initialIsOpen,
errorTypes,
styleNonce,
shadowDOMTarget,
onClose,
hideDisabledQueries,
theme,
} = config
this.#client = createSignal(client)
this.#queryFlavor = queryFlavor
this.#version = version
this.#onlineManager = onlineManager
this.#styleNonce = styleNonce
this.#shadowDOMTarget = shadowDOMTarget
this.#buttonPosition = createSignal(buttonPosition)
this.#position = createSignal(position)
this.#initialIsOpen = createSignal(initialIsOpen)
this.#errorTypes = createSignal(errorTypes)
this.#hideDisabledQueries = createSignal(hideDisabledQueries)
this.#onClose = createSignal(onClose)
this.#theme = createSignal(theme)
}
setButtonPosition(position: DevtoolsButtonPosition) {
this.#buttonPosition[1](position)
}
setPosition(position: DevtoolsPosition) {
this.#position[1](position)
}
setInitialIsOpen(isOpen: boolean) {
this.#initialIsOpen[1](isOpen)
}
setErrorTypes(errorTypes: Array<DevtoolsErrorType>) {
this.#errorTypes[1](errorTypes)
}
setClient(client: QueryClient) {
this.#client[1](client)
}
setOnClose(onClose: () => unknown) {
this.#onClose[1](() => onClose)
}
setTheme(theme?: Theme) {
this.#theme[1](theme)
}
mount<T extends HTMLElement>(el: T) {
if (this.#isMounted) {
throw new Error('Devtools is already mounted')
}
const dispose = render(() => {
const [btnPosition] = this.#buttonPosition
const [pos] = this.#position
const [isOpen] = this.#initialIsOpen
const [errors] = this.#errorTypes
const [hideDisabledQueries] = this.#hideDisabledQueries
const [queryClient] = this.#client
const [onClose] = this.#onClose
const [theme] = this.#theme
let Devtools: DevtoolsComponentType
if (this.#Component) {
Devtools = this.#Component
} else {
Devtools = lazy(() => import('./DevtoolsPanelComponent'))
this.#Component = Devtools
}
setupStyleSheet(this.#styleNonce, this.#shadowDOMTarget)
return (
<Devtools
queryFlavor={this.#queryFlavor}
version={this.#version}
onlineManager={this.#onlineManager}
shadowDOMTarget={this.#shadowDOMTarget}
{...{
get client() {
return queryClient()
},
get buttonPosition() {
return btnPosition()
},
get position() {
return pos()
},
get initialIsOpen() {
return isOpen()
},
get errorTypes() {
return errors()
},
get hideDisabledQueries() {
return hideDisabledQueries()
},
get onClose() {
return onClose()
},
get theme() {
return theme()
},
}}
/>
)
}, el)
this.#isMounted = true
this.#dispose = dispose
}
unmount() {
if (!this.#isMounted) {
throw new Error('Devtools is not mounted')
}
this.#dispose?.()
this.#isMounted = false
}
}
export { TanstackQueryDevtoolsPanel }
+17
View File
@@ -0,0 +1,17 @@
import { mutationSortFns, sortFns } from './utils'
import type { DevtoolsButtonPosition, DevtoolsPosition } from './contexts'
export const firstBreakpoint = 1024
export const secondBreakpoint = 796
export const thirdBreakpoint = 700
export const BUTTON_POSITION: DevtoolsButtonPosition = 'bottom-right'
export const POSITION: DevtoolsPosition = 'bottom'
export const THEME_PREFERENCE = 'system'
export const INITIAL_IS_OPEN = false
export const DEFAULT_HEIGHT = 500
export const PIP_DEFAULT_HEIGHT = 500
export const DEFAULT_WIDTH = 500
export const DEFAULT_SORT_FN_NAME = Object.keys(sortFns)[0]
export const DEFAULT_SORT_ORDER = 1
export const DEFAULT_MUTATION_SORT_FN_NAME = Object.keys(mutationSortFns)[0]
@@ -0,0 +1,203 @@
import {
createContext,
createEffect,
createMemo,
createSignal,
onCleanup,
useContext,
} from 'solid-js'
import { clearDelegatedEvents, delegateEvents } from 'solid-js/web'
import { PIP_DEFAULT_HEIGHT } from '../constants'
import { useQueryDevtoolsContext } from './QueryDevtoolsContext'
import type { Accessor, JSX } from 'solid-js'
import type { StorageObject, StorageSetter } from '@solid-primitives/storage'
interface PiPProviderProps {
children: JSX.Element
localStore: StorageObject<string>
setLocalStore: StorageSetter<string, unknown>
disabled?: boolean
}
type PiPContextType = {
pipWindow: Window | null
requestPipWindow: (width: number, height: number) => void
closePipWindow: () => void
disabled: boolean
}
class PipOpenError extends Error {}
const PiPContext = createContext<Accessor<PiPContextType> | undefined>(
undefined,
)
export const PiPProvider = (props: PiPProviderProps) => {
// Expose pipWindow that is currently active
const [pipWindow, setPipWindow] = createSignal<Window | null>(null)
// Close pipWindow programmatically
const closePipWindow = () => {
const w = pipWindow()
if (w != null) {
w.close()
setPipWindow(null)
}
}
// Open new pipWindow
const requestPipWindow = (width: number, height: number) => {
// We don't want to allow multiple requests.
if (pipWindow() != null) {
return
}
const pip = window.open(
'',
'TSQD-Devtools-Panel',
`width=${width},height=${height},popup`,
)
if (!pip) {
throw new PipOpenError(
'Failed to open popup. Please allow popups for this site to view the devtools in picture-in-picture mode.',
)
}
// Remove existing styles
pip.document.head.innerHTML = ''
// Remove existing body
pip.document.body.innerHTML = ''
// Clear Delegated Events
clearDelegatedEvents(pip.document)
pip.document.title = 'TanStack Query Devtools'
pip.document.body.style.margin = '0'
// Detect when window is closed by user
pip.addEventListener('pagehide', () => {
props.setLocalStore('pip_open', 'false')
setPipWindow(null)
})
// It is important to copy all parent window styles. Otherwise, there would be no CSS available at all
// https://developer.chrome.com/docs/web-platform/document-picture-in-picture/#copy-style-sheets-to-the-picture-in-picture-window
;[
...(useQueryDevtoolsContext().shadowDOMTarget || document).styleSheets,
].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules]
.map((rule) => rule.cssText)
.join('')
const style = document.createElement('style')
const style_node = styleSheet.ownerNode
let style_id = ''
if (style_node && 'id' in style_node) {
style_id = style_node.id
}
if (style_id) {
style.setAttribute('id', style_id)
}
style.textContent = cssRules
pip.document.head.appendChild(style)
} catch (e) {
const link = document.createElement('link')
if (styleSheet.href == null) {
return
}
link.rel = 'stylesheet'
link.type = styleSheet.type
link.media = styleSheet.media.toString()
link.href = styleSheet.href
pip.document.head.appendChild(link)
}
})
delegateEvents(
[
'focusin',
'focusout',
'pointermove',
'keydown',
'pointerdown',
'pointerup',
'click',
'mousedown',
'input',
],
pip.document,
)
props.setLocalStore('pip_open', 'true')
setPipWindow(pip)
}
createEffect(() => {
const pip_open = (props.localStore.pip_open ?? 'false') as 'true' | 'false'
if (pip_open === 'true' && !props.disabled) {
try {
requestPipWindow(
Number(window.innerWidth),
Number(props.localStore.height || PIP_DEFAULT_HEIGHT),
)
} catch (error) {
if (error instanceof PipOpenError) {
console.error(error.message)
props.setLocalStore('pip_open', 'false')
props.setLocalStore('open', 'false')
return
}
throw error
}
}
})
createEffect(() => {
// Setup mutation observer for goober styles with id `_goober
const gooberStyles = (
useQueryDevtoolsContext().shadowDOMTarget || document
).querySelector('#_goober')
const w = pipWindow()
if (gooberStyles && w) {
const observer = new MutationObserver(() => {
const pip_style = (
useQueryDevtoolsContext().shadowDOMTarget || w.document
).querySelector('#_goober')
if (pip_style) {
pip_style.textContent = gooberStyles.textContent
}
})
observer.observe(gooberStyles, {
childList: true, // observe direct children
subtree: true, // and lower descendants too
characterDataOldValue: true, // pass old data to callback
})
onCleanup(() => {
observer.disconnect()
})
}
})
const value = createMemo(() => ({
pipWindow: pipWindow(),
requestPipWindow,
closePipWindow,
disabled: props.disabled ?? false,
}))
return (
<PiPContext.Provider value={value}>{props.children}</PiPContext.Provider>
)
}
export const usePiPWindow = () => {
const context = createMemo(() => {
const ctx = useContext(PiPContext)
if (!ctx) {
throw new Error('usePiPWindow must be used within a PiPProvider')
}
return ctx()
})
return context
}
@@ -0,0 +1,47 @@
import { createContext, useContext } from 'solid-js'
import type { Query, QueryClient, onlineManager } from '@tanstack/query-core'
type XPosition = 'left' | 'right'
type YPosition = 'top' | 'bottom'
export type DevtoolsPosition = XPosition | YPosition
export type DevtoolsButtonPosition = `${YPosition}-${XPosition}` | 'relative'
export type Theme = 'dark' | 'light' | 'system'
export interface DevtoolsErrorType {
/**
* The name of the error.
*/
name: string
/**
* How the error is initialized.
*/
initializer: (query: Query) => Error
}
export interface QueryDevtoolsProps {
readonly client: QueryClient
queryFlavor: string
version: string
onlineManager: typeof onlineManager
buttonPosition?: DevtoolsButtonPosition
position?: DevtoolsPosition
initialIsOpen?: boolean
errorTypes?: Array<DevtoolsErrorType>
shadowDOMTarget?: ShadowRoot
onClose?: () => unknown
hideDisabledQueries?: boolean
theme?: Theme
}
export const QueryDevtoolsContext = createContext<QueryDevtoolsProps>({
client: undefined as unknown as QueryClient,
onlineManager: undefined as unknown as typeof onlineManager,
queryFlavor: '',
version: '',
shadowDOMTarget: undefined,
})
export function useQueryDevtoolsContext() {
return useContext(QueryDevtoolsContext)
}
@@ -0,0 +1,10 @@
import { createContext, useContext } from 'solid-js'
import type { Accessor } from 'solid-js'
export const ThemeContext = createContext<Accessor<'light' | 'dark'>>(
() => 'dark' as const,
)
export function useTheme() {
return useContext(ThemeContext)
}
+3
View File
@@ -0,0 +1,3 @@
export * from './PiPContext'
export * from './QueryDevtoolsContext'
export * from './ThemeContext'
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
export type {
DevtoolsButtonPosition,
DevtoolsErrorType,
DevtoolsPosition,
Theme,
} from './contexts'
export {
TanstackQueryDevtools,
type TanstackQueryDevtoolsConfig,
} from './TanstackQueryDevtools'
export {
TanstackQueryDevtoolsPanel,
type TanstackQueryDevtoolsPanelConfig,
} from './TanstackQueryDevtoolsPanel'
+299
View File
@@ -0,0 +1,299 @@
export const tokens = {
colors: {
inherit: 'inherit',
current: 'currentColor',
transparent: 'transparent',
black: '#000000',
white: '#ffffff',
neutral: {
50: '#f9fafb',
100: '#f2f4f7',
200: '#eaecf0',
300: '#d0d5dd',
400: '#98a2b3',
500: '#667085',
600: '#475467',
700: '#344054',
800: '#1d2939',
900: '#101828',
},
darkGray: {
50: '#525c7a',
100: '#49536e',
200: '#414962',
300: '#394056',
400: '#313749',
500: '#292e3d',
600: '#212530',
700: '#191c24',
800: '#111318',
900: '#0b0d10',
},
gray: {
50: '#f9fafb',
100: '#f2f4f7',
200: '#eaecf0',
300: '#d0d5dd',
400: '#98a2b3',
500: '#667085',
600: '#475467',
700: '#344054',
800: '#1d2939',
900: '#101828',
},
blue: {
25: '#F5FAFF',
50: '#EFF8FF',
100: '#D1E9FF',
200: '#B2DDFF',
300: '#84CAFF',
400: '#53B1FD',
500: '#2E90FA',
600: '#1570EF',
700: '#175CD3',
800: '#1849A9',
900: '#194185',
},
green: {
25: '#F6FEF9',
50: '#ECFDF3',
100: '#D1FADF',
200: '#A6F4C5',
300: '#6CE9A6',
400: '#32D583',
500: '#12B76A',
600: '#039855',
700: '#027A48',
800: '#05603A',
900: '#054F31',
},
red: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
yellow: {
25: '#FFFCF5',
50: '#FFFAEB',
100: '#FEF0C7',
200: '#FEDF89',
300: '#FEC84B',
400: '#FDB022',
500: '#F79009',
600: '#DC6803',
700: '#B54708',
800: '#93370D',
900: '#7A2E0E',
},
purple: {
25: '#FAFAFF',
50: '#F4F3FF',
100: '#EBE9FE',
200: '#D9D6FE',
300: '#BDB4FE',
400: '#9B8AFB',
500: '#7A5AF8',
600: '#6938EF',
700: '#5925DC',
800: '#4A1FB8',
900: '#3E1C96',
},
teal: {
25: '#F6FEFC',
50: '#F0FDF9',
100: '#CCFBEF',
200: '#99F6E0',
300: '#5FE9D0',
400: '#2ED3B7',
500: '#15B79E',
600: '#0E9384',
700: '#107569',
800: '#125D56',
900: '#134E48',
},
pink: {
25: '#fdf2f8',
50: '#fce7f3',
100: '#fbcfe8',
200: '#f9a8d4',
300: '#f472b6',
400: '#ec4899',
500: '#db2777',
600: '#be185d',
700: '#9d174d',
800: '#831843',
900: '#500724',
},
cyan: {
25: '#ecfeff',
50: '#cffafe',
100: '#a5f3fc',
200: '#67e8f9',
300: '#22d3ee',
400: '#06b6d4',
500: '#0891b2',
600: '#0e7490',
700: '#155e75',
800: '#164e63',
900: '#083344',
},
},
alpha: {
100: 'ff',
90: 'e5',
80: 'cc',
70: 'b3',
60: '99',
50: '80',
40: '66',
30: '4d',
20: '33',
10: '1a',
0: '00',
},
font: {
size: {
'2xs': 'calc(var(--tsqd-font-size) * 0.625)',
xs: 'calc(var(--tsqd-font-size) * 0.75)',
sm: 'calc(var(--tsqd-font-size) * 0.875)',
md: 'var(--tsqd-font-size)',
lg: 'calc(var(--tsqd-font-size) * 1.125)',
xl: 'calc(var(--tsqd-font-size) * 1.25)',
'2xl': 'calc(var(--tsqd-font-size) * 1.5)',
'3xl': 'calc(var(--tsqd-font-size) * 1.875)',
'4xl': 'calc(var(--tsqd-font-size) * 2.25)',
'5xl': 'calc(var(--tsqd-font-size) * 3)',
'6xl': 'calc(var(--tsqd-font-size) * 3.75)',
'7xl': 'calc(var(--tsqd-font-size) * 4.5)',
'8xl': 'calc(var(--tsqd-font-size) * 6)',
'9xl': 'calc(var(--tsqd-font-size) * 8)',
},
lineHeight: {
xs: 'calc(var(--tsqd-font-size) * 1)',
sm: 'calc(var(--tsqd-font-size) * 1.25)',
md: 'calc(var(--tsqd-font-size) * 1.5)',
lg: 'calc(var(--tsqd-font-size) * 1.75)',
xl: 'calc(var(--tsqd-font-size) * 2)',
'2xl': 'calc(var(--tsqd-font-size) * 2.25)',
'3xl': 'calc(var(--tsqd-font-size) * 2.5)',
'4xl': 'calc(var(--tsqd-font-size) * 2.75)',
'5xl': 'calc(var(--tsqd-font-size) * 3)',
'6xl': 'calc(var(--tsqd-font-size) * 3.25)',
'7xl': 'calc(var(--tsqd-font-size) * 3.5)',
'8xl': 'calc(var(--tsqd-font-size) * 3.75)',
'9xl': 'calc(var(--tsqd-font-size) * 4)',
},
weight: {
thin: '100',
extralight: '200',
light: '300',
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900',
},
},
breakpoints: {
xs: '320px',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
border: {
radius: {
none: '0px',
xs: 'calc(var(--tsqd-font-size) * 0.125)',
sm: 'calc(var(--tsqd-font-size) * 0.25)',
md: 'calc(var(--tsqd-font-size) * 0.375)',
lg: 'calc(var(--tsqd-font-size) * 0.5)',
xl: 'calc(var(--tsqd-font-size) * 0.75)',
'2xl': 'calc(var(--tsqd-font-size) * 1)',
'3xl': 'calc(var(--tsqd-font-size) * 1.5)',
full: '9999px',
},
},
size: {
0: '0px',
0.25: 'calc(var(--tsqd-font-size) * 0.0625)',
0.5: 'calc(var(--tsqd-font-size) * 0.125)',
1: 'calc(var(--tsqd-font-size) * 0.25)',
1.5: 'calc(var(--tsqd-font-size) * 0.375)',
2: 'calc(var(--tsqd-font-size) * 0.5)',
2.5: 'calc(var(--tsqd-font-size) * 0.625)',
3: 'calc(var(--tsqd-font-size) * 0.75)',
3.5: 'calc(var(--tsqd-font-size) * 0.875)',
4: 'calc(var(--tsqd-font-size) * 1)',
4.5: 'calc(var(--tsqd-font-size) * 1.125)',
5: 'calc(var(--tsqd-font-size) * 1.25)',
5.5: 'calc(var(--tsqd-font-size) * 1.375)',
6: 'calc(var(--tsqd-font-size) * 1.5)',
6.5: 'calc(var(--tsqd-font-size) * 1.625)',
7: 'calc(var(--tsqd-font-size) * 1.75)',
8: 'calc(var(--tsqd-font-size) * 2)',
9: 'calc(var(--tsqd-font-size) * 2.25)',
10: 'calc(var(--tsqd-font-size) * 2.5)',
11: 'calc(var(--tsqd-font-size) * 2.75)',
12: 'calc(var(--tsqd-font-size) * 3)',
14: 'calc(var(--tsqd-font-size) * 3.5)',
16: 'calc(var(--tsqd-font-size) * 4)',
20: 'calc(var(--tsqd-font-size) * 5)',
24: 'calc(var(--tsqd-font-size) * 6)',
28: 'calc(var(--tsqd-font-size) * 7)',
32: 'calc(var(--tsqd-font-size) * 8)',
36: 'calc(var(--tsqd-font-size) * 9)',
40: 'calc(var(--tsqd-font-size) * 10)',
44: 'calc(var(--tsqd-font-size) * 11)',
48: 'calc(var(--tsqd-font-size) * 12)',
52: 'calc(var(--tsqd-font-size) * 13)',
56: 'calc(var(--tsqd-font-size) * 14)',
60: 'calc(var(--tsqd-font-size) * 15)',
64: 'calc(var(--tsqd-font-size) * 16)',
72: 'calc(var(--tsqd-font-size) * 18)',
80: 'calc(var(--tsqd-font-size) * 20)',
96: 'calc(var(--tsqd-font-size) * 24)',
},
shadow: {
xs: (_: string = 'rgb(0 0 0 / 0.1)') =>
`0 1px 2px 0 rgb(0 0 0 / 0.05)` as const,
sm: (color: string = 'rgb(0 0 0 / 0.1)') =>
`0 1px 3px 0 ${color}, 0 1px 2px -1px ${color}` as const,
md: (color: string = 'rgb(0 0 0 / 0.1)') =>
`0 4px 6px -1px ${color}, 0 2px 4px -2px ${color}` as const,
lg: (color: string = 'rgb(0 0 0 / 0.1)') =>
`0 10px 15px -3px ${color}, 0 4px 6px -4px ${color}` as const,
xl: (color: string = 'rgb(0 0 0 / 0.1)') =>
`0 20px 25px -5px ${color}, 0 8px 10px -6px ${color}` as const,
'2xl': (color: string = 'rgb(0 0 0 / 0.25)') =>
`0 25px 50px -12px ${color}` as const,
inner: (color: string = 'rgb(0 0 0 / 0.05)') =>
`inset 0 2px 4px 0 ${color}` as const,
none: () => `none` as const,
},
zIndices: {
hide: -1,
auto: 'auto',
base: 0,
docked: 10,
dropdown: 1000,
sticky: 1100,
banner: 1200,
overlay: 1300,
modal: 1400,
popover: 1500,
skipLink: 1600,
toast: 1700,
tooltip: 1800,
},
} as const
+323
View File
@@ -0,0 +1,323 @@
import { serialize } from 'superjson'
import { createSignal, onCleanup, onMount } from 'solid-js'
import type { Mutation, Query } from '@tanstack/query-core'
import type { DevtoolsPosition } from './contexts'
export function getQueryStatusLabel(query: Query) {
return query.state.fetchStatus === 'fetching'
? 'fetching'
: !query.getObserversCount()
? 'inactive'
: query.state.fetchStatus === 'paused'
? 'paused'
: query.isStale()
? 'stale'
: 'fresh'
}
type QueryStatusLabel = 'fresh' | 'stale' | 'paused' | 'inactive' | 'fetching'
export function getSidedProp<T extends string>(
prop: T,
side: DevtoolsPosition,
) {
return `${prop}${
side.charAt(0).toUpperCase() + side.slice(1)
}` as `${T}${Capitalize<DevtoolsPosition>}`
}
export function getQueryStatusColor({
queryState,
observerCount,
isStale,
}: {
queryState: Query['state']
observerCount: number
isStale: boolean
}) {
return queryState.fetchStatus === 'fetching'
? 'blue'
: !observerCount
? 'gray'
: queryState.fetchStatus === 'paused'
? 'purple'
: isStale
? 'yellow'
: 'green'
}
export function getMutationStatusColor({
status,
isPaused,
}: {
status: Mutation['state']['status']
isPaused: boolean
}) {
return isPaused
? 'purple'
: status === 'error'
? 'red'
: status === 'pending'
? 'yellow'
: status === 'success'
? 'green'
: 'gray'
}
export function getQueryStatusColorByLabel(label: QueryStatusLabel) {
return label === 'fresh'
? 'green'
: label === 'stale'
? 'yellow'
: label === 'paused'
? 'purple'
: label === 'inactive'
? 'gray'
: 'blue'
}
/**
* Displays a string regardless the type of the data
* @param {unknown} value Value to be stringified
* @param {boolean} beautify Formats json to multiline
*/
export const displayValue = (value: unknown, beautify: boolean = false) => {
const { json } = serialize(value)
return JSON.stringify(json, null, beautify ? 2 : undefined)
}
// Sorting functions
type SortFn = (a: Query, b: Query) => number
const getStatusRank = (q: Query) =>
q.state.fetchStatus !== 'idle'
? 0
: !q.getObserversCount()
? 3
: q.isStale()
? 2
: 1
const queryHashSort: SortFn = (a, b) => a.queryHash.localeCompare(b.queryHash)
const dateSort: SortFn = (a, b) =>
a.state.dataUpdatedAt < b.state.dataUpdatedAt ? 1 : -1
const statusAndDateSort: SortFn = (a, b) => {
if (getStatusRank(a) === getStatusRank(b)) {
return dateSort(a, b)
}
return getStatusRank(a) > getStatusRank(b) ? 1 : -1
}
export const sortFns: Record<string, SortFn> = {
status: statusAndDateSort,
'query hash': queryHashSort,
'last updated': dateSort,
}
type MutationSortFn = (a: Mutation, b: Mutation) => number
const getMutationStatusRank = (m: Mutation) =>
m.state.isPaused
? 0
: m.state.status === 'error'
? 2
: m.state.status === 'pending'
? 1
: 3
const mutationDateSort: MutationSortFn = (a, b) =>
a.state.submittedAt < b.state.submittedAt ? 1 : -1
const mutationStatusSort: MutationSortFn = (a, b) => {
if (getMutationStatusRank(a) === getMutationStatusRank(b)) {
return mutationDateSort(a, b)
}
return getMutationStatusRank(a) > getMutationStatusRank(b) ? 1 : -1
}
export const mutationSortFns: Record<string, MutationSortFn> = {
status: mutationStatusSort,
'last updated': mutationDateSort,
}
export const convertRemToPixels = (rem: number) => {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
}
export const getPreferredColorScheme = () => {
const [colorScheme, setColorScheme] = createSignal<'light' | 'dark'>('dark')
onMount(() => {
const query = window.matchMedia('(prefers-color-scheme: dark)')
setColorScheme(query.matches ? 'dark' : 'light')
const listener = (e: MediaQueryListEvent) => {
setColorScheme(e.matches ? 'dark' : 'light')
}
query.addEventListener('change', listener)
onCleanup(() => query.removeEventListener('change', listener))
})
return colorScheme
}
/**
* updates nested data by path
*
* @param {unknown} oldData Data to be updated
* @param {Array<string>} updatePath Path to the data to be updated
* @param {unknown} value New value
*/
export const updateNestedDataByPath = (
oldData: unknown,
updatePath: Array<string>,
value: unknown,
): any => {
if (updatePath.length === 0) {
return value
}
if (oldData instanceof Map) {
const newData = new Map(oldData)
if (updatePath.length === 1) {
newData.set(updatePath[0], value)
return newData
}
const [head, ...tail] = updatePath
newData.set(head, updateNestedDataByPath(newData.get(head), tail, value))
return newData
}
if (oldData instanceof Set) {
const setAsArray = updateNestedDataByPath(
Array.from(oldData),
updatePath,
value,
)
return new Set(setAsArray)
}
if (Array.isArray(oldData)) {
const newData = [...oldData]
if (updatePath.length === 1) {
// @ts-expect-error
newData[updatePath[0]] = value
return newData
}
const [head, ...tail] = updatePath
// @ts-expect-error
newData[head] = updateNestedDataByPath(newData[head], tail, value)
return newData
}
if (oldData instanceof Object) {
const newData = { ...oldData }
if (updatePath.length === 1) {
// @ts-expect-error
newData[updatePath[0]] = value
return newData
}
const [head, ...tail] = updatePath
// @ts-expect-error
newData[head] = updateNestedDataByPath(newData[head], tail, value)
return newData
}
return oldData
}
/**
* Deletes nested data by path
*
* @param {unknown} oldData Data to be updated
* @param {Array<string>} deletePath Path to the data to be deleted
* @returns newData without the deleted items by path
*/
export const deleteNestedDataByPath = (
oldData: unknown,
deletePath: Array<string>,
): any => {
if (oldData instanceof Map) {
const newData = new Map(oldData)
if (deletePath.length === 1) {
newData.delete(deletePath[0])
return newData
}
const [head, ...tail] = deletePath
newData.set(head, deleteNestedDataByPath(newData.get(head), tail))
return newData
}
if (oldData instanceof Set) {
const setAsArray = deleteNestedDataByPath(Array.from(oldData), deletePath)
return new Set(setAsArray)
}
if (Array.isArray(oldData)) {
const newData = [...oldData]
if (deletePath.length === 1) {
return newData.filter((_, idx) => idx.toString() !== deletePath[0])
}
const [head, ...tail] = deletePath
// @ts-expect-error
newData[head] = deleteNestedDataByPath(newData[head], tail)
return newData
}
if (oldData instanceof Object) {
const newData = { ...oldData }
if (deletePath.length === 1) {
// @ts-expect-error
delete newData[deletePath[0]]
return newData
}
const [head, ...tail] = deletePath
// @ts-expect-error
newData[head] = deleteNestedDataByPath(newData[head], tail)
return newData
}
return oldData
}
// Sets up the goober stylesheet
// Adds a nonce to the style tag if needed
export const setupStyleSheet = (nonce?: string, target?: ShadowRoot) => {
if (!nonce) return
const styleExists =
document.querySelector('#_goober') || target?.querySelector('#_goober')
if (styleExists) return
const styleTag = document.createElement('style')
const textNode = document.createTextNode('')
styleTag.appendChild(textNode)
styleTag.id = '_goober'
styleTag.setAttribute('nonce', nonce)
if (target) {
target.appendChild(styleTag)
} else {
document.head.appendChild(styleTag)
}
}