diff --git a/package.json b/package.json
index 69fa365..b484287 100644
--- a/package.json
+++ b/package.json
@@ -56,15 +56,15 @@
},
"dependencies": {
"hightable": "0.26.3",
- "hyparquet": "1.25.0",
+ "hyparquet": "1.25.1",
"hyparquet-compressors": "1.1.1",
"icebird": "0.3.1",
- "squirreling": "0.9.1"
+ "squirreling": "0.9.4"
},
"devDependencies": {
- "@storybook/react-vite": "10.2.10",
+ "@storybook/react-vite": "10.2.13",
"@testing-library/react": "16.3.2",
- "@types/node": "25.2.3",
+ "@types/node": "25.3.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.4",
@@ -72,17 +72,17 @@
"eslint": "9.39.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
- "eslint-plugin-react-refresh": "0.5.0",
- "eslint-plugin-storybook": "10.2.10",
- "globals": "17.3.0",
+ "eslint-plugin-react-refresh": "0.5.2",
+ "eslint-plugin-storybook": "10.2.13",
+ "globals": "17.4.0",
"jsdom": "28.1.0",
- "nodemon": "3.1.11",
+ "nodemon": "3.1.14",
"npm-run-all": "4.1.5",
"react": "19.2.4",
"react-dom": "19.2.4",
- "storybook": "10.2.10",
+ "storybook": "10.2.13",
"typescript": "5.9.3",
- "typescript-eslint": "8.56.0",
+ "typescript-eslint": "8.56.1",
"vite": "7.3.1",
"vitest": "4.0.18"
},
diff --git a/src/components/Json/Json.module.css b/src/components/Json/Json.module.css
index 524284c..57dc61b 100644
--- a/src/components/Json/Json.module.css
+++ b/src/components/Json/Json.module.css
@@ -61,9 +61,20 @@
.string {
color: #eaa;
}
+.boolean {
+ color: #9ce;
+}
.other {
color: #d6d6d6;
}
.comment {
color: #ccd8;
}
+.showMore {
+ cursor: pointer;
+ color: #ccd8;
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+}
diff --git a/src/components/Json/Json.stories.tsx b/src/components/Json/Json.stories.tsx
index c02d678..b9d215b 100644
--- a/src/components/Json/Json.stories.tsx
+++ b/src/components/Json/Json.stories.tsx
@@ -43,6 +43,8 @@ export const Arrays: Story = {
strings2: Array.from({ length: 2 }, (_, i) => `hello ${i}`),
strings8: Array.from({ length: 8 }, (_, i) => `hello ${i}`),
strings100: Array.from({ length: 100 }, (_, i) => `hello ${i}`),
+ dates1: Array.from({ length: 1 }, (_, i) => new Date(2025, 0, i + 1)),
+ dates10: Array.from({ length: 10 }, (_, i) => new Date(2025, 0, i + 1)),
misc: Array.from({ length: 8 }, (_, i) => i % 2 ? `hello ${i}` : i),
misc2: Array.from({ length: 8 }, (_, i) => i % 3 === 0 ? i : i % 3 === 1 ? `hello ${i}` : [i, i + 1, i + 2]),
misc3: [1, 'hello', null, undefined],
@@ -53,11 +55,19 @@ export const Arrays: Story = {
render,
}
+export const TopLevelArray: Story = {
+ args: {
+ json: Array.from({ length: 300 }, (_, i) => [i, i + 1, i + 2]),
+ },
+ render,
+}
+
export const Objects: Story = {
args: {
json: {
empty: {},
numbers1: { k0: 1 },
+ dates1: { d0: new Date('2025-01-01') },
numbers8: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i])),
numbers100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, i])),
strings8: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, `hello ${i}`])),
@@ -65,9 +75,8 @@ export const Objects: Story = {
misc: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i % 2 ? `hello ${i}` : i])),
misc2: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i % 3 === 0 ? i : i % 3 === 1 ? `hello ${i}` : [i, i + 1, i + 2]])),
misc3: { k0: 1, k1: 'a', k2: null, k3: undefined },
- arrays100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, [i, i + 1, i + 2]])),
+ arrays200: Object.fromEntries(Array.from({ length: 200 }, (_, i) => [`k${i}`, [i, i + 1, i + 2]])),
},
- label: 'json',
},
render,
}
diff --git a/src/components/Json/Json.test.tsx b/src/components/Json/Json.test.tsx
index 3421666..ba6dadf 100644
--- a/src/components/Json/Json.test.tsx
+++ b/src/components/Json/Json.test.tsx
@@ -21,12 +21,13 @@ describe('Json Component', () => {
getByText('"bar"')
})
- it.for([
- [],
- [1, 2, 3],
- Array.from({ length: 101 }, (_, i) => i),
- ])('collapses any array with primitives', (array) => {
- const { getByRole } = render()
+ it('expands root array by default', () => {
+ const { getByRole } = render()
+ expect(getByRole('treeitem').ariaExpanded).toBe('true')
+ })
+
+ it('collapses array with primitives when expandRoot is false', () => {
+ const { getByRole } = render()
expect(getByRole('treeitem').ariaExpanded).toBe('false')
})
@@ -41,10 +42,9 @@ describe('Json Component', () => {
expect(queryByText(/length/)).toBeNull()
})
- it.for([
- Array.from({ length: 101 }, (_, i) => i),
- ])('hides long arrays with trailing comment about length', (array) => {
- const { getByText } = render()
+ it('hides long arrays with trailing comment about length when collapsed', () => {
+ const longArray = Array.from({ length: 101 }, (_, i) => i)
+ const { getByText } = render()
getByText('...')
getByText(/length/)
})
@@ -55,6 +55,11 @@ describe('Json Component', () => {
getByText('"value"')
})
+ it('renders a Date as its ISO string', () => {
+ const { getByText } = render()
+ getByText('"2025-01-01T00:00:00.000Z"')
+ })
+
it('renders nested objects', () => {
const { getByText } = render()
getByText('obj:')
@@ -109,46 +114,85 @@ describe('Json Component', () => {
getByText(/entries/)
})
- it.for([
- {},
- { a: 1, b: 2 },
- { a: 1, b: true, c: null, d: undefined },
- Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
- ])('collapses long objects, or objects with only primitive values (included empty object)', (obj) => {
- const { getByRole } = render()
+ it('expands root object by default', () => {
+ const { getByRole } = render()
+ expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
+ })
+
+ it('collapses objects with only primitive values when expandRoot is false', () => {
+ const { getByRole } = render()
expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('false')
})
- it.for([
- Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
- ])('hides the content and append number of entries when objects has many entries', (obj) => {
- const { getByText } = render()
+ it('hides the content and append number of entries when objects has many entries when collapsed', () => {
+ const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
+ const { getByText } = render()
getByText('...')
getByText(/entries/)
})
+ it('paginates large arrays showing only first 100 items', () => {
+ const largeArray = Array.from({ length: 150 }, (_, i) => i)
+ const { getByText, queryByText } = render()
+ getByText('0')
+ getByText('99')
+ expect(queryByText('100')).toBeNull()
+ getByText('Show more...')
+ })
+
+ it('shows more array items when clicking Show more...', async () => {
+ const largeArray = Array.from({ length: 150 }, (_, i) => i)
+ const { getByText, queryByText } = render()
+ const user = userEvent.setup()
+ await user.click(getByText('Show more...'))
+ getByText('100')
+ getByText('149')
+ expect(queryByText('Show more...')).toBeNull()
+ })
+
+ it('paginates large objects showing only first 100 entries', () => {
+ const largeObj = Object.fromEntries(Array.from({ length: 150 }, (_, i) => [`key${i}`, i]))
+ const { getByText, queryByText } = render()
+ getByText('key0:')
+ getByText('key99:')
+ expect(queryByText('key100:')).toBeNull()
+ getByText('Show more...')
+ })
+
+ it('shows more object entries when clicking Show more...', async () => {
+ const largeObj = Object.fromEntries(Array.from({ length: 150 }, (_, i) => [`key${i}`, i]))
+ const { getByText, queryByText } = render()
+ const user = userEvent.setup()
+ await user.click(getByText('Show more...'))
+ getByText('key100:')
+ getByText('key149:')
+ expect(queryByText('Show more...')).toBeNull()
+ })
+
it('toggles array collapse state', async () => {
const longArray = Array.from({ length: 101 }, (_, i) => i)
const { getByRole, getByText, queryByText } = render()
const treeItem = getByRole('treeitem')
- getByText('...')
+ expect(queryByText('...')).toBeNull() // expanded by default
const user = userEvent.setup()
- await user.click(treeItem)
- expect(queryByText('...')).toBeNull()
- await user.click(treeItem)
+ await user.click(treeItem) // collapse
getByText('...')
+ await user.click(treeItem) // expand again
+ expect(queryByText('...')).toBeNull()
})
it('toggles object collapse state', async () => {
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
- const { getByRole, getByText, queryByText } = render()
- const treeItem = getByRole('treeitem') // only one treeitem because the inner objects are collapsed and not represented as treeitems
- getByText('...')
- const user = userEvent.setup()
- await user.click(treeItem)
+ const { getAllByRole, getByRole, getByText, queryByText } = render()
+ const treeItem = getAllByRole('treeitem')[0] // expanded by default due to expandRoot
+ if (!treeItem) throw new Error('No root element found')
expect(queryByText('...')).toBeNull()
- await user.click(treeItem)
+ const user = userEvent.setup()
+ await user.click(treeItem) // collapse
+ getByRole('treeitem') // now only one treeitem
getByText('...')
+ await user.click(treeItem) // expand again
+ expect(queryByText('...')).toBeNull()
})
})
diff --git a/src/components/Json/Json.tsx b/src/components/Json/Json.tsx
index 31f98c6..28e8948 100644
--- a/src/components/Json/Json.tsx
+++ b/src/components/Json/Json.tsx
@@ -1,37 +1,46 @@
import { ReactNode, useState } from 'react'
-import styles from './Json.module.css'
-import { isPrimitive, shouldObjectCollapse } from './helpers.js'
import { cn } from '../../lib'
+import styles from './Json.module.css'
+import { isPrimitive, shouldObjectCollapse, stringifyPrimitive } from './helpers.js'
import { useWidth } from './useWidth.js'
+const defaultPageLimit = 100
+
interface JsonProps {
json: unknown
label?: string
className?: string
+ expandRoot?: boolean // Expand the top-level object/array by default
+ pageLimit?: number // Max items to render before showing "Show more..."
}
/**
* JSON viewer component with collapsible objects and arrays.
*/
-export default function Json({ json, label, className }: JsonProps): ReactNode {
+export default function Json({ json, label, className, expandRoot = true, pageLimit }: JsonProps): ReactNode {
return
-
+
}
-function JsonContent({ json, label }: JsonProps): ReactNode {
+function JsonContent({ json, label, expandRoot, pageLimit }: JsonProps): ReactNode {
let div
if (Array.isArray(json)) {
- div =
+ div =
+ } else if (json instanceof Date) {
+ const key = label ? {label}: : ''
+ div = <>{key}{`"${json.toISOString()}"`}>
} else if (typeof json === 'object' && json !== null) {
- div =
+ div =
} else {
// primitive
const key = label ? {label}: : ''
if (typeof json === 'string') {
- div = <>{key}{JSON.stringify(json)}>
+ div = <>{key}{`"${json}"`}>
} else if (typeof json === 'number') {
- div = <>{key}{JSON.stringify(json)}>
+ div = <>{key}{json.toString()}>
+ } else if (typeof json === 'boolean') {
+ div = <>{key}{json.toString()}>
} else if (typeof json === 'bigint') {
// it's not really json, but show it anyway
div = <>{key}{json.toString()}>
@@ -51,7 +60,7 @@ function CollapsedArray({ array }: {array: unknown[]}): ReactNode {
const separator = ', '
const children: ReactNode[] = []
- let suffix: string | undefined = undefined
+ let suffix: string | undefined
let characterCount = 0
for (const [index, value] of array.entries()) {
@@ -61,9 +70,7 @@ function CollapsedArray({ array }: {array: unknown[]}): ReactNode {
}
// should we continue?
if (isPrimitive(value)) {
- const asString = typeof value === 'bigint' ? value.toString() :
- value === undefined ? 'undefined' /* see JsonContent - even if JSON.stringify([undefined]) === '[null]' */:
- JSON.stringify(value)
+ const asString = stringifyPrimitive(value)
characterCount += asString.length
if (characterCount < maxCharacterCount) {
children.push()
@@ -85,13 +92,14 @@ function CollapsedArray({ array }: {array: unknown[]}): ReactNode {
)
}
-function JsonArray({ array, label }: { array: unknown[], label?: string }): ReactNode {
- const [collapsed, setCollapsed] = useState(shouldObjectCollapse(array))
+function JsonArray({ array, label, expandRoot, pageLimit = defaultPageLimit }: { array: unknown[], label?: string, expandRoot?: boolean, pageLimit?: number }): ReactNode {
+ const [collapsed, setCollapsed] = useState(!expandRoot && shouldObjectCollapse(array))
+ const [limit, setLimit] = useState(pageLimit)
const key = label ? {label}: : ''
if (collapsed) {
return { setCollapsed(false) }}>
{key}
-
+
}
return <>
@@ -100,20 +108,23 @@ function JsonArray({ array, label }: { array: unknown[], label?: string }): Reac
{'['}
- {array.map((item, index) => - {}
)}
+ {array.slice(0, limit).map((item, index) => )}
+ {array.length > limit && -
+
+
}
{']'}
>
}
-function CollapsedObject({ obj }: {obj: object}): ReactNode {
+function CollapsedObject({ obj }: { obj: object }): ReactNode {
const { elementRef, width } = useWidth()
const maxCharacterCount = Math.max(20, Math.floor(width / 8))
const separator = ', '
const kvSeparator = ': '
const children: ReactNode[] = []
- let suffix: string | undefined = undefined
+ let suffix: string | undefined
const entries = Object.entries(obj)
let characterCount = 0
@@ -124,9 +135,7 @@ function CollapsedObject({ obj }: {obj: object}): ReactNode {
}
// should we continue?
if (isPrimitive(value)) {
- const asString = typeof value === 'bigint' ? value.toString() :
- value === undefined ? 'undefined' /* see JsonContent - even if JSON.stringify([undefined]) === '[null]' */:
- JSON.stringify(value)
+ const asString = stringifyPrimitive(value)
characterCount += key.length + kvSeparator.length + asString.length
if (characterCount < maxCharacterCount) {
children.push()
@@ -148,26 +157,31 @@ function CollapsedObject({ obj }: {obj: object}): ReactNode {
)
}
-function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode {
- const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj))
+function JsonObject({ obj, label, expandRoot, pageLimit = defaultPageLimit }: { obj: object, label?: string, expandRoot?: boolean, pageLimit?: number }): ReactNode {
+ const [collapsed, setCollapsed] = useState(!expandRoot && shouldObjectCollapse(obj))
+ const [limit, setLimit] = useState(pageLimit)
const key = label ? {label}: : ''
if (collapsed) {
return { setCollapsed(false) }}>
{key}
-
+
}
+ const entries = Object.entries(obj)
return <>
{ setCollapsed(true) }}>
{key}
{'{'}
- {Object.entries(obj).map(([key, value]) =>
+ {entries.slice(0, limit).map(([key, value]) =>
-
-
+
)}
+ {entries.length > limit && -
+
+
}
{'}'}
>
diff --git a/src/components/Json/helpers.ts b/src/components/Json/helpers.ts
index 6862703..3f091ef 100644
--- a/src/components/Json/helpers.ts
+++ b/src/components/Json/helpers.ts
@@ -9,6 +9,16 @@ export function isPrimitive(value: unknown): boolean {
)
}
+export function stringifyPrimitive(value: unknown): string {
+ if (typeof value === 'bigint') {
+ return value.toString()
+ } else if (value === undefined) {
+ return 'undefined'
+ } else {
+ return JSON.stringify(value)
+ }
+}
+
export function shouldObjectCollapse(obj: object): boolean {
const values = Object.values(obj)
if (