fix(admin-panel): prevent layout shift when toggling feature flags (#12686)

Closes #12571
~~## What’s broken?~~
~~Toggling a feature flag in the admin panel re-renders the entire table
and uses a Framer Motion slide animation for the knob. Each animation
frame forces a layout recalculation all the way up, causing the page to
“jump.”~~

~~## What’s the fix?~~
~~Swap out the Framer Motion toggle for a pure-CSS version:~~
~~- Container is position: relative~~
~~- Knob is position: absolute and moves via left~~
~~- A transition: left 0.3s ease + will-change: left hint runs on the
compositor layer~~

~~This keeps the smooth slide effect but never triggers parent layout
reflows.~~

~~No changes to the public <Toggle> API or behavior.~~

~~TODO: test all toggles in app and stories if any~~

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
nitin
2025-06-18 15:26:19 +05:30
committed by GitHub
parent 657b87fd0c
commit fb6f2610c5
3 changed files with 113 additions and 11 deletions

View File

@ -20,15 +20,8 @@ import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
FeatureFlagKey,
useImpersonateMutation,
useUpdateWorkspaceFeatureFlagMutation,
} from '~/generated/graphql';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils';
import { AvatarChip } from 'twenty-ui/components';
import { Button, Toggle } from 'twenty-ui/input';
import {
H2Title,
IconEyeShare,
@ -36,7 +29,14 @@ import {
IconId,
IconUser,
} from 'twenty-ui/display';
import { Button, Toggle } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
FeatureFlagKey,
useImpersonateMutation,
useUpdateWorkspaceFeatureFlagMutation,
} from '~/generated/graphql';
type SettingsAdminWorkspaceContentProps = {
activeWorkspace: WorkspaceInfo | undefined;

View File

@ -12,28 +12,31 @@ type ContainerProps = {
};
const StyledContainer = styled.label<ContainerProps>`
flex-shrink: 0;
align-items: center;
background-color: ${({ theme, isOn, color }) =>
isOn ? (color ?? theme.color.blue) : theme.background.transparent.medium};
border-radius: 10px;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: ${({ toggleSize }) => (toggleSize === 'small' ? 16 : 20)}px;
transition: background-color 0.3s ease;
width: ${({ toggleSize }) => (toggleSize === 'small' ? 24 : 32)}px;
opacity: ${({ 'data-disabled': disabled }) => (disabled ? 0.5 : 1)};
pointer-events: ${({ 'data-disabled': disabled }) =>
disabled ? 'none' : 'auto'};
position: relative;
transition: background-color 0.3s ease;
width: ${({ toggleSize }) => (toggleSize === 'small' ? 24 : 32)}px;
`;
const StyledCircle = styled(motion.span)<{
size: ToggleSize;
}>`
display: block;
background-color: ${({ theme }) => theme.background.primary};
border-radius: 50%;
display: block;
height: ${({ size }) => (size === 'small' ? 12 : 16)}px;
left: 0;
position: absolute;
width: ${({ size }) => (size === 'small' ? 12 : 16)}px;
`;
@ -80,6 +83,7 @@ export const Toggle = ({
/>
<StyledCircle
initial="off"
animate={value ? 'on' : 'off'}
variants={circleVariants}
size={toggleSize}

View File

@ -0,0 +1,98 @@
import { Meta, StoryObj } from '@storybook/react';
import {
CatalogDecorator,
CatalogStory,
ComponentDecorator,
} from '@ui/testing';
import { MAIN_COLORS } from '@ui/theme';
import { useState } from 'react';
import { Toggle, ToggleSize } from '../Toggle';
type InteractiveToggleProps = {
initialValue?: boolean;
toggleSize?: ToggleSize;
color?: string;
disabled?: boolean;
};
const InteractiveToggle = ({
initialValue = false,
toggleSize = 'medium',
color,
disabled,
}: InteractiveToggleProps) => {
const [value, setValue] = useState(initialValue);
return (
<Toggle
value={value}
onChange={setValue}
toggleSize={toggleSize}
color={color}
disabled={disabled}
/>
);
};
const meta: Meta<typeof InteractiveToggle> = {
title: 'UI/Input/Toggle/Toggle',
component: InteractiveToggle,
};
export default meta;
type Story = StoryObj<typeof InteractiveToggle>;
export const Default: Story = {
args: {
initialValue: false,
disabled: false,
toggleSize: 'medium',
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof InteractiveToggle> = {
args: {},
argTypes: {
toggleSize: { control: false },
initialValue: { control: false },
disabled: { control: false },
color: { control: false },
},
parameters: {
catalog: {
dimensions: [
{
name: 'state',
values: ['disabled', 'off', 'on'],
props: (state: string) => {
if (state === 'disabled') {
return { disabled: true, initialValue: false };
}
if (state === 'on') {
return { initialValue: true };
}
return { initialValue: false };
},
},
{
name: 'size',
values: ['small', 'medium'] satisfies ToggleSize[],
props: (toggleSize: ToggleSize) => ({ toggleSize }),
},
{
name: 'color',
values: ['default', 'custom color'],
props: (color: string) => {
if (color === 'default') {
return {};
}
return { color: MAIN_COLORS.yellow };
},
},
],
},
},
decorators: [CatalogDecorator],
};