Migrate twenty UI (#21407)

## Migrate all `twenty-ui-deprecated` components into `twenty-ui`

Ports all **192 components** and **70 stories** into the new `twenty-ui`
package with full public-API parity (export diff: 0 missing / 0 extra
across all 13 modules; story titles byte-identical for the Argos
cross-package diff).

- **Styling:** Linaria → SCSS Modules, `var(--t-*)` tokens, `data-*`
state. Canonical pattern in `Button.module.scss`.
- **Behavior:** Base UI where mapped (Checkbox, Radio, Modal→Dialog,
Tooltip drops `react-tooltip`, JSON tree→Collapsible); framer kept only
where animation is the public contract.
- **Fixed an inert axe gate** in `.storybook/vitest.setup.ts` (a11y
addon annotations were never registered). Now live; 119 stories carry
`a11y: 'todo'` overrides pending a fix pass.
This commit is contained in:
Raphaël Bosi
2026-06-11 11:02:28 +02:00
committed by GitHub
parent c05c34aaec
commit d596c26f46
676 changed files with 33509 additions and 163 deletions
@@ -0,0 +1,661 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { JsonTree } from '@ui/json-visualizer/components/JsonTree';
import { isTwoFirstDepths } from '@ui/json-visualizer/utils/isTwoFirstDepths';
import {
expect,
fn,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from 'storybook/test';
const meta: Meta<typeof JsonTree> = {
title: 'UI/JsonVisualizer/JsonTree',
component: JsonTree,
args: {
shouldExpandNodeInitially: () => true,
emptyArrayLabel: 'Empty Array',
emptyObjectLabel: 'Empty Object',
emptyStringLabel: '[empty string]',
arrowButtonCollapsedLabel: 'Expand',
arrowButtonExpandedLabel: 'Collapse',
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof JsonTree>;
export const String: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: 'Hello',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const node = await canvas.findByText('Hello');
expect(node).toBeVisible();
},
};
export const StringWithSpecialCharacters: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: 'Merry \n Christmas \t 🎄',
onNodeValueClick: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const node = await canvas.findByText('Merry Christmas 🎄');
await userEvent.click(node);
await waitFor(() => {
expect(args.onNodeValueClick).toHaveBeenCalledWith(
'Merry \n Christmas \t 🎄',
);
});
},
};
export const Number: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: 42,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const node = await canvas.findByText('42');
expect(node).toBeVisible();
},
};
export const Boolean: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const node = await canvas.findByText('true');
expect(node).toBeVisible();
},
};
export const Null: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: null,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const node = await canvas.findByText('null');
expect(node).toBeVisible();
},
};
export const ArraySimple: Story = {
args: {
value: [1, 2, 3],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const node = await canvas.findByText('3');
expect(node).toBeVisible();
},
};
export const ArrayEmpty: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emptyState = await canvas.findByText('Empty Array');
expect(emptyState).toBeVisible();
},
};
export const ArrayNested: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: [1, 2, ['a', 'b', 'c'], 3],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedArrayElements = await canvas.findByText('[3]');
expect(nestedArrayElements).toBeVisible();
},
};
export const ArrayNestedEmpty: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: [1, 2, [], 3],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedArrayElements = await canvas.findByText('[0]');
expect(nestedArrayElements).toBeVisible();
const emptyState = await canvas.findByText('Empty Array');
expect(emptyState).toBeVisible();
},
};
export const ArrayWithObjects: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: [
{
name: 'John Doe',
age: 30,
},
{
name: 'John Dowl',
age: 42,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedObjectItemsCounts = await canvas.findAllByText('{2}');
expect(nestedObjectItemsCounts).toHaveLength(2);
},
};
export const ObjectSimple: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
name: 'John Doe',
age: 30,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const name = await canvas.findByText('John Doe');
expect(name).toBeVisible();
const age = await canvas.findByText('30');
expect(age).toBeVisible();
},
};
export const ObjectEmpty: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emptyState = await canvas.findByText('Empty Object');
expect(emptyState).toBeVisible();
},
};
export const ObjectNested: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
person: {
name: 'John Doe',
address: {
street: '123 Main St',
city: 'New York',
},
},
isActive: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedObjectItemsCounts = await canvas.findAllByText('{2}');
expect(nestedObjectItemsCounts).toHaveLength(2);
},
};
export const ObjectNestedEmpty: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
person: {},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedObjectItemsCount = await canvas.findByText('{0}');
expect(nestedObjectItemsCount).toBeVisible();
const emptyState = await canvas.findByText('Empty Object');
expect(emptyState).toBeVisible();
},
};
export const ObjectWithArray: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
settings: {
theme: 'dark',
notifications: true,
},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedArrayCount = await canvas.findByText('[2]');
expect(nestedArrayCount).toBeVisible();
const nestedObjectCounts = await canvas.findAllByText('{2}');
expect(nestedObjectCounts).toHaveLength(3);
},
};
export const NestedElementCanBeCollapsed: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
person: {
name: 'John Doe',
age: 12,
},
isActive: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggleButton = await canvas.findByRole('button', {
name: 'Collapse',
});
const ageElement = canvas.getByText('age');
await Promise.all([
waitForElementToBeRemoved(ageElement),
userEvent.click(toggleButton),
]);
expect(toggleButton).toHaveTextContent('Expand');
},
};
export const ExpandingElementExpandsAllItsDescendants: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
person: {
name: 'John Doe',
address: {
street: '123 Main St',
city: 'New York',
country: {
name: 'USA',
code: 'US',
},
},
},
isActive: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
{
const allCollapseButtons = await canvas.findAllByRole('button', {
name: 'Collapse',
});
expect(allCollapseButtons).toHaveLength(3);
for (const collapseButton of allCollapseButtons.reverse()) {
await userEvent.click(collapseButton);
}
}
const rootExpandButton = await canvas.findByRole('button', {
name: 'Expand',
});
await userEvent.click(rootExpandButton);
{
const allCollapseButtons = await canvas.findAllByRole('button', {
name: 'Collapse',
});
expect(allCollapseButtons).toHaveLength(3);
}
},
};
export const ExpandTwoFirstDepths: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
person: {
name: 'John Doe',
address: {
street: '123 Main St',
city: 'New York',
country: {
name: 'USA',
code: 'US',
},
},
},
isActive: true,
},
shouldExpandNodeInitially: isTwoFirstDepths,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nameElement = await canvas.findByText('name');
expect(nameElement).toBeVisible();
const addressElement = await canvas.findByText('address');
expect(addressElement).toBeVisible();
const streetElement = canvas.queryByText('street');
expect(streetElement).not.toBeInTheDocument();
const countrCodeElement = canvas.queryByText('code');
expect(countrCodeElement).not.toBeInTheDocument();
},
};
export const ReallyDeepNestedObject: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
a: {
b: {
c: {
d: {
e: {
f: {
g: {
h: {
i: {
j: {
k: {
l: {
m: {
n: {
o: {
p: {
q: {
r: {
s: {
t: {
u: {
v: {
w: {
x: {
y: {
z: {
end: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
bis: {
c: {
d: {
e: {
f: {
g: {
h: {
i: {
j: {
k: {
l: {
m: {
n: {
o: {
p: {
q: {
r: {
s: {
t: {
u: {
v: {
w: {
x: {
y: {
z: {
end: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const finalNodes = await canvas.findAllByText('end');
expect(finalNodes).toHaveLength(2);
expect(finalNodes[0]).toBeVisible();
expect(finalNodes[1]).toBeVisible();
},
};
export const LongText: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum iaculis est tincidunt, sagittis neque vitae, sodales purus.':
'Ut lobortis ultricies purus, sit amet porta eros. Suspendisse efficitur quam vitae diam imperdiet feugiat. Etiam vel bibendum elit.',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const label = await canvas.findByText(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum iaculis est tincidunt, sagittis neque vitae, sodales purus.',
);
expect(label).toBeVisible();
const value = await canvas.findByText(
'Ut lobortis ultricies purus, sit amet porta eros. Suspendisse efficitur quam vitae diam imperdiet feugiat. Etiam vel bibendum elit.',
);
expect(value).toBeVisible();
},
};
export const BlueHighlighting: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
name: 'John Doe',
age: 30,
},
getNodeHighlighting: () => 'blue',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const ageElement = await canvas.findByText('age');
expect(ageElement).toBeVisible();
},
};
export const PartialBlueHighlighting: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
name: 'John Doe',
age: 30,
address: {
city: 'Paris',
},
},
getNodeHighlighting: (keyPath: string) =>
keyPath === 'address' ? 'partial-blue' : undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const ageElement = await canvas.findByText('age');
expect(ageElement).toBeVisible();
},
};
export const RedHighlighting: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
name: 'John Doe',
age: 30,
address: {
city: 'Paris',
},
},
getNodeHighlighting: () => 'red',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const ageElement = await canvas.findByText('age');
expect(ageElement).toBeVisible();
},
};
export const CopyJsonNodeValue: Story = {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
parameters: { a11y: { test: 'todo' } },
args: {
value: {
name: 'John Doe',
age: 30,
},
onNodeValueClick: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const nameValue = await canvas.findByText('John Doe');
await userEvent.click(nameValue);
await waitFor(() => {
expect(args.onNodeValueClick).toHaveBeenCalledWith('John Doe');
});
const ageValue = await canvas.findByText('30');
await userEvent.click(ageValue);
await waitFor(() => {
expect(args.onNodeValueClick).toHaveBeenCalledWith('30');
});
},
};
@@ -0,0 +1,38 @@
import { IconBrackets } from '@ui/display';
import { JsonNestedNode } from '@ui/json-visualizer/components/JsonNestedNode';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
import { type JsonArray } from 'type-fest';
export const JsonArrayNode = ({
label,
value,
depth,
keyPath,
highlighting,
}: {
label?: string;
value: JsonArray;
depth: number;
keyPath: string;
highlighting: JsonNodeHighlighting | undefined;
}) => {
const { emptyArrayLabel } = useJsonTreeContextOrThrow();
return (
<JsonNestedNode
elements={[...value.entries()].map(([key, value]) => ({
id: key,
label: String(key),
value,
}))}
renderElementsCount={(count) => `[${count}]`}
label={label}
Icon={IconBrackets}
depth={depth}
emptyElementsText={emptyArrayLabel}
keyPath={keyPath}
highlighting={highlighting}
/>
);
};
@@ -0,0 +1,50 @@
.container {
display: grid;
list-style-type: none;
}
.labelContainer {
display: flex;
align-items: center;
gap: var(--t-spacing-2);
}
.elementsCount {
color: var(--t-font-color-tertiary);
}
.elementsCountRed {
color: var(--t-font-color-danger);
}
// Base UI sets --collapsible-panel-height to a pixel value during
// open/close transitions and back to auto once fully open, so nested
// nodes can still resize their ancestors.
.panel {
height: var(--collapsible-panel-height);
overflow: clip;
transition:
height 0.3s ease,
opacity 0.3s ease;
&[data-starting-style],
&[data-ending-style] {
height: 0;
opacity: 0;
}
}
.list {
margin: 0;
padding: 0;
display: grid;
row-gap: var(--t-spacing-2);
}
.nested {
padding-left: var(--t-spacing-8);
> :first-of-type {
margin-top: var(--t-spacing-2);
}
}
@@ -0,0 +1,10 @@
declare const classNames: {
readonly container: 'container';
readonly labelContainer: 'labelContainer';
readonly elementsCount: 'elementsCount';
readonly elementsCountRed: 'elementsCountRed';
readonly panel: 'panel';
readonly list: 'list';
readonly nested: 'nested';
};
export default classNames;
@@ -0,0 +1,119 @@
import { Collapsible } from '@base-ui/react/collapsible';
import { isNonEmptyString } from '@sniptt/guards';
import { clsx } from 'clsx';
import { useState } from 'react';
import { isDefined } from '@ui/utilities/utils/isDefined';
import { type JsonValue } from 'type-fest';
import { type IconComponent } from '@ui/display';
import { JsonArrow } from '@ui/json-visualizer/components/internal/JsonArrow';
import { JsonNodeLabel } from '@ui/json-visualizer/components/internal/JsonNodeLabel';
import { JsonNodeValue } from '@ui/json-visualizer/components/internal/JsonNodeValue';
import { JsonNode } from '@ui/json-visualizer/components/JsonNode';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
import styles from './JsonNestedNode.module.scss';
export const JsonNestedNode = ({
label,
Icon,
elements,
renderElementsCount,
emptyElementsText,
depth,
keyPath,
highlighting,
}: {
label?: string;
Icon: IconComponent;
elements: Array<{ id: string | number; label: string; value: JsonValue }>;
renderElementsCount?: (count: number) => string;
emptyElementsText: string;
depth: number;
keyPath: string;
highlighting?: JsonNodeHighlighting | undefined;
}) => {
const { shouldExpandNodeInitially } = useJsonTreeContextOrThrow();
const hideRoot = !isDefined(label);
const [isOpen, setIsOpen] = useState(
shouldExpandNodeInitially({ keyPath, depth }),
);
const renderedChildren = (
<ul className={clsx(styles.list, depth > 0 && styles.nested)}>
{elements.length === 0 ? (
<JsonNodeValue valueAsString={emptyElementsText} />
) : (
elements.map(({ id, label, value }) => {
const nextKeyPath = isNonEmptyString(keyPath)
? `${keyPath}.${id}`
: String(id);
return (
<JsonNode
key={id}
label={label}
value={value}
depth={depth + 1}
keyPath={nextKeyPath}
/>
);
})
)}
</ul>
);
const handleArrowClick = () => {
setIsOpen(!isOpen);
};
if (hideRoot) {
return <li className={styles.container}>{renderedChildren}</li>;
}
return (
<Collapsible.Root
className={styles.container}
open={isOpen}
render={<li />}
>
<div className={styles.labelContainer}>
<JsonArrow
isOpen={isOpen}
onClick={handleArrowClick}
variant={
highlighting === 'partial-blue'
? 'blue'
: highlighting === 'red'
? highlighting
: undefined
}
/>
<JsonNodeLabel
label={label}
Icon={Icon}
highlighting={highlighting === 'red' ? highlighting : undefined}
/>
{renderElementsCount && (
<span
className={clsx(
styles.elementsCount,
highlighting === 'red' && styles.elementsCountRed,
)}
>
{renderElementsCount(elements.length)}
</span>
)}
</div>
<Collapsible.Panel className={styles.panel}>
{renderedChildren}
</Collapsible.Panel>
</Collapsible.Root>
);
};
@@ -0,0 +1,101 @@
import {
isBoolean,
isNonEmptyString,
isNumber,
isString,
} from '@sniptt/guards';
import {
IconCheckbox,
IconCircleOff,
IconNumber9,
IconTypography,
} from '@ui/display';
import { JsonArrayNode } from '@ui/json-visualizer/components/JsonArrayNode';
import { JsonObjectNode } from '@ui/json-visualizer/components/JsonObjectNode';
import { JsonValueNode } from '@ui/json-visualizer/components/JsonValueNode';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { isArray } from '@ui/json-visualizer/utils/isArray';
import { isDefined } from '@ui/utilities/utils/isDefined';
import { type JsonValue } from 'type-fest';
export const JsonNode = ({
label,
value,
depth,
keyPath,
}: {
label?: string;
value: JsonValue;
depth: number;
keyPath: string;
}) => {
const { getNodeHighlighting, emptyStringLabel } = useJsonTreeContextOrThrow();
const highlighting = getNodeHighlighting?.(keyPath);
if (!isDefined(value)) {
return (
<JsonValueNode
label={label}
valueAsString="null"
Icon={IconCircleOff}
highlighting={highlighting}
/>
);
}
if (isString(value)) {
return (
<JsonValueNode
label={label}
valueAsString={isNonEmptyString(value) ? value : emptyStringLabel}
Icon={IconTypography}
highlighting={highlighting}
/>
);
}
if (isNumber(value)) {
return (
<JsonValueNode
label={label}
valueAsString={String(value)}
Icon={IconNumber9}
highlighting={highlighting}
/>
);
}
if (isBoolean(value)) {
return (
<JsonValueNode
label={label}
valueAsString={String(value)}
Icon={IconCheckbox}
highlighting={highlighting}
/>
);
}
if (isArray(value)) {
return (
<JsonArrayNode
label={label}
value={value}
depth={depth}
keyPath={keyPath}
highlighting={highlighting}
/>
);
}
return (
<JsonObjectNode
label={label}
value={value}
depth={depth}
keyPath={keyPath}
highlighting={highlighting}
/>
);
};
@@ -0,0 +1,38 @@
import { IconCube } from '@ui/display';
import { JsonNestedNode } from '@ui/json-visualizer/components/JsonNestedNode';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
import { type JsonObject } from 'type-fest';
export const JsonObjectNode = ({
label,
value,
depth,
keyPath,
highlighting,
}: {
label?: string;
value: JsonObject;
depth: number;
keyPath: string;
highlighting: JsonNodeHighlighting | undefined;
}) => {
const { emptyObjectLabel } = useJsonTreeContextOrThrow();
return (
<JsonNestedNode
elements={Object.entries(value).map(([key, value]) => ({
id: key,
label: key,
value,
}))}
renderElementsCount={(count) => `{${count}}`}
label={label}
Icon={IconCube}
depth={depth}
emptyElementsText={emptyObjectLabel}
keyPath={keyPath}
highlighting={highlighting}
/>
);
};
@@ -0,0 +1,49 @@
import { JsonList } from '@ui/json-visualizer/components/internal/JsonList';
import { JsonNode } from '@ui/json-visualizer/components/JsonNode';
import { JsonTreeContextProvider } from '@ui/json-visualizer/components/JsonTreeContextProvider';
import { type ShouldExpandNodeInitiallyProps } from '@ui/json-visualizer/contexts/JsonTreeContext';
import { type GetJsonNodeHighlighting } from '@ui/json-visualizer/types/GetJsonNodeHighlighting';
import { type JsonValue } from 'type-fest';
export const JsonTree = ({
value,
getNodeHighlighting,
shouldExpandNodeInitially,
emptyArrayLabel,
emptyObjectLabel,
emptyStringLabel,
arrowButtonCollapsedLabel,
arrowButtonExpandedLabel,
onNodeValueClick,
}: {
value: JsonValue;
getNodeHighlighting?: GetJsonNodeHighlighting;
shouldExpandNodeInitially: (
params: ShouldExpandNodeInitiallyProps,
) => boolean;
emptyArrayLabel: string;
emptyObjectLabel: string;
emptyStringLabel: string;
arrowButtonCollapsedLabel: string;
arrowButtonExpandedLabel: string;
onNodeValueClick?: (valueAsString: string) => void;
}) => {
return (
<JsonTreeContextProvider
value={{
getNodeHighlighting,
shouldExpandNodeInitially,
emptyArrayLabel,
emptyObjectLabel,
emptyStringLabel,
arrowButtonCollapsedLabel,
arrowButtonExpandedLabel,
onNodeValueClick,
}}
>
<JsonList depth={0}>
<JsonNode value={value} depth={0} keyPath="" />
</JsonList>
</JsonTreeContextProvider>
);
};
@@ -0,0 +1,18 @@
import {
JsonTreeContext,
type JsonTreeContextType,
} from '@ui/json-visualizer/contexts/JsonTreeContext';
export const JsonTreeContextProvider = ({
value,
children,
}: {
value: JsonTreeContextType;
children: React.ReactNode;
}) => {
return (
<JsonTreeContext.Provider value={value}>
{children}
</JsonTreeContext.Provider>
);
};
@@ -0,0 +1,7 @@
.listItem {
align-items: center;
column-gap: var(--t-spacing-2);
display: flex;
list-style-type: none;
white-space: nowrap;
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly listItem: 'listItem';
};
export default classNames;
@@ -0,0 +1,39 @@
import { type IconComponent } from '@ui/display';
import { JsonNodeLabel } from '@ui/json-visualizer/components/internal/JsonNodeLabel';
import { JsonNodeValue } from '@ui/json-visualizer/components/internal/JsonNodeValue';
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
import styles from './JsonValueNode.module.scss';
type JsonValueNodeProps = {
valueAsString: string;
highlighting: JsonNodeHighlighting | undefined;
} & (
| {
label: string;
Icon: IconComponent;
}
| {
label?: never;
Icon?: unknown;
}
);
export const JsonValueNode = (props: JsonValueNodeProps) => {
return (
<li className={styles.listItem}>
{props.label && (
<JsonNodeLabel
label={props.label}
Icon={props.Icon}
highlighting={props.highlighting}
/>
)}
<JsonNodeValue
valueAsString={props.valueAsString}
highlighting={props.highlighting}
/>
</li>
);
};
@@ -0,0 +1,29 @@
.button {
align-items: center;
background-color: var(--t-background-transparent-lighter);
border-color: var(--t-border-color-medium);
border-radius: var(--t-border-radius-sm);
border-style: solid;
border-width: 1px;
display: flex;
justify-content: center;
padding-inline: var(--t-spacing-1);
height: 24px;
width: 24px;
box-sizing: border-box;
cursor: pointer;
}
.red {
background-color: var(--t-background-danger);
border-color: var(--t-border-color-danger);
}
.chevron {
transform: rotate(-90deg);
transition: transform 0.3s ease;
&[data-open] {
transform: rotate(0deg);
}
}
@@ -0,0 +1,6 @@
declare const classNames: {
readonly button: 'button';
readonly red: 'red';
readonly chevron: 'chevron';
};
export default classNames;
@@ -0,0 +1,44 @@
import { VisibilityHidden } from '@ui/accessibility';
import { IconChevronDown } from '@ui/display';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { ThemeContext, themeCssVariables } from '@ui/theme-constants';
import { clsx } from 'clsx';
import { useContext } from 'react';
import styles from './JsonArrow.module.scss';
export const JsonArrow = ({
isOpen,
onClick,
variant,
}: {
isOpen: boolean;
onClick: () => void;
variant?: 'blue' | 'red';
}) => {
const { theme } = useContext(ThemeContext);
const { arrowButtonCollapsedLabel, arrowButtonExpandedLabel } =
useJsonTreeContextOrThrow();
const iconColor =
variant === 'blue'
? themeCssVariables.color.blue
: variant === 'red'
? themeCssVariables.font.color.danger
: themeCssVariables.font.color.secondary;
return (
<button
className={clsx(styles.button, variant === 'red' && styles.red)}
onClick={onClick}
>
<VisibilityHidden>
{isOpen ? arrowButtonExpandedLabel : arrowButtonCollapsedLabel}
</VisibilityHidden>
<div className={styles.chevron} data-open={isOpen || undefined}>
<IconChevronDown size={theme.icon.size.md} color={iconColor} />
</div>
</button>
);
};
@@ -0,0 +1,15 @@
.list {
margin: 0;
padding: 0;
display: grid;
row-gap: var(--t-spacing-2);
}
.nested {
padding-left: var(--t-spacing-8);
> :first-of-type {
margin-top: var(--t-spacing-2);
}
}
@@ -0,0 +1,5 @@
declare const classNames: {
readonly list: 'list';
readonly nested: 'nested';
};
export default classNames;
@@ -0,0 +1,17 @@
import { clsx } from 'clsx';
import styles from './JsonList.module.scss';
export const JsonList = ({
depth,
className,
children,
}: {
depth: number;
className?: string;
children?: React.ReactNode;
}) => (
<ul className={clsx(styles.list, depth > 0 && styles.nested, className)}>
{children}
</ul>
);
@@ -0,0 +1,6 @@
.listItem {
align-items: center;
display: flex;
list-style-type: none;
white-space: nowrap;
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly listItem: 'listItem';
};
export default classNames;
@@ -0,0 +1,11 @@
import { clsx } from 'clsx';
import styles from './JsonListItem.module.scss';
export const JsonListItem = ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => <li className={clsx(styles.listItem, className)}>{children}</li>;
@@ -0,0 +1,34 @@
.labelContainer {
background-color: var(--t-background-transparent-lighter);
border-color: var(--t-border-color-medium);
color: var(--t-font-color-primary);
border-radius: var(--t-border-radius-sm);
border-style: solid;
border-width: 1px;
column-gap: var(--t-spacing-2);
display: inline-flex;
align-items: center;
height: 24px;
box-sizing: border-box;
font-size: var(--t-font-size-md);
white-space: nowrap;
padding-inline: var(--t-spacing-2);
> span {
align-items: center;
display: inline-flex;
line-height: 1;
}
}
.blue {
background-color: var(--t-color-blue3);
border-color: var(--t-color-blue5);
color: var(--t-color-blue);
}
.red {
background-color: var(--t-background-danger);
border-color: var(--t-border-color-danger);
color: var(--t-font-color-danger);
}
@@ -0,0 +1,6 @@
declare const classNames: {
readonly labelContainer: 'labelContainer';
readonly blue: 'blue';
readonly red: 'red';
};
export default classNames;
@@ -0,0 +1,42 @@
import { type IconComponent } from '@ui/display';
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
import { ThemeContext } from '@ui/theme-constants';
import { clsx } from 'clsx';
import { useContext } from 'react';
import styles from './JsonNodeLabel.module.scss';
export const JsonNodeLabel = ({
label,
Icon,
highlighting,
}: {
label: string;
Icon: IconComponent;
highlighting?: JsonNodeHighlighting | undefined;
}) => {
const { theme } = useContext(ThemeContext);
return (
<span
className={clsx(
styles.labelContainer,
highlighting === 'blue' && styles.blue,
highlighting === 'red' && styles.red,
)}
>
<Icon
size={theme.icon.size.md}
color={
highlighting === 'blue'
? theme.color.blue
: highlighting === 'red'
? theme.font.color.danger
: theme.font.color.tertiary
}
/>
<span>{label}</span>
</span>
);
};
@@ -0,0 +1,16 @@
.text {
align-items: center;
box-sizing: border-box;
color: var(--t-font-color-tertiary);
display: inline-flex;
height: 24px;
line-height: 1;
}
.blue {
color: var(--t-color-blue8);
}
.red {
color: var(--t-color-red8);
}
@@ -0,0 +1,6 @@
declare const classNames: {
readonly text: 'text';
readonly blue: 'blue';
readonly red: 'red';
};
export default classNames;
@@ -0,0 +1,32 @@
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
import { clsx } from 'clsx';
import styles from './JsonNodeValue.module.scss';
export const JsonNodeValue = ({
valueAsString,
highlighting,
}: {
valueAsString: string;
highlighting?: JsonNodeHighlighting | undefined;
}) => {
const { onNodeValueClick } = useJsonTreeContextOrThrow();
const handleClick = () => {
onNodeValueClick?.(valueAsString);
};
return (
<span
className={clsx(
styles.text,
highlighting === 'blue' && styles.blue,
highlighting === 'red' && styles.red,
)}
onClick={handleClick}
>
{valueAsString}
</span>
);
};
@@ -0,0 +1,21 @@
import { type GetJsonNodeHighlighting } from '@ui/json-visualizer/types/GetJsonNodeHighlighting';
import { createContext } from 'react';
export type ShouldExpandNodeInitiallyProps = { keyPath: string; depth: number };
export type JsonTreeContextType = {
getNodeHighlighting?: GetJsonNodeHighlighting;
shouldExpandNodeInitially: (
params: ShouldExpandNodeInitiallyProps,
) => boolean;
emptyStringLabel: string;
emptyArrayLabel: string;
emptyObjectLabel: string;
arrowButtonCollapsedLabel: string;
arrowButtonExpandedLabel: string;
onNodeValueClick?: (valueAsString: string) => void;
};
export const JsonTreeContext = createContext<JsonTreeContextType | undefined>(
undefined,
);
@@ -0,0 +1,15 @@
import { JsonTreeContext } from '@ui/json-visualizer/contexts/JsonTreeContext';
import { useContext } from 'react';
import { isDefined } from '@ui/utilities/utils/isDefined';
export const useJsonTreeContextOrThrow = () => {
const value = useContext(JsonTreeContext);
if (!isDefined(value)) {
throw new Error(
'useJsonTreeContextOrThrow must be used within a JsonTreeContextProvider',
);
}
return value;
};
@@ -7,4 +7,20 @@
* |___/
*/
export {};
export { JsonArrayNode } from './components/JsonArrayNode';
export { JsonNestedNode } from './components/JsonNestedNode';
export { JsonNode } from './components/JsonNode';
export { JsonObjectNode } from './components/JsonObjectNode';
export { JsonTree } from './components/JsonTree';
export { JsonTreeContextProvider } from './components/JsonTreeContextProvider';
export { JsonValueNode } from './components/JsonValueNode';
export type {
ShouldExpandNodeInitiallyProps,
JsonTreeContextType,
} from './contexts/JsonTreeContext';
export { JsonTreeContext } from './contexts/JsonTreeContext';
export { useJsonTreeContextOrThrow } from './hooks/useJsonTreeContextOrThrow';
export type { GetJsonNodeHighlighting } from './types/GetJsonNodeHighlighting';
export type { JsonNodeHighlighting } from './types/JsonNodeHighlighting';
export { isArray } from './utils/isArray';
export { isTwoFirstDepths } from './utils/isTwoFirstDepths';
@@ -0,0 +1,5 @@
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
export type GetJsonNodeHighlighting = (
keyPath: string,
) => JsonNodeHighlighting | undefined;
@@ -0,0 +1,5 @@
import { type ThemeColor } from '@ui/theme';
export type JsonNodeHighlighting =
| Extract<ThemeColor, 'blue' | 'red'>
| 'partial-blue';
@@ -0,0 +1,5 @@
import { isArray as _isArray } from '@sniptt/guards';
export const isArray = (
value: unknown,
): value is unknown[] | readonly unknown[] => _isArray(value);
@@ -0,0 +1,4 @@
import { type ShouldExpandNodeInitiallyProps } from '@ui/json-visualizer/contexts/JsonTreeContext';
export const isTwoFirstDepths = ({ depth }: ShouldExpandNodeInitiallyProps) =>
depth <= 1;