Reorganize twenty-ui into best-practice component domains and per-component folders (#21745)

Reorganizes `twenty-ui`'s component organization to follow how the best
UI libraries (MUI, Mantine, Base UI, Polaris) structure their source,
now that the package has stabilized.

**Taxonomy** — dissolves the meaningless `components/` junk-drawer and
the 107-file `display/` mega-category. New domains/subpaths:
`data-display`, `typography`, `icon`, `surfaces`; `feedback` and
`layout` absorb the rest (banners/callout/info + placeholders →
feedback; modal/card → surfaces; motion + separators → layout).

**Per-component layout** — every component is now
`<domain>/<ComponentName>/<ComponentName>.tsx` with colocated
styles/stories/types, `internal/` for private helpers and `parts/` for
re-exported compound sub-parts. The redundant inner `/components/` is
gone. `icon` and `json-visualizer` are kept as cohesive subsystems.

**Also:** adds a tree-shakeable root barrel (`import { Button } from
'twenty-ui'`), the generator now owns `individual-entry.ts`, and a real
barrel-leak bug is fixed (private `internals/` parts were leaking into
the public API).

Consumer imports (~1.2k files) and the `twenty-sdk` UI aggregator were
updated by codemod. The change is **export-neutral** except 16
intentionally-removed private internals symbols (all verified
unconsumed). Gates green: typecheck, lint, build, size-limit, storybook.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21745?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-06-18 10:31:29 +02:00
committed by GitHub
parent 640a8e6ca6
commit 8034c7725f
1580 changed files with 2382 additions and 2404 deletions
@@ -0,0 +1,3 @@
.tabTooltipWrapper {
display: flex;
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly tabTooltipWrapper: 'tabTooltipWrapper';
};
export default classNames;
@@ -0,0 +1,87 @@
import { type IconComponent } from '@ui/icon';
import { AppTooltip, TooltipDelay } from '@ui/surfaces';
import {
StyledTabButton,
StyledTabContainer,
} from '@ui/input/TabButton/parts/StyledTabBase';
import {
TabContent,
type TabContentProps,
} from '@ui/input/TabButton/parts/TabContent';
import { type ReactElement } from 'react';
import styles from './TabButton.module.scss';
export { StyledTabContainer, TabContent };
export type { TabContentProps };
type TabButtonProps = {
id: string;
active?: boolean;
disabled?: boolean;
to?: string;
LeftIcon?: IconComponent;
className?: string;
title?: string;
onClick?: () => void;
logo?: string;
RightIcon?: IconComponent;
pill?: string | ReactElement;
contentSize?: 'sm' | 'md';
disableTestId?: boolean;
tooltipContent?: string;
};
export const TabButton = ({
id,
active,
disabled,
to,
LeftIcon,
className,
title,
onClick,
logo,
RightIcon,
pill,
contentSize = 'sm',
disableTestId = false,
tooltipContent,
}: TabButtonProps) => {
const tabElementId = `tab-${id}`;
return (
<div key={id} id={tabElementId} className={styles.tabTooltipWrapper}>
<StyledTabButton
data-testid={disableTestId ? undefined : `tab-${id}`}
active={active}
disabled={disabled}
to={to}
className={className}
onClick={onClick}
>
<TabContent
id={id}
active={active}
disabled={disabled}
LeftIcon={LeftIcon}
title={title}
logo={logo}
RightIcon={RightIcon}
pill={pill}
contentSize={contentSize}
/>
</StyledTabButton>
{tooltipContent && (
<AppTooltip
anchorSelect={`#${tabElementId}`}
content={tooltipContent}
noArrow
place="bottom"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
)}
</div>
);
};
@@ -0,0 +1,18 @@
.tabContainer {
display: flex;
gap: var(--t-spacing-1);
height: 40px;
user-select: none;
position: relative;
align-items: stretch;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--t-border-color-light);
}
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly tabContainer: 'tabContainer';
};
export default classNames;
@@ -0,0 +1,237 @@
/* oxlint-disable react/jsx-props-no-spreading */
import { type Meta, type StoryObj } from '@storybook/react-vite';
import {
IconCheckbox,
IconChevronDown,
IconMail,
IconSearch,
IconSettings,
IconUser,
} from '@ui/icon';
import { TabButton } from '@ui/input/TabButton/TabButton';
import {
AVATAR_URL_MOCK,
CatalogDecorator,
type CatalogStory,
ComponentWithRouterDecorator,
JotaiRootDecorator,
} from '@ui/testing';
import { type ReactNode } from 'react';
import styles from './TabButton.stories.module.scss';
const TabContainer = ({ children }: { children?: ReactNode }) => {
return <div className={styles.tabContainer}>{children}</div>;
};
const meta: Meta<typeof TabButton> = {
title: 'UI/Input/Button/TabButton',
component: TabButton,
decorators: [ComponentWithRouterDecorator, JotaiRootDecorator],
args: {
id: 'tab-button',
title: 'Tab Title',
active: false,
disabled: false,
contentSize: 'sm',
},
argTypes: {
LeftIcon: { control: false },
RightIcon: { control: false },
pill: { control: 'text' },
contentSize: {
control: 'select',
options: ['sm', 'md'],
},
},
};
export default meta;
type Story = StoryObj<typeof TabButton>;
export const Default: Story = {
args: {
title: 'General',
LeftIcon: IconSettings,
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const Active: Story = {
args: {
title: 'Active Tab',
LeftIcon: IconUser,
active: true,
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const Disabled: Story = {
args: {
title: 'Disabled Tab',
LeftIcon: IconCheckbox,
disabled: true,
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const WithLogo: Story = {
args: {
title: 'Company',
logo: AVATAR_URL_MOCK,
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const WithStringPill: Story = {
args: {
title: 'Messages',
LeftIcon: IconMail,
pill: '12',
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const WithBothIcons: Story = {
args: {
title: 'Search',
LeftIcon: IconSearch,
RightIcon: IconChevronDown,
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const AsLink: Story = {
args: {
title: 'Link Tab',
LeftIcon: IconUser,
to: '/profile',
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const SmallContent: Story = {
args: {
title: 'Small',
LeftIcon: IconSettings,
contentSize: 'sm',
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const MediumContent: Story = {
args: {
title: 'Medium',
LeftIcon: IconSettings,
contentSize: 'md',
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
};
export const Catalog: CatalogStory<Story, typeof TabButton> = {
args: {
title: 'Tab title',
LeftIcon: IconCheckbox,
},
argTypes: {
active: { control: false },
disabled: { control: false },
onClick: { control: false },
to: { control: false },
},
render: (args) => (
<TabContainer>
<TabButton {...args} />
</TabContainer>
),
parameters: {
// TODO(a11y): violations inherited from deprecated story; fix during a11y pass
a11y: { test: 'todo' },
pseudo: { hover: ['.hover'], active: ['.active'] },
catalog: {
dimensions: [
{
name: 'states',
values: ['default', 'hover', 'active'],
props: (state: string) =>
state === 'default' ? {} : { className: state },
},
{
name: 'State',
values: ['active', 'inactive', 'disabled'],
labels: (state: string) => state,
props: (state: string) => ({
active: state === 'active',
disabled: state === 'disabled',
}),
},
{
name: 'Content Size',
values: ['sm', 'md'],
labels: (size: string) => size,
props: (size: string) => ({ contentSize: size as 'sm' | 'md' }),
},
{
name: 'Content',
values: ['icon', 'logo', 'pill'],
props: (content: string) => {
switch (content) {
case 'icon':
return { LeftIcon: IconSettings };
case 'logo':
return {
logo: AVATAR_URL_MOCK,
LeftIcon: undefined,
};
case 'pill':
return { LeftIcon: IconMail, pill: '5' };
default:
return {};
}
},
},
],
},
layout: 'centered',
viewport: {
defaultViewport: 'responsive',
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,100 @@
.tabButton {
all: unset;
align-items: center;
color: var(--t-font-color-secondary);
cursor: pointer;
background-color: transparent;
border: none;
font-family: inherit;
display: flex;
gap: var(--t-spacing-1);
justify-content: center;
text-decoration: none;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: transparent;
z-index: 1;
}
&[data-disabled] {
color: var(--t-font-color-light);
pointer-events: none;
}
// active wins over disabled, matching the legacy Linaria ternary
// (active ? primary : disabled ? light : secondary)
&[data-active] {
color: var(--t-font-color-primary);
&::after {
background-color: var(--t-border-color-inverted);
}
}
}
.tabContainer {
align-items: center;
color: var(--t-font-color-secondary);
cursor: pointer;
background-color: transparent;
display: flex;
gap: var(--t-spacing-1);
justify-content: center;
text-decoration: none;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: transparent;
z-index: 1;
}
&[data-disabled] {
color: var(--t-font-color-light);
}
&[data-active] {
color: var(--t-font-color-primary);
&::after {
background-color: var(--t-border-color-inverted);
}
}
}
.tabHover {
box-sizing: border-box;
display: flex;
gap: var(--t-spacing-1);
// the legacy ternary fell through to the md padding when contentSize was
// undefined, so md padding is the base and sm is the override
padding: var(--t-spacing-2) var(--t-spacing-2);
font-weight: var(--t-font-weight-medium);
width: 100%;
white-space: nowrap;
border-radius: var(--t-border-radius-sm);
&[data-content-size='sm'] {
padding: var(--t-spacing-1) var(--t-spacing-2);
}
&:hover {
background: var(--t-background-tertiary);
}
&:active {
background: var(--t-background-quaternary);
}
}
@@ -0,0 +1,6 @@
declare const classNames: {
readonly tabButton: 'tabButton';
readonly tabContainer: 'tabContainer';
readonly tabHover: 'tabHover';
};
export default classNames;
@@ -0,0 +1,84 @@
import { clsx } from 'clsx';
import { type ComponentPropsWithoutRef, forwardRef } from 'react';
import { Link } from 'react-router-dom';
import styles from './StyledTabBase.module.scss';
// The deprecated Linaria styled components forwarded refs and arbitrary
// native props (twenty-front spreads drag-and-drop props onto
// StyledTabContainer), so the ports preserve that contract.
type StyledTabButtonProps = {
active?: boolean;
disabled?: boolean;
to?: string;
} & ComponentPropsWithoutRef<'button'>;
export const StyledTabButton = forwardRef<HTMLElement, StyledTabButtonProps>(
({ active, disabled, to, className, children, ...rest }, ref) => {
// Replaces the legacy Linaria `as` polymorphism: react-router Link when a
// `to` is provided, a native button otherwise. Typed as any to forward all
// props untyped, exactly like the legacy `as` prop did.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TabButtonComponent: any = to ? Link : 'button';
return (
<TabButtonComponent
ref={ref}
to={to}
disabled={to ? undefined : disabled}
className={clsx(styles.tabButton, className)}
data-active={active || undefined}
data-disabled={disabled || undefined}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...rest}
>
{children}
</TabButtonComponent>
);
},
);
StyledTabButton.displayName = 'StyledTabButton';
type StyledTabContainerProps = {
active?: boolean;
disabled?: boolean;
} & ComponentPropsWithoutRef<'div'>;
export const StyledTabContainer = forwardRef<
HTMLDivElement,
StyledTabContainerProps
>(({ active, disabled, className, children, ...rest }, ref) => (
<div
ref={ref}
className={clsx(styles.tabContainer, className)}
data-active={active || undefined}
data-disabled={disabled || undefined}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...rest}
>
{children}
</div>
));
StyledTabContainer.displayName = 'StyledTabContainer';
type StyledTabHoverProps = {
contentSize?: 'sm' | 'md';
} & ComponentPropsWithoutRef<'span'>;
export const StyledTabHover = forwardRef<HTMLSpanElement, StyledTabHoverProps>(
({ contentSize, className, children, ...rest }, ref) => (
<span
ref={ref}
className={clsx(styles.tabHover, className)}
data-content-size={contentSize}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...rest}
>
{children}
</span>
),
);
StyledTabHover.displayName = 'StyledTabHover';
@@ -0,0 +1,49 @@
import { Pill } from '@ui/data-display/Pill/Pill';
import { Avatar } from '@ui/data-display';
import { type IconComponent } from '@ui/icon';
import { ThemeContext } from '@ui/theme-constants';
import { type ReactElement, useContext } from 'react';
import { StyledTabHover } from '@ui/input/TabButton/parts/StyledTabBase';
export type TabContentProps = {
id: string;
active?: boolean;
disabled?: boolean;
LeftIcon?: IconComponent;
title?: string;
logo?: string;
RightIcon?: IconComponent;
pill?: string | ReactElement;
contentSize?: 'sm' | 'md';
className?: string;
};
export const TabContent = ({
active,
disabled,
LeftIcon,
title,
logo,
RightIcon,
pill,
contentSize = 'sm',
className,
}: TabContentProps) => {
const { theme } = useContext(ThemeContext);
const iconColor = active
? theme.font.color.primary
: disabled
? theme.font.color.extraLight
: theme.font.color.secondary;
return (
<StyledTabHover contentSize={contentSize} className={className}>
{LeftIcon && <LeftIcon color={iconColor} size={theme.icon.size.md} />}
{logo && <Avatar avatarUrl={logo} size="md" placeholder={title} />}
{title}
{RightIcon && <RightIcon color={iconColor} size={theme.icon.size.md} />}
{pill && (typeof pill === 'string' ? <Pill label={pill} /> : pill)}
</StyledTabHover>
);
};