diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx index 014d96c1d..66432de58 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx @@ -17,12 +17,12 @@ import { useFloating, } from '@floating-ui/react'; import { MouseEvent, ReactNode } from 'react'; -import { flushSync } from 'react-dom'; import { Keys } from 'react-hotkeys-hook'; import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared'; import { sleep } from '~/utils/sleep'; import { useDropdown } from '../hooks/useDropdown'; +import { flushSync } from 'react-dom'; const StyledDropdownFallbackAnchor = styled.div` left: 0; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/__stories__/DropdownMenu.stories.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/__stories__/DropdownMenu.stories.tsx index a654d10aa..e24ef3556 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/__stories__/DropdownMenu.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/__stories__/DropdownMenu.stories.tsx @@ -82,9 +82,7 @@ export const Empty: Story = { play: async () => { const canvas = within(document.body); - const buttons = await canvas.findAllByRole('button', { - name: 'Open Dropdown', - }); + const buttons = await canvas.findAllByRole('button'); userEvent.click(buttons[0]); await waitFor(async () => { @@ -225,16 +223,16 @@ export const WithHeaders: Story = { Subheader 1 <> - {optionsMock.slice(0, 3).map(({ name }) => ( - + {optionsMock.slice(0, 3).map((item) => ( + ))} Subheader 2 - {optionsMock.slice(3).map(({ name }) => ( - + {optionsMock.slice(3).map((item) => ( + ))} @@ -282,7 +280,7 @@ export const WithInput: Story = { {optionsMock.map(({ name }) => ( - + ))} diff --git a/packages/twenty-front/src/pages/settings/__stories__/SettingsBilling.stories.tsx b/packages/twenty-front/src/pages/settings/__stories__/SettingsBilling.stories.tsx index 521a69103..40f84d41c 100644 --- a/packages/twenty-front/src/pages/settings/__stories__/SettingsBilling.stories.tsx +++ b/packages/twenty-front/src/pages/settings/__stories__/SettingsBilling.stories.tsx @@ -1,3 +1,4 @@ +import { expect } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; import { within } from '@storybook/test'; @@ -31,6 +32,12 @@ export const Default: Story = { const canvas = within(canvasElement); sleep(1000); - await canvas.findByRole('button', { name: 'View billing details' }); + const buttons = await canvas.findAllByRole('button'); + + expect( + buttons.findIndex((button) => + button.outerHTML.includes('View billing details'), + ), + ).toBeGreaterThan(-1); }, }; diff --git a/packages/twenty-front/src/pages/settings/__stories__/SettingsWorkspaceMembers.stories.tsx b/packages/twenty-front/src/pages/settings/__stories__/SettingsWorkspaceMembers.stories.tsx index c8a0172c4..e03563b52 100644 --- a/packages/twenty-front/src/pages/settings/__stories__/SettingsWorkspaceMembers.stories.tsx +++ b/packages/twenty-front/src/pages/settings/__stories__/SettingsWorkspaceMembers.stories.tsx @@ -1,3 +1,4 @@ +import { expect } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; import { within } from '@storybook/test'; @@ -30,6 +31,10 @@ export const Default: Story = { await sleep(1000); - await canvas.getByRole('button', { name: 'Copy link' }); + const buttons = await canvas.getAllByRole('button'); + + expect( + buttons.findIndex((button) => button.outerHTML.includes('Copy link')), + ).toBeGreaterThan(-1); }, }; diff --git a/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts b/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts index 56a48dc44..aa129bea5 100644 --- a/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts +++ b/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts @@ -28,7 +28,7 @@ describe('formatDateString', () => { it('should format date as relative when displayAsRelativeDate is true', () => { const mockDate = DateTime.now().minus({ months: 2 }).toISO(); - const mockRelativeDate = '2 months ago'; + const mockRelativeDate = 'about 2 months ago'; jest.mock('@/localization/utils/formatDateISOStringToRelativeDate', () => ({ formatDateISOStringToRelativeDate: jest diff --git a/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts b/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts index 6cbf77a25..602c4add4 100644 --- a/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts +++ b/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts @@ -30,7 +30,7 @@ describe('formatDateTimeString', () => { it('should format date as relative when displayAsRelativeDate is true', () => { const mockDate = DateTime.now().minus({ months: 2 }).toISO(); - const mockRelativeDate = '2 months ago'; + const mockRelativeDate = 'about 2 months ago'; jest.mock('@/localization/utils/formatDateISOStringToRelativeDate', () => ({ formatDateISOStringToRelativeDate: jest diff --git a/packages/twenty-ui/src/display/info/components/Info.tsx b/packages/twenty-ui/src/display/info/components/Info.tsx index d865c7b63..21db56222 100644 --- a/packages/twenty-ui/src/display/info/components/Info.tsx +++ b/packages/twenty-ui/src/display/info/components/Info.tsx @@ -2,7 +2,7 @@ import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { IconInfoCircle } from '@ui/display/icon/components/TablerIcons'; -import { Button } from '@ui/input/button/components/Button'; +import { Button } from '@ui/input/button/components/Button/Button'; import React from 'react'; import { Link } from 'react-router-dom'; diff --git a/packages/twenty-ui/src/input/button/components/AnimatedButton.tsx b/packages/twenty-ui/src/input/button/components/AnimatedButton.tsx index d0ee7e7f1..2d1ee0ce1 100644 --- a/packages/twenty-ui/src/input/button/components/AnimatedButton.tsx +++ b/packages/twenty-ui/src/input/button/components/AnimatedButton.tsx @@ -6,7 +6,12 @@ import { getOsShortcutSeparator } from '@ui/utilities/device/getOsShortcutSepara import { MotionProps, motion } from 'framer-motion'; import { Link } from 'react-router-dom'; -import { ButtonAccent, ButtonProps, ButtonSize, ButtonVariant } from './Button'; +import { + ButtonAccent, + ButtonProps, + ButtonSize, + ButtonVariant, +} from './Button/Button'; export type AnimatedButtonProps = ButtonProps & Pick & { diff --git a/packages/twenty-ui/src/input/button/components/Button.tsx b/packages/twenty-ui/src/input/button/components/Button/Button.tsx similarity index 78% rename from packages/twenty-ui/src/input/button/components/Button.tsx rename to packages/twenty-ui/src/input/button/components/Button/Button.tsx index 536977168..f107f0cd5 100644 --- a/packages/twenty-ui/src/input/button/components/Button.tsx +++ b/packages/twenty-ui/src/input/button/components/Button/Button.tsx @@ -1,12 +1,14 @@ import isPropValid from '@emotion/is-prop-valid'; -import { css, useTheme } from '@emotion/react'; +import { css } from '@emotion/react'; import styled from '@emotion/styled'; -import { Pill } from '@ui/components/Pill/Pill'; import { IconComponent } from '@ui/display/icon/types/IconComponent'; import { useIsMobile } from '@ui/utilities'; -import { getOsShortcutSeparator } from '@ui/utilities/device/getOsShortcutSeparator'; -import React from 'react'; +import React, { useState } from 'react'; import { Link } from 'react-router-dom'; +import { ButtonText } from './internal/ButtonText'; +import { ButtonIcon } from '@ui/input/button/components/Button/internal/ButtonIcon'; +import { ButtonSoon } from '@ui/input/button/components/Button/internal/ButtonSoon'; +import { ButtonHotkeys } from '@ui/input/button/components/Button/internal/ButtonHotKeys'; export type ButtonSize = 'medium' | 'small'; export type ButtonPosition = 'standalone' | 'left' | 'middle' | 'right'; @@ -33,6 +35,7 @@ export type ButtonProps = { dataTestId?: string; hotkeys?: string[]; ariaLabel?: string; + loading?: boolean; } & React.ComponentProps<'button'>; const StyledButton = styled('button', { @@ -51,7 +54,8 @@ const StyledButton = styled('button', { | 'justify' | 'to' | 'target' - > + | 'loading' + > & { hasIcon: boolean } >` align-items: center; ${({ theme, variant, inverted, accent, disabled, focus }) => { @@ -337,8 +341,10 @@ const StyledButton = styled('button', { gap: ${({ theme }) => theme.spacing(1)}; height: ${({ size }) => (size === 'small' ? '24px' : '32px')}; justify-content: ${({ justify }) => justify}; - padding: ${({ theme }) => { - return `0 ${theme.spacing(2)}`; + padding: ${({ theme, hasIcon }) => { + return `0 ${theme.spacing(2)} 0 ${ + hasIcon ? theme.spacing(7) : theme.spacing(2) + }`; }}; transition: background 0.1s ease; @@ -352,47 +358,56 @@ const StyledButton = styled('button', { } `; -const StyledSoonPill = styled(Pill)` - margin-left: auto; -`; +const StyledButtonWrapper = styled.div< + Pick +>` + ${({ theme, variant, accent, inverted, disabled }) => css` + --tw-button-color: ${(() => { + switch (variant) { + case 'primary': + switch (accent) { + case 'default': + return !inverted + ? !disabled + ? theme.font.color.secondary + : theme.font.color.extraLight + : theme.font.color.secondary; + case 'blue': + return !inverted ? theme.grayScale.gray0 : theme.color.blue; + case 'danger': + return !inverted ? theme.background.primary : theme.color.red; + } + break; + case 'secondary': + case 'tertiary': + switch (accent) { + case 'default': + return !inverted + ? !disabled + ? theme.font.color.secondary + : theme.font.color.extraLight + : theme.font.color.inverted; + case 'blue': + return !inverted + ? !disabled + ? theme.color.blue + : theme.accent.accent4060 + : theme.font.color.inverted; + case 'danger': + return !inverted + ? theme.font.color.danger + : theme.font.color.inverted; + } + break; + } + return theme.font.color.secondary; // Valeur par défaut + })()}; + `} -const StyledSeparator = styled.div<{ - buttonSize: ButtonSize; - accent: ButtonAccent; -}>` - background: ${({ theme, accent }) => { - switch (accent) { - case 'blue': - return theme.border.color.blue; - case 'danger': - return theme.border.color.danger; - default: - return theme.font.color.light; - } - }}; - height: ${({ theme, buttonSize }) => - theme.spacing(buttonSize === 'small' ? 2 : 4)}; - margin: 0; - width: 1px; -`; - -const StyledShortcutLabel = styled.div<{ - variant: ButtonVariant; - accent: ButtonAccent; -}>` - color: ${({ theme, variant, accent }) => { - switch (accent) { - case 'blue': - return theme.border.color.blue; - case 'danger': - return variant === 'primary' - ? theme.border.color.danger - : theme.color.red40; - default: - return theme.font.color.light; - } - }}; - font-weight: ${({ theme }) => theme.font.weight.medium}; + height: 100%; + max-width: ${({ loading, theme }) => + loading ? `calc(100% - ${theme.spacing(8)})` : 'none'}; + position: relative; `; export const Button = ({ @@ -416,46 +431,53 @@ export const Button = ({ hotkeys, ariaLabel, type, + loading, }: ButtonProps) => { - const theme = useTheme(); - const isMobile = useIsMobile(); - const [isFocused, setIsFocused] = React.useState(propFocus); + const [isFocused, setIsFocused] = useState(propFocus); return ( - setIsFocused(true)} - onBlur={() => setIsFocused(false)} > - {Icon && } - {title} - {hotkeys && !isMobile && ( - <> - - - {hotkeys.join(getOsShortcutSeparator())} - - - )} - {soon && } - + {(loading || Icon) && } + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + > + + {hotkeys && !isMobile && ( + + )} + {soon && } + + ); }; diff --git a/packages/twenty-ui/src/input/button/components/Button/constant.ts b/packages/twenty-ui/src/input/button/components/Button/constant.ts new file mode 100644 index 000000000..62b9b9e06 --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/Button/constant.ts @@ -0,0 +1 @@ +export const baseTransitionTiming = 300; diff --git a/packages/twenty-ui/src/input/button/components/Button/internal/ButtonHotKeys.tsx b/packages/twenty-ui/src/input/button/components/Button/internal/ButtonHotKeys.tsx new file mode 100644 index 000000000..87b436005 --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/Button/internal/ButtonHotKeys.tsx @@ -0,0 +1,61 @@ +import { getOsShortcutSeparator } from '@ui/utilities'; +import { ButtonAccent, ButtonSize, ButtonVariant } from '@ui/input'; +import styled from '@emotion/styled'; + +const StyledSeparator = styled.div<{ + buttonSize: ButtonSize; + accent: ButtonAccent; +}>` + background: ${({ theme, accent }) => { + switch (accent) { + case 'blue': + return theme.border.color.blue; + case 'danger': + return theme.border.color.danger; + default: + return theme.font.color.light; + } + }}; + height: ${({ theme, buttonSize }) => + theme.spacing(buttonSize === 'small' ? 2 : 4)}; + margin: 0; + width: 1px; +`; + +const StyledShortcutLabel = styled.div<{ + variant: ButtonVariant; + accent: ButtonAccent; +}>` + color: ${({ theme, variant, accent }) => { + switch (accent) { + case 'blue': + return theme.border.color.blue; + case 'danger': + return variant === 'primary' + ? theme.border.color.danger + : theme.color.red40; + default: + return theme.font.color.light; + } + }}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +export const ButtonHotkeys = ({ + size, + accent, + variant, + hotkeys, +}: { + size: ButtonSize; + accent: ButtonAccent; + variant: ButtonVariant; + hotkeys: string[]; +}) => ( + <> + + + {hotkeys.join(getOsShortcutSeparator())} + + +); diff --git a/packages/twenty-ui/src/input/button/components/Button/internal/ButtonIcon.tsx b/packages/twenty-ui/src/input/button/components/Button/internal/ButtonIcon.tsx new file mode 100644 index 000000000..41ae8fe4e --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/Button/internal/ButtonIcon.tsx @@ -0,0 +1,69 @@ +import { Loader } from '@ui/feedback'; +import { baseTransitionTiming } from '@ui/input/button/components/Button/constant'; +import { IconComponent } from '@ui/display'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { isDefined } from 'twenty-shared'; + +const StyledIcon = styled.div<{ + loading?: boolean; +}>` + align-items: center; + display: flex; + height: 100%; + color: var(--tw-button-color); + + padding: 8px; + + opacity: ${({ loading }) => (loading ? 0 : 1)}; + transition: opacity ${baseTransitionTiming / 2}ms ease; + transition-delay: ${({ loading }) => + loading ? '0ms' : `${baseTransitionTiming / 2}ms`}; +`; + +const StyledIconWrapper = styled.div<{ loading?: boolean }>` + align-items: center; + display: flex; + height: 100%; + + position: absolute; + top: 50%; + transform: translateY(-50%); + + width: ${({ loading }) => (loading ? 0 : '100%')}; +`; + +const StyledLoader = styled.div<{ loading?: boolean }>` + left: ${({ theme }) => theme.spacing(2)}; + opacity: ${({ loading }) => (loading ? 1 : 0)}; + position: absolute; + + transition: opacity ${baseTransitionTiming / 2}ms ease; + transition-delay: ${({ loading }) => + loading ? `${baseTransitionTiming / 2}ms` : '0ms'}; + width: ${({ theme }) => theme.spacing(6)}; +`; + +export const ButtonIcon = ({ + Icon, + loading, +}: { + Icon?: IconComponent; + loading?: boolean; +}) => { + const theme = useTheme(); + return ( + + {isDefined(loading) && ( + + + + )} + {Icon && ( + + + + )} + + ); +}; diff --git a/packages/twenty-ui/src/input/button/components/Button/internal/ButtonSoon.tsx b/packages/twenty-ui/src/input/button/components/Button/internal/ButtonSoon.tsx new file mode 100644 index 000000000..a9533c00a --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/Button/internal/ButtonSoon.tsx @@ -0,0 +1,8 @@ +import styled from '@emotion/styled'; +import { Pill } from '@ui/components'; + +const StyledSoonPill = styled(Pill)` + margin-left: auto; +`; + +export const ButtonSoon = () => ; diff --git a/packages/twenty-ui/src/input/button/components/Button/internal/ButtonText.tsx b/packages/twenty-ui/src/input/button/components/Button/internal/ButtonText.tsx new file mode 100644 index 000000000..25d8ce0a2 --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/Button/internal/ButtonText.tsx @@ -0,0 +1,59 @@ +import styled from '@emotion/styled'; +import { baseTransitionTiming } from '@ui/input/button/components/Button/constant'; + +const StyledEllipsis = styled.div<{ loading?: boolean }>` + right: 0; + clip-path: ${({ theme, loading }) => + loading ? `inset(0 0 0 0)` : `inset(0 0 0 ${theme.spacing(6)})`}; + overflow: hidden; + position: absolute; + + transition: clip-path ${baseTransitionTiming}ms ease; +`; + +const StyledTextWrapper = styled.div` + align-items: center; + display: flex; + height: 100%; + justify-content: center; + position: relative; +`; + +const StyledText = styled.div<{ loading?: boolean; hasIcon: boolean }>` + clip-path: ${({ loading, theme, hasIcon }) => + loading + ? ` inset(0 ${!hasIcon ? theme.spacing(12) : theme.spacing(6)} 0 0)` + : ' inset(0 0 0 0)'}; + + overflow: hidden; + + transform: ${({ theme, loading, hasIcon }) => + loading + ? `translateX(${!hasIcon ? theme.spacing(7) : theme.spacing(3)})` + : 'none'}; + + transition: + transform ${baseTransitionTiming}ms ease, + clip-path ${baseTransitionTiming}ms ease; + + transition-delay: ${({ loading }) => + loading ? '0ms' : `${baseTransitionTiming / 4}ms`}; + white-space: nowrap; +`; + +export const ButtonText = ({ + hasIcon = false, + loading, + title, +}: { + loading?: boolean; + hasIcon: boolean; + title?: string; +}) => ( + + + {title} + + ... + +); diff --git a/packages/twenty-ui/src/input/button/components/ButtonGroup.tsx b/packages/twenty-ui/src/input/button/components/ButtonGroup.tsx index 9735685b3..f71a98133 100644 --- a/packages/twenty-ui/src/input/button/components/ButtonGroup.tsx +++ b/packages/twenty-ui/src/input/button/components/ButtonGroup.tsx @@ -3,7 +3,7 @@ import React, { ReactNode } from 'react'; import { isDefined } from 'twenty-shared'; -import { ButtonPosition, ButtonProps } from './Button'; +import { ButtonPosition, ButtonProps } from './Button/Button'; const StyledButtonGroupContainer = styled.div` border-radius: ${({ theme }) => theme.border.radius.md}; diff --git a/packages/twenty-ui/src/input/button/components/__stories__/Button.stories.tsx b/packages/twenty-ui/src/input/button/components/__stories__/Button.stories.tsx index a52510fd5..cb5950355 100644 --- a/packages/twenty-ui/src/input/button/components/__stories__/Button.stories.tsx +++ b/packages/twenty-ui/src/input/button/components/__stories__/Button.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; -import { IconSearch } from '@ui/display'; +import { IconReload, IconSearch } from '@ui/display'; import { CatalogDecorator, CatalogStory, @@ -11,7 +11,7 @@ import { ButtonPosition, ButtonSize, ButtonVariant, -} from '../Button'; +} from '../Button/Button'; const meta: Meta = { title: 'UI/Input/Button/Button', @@ -39,6 +39,7 @@ export const Default: Story = { position: 'standalone', Icon: IconSearch, className: '', + loading: false, }, decorators: [ComponentDecorator], }; @@ -55,6 +56,7 @@ export const Catalog: CatalogStory = { soon: { control: false }, position: { control: false }, className: { control: false }, + loading: { control: false }, }, parameters: { pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] }, @@ -322,3 +324,35 @@ export const FullWidth: Story = { }, decorators: [ComponentDecorator], }; + +export const LoadingButton: Story = { + args: { + title: 'Reload', + Icon: IconReload, + loading: true, + }, + argTypes: { + size: { control: false }, + variant: { control: false }, + accent: { control: false }, + focus: { control: false }, + disabled: { control: false }, + fullWidth: { control: false }, + soon: { control: false }, + position: { control: false }, + className: { control: false }, + loading: { control: 'boolean' }, + }, + parameters: { + catalog: { + loading: [ + { + name: 'loading', + values: [true, false] satisfies boolean[], + props: (value: boolean) => ({ loading: value }), + }, + ], + }, + }, + decorators: [ComponentDecorator], +}; diff --git a/packages/twenty-ui/src/input/button/components/__stories__/ButtonGroup.stories.tsx b/packages/twenty-ui/src/input/button/components/__stories__/ButtonGroup.stories.tsx index ec4e492c7..ce03eb7e8 100644 --- a/packages/twenty-ui/src/input/button/components/__stories__/ButtonGroup.stories.tsx +++ b/packages/twenty-ui/src/input/button/components/__stories__/ButtonGroup.stories.tsx @@ -5,7 +5,12 @@ import { CatalogStory, ComponentDecorator, } from '@ui/testing'; -import { Button, ButtonAccent, ButtonSize, ButtonVariant } from '../Button'; +import { + Button, + ButtonAccent, + ButtonSize, + ButtonVariant, +} from '../Button/Button'; import { ButtonGroup } from '../ButtonGroup'; const meta: Meta = { diff --git a/packages/twenty-ui/src/input/button/components/index.ts b/packages/twenty-ui/src/input/button/components/index.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/twenty-ui/src/input/index.ts b/packages/twenty-ui/src/input/index.ts index 6553c8b19..f895f4273 100644 --- a/packages/twenty-ui/src/input/index.ts +++ b/packages/twenty-ui/src/input/index.ts @@ -1,6 +1,7 @@ export * from './button/components/AnimatedButton'; export * from './button/components/AnimatedLightIconButton'; -export * from './button/components/Button'; +export * from './button/components/Button/Button'; +export * from './button/components/Button/constant'; export * from './button/components/ButtonGroup'; export * from './button/components/ColorPickerButton'; export * from './button/components/FloatingButton';