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,11 @@
.layout {
background: var(--t-background-primary);
border: 1px solid var(--t-border-color-light);
border-radius: 5px;
display: flex;
flex-direction: row;
max-width: calc(100% - 40px);
padding: 20px;
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly layout: 'layout';
};
export default classNames;
@@ -0,0 +1,32 @@
import { isDefined } from '@ui/utilities/utils/isDefined';
import styles from './ComponentStorybookLayout.module.scss';
type ComponentStorybookLayoutProps = {
width?: number;
backgroundColor?: string | undefined;
height?: number;
children: JSX.Element;
};
export const ComponentStorybookLayout = ({
width,
backgroundColor,
height,
children,
}: ComponentStorybookLayoutProps) => {
return (
<div
className={styles.layout}
style={{
// background falls back to the SCSS default when undefined
background: backgroundColor,
height: isDefined(height) ? `${height}px` : 'fit-content',
minWidth: width ? 'unset' : '300px',
width: width ? `${width}px` : 'fit-content',
}}
>
{children}
</div>
);
};
@@ -0,0 +1,65 @@
.columnTitle {
font-size: var(--t-font-size-lg);
font-weight: var(--t-font-weight-semi-bold);
margin: var(--t-spacing-2);
}
.rowsTitle {
color: var(--t-font-color-secondary);
font-size: var(--t-font-size-md);
font-weight: var(--t-font-weight-semi-bold);
margin: var(--t-spacing-2);
width: 100px;
}
.rowTitle {
color: var(--t-font-color-tertiary);
font-size: var(--t-font-size-md);
font-weight: var(--t-font-weight-semi-bold);
margin: var(--t-spacing-2);
width: 100px;
}
.elementTitle {
color: var(--t-font-color-light);
font-size: var(--t-font-size-xs);
font-weight: var(--t-font-weight-semi-bold);
margin-bottom: var(--t-spacing-1);
text-align: center;
text-transform: uppercase;
}
.container {
display: flex;
flex-direction: row;
}
.columnContainer {
display: flex;
flex-direction: column;
padding: var(--t-spacing-2);
}
.rowsContainer {
display: flex;
flex-direction: column;
gap: var(--t-spacing-2);
}
.rowContainer {
display: flex;
flex: 1;
flex-direction: row;
gap: var(--t-spacing-2);
}
.elementContainer {
display: flex;
}
.cellContainer {
align-items: center;
display: flex;
flex-direction: column;
padding: var(--t-spacing-2);
}
@@ -0,0 +1,13 @@
declare const classNames: {
readonly columnTitle: 'columnTitle';
readonly rowsTitle: 'rowsTitle';
readonly rowTitle: 'rowTitle';
readonly elementTitle: 'elementTitle';
readonly container: 'container';
readonly columnContainer: 'columnContainer';
readonly rowsContainer: 'rowsContainer';
readonly rowContainer: 'rowContainer';
readonly elementContainer: 'elementContainer';
readonly cellContainer: 'cellContainer';
};
export default classNames;
@@ -0,0 +1,117 @@
import { isNumber, isString } from '@sniptt/guards';
import { type Decorator } from '@storybook/react-vite';
import { clsx } from 'clsx';
import { type ComponentProps, type JSX } from 'react';
import styles from './CatalogDecorator.module.scss';
const emptyDimension = {
name: '',
values: [undefined],
props: () => ({}),
} as CatalogDimension;
const isStringOrNumber = (term: unknown): term is string | number =>
isString(term) || isNumber(term);
export type CatalogDimension<
ComponentType extends React.ElementType = () => JSX.Element,
> = {
name: string;
values: any[];
props: (value: any) => Partial<ComponentProps<ComponentType>>;
labels?: (value: any) => string;
};
export type CatalogOptions = {
elementContainer?: {
width?: number;
style?: React.CSSProperties;
className?: string;
};
};
export const CatalogDecorator: Decorator = (Story, context) => {
const {
catalog: { dimensions = [], options = {} } = {
dimensions: [],
options: {},
},
} = context.parameters || {};
if (!dimensions || !Array.isArray(dimensions)) {
return <Story />;
}
const [
dimension1 = emptyDimension,
dimension2 = emptyDimension,
dimension3 = emptyDimension,
dimension4 = emptyDimension,
] = dimensions as CatalogDimension[];
return (
<div className={styles.container}>
{dimension4.values.map((value4: any, index4: number) => (
<div className={styles.columnContainer} key={`d4-${index4}`}>
<h1 className={styles.columnTitle}>
{dimension4.labels?.(value4) ??
(isStringOrNumber(value4) ? value4 : '')}
</h1>
{dimension3.values.map((value3: any, index3: number) => (
<div className={styles.rowsContainer} key={`d3-${index3}`}>
<h2 className={styles.rowsTitle}>
{dimension3.labels?.(value3) ??
(isStringOrNumber(value3) ? value3 : '')}
</h2>
{dimension2.values.map((value2: any, index2: number) => (
<div className={styles.rowContainer} key={`d2-${index2}`}>
<h3 className={styles.rowTitle}>
{dimension2.labels?.(value2) ??
(isStringOrNumber(value2) ? value2 : '')}
</h3>
{dimension1.values.map((value1: any, index1: number) => {
return (
<div
className={styles.cellContainer}
key={`d1-${index1}`}
id={value1}
>
<span className={styles.elementTitle}>
{dimension1.labels?.(value1) ??
(isStringOrNumber(value1) ? value1 : '')}
</span>
<div
className={clsx(
styles.elementContainer,
options?.elementContainer?.className,
)}
style={{
minWidth: options?.elementContainer?.width
? `${options.elementContainer.width}px`
: 'auto',
...options?.elementContainer?.style,
}}
>
<Story
args={{
...context.args,
...dimension1.props(value1),
...dimension2.props(value2),
...dimension3.props(value3),
...dimension4.props(value4),
}}
/>
</div>
</div>
);
})}
</div>
))}
</div>
))}
</div>
))}
</div>
);
};
@@ -0,0 +1,40 @@
import { type Decorator } from '@storybook/react-vite';
import { themeCssVariables } from '@ui/theme-constants';
import { ComponentStorybookLayout } from '../ComponentStorybookLayout';
const getBackgroundColor = (
inverted: boolean,
accent: string,
): string | undefined => {
if (!inverted) return undefined;
switch (accent) {
case 'default':
return themeCssVariables.grayScale.gray11;
case 'danger':
return themeCssVariables.color.red;
case 'blue':
return themeCssVariables.color.blue;
default:
return undefined;
}
};
export const ComponentDecorator: Decorator = (Story, context) => {
const { container } = context.parameters;
const inverted = context.args.inverted as boolean;
const accent = context.args.accent as string;
const backgroundColor = getBackgroundColor(inverted, accent);
return (
<ComponentStorybookLayout
width={container?.width}
height={container?.height}
backgroundColor={backgroundColor}
>
<Story />
</ComponentStorybookLayout>
);
};
@@ -0,0 +1,82 @@
import { type Decorator } from '@storybook/react-vite';
import {
createMemoryRouter,
createRoutesFromElements,
Outlet,
Route,
RouterProvider,
} from 'react-router-dom';
import { ComponentStorybookLayout } from '../ComponentStorybookLayout';
interface StrictArgs {
[name: string]: unknown;
}
export type RouteParams = {
[param: string]: string;
};
export const isRouteParams = (obj: any): obj is RouteParams => {
if (typeof obj !== 'object' || obj === null) {
return false;
}
return Object.keys(obj).every((key) => typeof obj[key] === 'string');
};
export const computeLocation = (
routePath: string,
routeParams?: RouteParams,
) => {
return {
pathname: routePath.replace(
/:(\w+)/g,
(paramName) => routeParams?.[paramName] ?? '',
),
};
};
const Providers = () => (
<ComponentStorybookLayout>
<Outlet />
</ComponentStorybookLayout>
);
const createRouter = ({
Story,
args,
initialEntries,
initialIndex,
}: {
Story: () => JSX.Element;
args: StrictArgs;
initialEntries?: {
pathname: string;
}[];
initialIndex?: number;
}) =>
createMemoryRouter(
createRoutesFromElements(
<Route element={<Providers />}>
<Route path={(args.routePath as string) ?? '*'} element={<Story />} />
</Route>,
),
{ initialEntries, initialIndex },
);
export const ComponentWithRouterDecorator: Decorator = (Story, { args }) => {
return (
<RouterProvider
router={createRouter({
Story,
args,
initialEntries:
args.routePath &&
typeof args.routePath === 'string' &&
(args.routeParams === undefined || isRouteParams(args.routeParams))
? [computeLocation(args.routePath, args.routeParams)]
: [{ pathname: '/' }],
})}
/>
);
};
@@ -0,0 +1,9 @@
import { type Decorator } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
export const JotaiRootDecorator: Decorator = (Story) => (
<JotaiProvider>
<Story />
</JotaiProvider>
);
@@ -0,0 +1,8 @@
import { type Decorator } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
export const RouterDecorator: Decorator = (Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
);
+17 -1
View File
@@ -7,4 +7,20 @@
* |___/
*/
export {};
export { ComponentStorybookLayout } from './ComponentStorybookLayout';
export type {
CatalogDimension,
CatalogOptions,
} from './decorators/CatalogDecorator';
export { CatalogDecorator } from './decorators/CatalogDecorator';
export { ComponentDecorator } from './decorators/ComponentDecorator';
export type { RouteParams } from './decorators/ComponentWithRouterDecorator';
export {
isRouteParams,
computeLocation,
ComponentWithRouterDecorator,
} from './decorators/ComponentWithRouterDecorator';
export { JotaiRootDecorator } from './decorators/JotaiRootDecorator';
export { RouterDecorator } from './decorators/RouterDecorator';
export { AVATAR_URL_MOCK } from './mocks/avatarUrlMock';
export type { CatalogStory } from './types/CatalogStory';
@@ -0,0 +1,2 @@
export const AVATAR_URL_MOCK =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAYABgAAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABgAAAAAQAAAGAAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAABSgAwAEAAAAAQAAABQAAAAA/8AAEQgAFAAUAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMACwgICggHCwoJCg0MCw0RHBIRDw8RIhkaFBwpJCsqKCQnJy0yQDctMD0wJyc4TDk9Q0VISUgrNk9VTkZUQEdIRf/bAEMBDA0NEQ8RIRISIUUuJy5FRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRf/dAAQAAv/aAAwDAQACEQMRAD8Ava1q728otYY98joSCTgZrnbXWdTtrhrfVZXWLafmcAEkdgR/hVltQku9Q8+OIEBcGOT+ID0PY1ka1KH2u8ToqnPLbmIqG7u6LtbQ7RXBRec4Uck9eKXcPWsKDWVnhWSL5kYcFelSf2m3901POh8jP//QoyIAnTuKpXsY82NsksUyWPU5q/L9z8RVK++/F/uCsVsaEURwgA4HtT9x9TUcf3KfUGh//9k=';
@@ -0,0 +1,24 @@
import { type StoryObj } from '@storybook/react-vite';
import { type ElementType } from 'react';
import {
type CatalogDimension,
type CatalogOptions,
} from '../decorators/CatalogDecorator';
export type CatalogStory<
StoryType extends StoryObj<ComponentType>,
ComponentType extends ElementType,
> = {
args?: StoryType['args'];
argTypes?: StoryType['argTypes'];
play?: StoryType['play'];
render?: StoryType['render'];
parameters: StoryType['parameters'] & {
catalog: {
dimensions: CatalogDimension<ComponentType>[];
options?: CatalogOptions;
};
};
decorators: StoryType['decorators'];
};