Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions
@@ -0,0 +1,10 @@
import styled from '@emotion/styled';
const StyledCard = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(3)};
`;
export { StyledCard as Card };
@@ -0,0 +1,13 @@
import { Meta, StoryObj } from '@storybook/react';
import { Card } from '../Card';
const meta: Meta<typeof Card> = {
title: 'UI/Layout/Card/Card',
component: Card,
};
export default meta;
type Story = StoryObj<typeof Card>;
export const Default: Story = {};
@@ -0,0 +1,43 @@
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
import { Meta, StoryObj } from '@storybook/react';
import { IconBell } from '@/ui/display/icon';
import { MenuItemDraggable } from '@/ui/navigation/menu-item/components/MenuItemDraggable';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DraggableItem } from '../components/DraggableItem';
const meta: Meta<typeof DraggableItem> = {
title: 'UI/Layout/DraggableList/DraggableItem',
component: DraggableItem,
decorators: [
(Story) => (
<DragDropContext onDragEnd={() => jest.fn()}>
<Droppable droppableId="droppable-id">
{(_provided) => <Story />}
</Droppable>
</DragDropContext>
),
ComponentDecorator,
],
parameters: {
container: { width: 100 },
},
argTypes: {
itemComponent: { control: { disable: true } },
},
args: {
draggableId: 'draggable-1',
index: 0,
isDragDisabled: false,
itemComponent: (
<MenuItemDraggable LeftIcon={IconBell} text="Draggable item 1" />
),
},
};
export default meta;
type Story = StoryObj<typeof DraggableItem>;
export const Default: Story = {};
@@ -0,0 +1,57 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconBell } from '@/ui/display/icon';
import { MenuItemDraggable } from '@/ui/navigation/menu-item/components/MenuItemDraggable';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DraggableItem } from '../components/DraggableItem';
import { DraggableList } from '../components/DraggableList';
const meta: Meta<typeof DraggableList> = {
title: 'UI/Layout/DraggableList/DraggableList',
component: DraggableList,
decorators: [ComponentDecorator],
parameters: {
onDragEnd: () => console.log('dragged'),
},
argTypes: {
draggableItems: { control: false },
},
args: {
draggableItems: (
<>
<DraggableItem
draggableId="draggable-1"
index={0}
isDragDisabled={false}
itemComponent={
<MenuItemDraggable
LeftIcon={IconBell}
text="Non Draggable item 1"
/>
}
/>
<DraggableItem
draggableId="draggable-2"
index={1}
itemComponent={
<MenuItemDraggable LeftIcon={IconBell} text="Draggable item 2" />
}
/>
<DraggableItem
draggableId="draggable-3"
index={2}
itemComponent={
<MenuItemDraggable LeftIcon={IconBell} text="Draggable item 3" />
}
/>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof DraggableItem>;
export const Default: Story = {};
@@ -0,0 +1,55 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import { Draggable } from '@hello-pangea/dnd';
type DraggableItemProps = {
draggableId: string;
isDragDisabled?: boolean;
index: number;
itemComponent: JSX.Element;
};
export const DraggableItem = ({
draggableId,
isDragDisabled = false,
index,
itemComponent,
}: DraggableItemProps) => {
const theme = useTheme();
return (
<Draggable
key={draggableId}
draggableId={draggableId}
index={index}
isDragDisabled={isDragDisabled}
>
{(draggableProvided, draggableSnapshot) => {
const draggableStyle = draggableProvided.draggableProps.style;
const isDragged = draggableSnapshot.isDragging;
return (
<div
ref={draggableProvided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
style={{
...draggableStyle,
left: 'auto',
top: 'auto',
transform: draggableStyle?.transform?.replace(
/\(-?\d+px,/,
'(0,',
),
background: isDragged
? theme.background.transparent.light
: 'none',
}}
>
{itemComponent}
</div>
);
}}
</Draggable>
);
};
@@ -0,0 +1,42 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import {
DragDropContext,
Droppable,
OnDragEndResponder,
} from '@hello-pangea/dnd';
import { v4 } from 'uuid';
type DraggableListProps = {
draggableItems: React.ReactNode;
onDragEnd: OnDragEndResponder;
};
const StyledDragDropItemsWrapper = styled.div`
width: 100%;
`;
export const DraggableList = ({
draggableItems,
onDragEnd,
}: DraggableListProps) => {
const [v4Persistable] = useState(v4());
return (
<DragDropContext onDragEnd={onDragEnd}>
<StyledDragDropItemsWrapper>
<Droppable droppableId={v4Persistable}>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{draggableItems}
{provided.placeholder}
</div>
)}
</Droppable>
</StyledDragDropItemsWrapper>
</DragDropContext>
);
};
@@ -0,0 +1,128 @@
import { useRef } from 'react';
import { Keys } from 'react-hotkeys-hook';
import {
autoUpdate,
flip,
offset,
Placement,
useFloating,
} from '@floating-ui/react';
import { Key } from 'ts-key-enum';
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useDropdown } from '../hooks/useDropdown';
import { useInternalHotkeyScopeManagement } from '../hooks/useInternalHotkeyScopeManagement';
import { DropdownMenu } from './DropdownMenu';
import { DropdownOnToggleEffect } from './DropdownOnToggleEffect';
type DropdownProps = {
clickableComponent?: JSX.Element | JSX.Element[];
dropdownComponents: JSX.Element | JSX.Element[];
hotkey?: {
key: Keys;
scope: string;
};
dropdownHotkeyScope: HotkeyScope;
dropdownPlacement?: Placement;
dropdownMenuWidth?: number;
dropdownOffset?: { x?: number; y?: number };
onClickOutside?: () => void;
onClose?: () => void;
onOpen?: () => void;
};
export const Dropdown = ({
clickableComponent,
dropdownComponents,
dropdownMenuWidth,
hotkey,
dropdownHotkeyScope,
dropdownPlacement = 'bottom-end',
dropdownOffset = { x: 0, y: 0 },
onClickOutside,
onClose,
onOpen,
}: DropdownProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { isDropdownOpen, toggleDropdown, closeDropdown, dropdownWidth } =
useDropdown();
const offsetMiddlewares = [];
if (dropdownOffset.x) {
offsetMiddlewares.push(offset({ crossAxis: dropdownOffset.x }));
}
if (dropdownOffset.y) {
offsetMiddlewares.push(offset({ mainAxis: dropdownOffset.y }));
}
const { refs, floatingStyles } = useFloating({
placement: dropdownPlacement,
middleware: [flip(), ...offsetMiddlewares],
whileElementsMounted: autoUpdate,
});
const handleHotkeyTriggered = () => {
toggleDropdown();
};
useListenClickOutside({
refs: [containerRef],
callback: () => {
onClickOutside?.();
if (isDropdownOpen) {
closeDropdown();
}
},
});
useInternalHotkeyScopeManagement({
dropdownHotkeyScopeFromParent: dropdownHotkeyScope,
});
useScopedHotkeys(
Key.Escape,
() => {
closeDropdown();
},
dropdownHotkeyScope.scope,
[closeDropdown],
);
return (
<div ref={containerRef}>
{clickableComponent && (
<div ref={refs.setReference} onClick={toggleDropdown}>
{clickableComponent}
</div>
)}
{hotkey && (
<HotkeyEffect
hotkey={hotkey}
onHotkeyTriggered={handleHotkeyTriggered}
/>
)}
{isDropdownOpen && (
<DropdownMenu
width={dropdownMenuWidth ?? dropdownWidth}
data-select-disable
ref={refs.setFloating}
style={floatingStyles}
>
{dropdownComponents}
</DropdownMenu>
)}
<DropdownOnToggleEffect
onDropdownClose={onClose}
onDropdownOpen={onOpen}
/>
</div>
);
};
@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
const StyledDropdownMenu = styled.div<{
disableBlur?: boolean;
width?: `${string}px` | 'auto' | number;
}>`
backdrop-filter: ${({ disableBlur }) =>
disableBlur ? 'none' : 'blur(20px)'};
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
width: ${({ width }) =>
width ? `${typeof width === 'number' ? `${width}px` : width}` : '160px'};
`;
export const DropdownMenu = StyledDropdownMenu;
@@ -0,0 +1,66 @@
import { ComponentProps, MouseEvent } from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
const StyledHeader = styled.li`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding: ${({ theme }) => theme.spacing(1)};
user-select: none;
`;
const StyledChildrenWrapper = styled.span`
padding: 0 ${({ theme }) => theme.spacing(1)};
`;
const StyledLightIconButton = styled(LightIconButton)`
display: inline-flex;
margin-left: auto;
margin-right: 0;
`;
type DropdownMenuHeaderProps = ComponentProps<'li'> & {
StartIcon?: IconComponent;
EndIcon?: IconComponent;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
testId?: string;
};
export const DropdownMenuHeader = ({
children,
StartIcon,
EndIcon,
onClick,
testId,
}: DropdownMenuHeaderProps) => {
return (
<StyledHeader data-testid={testId}>
{StartIcon && (
<LightIconButton
testId="dropdown-menu-header-end-icon"
Icon={StartIcon}
onClick={onClick}
accent="tertiary"
size="small"
/>
)}
<StyledChildrenWrapper>{children}</StyledChildrenWrapper>
{EndIcon && (
<StyledLightIconButton
testId="dropdown-menu-header-end-icon"
Icon={EndIcon}
onClick={onClick}
accent="tertiary"
size="small"
/>
)}
</StyledHeader>
);
};
@@ -0,0 +1,44 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { rgba } from '@/ui/theme/constants/colors';
import { textInputStyle } from '@/ui/theme/constants/effects';
const StyledInput = styled.input`
${textInputStyle}
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 32px;
position: relative;
width: 100%;
&:focus {
border-color: ${({ theme }) => theme.color.blue};
box-shadow: 0px 0px 0px 3px ${({ theme }) => rgba(theme.color.blue, 0.1)};
}
`;
const StyledInputContainer = styled.div`
box-sizing: border-box;
padding: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
export const DropdownMenuInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(({ autoFocus, defaultValue, placeholder }, ref) => {
return (
<StyledInputContainer>
<StyledInput
autoFocus={autoFocus}
defaultValue={defaultValue}
placeholder={placeholder}
ref={ref}
/>
</StyledInputContainer>
);
});
@@ -0,0 +1,55 @@
import styled from '@emotion/styled';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledDropdownMenuItemsExternalContainer = styled.div<{
hasMaxHeight?: boolean;
}>`
--padding: ${({ theme }) => theme.spacing(1)};
align-items: flex-start;
display: flex;
flex-direction: column;
gap: 2px;
height: 100%;
max-height: ${({ hasMaxHeight }) => (hasMaxHeight ? '180px' : 'none')};
overflow-y: auto;
padding: var(--padding);
padding-right: 0;
width: calc(100% - 1 * var(--padding));
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
width: 100%;
`;
const StyledDropdownMenuItemsInternalContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: 2px;
height: 100%;
width: 100%;
`;
export const DropdownMenuItemsContainer = ({
children,
hasMaxHeight,
}: {
children: React.ReactNode;
hasMaxHeight?: boolean;
}) => {
return (
<StyledDropdownMenuItemsExternalContainer hasMaxHeight={hasMaxHeight}>
<StyledScrollWrapper>
<StyledDropdownMenuItemsInternalContainer>
{children}
</StyledDropdownMenuItemsInternalContainer>
</StyledScrollWrapper>
</StyledDropdownMenuItemsExternalContainer>
);
};
@@ -0,0 +1,47 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
const StyledDropdownMenuSearchInputContainer = styled.div`
--vertical-padding: ${({ theme }) => theme.spacing(1)};
align-items: center;
display: flex;
flex-direction: row;
height: calc(36px - 2 * var(--vertical-padding));
padding: var(--vertical-padding) 0;
width: 100%;
`;
const StyledInput = styled.input`
${textInputStyle}
font-size: ${({ theme }) => theme.font.size.sm};
width: 100%;
&[type='number']::-webkit-outer-spin-button,
&[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type='number'] {
-moz-appearance: textfield;
}
`;
export const DropdownMenuSearchInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(({ value, onChange, autoFocus, placeholder = 'Search', type }, ref) => (
<StyledDropdownMenuSearchInputContainer>
<StyledInput
autoComplete="off"
{...{ autoFocus, onChange, placeholder, type, value }}
ref={ref}
/>
</StyledDropdownMenuSearchInputContainer>
));
@@ -0,0 +1,10 @@
import styled from '@emotion/styled';
const StyledDropdownMenuSeparator = styled.div`
background-color: ${({ theme }) => theme.border.color.light};
height: 1px;
width: 100%;
`;
export const DropdownMenuSeparator = StyledDropdownMenuSeparator;
@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const DropdownOnToggleEffect = ({
onDropdownClose,
onDropdownOpen,
}: {
onDropdownClose?: () => void;
onDropdownOpen?: () => void;
}) => {
const { isDropdownOpen } = useDropdown();
useEffect(() => {
if (isDropdownOpen) {
onDropdownOpen?.();
} else {
onDropdownClose?.();
}
}, [isDropdownOpen, onDropdownClose, onDropdownOpen]);
return null;
};
@@ -0,0 +1,27 @@
import styled from '@emotion/styled';
type StyledDropdownButtonProps = {
isUnfolded: boolean;
isActive?: boolean;
};
export const StyledDropdownButtonContainer = styled.div<StyledDropdownButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ isActive, theme, color }) =>
color ?? (isActive ? theme.color.blue : 'none')};
cursor: pointer;
display: flex;
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
padding: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
user-select: none;
&:hover {
filter: brightness(0.95);
}
`;
@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
export const StyledDropdownMenuSubheader = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xxs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
text-transform: uppercase;
width: 100%;
`;
@@ -0,0 +1,27 @@
import styled from '@emotion/styled';
type StyledDropdownButtonProps = {
isUnfolded?: boolean;
isActive?: boolean;
};
export const StyledHeaderDropdownButton = styled.div<StyledDropdownButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ isActive, theme, color }) =>
color ?? (isActive ? theme.color.blue : theme.font.color.secondary)};
cursor: pointer;
display: flex;
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
padding: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
user-select: none;
&:hover {
filter: brightness(0.95);
}
`;
@@ -0,0 +1,319 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { PlayFunction } from '@storybook/types';
import { Button } from '@/ui/input/button/components/Button';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar';
import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar';
import { Avatar } from '@/users/components/Avatar';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DropdownScope } from '../../scopes/DropdownScope';
import { Dropdown } from '../Dropdown';
import { DropdownMenuHeader } from '../DropdownMenuHeader';
import { DropdownMenuInput } from '../DropdownMenuInput';
import { DropdownMenuItemsContainer } from '../DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '../DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '../DropdownMenuSeparator';
import { StyledDropdownMenuSubheader } from '../StyledDropdownMenuSubheader';
const meta: Meta<typeof Dropdown> = {
title: 'UI/Layout/Dropdown/Dropdown',
component: Dropdown,
decorators: [
ComponentDecorator,
(Story) => (
<DropdownScope dropdownScopeId="testDropdownMenu">
<Story />
</DropdownScope>
),
],
args: {
clickableComponent: <Button title="Open Dropdown" />,
dropdownHotkeyScope: { scope: 'testDropdownMenu' },
dropdownOffset: { x: 0, y: 8 },
},
argTypes: {
clickableComponent: { control: false },
dropdownHotkeyScope: { control: false },
dropdownOffset: { control: false },
dropdownComponents: { control: false },
},
};
export default meta;
type Story = StoryObj<typeof Dropdown>;
const StyledContainer = styled.div`
height: 600px;
position: relative;
width: 300px;
`;
const StyledMenuAbsolutePositionWrapper = styled.div`
height: fit-content;
width: fit-content;
`;
const WithContentBelowDecorator: Decorator = (Story) => (
<StyledContainer>
<StyledMenuAbsolutePositionWrapper>
<Story />
</StyledMenuAbsolutePositionWrapper>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</StyledContainer>
);
const StyledEmptyDropdownContent = styled.div`
height: 400px;
width: 100%;
`;
export const Empty: Story = {
args: {
dropdownComponents: (
<StyledEmptyDropdownContent data-testid="dropdown-content" />
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole('button');
userEvent.click(button);
await waitFor(async () => {
const fakeMenu = await canvas.findByTestId('dropdown-content');
expect(fakeMenu).toBeInTheDocument();
});
userEvent.click(button);
await waitFor(async () => {
const fakeMenu = await canvas.findByTestId('dropdown-content');
expect(fakeMenu).not.toBeInTheDocument();
});
userEvent.click(button);
await waitFor(async () => {
const fakeMenu = await canvas.findByTestId('dropdown-content');
expect(fakeMenu).toBeInTheDocument();
});
},
};
const avatarUrl =
'https://s3-alpha-sig.figma.com/img/bbb5/4905/f0a52cc2b9aaeb0a82a360d478dae8bf?Expires=1687132800&Signature=iVBr0BADa3LHoFVGbwqO-wxC51n1o~ZyFD-w7nyTyFP4yB-Y6zFawL-igewaFf6PrlumCyMJThDLAAc-s-Cu35SBL8BjzLQ6HymzCXbrblUADMB208PnMAvc1EEUDq8TyryFjRO~GggLBk5yR0EXzZ3zenqnDEGEoQZR~TRqS~uDF-GwQB3eX~VdnuiU2iittWJkajIDmZtpN3yWtl4H630A3opQvBnVHZjXAL5YPkdh87-a-H~6FusWvvfJxfNC2ZzbrARzXofo8dUFtH7zUXGCC~eUk~hIuLbLuz024lFQOjiWq2VKyB7dQQuGFpM-OZQEV8tSfkViP8uzDLTaCg__&Key-Pair-Id=APKAQ4GOSFWCVNEHN3O4';
const optionsMock = [
{
id: '1',
name: 'Company A',
avatarUrl,
},
{
id: '2',
name: 'Company B',
avatarUrl,
},
{
id: '3',
name: 'Company C',
avatarUrl,
},
{
id: '4',
name: 'Person 2',
avatarUrl,
},
{
id: '5',
name: 'Company D',
avatarUrl,
},
{
id: '6',
name: 'Person 1',
avatarUrl,
},
];
const FakeSelectableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
const [selectedItem, setSelectedItem] = useState<string | null>(null);
return (
<>
{optionsMock.map((item) => (
<MenuItemSelectAvatar
key={item.id}
selected={selectedItem === item.id}
onClick={() => setSelectedItem(item.id)}
avatar={
hasAvatar ? (
<Avatar
placeholder="A"
avatarUrl={item.avatarUrl}
size="md"
type="squared"
/>
) : undefined
}
text={item.name}
/>
))}
</>
);
};
const FakeCheckableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
const [selectedItemsById, setSelectedItemsById] = useState<
Record<string, boolean>
>({});
return (
<>
{optionsMock.map((item) => (
<MenuItemMultiSelectAvatar
key={item.id}
selected={selectedItemsById[item.id]}
onSelectChange={(checked) =>
setSelectedItemsById((previous) => ({
...previous,
[item.id]: checked,
}))
}
avatar={
hasAvatar ? (
<Avatar
placeholder="A"
avatarUrl={item.avatarUrl}
size="md"
type="squared"
/>
) : undefined
}
text={item.name}
/>
))}
</>
);
};
const playInteraction: PlayFunction<any, any> = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole('button');
userEvent.click(button);
await waitFor(async () => {
expect(canvas.getByText('Company A')).toBeInTheDocument();
});
};
export const WithHeaders: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<>
<DropdownMenuHeader>Header</DropdownMenuHeader>
<DropdownMenuSeparator />
<StyledDropdownMenuSubheader>Subheader 1</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer hasMaxHeight>
<>
{optionsMock.slice(0, 3).map(({ name }) => (
<MenuItem text={name} />
))}
</>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<StyledDropdownMenuSubheader>Subheader 2</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer>
{optionsMock.slice(3).map(({ name }) => (
<MenuItem text={name} />
))}
</DropdownMenuItemsContainer>
</>
),
},
play: playInteraction,
};
export const SearchWithLoadingMenu: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<>
<DropdownMenuSearchInput value="query" autoFocus />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuSkeletonItem />
</DropdownMenuItemsContainer>
</>
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole('button');
await waitFor(() => {
userEvent.click(button);
expect(canvas.getByDisplayValue('query')).toBeInTheDocument();
});
await waitFor(() => {
userEvent.click(button);
expect(canvas.queryByDisplayValue('query')).not.toBeInTheDocument();
});
},
};
export const WithInput: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<>
<DropdownMenuInput defaultValue="Lorem ipsum" autoFocus />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{optionsMock.map(({ name }) => (
<MenuItem text={name} />
))}
</DropdownMenuItemsContainer>
</>
),
},
play: playInteraction,
};
export const SelectableMenuItemWithAvatar: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<DropdownMenuItemsContainer hasMaxHeight>
<FakeSelectableMenuItemList hasAvatar />
</DropdownMenuItemsContainer>
),
},
play: playInteraction,
};
export const CheckableMenuItemWithAvatar: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<DropdownMenuItemsContainer hasMaxHeight>
<FakeCheckableMenuItemList hasAvatar />
</DropdownMenuItemsContainer>
),
},
play: playInteraction,
};
@@ -0,0 +1,21 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DropdownMenuInput } from '../DropdownMenuInput';
const meta: Meta<typeof DropdownMenuInput> = {
title: 'UI/Layout/Dropdown/DropdownMenuInput',
component: DropdownMenuInput,
decorators: [ComponentDecorator],
args: { defaultValue: 'Lorem ipsum' },
};
export default meta;
type Story = StoryObj<typeof DropdownMenuInput>;
export const Default: Story = {};
export const Focused: Story = {
args: { autoFocus: true },
};
@@ -0,0 +1,69 @@
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { DropdownScopeInternalContext } from '../scopes/scope-internal-context/DropdownScopeInternalContext';
import { useDropdownStates } from './useDropdownStates';
type UseDropdownProps = {
dropdownScopeId?: string;
};
export const useDropdown = (props?: UseDropdownProps) => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const scopeId = useAvailableScopeIdOrThrow(
DropdownScopeInternalContext,
props?.dropdownScopeId,
);
const {
dropdownHotkeyScope,
setDropdownHotkeyScope,
isDropdownOpen,
setIsDropdownOpen,
dropdownWidth,
setDropdownWidth,
} = useDropdownStates({
scopeId,
});
const closeDropdownButton = () => {
goBackToPreviousHotkeyScope();
setIsDropdownOpen(false);
};
const openDropdownButton = () => {
setIsDropdownOpen(true);
if (dropdownHotkeyScope) {
setHotkeyScopeAndMemorizePreviousScope(
dropdownHotkeyScope.scope,
dropdownHotkeyScope.customScopes,
);
}
};
const toggleDropdownButton = () => {
if (isDropdownOpen) {
closeDropdownButton();
} else {
openDropdownButton();
}
};
return {
scopeId,
isDropdownOpen: isDropdownOpen,
closeDropdown: closeDropdownButton,
toggleDropdown: toggleDropdownButton,
openDropdown: openDropdownButton,
dropdownHotkeyScope,
setDropdownHotkeyScope,
dropdownWidth,
setDropdownWidth,
};
};
@@ -0,0 +1,31 @@
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
import { dropdownHotkeyScopeScopedState } from '../states/dropdownHotkeyScopeScopedState';
import { dropdownWidthScopedState } from '../states/dropdownWidthScopedState';
import { isDropdownOpenScopedState } from '../states/isDropdownOpenScopedState';
export const useDropdownStates = ({ scopeId }: { scopeId: string }) => {
const [isDropdownOpen, setIsDropdownOpen] = useRecoilScopedStateV2(
isDropdownOpenScopedState,
scopeId,
);
const [dropdownHotkeyScope, setDropdownHotkeyScope] = useRecoilScopedStateV2(
dropdownHotkeyScopeScopedState,
scopeId,
);
const [dropdownWidth, setDropdownWidth] = useRecoilScopedStateV2(
dropdownWidthScopedState,
scopeId,
);
return {
isDropdownOpen,
setIsDropdownOpen,
dropdownHotkeyScope,
setDropdownHotkeyScope,
dropdownWidth,
setDropdownWidth,
};
};
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useDropdown } from './useDropdown';
export const useInternalHotkeyScopeManagement = ({
dropdownHotkeyScopeFromParent,
}: {
dropdownHotkeyScopeFromParent?: HotkeyScope;
}) => {
const { dropdownHotkeyScope, setDropdownHotkeyScope } = useDropdown();
useEffect(() => {
if (!isDeeplyEqual(dropdownHotkeyScopeFromParent, dropdownHotkeyScope)) {
setDropdownHotkeyScope(dropdownHotkeyScopeFromParent);
}
}, [
dropdownHotkeyScope,
dropdownHotkeyScopeFromParent,
setDropdownHotkeyScope,
]);
};
@@ -0,0 +1,19 @@
import { ReactNode } from 'react';
import { DropdownScopeInternalContext } from './scope-internal-context/DropdownScopeInternalContext';
type DropdownScopeProps = {
children: ReactNode;
dropdownScopeId: string;
};
export const DropdownScope = ({
children,
dropdownScopeId,
}: DropdownScopeProps) => {
return (
<DropdownScopeInternalContext.Provider value={{ scopeId: dropdownScopeId }}>
{children}
</DropdownScopeInternalContext.Provider>
);
};
@@ -0,0 +1,7 @@
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
type DropdownScopeInternalContextProps = ScopedStateKey;
export const DropdownScopeInternalContext =
createScopeInternalContext<DropdownScopeInternalContextProps>();
@@ -0,0 +1,9 @@
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const dropdownHotkeyScopeScopedState = createScopedState<
HotkeyScope | null | undefined
>({
key: 'dropdownHotkeyScopeScopedState',
defaultValue: null,
});
@@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const dropdownWidthScopedState = createScopedState<number | undefined>({
key: 'dropdownWidthScopedState',
defaultValue: 160,
});
@@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const isDropdownOpenScopedState = createScopedState<boolean>({
key: 'isDropdownOpenScopedState',
defaultValue: false,
});
@@ -0,0 +1,6 @@
import { useLocation } from 'react-router-dom';
export const useIsMenuNavbarDisplayed = () => {
const currentPath = useLocation().pathname;
return currentPath.match(/^\/companies(\/.*)?$/) !== null;
};
@@ -0,0 +1,132 @@
import { ReactNode, useState } from 'react';
import styled from '@emotion/styled';
import { AnimatePresence, LayoutGroup } from 'framer-motion';
import debounce from 'lodash.debounce';
import {
H1Title,
H1TitleFontColor,
} from '@/ui/display/typography/components/H1Title';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { Modal } from '@/ui/layout/modal/components/Modal';
import {
Section,
SectionAlignment,
SectionFontColor,
} from '@/ui/layout/section/components/Section';
export type ConfirmationModalProps = {
isOpen: boolean;
title: string;
subtitle: ReactNode;
setIsOpen: (val: boolean) => void;
onConfirmClick: () => void;
deleteButtonText?: string;
confirmationPlaceholder?: string;
confirmationValue?: string;
};
const StyledConfirmationModal = styled(Modal)`
border-radius: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(6)};
width: calc(400px - ${({ theme }) => theme.spacing(32)});
`;
const StyledCenteredButton = styled(Button)`
justify-content: center;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledCenteredTitle = styled.div`
text-align: center;
`;
export const StyledConfirmationButton = styled(StyledCenteredButton)`
border-color: ${({ theme }) => theme.border.color.danger};
box-shadow: none;
color: ${({ theme }) => theme.color.red};
font-size: ${({ theme }) => theme.font.size.md};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
:hover {
background-color: ${({ theme }) => theme.color.red10};
}
`;
export const ConfirmationModal = ({
isOpen = false,
title,
subtitle,
setIsOpen,
onConfirmClick,
deleteButtonText = 'Delete',
confirmationValue,
confirmationPlaceholder,
}: ConfirmationModalProps) => {
const [inputConfirmationValue, setInputConfirmationValue] =
useState<string>('');
const [isValidValue, setIsValidValue] = useState(!confirmationValue);
const handleInputConfimrationValueChange = (value: string) => {
setInputConfirmationValue(value);
isValueMatchingInput(confirmationValue, value);
};
const isValueMatchingInput = debounce(
(value?: string, inputValue?: string) => {
setIsValidValue(Boolean(value && inputValue && value === inputValue));
},
250,
);
return (
<AnimatePresence mode="wait">
<LayoutGroup>
<StyledConfirmationModal
isOpen={isOpen}
onClose={() => {
if (isOpen) {
setIsOpen(false);
}
}}
onEnter={onConfirmClick}
>
<StyledCenteredTitle>
<H1Title title={title} fontColor={H1TitleFontColor.Primary} />
</StyledCenteredTitle>
<Section
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{subtitle}
</Section>
{confirmationValue && (
<Section>
<TextInput
value={inputConfirmationValue}
onChange={handleInputConfimrationValueChange}
placeholder={confirmationPlaceholder}
fullWidth
key={'input-' + confirmationValue}
/>
</Section>
)}
<StyledCenteredButton
onClick={onConfirmClick}
variant="secondary"
accent="danger"
title={deleteButtonText}
disabled={!isValidValue}
fullWidth
/>
<StyledCenteredButton
onClick={() => setIsOpen(false)}
variant="secondary"
title="Cancel"
fullWidth
/>
</StyledConfirmationModal>
</LayoutGroup>
</AnimatePresence>
);
};
@@ -0,0 +1,225 @@
import React, { useEffect, useRef } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { Key } from 'ts-key-enum';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { ModalHotkeyScope } from './types/ModalHotkeyScope';
const StyledModalDiv = styled(motion.div)<{
size?: ModalSize;
padding?: ModalPadding;
}>`
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
overflow: hidden;
max-height: 90vh;
z-index: 10000; // should be higher than Backdrop's z-index
width: ${({ size, theme }) => {
switch (size) {
case 'small':
return theme.modal.size.sm;
case 'medium':
return theme.modal.size.md;
case 'large':
return theme.modal.size.lg;
default:
return 'auto';
}
}};
padding: ${({ padding, theme }) => {
switch (padding) {
case 'none':
return theme.spacing(0);
case 'small':
return theme.spacing(2);
case 'medium':
return theme.spacing(4);
case 'large':
return theme.spacing(6);
default:
return 'auto';
}
}};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex: 1 1 0%;
flex-direction: column;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(10)};
`;
const StyledFooter = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledBackDrop = styled(motion.div)`
align-items: center;
background: ${({ theme }) => theme.background.overlay};
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 9999;
`;
/**
* Modal components
*/
type ModalHeaderProps = React.PropsWithChildren & {
className?: string;
};
const ModalHeader = ({ children, className }: ModalHeaderProps) => (
<StyledHeader className={className}>{children}</StyledHeader>
);
type ModalContentProps = React.PropsWithChildren & {
className?: string;
};
const ModalContent = ({ children, className }: ModalContentProps) => (
<StyledContent className={className}>{children}</StyledContent>
);
type ModalFooterProps = React.PropsWithChildren & {
className?: string;
};
const ModalFooter = ({ children, className }: ModalFooterProps) => (
<StyledFooter className={className}>{children}</StyledFooter>
);
/**
* Modal
*/
export type ModalSize = 'small' | 'medium' | 'large';
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
type ModalProps = React.PropsWithChildren & {
isOpen?: boolean;
onClose?: () => void;
hotkeyScope?: ModalHotkeyScope;
onEnter?: () => void;
size?: ModalSize;
padding?: ModalPadding;
className?: string;
};
const modalVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
export const Modal = ({
isOpen = false,
children,
onClose,
hotkeyScope = ModalHotkeyScope.Default,
onEnter,
size = 'medium',
padding = 'medium',
className,
}: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [modalRef],
callback: () => onClose?.(),
mode: ClickOutsideMode.comparePixels,
});
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
useScopedHotkeys(
[Key.Escape],
() => {
onClose?.();
},
hotkeyScope,
[onClose],
);
useScopedHotkeys(
[Key.Enter],
() => {
onEnter?.();
},
hotkeyScope,
);
useEffect(() => {
if (isOpen) {
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
} else {
goBackToPreviousHotkeyScope();
}
}, [
goBackToPreviousHotkeyScope,
hotkeyScope,
isOpen,
setHotkeyScopeAndMemorizePreviousScope,
]);
return isOpen ? (
<StyledBackDrop>
<StyledModalDiv
// framer-motion seems to have typing problems with refs
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
variants={modalVariants}
className={className}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
) : (
<></>
);
};
Modal.Header = ModalHeader;
Modal.Content = ModalContent;
Modal.Footer = ModalFooter;
@@ -0,0 +1,33 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ConfirmationModal } from '../ConfirmationModal';
const meta: Meta<typeof ConfirmationModal> = {
title: 'UI/Layout/Modal/ConfirmationModal',
component: ConfirmationModal,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof ConfirmationModal>;
export const Default: Story = {
args: {
isOpen: true,
title: 'Pariatur labore.',
subtitle: 'Velit dolore aliquip laborum occaecat fugiat.',
deleteButtonText: 'Delete',
},
decorators: [ComponentDecorator],
};
export const InputConfirmation: Story = {
args: {
confirmationValue: 'email@test.dev',
confirmationPlaceholder: 'email@test.dev',
...Default.args,
},
decorators: Default.decorators,
};
@@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Modal } from '../Modal';
import { ModalHotkeyScope } from '../types/ModalHotkeyScope';
const meta: Meta<typeof Modal> = {
title: 'UI/Layout/Modal/Modal',
component: Modal,
};
export default meta;
type Story = StoryObj<typeof Modal>;
export const Default: Story = {
args: {
isOpen: true,
size: 'medium',
padding: 'medium',
hotkeyScope: ModalHotkeyScope.Default,
children: (
<>
<Modal.Header>Stay in touch</Modal.Header>
<Modal.Content>
This is a dummy newletter form so don't bother trying to test it. Not
that I expect you to, anyways. :)
</Modal.Content>
<Modal.Footer>
By using Twenty, you're opting for the finest CRM experience you'll
ever encounter.
</Modal.Footer>
</>
),
},
decorators: [ComponentDecorator],
argTypes: {
children: { control: false },
},
};
@@ -0,0 +1,3 @@
export enum ModalHotkeyScope {
Default = 'default',
}
@@ -0,0 +1,91 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { AnimatePresence, LayoutGroup } from 'framer-motion';
import { AuthModal } from '@/auth/components/Modal';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar';
import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
const StyledLayout = styled.div`
background: ${({ theme }) => theme.background.noisy};
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
scrollbar-color: ${({ theme }) => theme.border.color.medium};
scrollbar-width: 4px;
width: 100%;
*::-webkit-scrollbar {
height: 4px;
width: 4px;
}
*::-webkit-scrollbar-corner {
background-color: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: ${({ theme }) => theme.border.radius.sm};
}
`;
const StyledPageContainer = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: row;
min-height: 0;
`;
const StyledAppNavigationDrawer = styled(AppNavigationDrawer)`
flex-shrink: 0;
`;
const StyledMainContainer = styled.div`
display: flex;
flex: 0 1 100%;
overflow: hidden;
`;
type DefaultLayoutProps = {
children: ReactNode;
};
export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
const onboardingStatus = useOnboardingStatus();
const isMobile = useIsMobile();
return (
<StyledLayout>
<CommandMenu />
<KeyboardShortcutMenu />
<StyledPageContainer>
<StyledAppNavigationDrawer />
<StyledMainContainer>
{onboardingStatus &&
onboardingStatus !== OnboardingStatus.Completed ? (
<>
<SignInBackgroundMockPage />
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>{children}</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<AppErrorBoundary>{children}</AppErrorBoundary>
)}
</StyledMainContainer>
</StyledPageContainer>
{isMobile && <MobileNavigationBar />}
</StyledLayout>
);
};
@@ -0,0 +1,18 @@
import { IconPlus } from '@/ui/display/icon';
import { IconButton } from '@/ui/input/button/components/IconButton';
type PageAddButtonProps = {
onClick: () => void;
};
export const PageAddButton = ({ onClick }: PageAddButtonProps) => (
<IconButton
Icon={IconPlus}
dataTestId="add-button"
size="medium"
variant="secondary"
accent="default"
onClick={onClick}
ariaLabel="Add"
/>
);
@@ -0,0 +1 @@
export { RightDrawerContainer as PageBody } from './RightDrawerContainer';
@@ -0,0 +1,9 @@
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
export { StyledContainer as PageContainer };
@@ -0,0 +1,21 @@
import { IconHeart } from '@/ui/display/icon';
import { IconButton } from '@/ui/input/button/components/IconButton';
type PageFavoriteButtonProps = {
isFavorite: boolean;
onClick: () => void;
};
export const PageFavoriteButton = ({
isFavorite,
onClick,
}: PageFavoriteButtonProps) => (
<IconButton
Icon={IconHeart}
size="medium"
variant="secondary"
data-testid="add-button"
accent={isFavorite ? 'danger' : 'default'}
onClick={onClick}
/>
);
@@ -0,0 +1,122 @@
import { ComponentProps, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconChevronLeft } from '@/ui/display/icon/index';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip';
import { IconButton } from '@/ui/input/button/components/IconButton';
import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerCollapseButton';
import { isNavigationDrawerOpenState } from '@/ui/navigation/states/isNavigationDrawerOpenState';
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
export const PAGE_BAR_MIN_HEIGHT = 40;
const StyledTopBarContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.noisy};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
font-size: ${({ theme }) => theme.font.size.lg};
justify-content: space-between;
min-height: ${PAGE_BAR_MIN_HEIGHT}px;
padding: ${({ theme }) => theme.spacing(2)};
padding-left: 0;
padding-right: ${({ theme }) => theme.spacing(3)};
z-index: 20;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${({ theme }) => theme.spacing(3)};
}
`;
const StyledLeftContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
width: 100%;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${({ theme }) => theme.spacing(1)};
}
`;
const StyledTitleContainer = styled.div`
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
margin-left: ${({ theme }) => theme.spacing(1)};
max-width: 50%;
`;
const StyledBackIconButton = styled(IconButton)`
margin-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledTopBarIconStyledTitleContainer = styled.div`
align-items: center;
display: flex;
flex: 1 0 100%;
flex-direction: row;
`;
const StyledPageActionContainer = styled.div`
display: inline-flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledTopBarButtonContainer = styled.div`
margin-left: ${({ theme }) => theme.spacing(1)};
margin-right: ${({ theme }) => theme.spacing(1)};
`;
type PageHeaderProps = ComponentProps<'div'> & {
title: string;
hasBackButton?: boolean;
Icon: IconComponent;
children?: ReactNode;
};
export const PageHeader = ({
title,
hasBackButton,
Icon,
children,
}: PageHeaderProps) => {
const isMobile = useIsMobile();
const navigate = useNavigate();
const theme = useTheme();
const isNavigationDrawerOpen = useRecoilValue(isNavigationDrawerOpenState);
return (
<StyledTopBarContainer>
<StyledLeftContainer>
{!isMobile && !isNavigationDrawerOpen && (
<StyledTopBarButtonContainer>
<NavigationDrawerCollapseButton direction="right" />
</StyledTopBarButtonContainer>
)}
{hasBackButton && (
<StyledBackIconButton
Icon={IconChevronLeft}
size={isMobile ? 'small' : 'medium'}
onClick={() => navigate(-1)}
variant="tertiary"
/>
)}
<StyledTopBarIconStyledTitleContainer>
{Icon && <Icon size={theme.icon.size.md} />}
<StyledTitleContainer data-testid="top-bar-title">
<OverflowingTextWithTooltip text={title} />
</StyledTitleContainer>
</StyledTopBarIconStyledTitleContainer>
</StyledLeftContainer>
<StyledPageActionContainer>{children}</StyledPageActionContainer>
</StyledTopBarContainer>
);
};
@@ -0,0 +1,16 @@
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
type PageHotkeysEffectProps = {
onAddButtonClick?: () => void;
};
export const PageHotkeysEffect = ({
onAddButtonClick,
}: PageHotkeysEffectProps) => {
useScopedHotkeys('c', () => onAddButtonClick?.(), TableHotkeyScope.Table, [
onAddButtonClick,
]);
return <></>;
};
@@ -0,0 +1,15 @@
import React from 'react';
import styled from '@emotion/styled';
const StyledPanel = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
height: 100%;
overflow: auto;
width: 100%;
`;
export const PagePanel = ({ children }: { children: React.ReactNode }) => (
<StyledPanel>{children}</StyledPanel>
);
@@ -0,0 +1,50 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { RightDrawer } from '@/ui/layout/right-drawer/components/RightDrawer';
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
import { PagePanel } from './PagePanel';
type RightDrawerContainerProps = {
children: ReactNode;
};
const StyledMainContainer = styled.div`
background: ${({ theme }) => theme.background.noisy};
box-sizing: border-box;
display: flex;
flex: 1 1 auto;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
min-height: 0;
padding: ${({ theme }) => theme.spacing(0, 3)};
width: 100%;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${({ theme }) => theme.spacing(3)};
padding-bottom: 0;
}
`;
type LeftContainerProps = {
isRightDrawerOpen?: boolean;
};
const StyledLeftContainer = styled.div<LeftContainerProps>`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
export const RightDrawerContainer = ({
children,
}: RightDrawerContainerProps) => (
<StyledMainContainer>
<StyledLeftContainer>
<PagePanel>{children}</PagePanel>
</StyledLeftContainer>
<RightDrawer />
</StyledMainContainer>
);
@@ -0,0 +1,44 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledOuterContainer = styled.div`
display: flex;
gap: ${({ theme }) => (useIsMobile() ? theme.spacing(3) : '0')};
height: ${() => (useIsMobile() ? '100%' : '100%')};
overflow-x: ${() => (useIsMobile() ? 'hidden' : 'auto')};
width: 100%;
`;
const StyledInnerContainer = styled.div`
display: flex;
flex-direction: ${() => (useIsMobile() ? 'column' : 'row')};
width: 100%;
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
background-color: ${({ theme }) => theme.background.secondary};
border-radius: ${({ theme }) => theme.border.radius.md};
`;
export type ShowPageContainerProps = {
children: ReactElement[];
};
export const ShowPageContainer = ({ children }: ShowPageContainerProps) => {
const isMobile = useIsMobile();
return isMobile ? (
<StyledOuterContainer>
<StyledScrollWrapper>
<StyledInnerContainer>{children}</StyledInnerContainer>
</StyledScrollWrapper>
</StyledOuterContainer>
) : (
<StyledOuterContainer>
<StyledInnerContainer>{children}</StyledInnerContainer>
</StyledOuterContainer>
);
};
@@ -0,0 +1,36 @@
import { JSX } from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { PageHeader } from './PageHeader';
import { RightDrawerContainer } from './RightDrawerContainer';
type SubMenuTopBarContainerProps = {
children: JSX.Element | JSX.Element[];
title: string;
Icon: IconComponent;
};
const StyledContainer = styled.div<{ isMobile: boolean }>`
display: flex;
flex-direction: column;
padding-top: ${({ theme, isMobile }) => (!isMobile ? theme.spacing(3) : 0)};
width: 100%;
`;
export const SubMenuTopBarContainer = ({
children,
title,
Icon,
}: SubMenuTopBarContainerProps) => {
const isMobile = useIsMobile();
return (
<StyledContainer isMobile={isMobile}>
{isMobile && <PageHeader title={title} Icon={Icon} />}
<RightDrawerContainer>{children}</RightDrawerContainer>
</StyledContainer>
);
};
@@ -0,0 +1,96 @@
import { useRef } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isDefined } from '~/utils/isDefined';
import { useRightDrawer } from '../hooks/useRightDrawer';
import { isRightDrawerExpandedState } from '../states/isRightDrawerExpandedState';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerHotkeyScope } from '../types/RightDrawerHotkeyScope';
import { RightDrawerRouter } from './RightDrawerRouter';
const StyledContainer = styled(motion.div)`
background: ${({ theme }) => theme.background.primary};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
height: 100%;
overflow-x: hidden;
position: fixed;
right: 0;
top: 0;
z-index: 100;
`;
const StyledRightDrawer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
`;
export const RightDrawer = () => {
const [isRightDrawerOpen, setIsRightDrawerOpen] = useRecoilState(
isRightDrawerOpenState,
);
const [isRightDrawerExpanded] = useRecoilState(isRightDrawerExpandedState);
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
const { closeRightDrawer } = useRightDrawer();
const rightDrawerRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [rightDrawerRef],
callback: () => closeRightDrawer(),
mode: ClickOutsideMode.comparePixels,
});
const theme = useTheme();
useScopedHotkeys(
[Key.Escape],
() => closeRightDrawer(),
RightDrawerHotkeyScope.RightDrawer,
[setIsRightDrawerOpen],
);
const isMobile = useIsMobile();
const rightDrawerWidth = isRightDrawerOpen
? isMobile || isRightDrawerExpanded
? '100%'
: theme.rightDrawerWidth
: '0';
if (!isDefined(rightDrawerPage)) {
return <></>;
}
return (
<StyledContainer
animate={{
width: rightDrawerWidth,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
<StyledRightDrawer ref={rightDrawerRef}>
{isRightDrawerOpen && <RightDrawerRouter />}
</StyledRightDrawer>
</StyledContainer>
);
};
@@ -0,0 +1,51 @@
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { RightDrawerCreateActivity } from '@/activities/right-drawer/components/create/RightDrawerCreateActivity';
import { RightDrawerEditActivity } from '@/activities/right-drawer/components/edit/RightDrawerEditActivity';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
import { RightDrawerTopBar } from './RightDrawerTopBar';
const StyledRightDrawerPage = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`;
const StyledRightDrawerBody = styled.div`
display: flex;
flex-direction: column;
height: calc(
100vh - ${({ theme }) => theme.spacing(14)} - 1px
); // (-1 for border)
overflow: auto;
position: relative;
`;
export const RightDrawerRouter = () => {
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
let page = <></>;
switch (rightDrawerPage) {
case RightDrawerPages.CreateActivity:
page = <RightDrawerCreateActivity />;
break;
case RightDrawerPages.EditActivity:
page = <RightDrawerEditActivity />;
break;
default:
break;
}
return (
<StyledRightDrawerPage>
<RightDrawerTopBar />
<StyledRightDrawerBody>{page}</StyledRightDrawerBody>
</StyledRightDrawerPage>
);
};
@@ -0,0 +1,44 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { ActivityActionBar } from '@/activities/right-drawer/components/ActivityActionBar';
import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { RightDrawerTopBarCloseButton } from './RightDrawerTopBarCloseButton';
import { RightDrawerTopBarExpandButton } from './RightDrawerTopBarExpandButton';
const StyledRightDrawerTopBar = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
flex-direction: row;
font-size: ${({ theme }) => theme.font.size.md};
gap: ${({ theme }) => theme.spacing(1)};
height: 56px;
justify-content: space-between;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledTopBarWrapper = styled.div`
display: flex;
`;
export const RightDrawerTopBar = () => {
const isMobile = useIsMobile();
const viewableActivityId = useRecoilValue(viewableActivityIdState);
return (
<StyledRightDrawerTopBar>
<StyledTopBarWrapper>
<RightDrawerTopBarCloseButton />
{!isMobile && <RightDrawerTopBarExpandButton />}
</StyledTopBarWrapper>
<ActivityActionBar activityId={viewableActivityId ?? ''} />
</StyledRightDrawerTopBar>
);
};
@@ -0,0 +1,21 @@
import { IconChevronsRight } from '@/ui/display/icon/index';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { useRightDrawer } from '../hooks/useRightDrawer';
export const RightDrawerTopBarCloseButton = () => {
const { closeRightDrawer } = useRightDrawer();
const handleButtonClick = () => {
closeRightDrawer();
};
return (
<LightIconButton
Icon={IconChevronsRight}
onClick={handleButtonClick}
size="medium"
accent="tertiary"
/>
);
};
@@ -0,0 +1,32 @@
import { useRecoilState } from 'recoil';
import {
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
} from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { isRightDrawerExpandedState } from '../states/isRightDrawerExpandedState';
export const RightDrawerTopBarExpandButton = () => {
const [isRightDrawerExpanded, setIsRightDrawerExpanded] = useRecoilState(
isRightDrawerExpandedState,
);
const handleButtonClick = () => {
setIsRightDrawerExpanded(!isRightDrawerExpanded);
};
return (
<LightIconButton
size="medium"
accent="tertiary"
Icon={
isRightDrawerExpanded
? IconLayoutSidebarRightCollapse
: IconLayoutSidebarRightExpand
}
onClick={handleButtonClick}
/>
);
};
@@ -0,0 +1,27 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { RightDrawerTopBar } from '../RightDrawerTopBar';
const meta: Meta<typeof RightDrawerTopBar> = {
title: 'UI/Layout/RightDrawer/RightDrawerTopBar',
component: RightDrawerTopBar,
decorators: [
(Story) => (
<div style={{ width: '500px' }}>
<Story />
</div>
),
ComponentDecorator,
],
parameters: {
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof RightDrawerTopBar>;
export const Default: Story = {};
@@ -0,0 +1,31 @@
import { useRecoilState } from 'recoil';
import { isRightDrawerExpandedState } from '../states/isRightDrawerExpandedState';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
export const useRightDrawer = () => {
const [, setIsRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
const [, setIsRightDrawerExpanded] = useRecoilState(
isRightDrawerExpandedState,
);
const [, setRightDrawerPage] = useRecoilState(rightDrawerPageState);
const openRightDrawer = (rightDrawerPage: RightDrawerPages) => {
setRightDrawerPage(rightDrawerPage);
setIsRightDrawerExpanded(false);
setIsRightDrawerOpen(true);
};
const closeRightDrawer = () => {
setIsRightDrawerExpanded(false);
setIsRightDrawerOpen(false);
};
return {
openRightDrawer,
closeRightDrawer,
};
};
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isRightDrawerExpandedState = atom<boolean>({
key: 'isRightDrawerExpandedState',
default: false,
});
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isRightDrawerOpenState = atom<boolean>({
key: 'ui/layout/is-right-drawer-open',
default: false,
});
@@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { RightDrawerPages } from '../types/RightDrawerPages';
export const rightDrawerPageState = atom<RightDrawerPages | null>({
key: 'ui/layout/right-drawer-page',
default: null,
});
@@ -0,0 +1,3 @@
export enum RightDrawerHotkeyScope {
RightDrawer = 'right-drawer',
}
@@ -0,0 +1,4 @@
export enum RightDrawerPages {
CreateActivity = 'create-activity',
EditActivity = 'edit-activity',
}
@@ -0,0 +1,48 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
type SectionProps = {
children: ReactNode;
className?: string;
alignment?: SectionAlignment;
fullWidth?: boolean;
fontColor?: SectionFontColor;
};
export enum SectionAlignment {
Left = 'left',
Center = 'center',
}
export enum SectionFontColor {
Primary = 'primary',
Secondary = 'secondary',
Tertiary = 'tertiary',
}
const StyledSection = styled.div<{
alignment: SectionAlignment;
fullWidth: boolean;
fontColor: SectionFontColor;
}>`
color: ${({ theme, fontColor }) => theme.font.color[fontColor]};
text-align: ${({ alignment }) => alignment};
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
`;
export const Section = ({
children,
className,
alignment = SectionAlignment.Left,
fullWidth = true,
fontColor = SectionFontColor.Primary,
}: SectionProps) => (
<StyledSection
className={className}
alignment={alignment}
fullWidth={fullWidth}
fontColor={fontColor}
>
{children}
</StyledSection>
);
@@ -0,0 +1,27 @@
import React, { useEffect, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useSelectableListScopedStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListScopedStates';
type SelectableItemProps = {
itemId: string;
children: React.ReactElement;
};
export const SelectableItem = ({ itemId, children }: SelectableItemProps) => {
const { isSelectedItemIdSelector } = useSelectableListScopedStates({
itemId: itemId,
});
const isSelectedItemId = useRecoilValue(isSelectedItemIdSelector);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isSelectedItemId) {
scrollRef.current?.scrollIntoView({ block: 'nearest' });
}
}, [isSelectedItemId]);
return <div ref={scrollRef}>{children}</div>;
};
@@ -0,0 +1,49 @@
import { ReactNode, useEffect } from 'react';
import styled from '@emotion/styled';
import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { SelectableListScope } from '@/ui/layout/selectable-list/scopes/SelectableListScope';
type SelectableListProps = {
children: ReactNode;
selectableListId: string;
selectableItemIds: string[][];
onSelect?: (selected: string) => void;
hotkeyScope: string;
onEnter?: (itemId: string) => void;
};
const StyledSelectableItemsContainer = styled.div`
width: 100%;
`;
export const SelectableList = ({
children,
selectableListId,
hotkeyScope,
selectableItemIds,
onEnter,
}: SelectableListProps) => {
useSelectableListHotKeys(selectableListId, hotkeyScope);
const { setSelectableItemIds, setSelectableListOnEnter } = useSelectableList({
selectableListId,
});
useEffect(() => {
setSelectableListOnEnter(() => onEnter);
}, [onEnter, setSelectableListOnEnter]);
useEffect(() => {
setSelectableItemIds(selectableItemIds);
}, [selectableItemIds, setSelectableItemIds]);
return (
<SelectableListScope selectableListScopeId={selectableListId}>
<StyledSelectableItemsContainer>
{children}
</StyledSelectableItemsContainer>
</SelectableListScope>
);
};
@@ -0,0 +1,160 @@
import { useRecoilCallback } from 'recoil';
import { Key } from 'ts-key-enum';
import { getSelectableListScopedStates } from '@/ui/layout/selectable-list/utils/internal/getSelectableListScopedStates';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
type Direction = 'up' | 'down' | 'left' | 'right';
export const useSelectableListHotKeys = (
scopeId: string,
hotkeyScope: string,
) => {
const findPosition = (
selectableItemIds: string[][],
selectedItemId?: string | null,
) => {
if (!selectedItemId) {
// If nothing is selected, return the default position
return { row: 0, col: 0 };
}
for (let row = 0; row < selectableItemIds.length; row++) {
const col = selectableItemIds[row].indexOf(selectedItemId);
if (col !== -1) {
return { row, col };
}
}
return { row: 0, col: 0 };
};
const handleSelect = useRecoilCallback(
({ snapshot, set }) =>
(direction: Direction) => {
const { selectedItemIdState, selectableItemIdsState } =
getSelectableListScopedStates({
selectableListScopeId: scopeId,
});
const selectedItemId = getSnapshotValue(snapshot, selectedItemIdState);
const selectableItemIds = getSnapshotValue(
snapshot,
selectableItemIdsState,
);
const { row: currentRow, col: currentCol } = findPosition(
selectableItemIds,
selectedItemId,
);
const computeNextId = (direction: Direction) => {
if (selectableItemIds.length === 0) {
return;
}
const isSingleRow = selectableItemIds.length === 1;
let nextRow: number;
let nextCol: number;
switch (direction) {
case 'up':
nextRow = isSingleRow ? currentRow : Math.max(0, currentRow - 1);
nextCol = isSingleRow ? Math.max(0, currentCol - 1) : currentCol;
break;
case 'down':
nextRow = isSingleRow
? currentRow
: Math.min(selectableItemIds.length - 1, currentRow + 1);
nextCol = isSingleRow
? Math.min(
selectableItemIds[currentRow].length - 1,
currentCol + 1,
)
: currentCol;
break;
case 'left':
nextRow = currentRow;
nextCol = Math.max(0, currentCol - 1);
break;
case 'right':
nextRow = currentRow;
nextCol = Math.min(
selectableItemIds[currentRow].length - 1,
currentCol + 1,
);
break;
default:
nextRow = currentRow;
nextCol = currentCol;
}
return selectableItemIds[nextRow][nextCol];
};
const nextId = computeNextId(direction);
if (nextId) {
const { isSelectedItemIdSelector } = getSelectableListScopedStates({
selectableListScopeId: scopeId,
itemId: nextId,
});
set(isSelectedItemIdSelector, true);
set(selectedItemIdState, nextId);
}
if (selectedItemId) {
const { isSelectedItemIdSelector } = getSelectableListScopedStates({
selectableListScopeId: scopeId,
itemId: selectedItemId,
});
set(isSelectedItemIdSelector, false);
}
},
[scopeId],
);
useScopedHotkeys(Key.ArrowUp, () => handleSelect('up'), hotkeyScope, []);
useScopedHotkeys(Key.ArrowDown, () => handleSelect('down'), hotkeyScope, []);
useScopedHotkeys(Key.ArrowLeft, () => handleSelect('left'), hotkeyScope, []);
useScopedHotkeys(
Key.ArrowRight,
() => handleSelect('right'),
hotkeyScope,
[],
);
useScopedHotkeys(
Key.Enter,
useRecoilCallback(
({ snapshot }) =>
() => {
const { selectedItemIdState, selectableListOnEnterState } =
getSelectableListScopedStates({
selectableListScopeId: scopeId,
});
const selectedItemId = getSnapshotValue(
snapshot,
selectedItemIdState,
);
const onEnter = getSnapshotValue(
snapshot,
selectableListOnEnterState,
);
if (selectedItemId) {
onEnter?.(selectedItemId);
}
},
[scopeId],
),
hotkeyScope,
[],
);
return <></>;
};
@@ -0,0 +1,36 @@
import { SelectableListScopeInternalContext } from '@/ui/layout/selectable-list/scopes/scope-internal-context/SelectableListScopeInternalContext';
import { getSelectableListScopedStates } from '@/ui/layout/selectable-list/utils/internal/getSelectableListScopedStates';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
type UseSelectableListScopedStatesProps = {
selectableListScopeId?: string;
itemId?: string;
};
export const useSelectableListScopedStates = (
args?: UseSelectableListScopedStatesProps,
) => {
const { selectableListScopeId, itemId } = args ?? {};
const scopeId = useAvailableScopeIdOrThrow(
SelectableListScopeInternalContext,
selectableListScopeId,
);
const {
selectedItemIdState,
selectableItemIdsState,
isSelectedItemIdSelector,
selectableListOnEnterState,
} = getSelectableListScopedStates({
selectableListScopeId: scopeId,
itemId: itemId,
});
return {
scopeId,
isSelectedItemIdSelector,
selectableItemIdsState,
selectedItemIdState,
selectableListOnEnterState,
};
};
@@ -0,0 +1,40 @@
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useSelectableListScopedStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListScopedStates';
import { SelectableListScopeInternalContext } from '@/ui/layout/selectable-list/scopes/scope-internal-context/SelectableListScopeInternalContext';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
type UseSelectableListProps = {
selectableListId?: string;
itemId?: string;
};
export const useSelectableList = (props?: UseSelectableListProps) => {
const scopeId = useAvailableScopeIdOrThrow(
SelectableListScopeInternalContext,
props?.selectableListId,
);
const {
selectableItemIdsState,
isSelectedItemIdSelector,
selectableListOnEnterState,
} = useSelectableListScopedStates({
selectableListScopeId: scopeId,
itemId: props?.itemId,
});
const setSelectableItemIds = useSetRecoilState(selectableItemIdsState);
const setSelectableListOnEnter = useSetRecoilState(
selectableListOnEnterState,
);
const isSelectedItemId = useRecoilValue(isSelectedItemIdSelector);
return {
setSelectableItemIds,
isSelectedItemId,
setSelectableListOnEnter,
selectableListId: scopeId,
isSelectedItemIdSelector,
};
};
@@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import { SelectableListScopeInternalContext } from './scope-internal-context/SelectableListScopeInternalContext';
type SelectableListScopeProps = {
children: ReactNode;
selectableListScopeId: string;
};
export const SelectableListScope = ({
children,
selectableListScopeId,
}: SelectableListScopeProps) => {
return (
<SelectableListScopeInternalContext.Provider
value={{ scopeId: selectableListScopeId }}
>
{children}
</SelectableListScopeInternalContext.Provider>
);
};
@@ -0,0 +1,7 @@
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
type SelectableListScopeInternalContextProps = ScopedStateKey;
export const SelectableListScopeInternalContext =
createScopeInternalContext<SelectableListScopeInternalContextProps>();
@@ -0,0 +1,9 @@
import { createScopedFamilyState } from '@/ui/utilities/recoil-scope/utils/createScopedFamilyState';
export const isSelectedItemIdMapScopedFamilyState = createScopedFamilyState<
boolean,
string
>({
key: 'isSelectedItemIdMapScopedFamilyState',
defaultValue: false,
});
@@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const selectableItemIdsScopedState = createScopedState<string[][]>({
key: 'selectableItemIdsScopedState',
defaultValue: [[]],
});
@@ -0,0 +1,8 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const selectableListOnEnterScopedState = createScopedState<
((itemId: string) => void) | undefined
>({
key: 'selectableListOnEnterScopedState',
defaultValue: undefined,
});
@@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const selectedItemIdScopedState = createScopedState<string | null>({
key: 'selectedItemIdScopedState',
defaultValue: null,
});
@@ -0,0 +1,26 @@
import { selectorFamily } from 'recoil';
import { isSelectedItemIdMapScopedFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdMapScopedFamilyState';
export const isSelectedItemIdScopedFamilySelector = selectorFamily({
key: 'isSelectedItemIdScopedFamilySelector',
get:
({ scopeId, itemId }: { scopeId: string; itemId: string }) =>
({ get }) =>
get(
isSelectedItemIdMapScopedFamilyState({
scopeId: scopeId,
familyKey: itemId,
}),
),
set:
({ scopeId, itemId }: { scopeId: string; itemId: string }) =>
({ set }, newValue) =>
set(
isSelectedItemIdMapScopedFamilyState({
scopeId: scopeId,
familyKey: itemId,
}),
newValue,
),
});
@@ -0,0 +1,42 @@
import { selectableItemIdsScopedState } from '@/ui/layout/selectable-list/states/selectableItemIdsScopedState';
import { selectableListOnEnterScopedState } from '@/ui/layout/selectable-list/states/selectableListOnEnterScopedState';
import { selectedItemIdScopedState } from '@/ui/layout/selectable-list/states/selectedItemIdScopedState';
import { isSelectedItemIdScopedFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdScopedFamilySelector';
import { getScopedState } from '@/ui/utilities/recoil-scope/utils/getScopedState';
const UNDEFINED_SELECTABLE_ITEM_ID = 'UNDEFINED_SELECTABLE_ITEM_ID';
export const getSelectableListScopedStates = ({
selectableListScopeId,
itemId,
}: {
selectableListScopeId: string;
itemId?: string;
}) => {
const isSelectedItemIdSelector = isSelectedItemIdScopedFamilySelector({
scopeId: selectableListScopeId,
itemId: itemId ?? UNDEFINED_SELECTABLE_ITEM_ID,
});
const selectedItemIdState = getScopedState(
selectedItemIdScopedState,
selectableListScopeId,
);
const selectableItemIdsState = getScopedState(
selectableItemIdsScopedState,
selectableListScopeId,
);
const selectableListOnEnterState = getScopedState(
selectableListOnEnterScopedState,
selectableListScopeId,
);
return {
isSelectedItemIdSelector,
selectableItemIdsState,
selectedItemIdState,
selectableListOnEnterState,
};
};
@@ -0,0 +1,75 @@
import styled from '@emotion/styled';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityType } from '@/activities/types/Activity';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { IconCheckbox, IconNotes, IconPlus } from '@/ui/display/icon/index';
import { IconButton } from '@/ui/input/button/components/IconButton';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { Dropdown } from '../../dropdown/components/Dropdown';
import { DropdownMenu } from '../../dropdown/components/DropdownMenu';
import { DropdownScope } from '../../dropdown/scopes/DropdownScope';
const StyledContainer = styled.div`
z-index: 1;
`;
export const ShowPageAddButton = ({
entity,
}: {
entity: ActivityTargetableEntity;
}) => {
const { closeDropdown, toggleDropdown } = useDropdown({
dropdownScopeId: 'add-show-page',
});
const openCreateActivity = useOpenCreateActivityDrawer();
const handleSelect = (type: ActivityType) => {
openCreateActivity({ type, targetableEntities: [entity] });
closeDropdown();
};
return (
<StyledContainer>
<DropdownScope dropdownScopeId="add-show-page">
<Dropdown
clickableComponent={
<IconButton
Icon={IconPlus}
size="medium"
dataTestId="add-showpage-button"
accent="default"
variant="secondary"
onClick={toggleDropdown}
/>
}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => handleSelect('Note')}
accent="default"
LeftIcon={IconNotes}
text="Note"
/>
<MenuItem
onClick={() => handleSelect('Task')}
accent="default"
LeftIcon={IconCheckbox}
text="Task"
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
}
dropdownHotkeyScope={{
scope: PageHotkeyScope.ShowPage,
}}
/>
</DropdownScope>
</StyledContainer>
);
};
@@ -0,0 +1,61 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledOuterContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border-bottom-left-radius: 8px;
border-right: ${({ theme }) => {
const isMobile = useIsMobile();
return !isMobile ? `1px solid ${theme.border.color.medium}` : 'none';
}};
border-top-left-radius: 8px;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
z-index: 10;
`;
const StyledInnerContainer = styled.div`
display: flex;
flex-direction: column;
padding: 0px ${({ theme }) => theme.spacing(2)} 0px
${({ theme }) => theme.spacing(3)};
width: ${({ theme }) => {
const isMobile = useIsMobile();
return isMobile ? `calc(100% - ${theme.spacing(5)})` : '320px';
}};
`;
const StyledIntermediateContainer = styled.div`
display: flex;
flex-direction: column;
padding-bottom: ${({ theme }) => theme.spacing(3)};
`;
export type ShowPageLeftContainerProps = {
children: ReactElement[];
};
export const ShowPageLeftContainer = ({
children,
}: ShowPageLeftContainerProps) => {
const isMobile = useIsMobile();
return isMobile ? (
<StyledOuterContainer>
<StyledInnerContainer>{children}</StyledInnerContainer>
</StyledOuterContainer>
) : (
<StyledOuterContainer>
<ScrollWrapper>
<StyledIntermediateContainer>
<StyledInnerContainer>{children}</StyledInnerContainer>
</StyledIntermediateContainer>
</ScrollWrapper>
</StyledOuterContainer>
);
};
@@ -0,0 +1,109 @@
import styled from '@emotion/styled';
import { Attachments } from '@/activities/files/components/Attachments';
import { Notes } from '@/activities/notes/components/Notes';
import { EntityTasks } from '@/activities/tasks/components/EntityTasks';
import { Timeline } from '@/activities/timeline/components/Timeline';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import {
IconCheckbox,
IconMail,
IconNotes,
IconPaperclip,
IconTimelineEvent,
} from '@/ui/display/icon';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdScopedState } from '@/ui/layout/tab/states/activeTabIdScopedState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ShowPageRecoilScopeContext } from '../../states/ShowPageRecoilScopeContext';
const StyledShowPageRightContainer = styled.div`
display: flex;
flex: 1 0 0;
flex-direction: column;
justify-content: start;
overflow: ${() => (useIsMobile() ? 'none' : 'hidden')};
width: calc(100% + 4px);
`;
const StyledTabListContainer = styled.div`
align-items: center;
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: 40px;
`;
type ShowPageRightContainerProps = {
entity: ActivityTargetableEntity;
timeline?: boolean;
tasks?: boolean;
notes?: boolean;
emails?: boolean;
};
export const ShowPageRightContainer = ({
entity,
timeline,
tasks,
notes,
emails,
}: ShowPageRightContainerProps) => {
const TASK_TABS = [
{
id: 'timeline',
title: 'Timeline',
Icon: IconTimelineEvent,
hide: !timeline,
disabled: entity.type === 'Custom',
},
{
id: 'tasks',
title: 'Tasks',
Icon: IconCheckbox,
hide: !tasks,
disabled: entity.type === 'Custom',
},
{
id: 'notes',
title: 'Notes',
Icon: IconNotes,
hide: !notes,
disabled: entity.type === 'Custom',
},
{
id: 'files',
title: 'Files',
Icon: IconPaperclip,
hide: !notes,
disabled: entity.type === 'Custom',
},
{
id: 'emails',
title: 'Emails',
Icon: IconMail,
hide: !emails,
disabled: true,
},
];
const [activeTabId] = useRecoilScopedState(
activeTabIdScopedState,
ShowPageRecoilScopeContext,
);
return (
<StyledShowPageRightContainer>
<StyledTabListContainer>
<TabList context={ShowPageRecoilScopeContext} tabs={TASK_TABS} />
</StyledTabListContainer>
{activeTabId === 'timeline' && <Timeline entity={entity} />}
{activeTabId === 'tasks' && <EntityTasks entity={entity} />}
{activeTabId === 'notes' && <Notes entity={entity} />}
{activeTabId === 'files' && <Attachments targetableEntity={entity} />}
</StyledShowPageRightContainer>
);
};
@@ -0,0 +1,136 @@
import { ChangeEvent, useRef } from 'react';
import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { v4 as uuidV4 } from 'uuid';
import { Avatar, AvatarType } from '@/users/components/Avatar';
import {
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
import { OverflowingTextWithTooltip } from '../../../display/tooltip/OverflowingTextWithTooltip';
type ShowPageSummaryCardProps = {
id?: string;
logoOrAvatar?: string;
title: string;
date: string;
renderTitleEditComponent?: () => JSX.Element;
onUploadPicture?: (file: File) => void;
avatarType: AvatarType;
};
const StyledShowPageSummaryCard = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
padding: ${({ theme }) => theme.spacing(6)} ${({ theme }) => theme.spacing(3)}
${({ theme }) => theme.spacing(3)} ${({ theme }) => theme.spacing(3)};
`;
const StyledInfoContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
const StyledDate = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
`;
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
justify-content: center;
width: 100%;
`;
const StyledTooltip = styled(Tooltip)`
background-color: ${({ theme }) => theme.background.primary};
box-shadow: ${({ theme }) => theme.boxShadow.light};
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledAvatarWrapper = styled.div`
cursor: pointer;
`;
const StyledFileInput = styled.input`
display: none;
`;
export const ShowPageSummaryCard = ({
id,
logoOrAvatar,
title,
date,
avatarType,
renderTitleEditComponent,
onUploadPicture,
}: ShowPageSummaryCardProps) => {
const beautifiedCreatedAt =
date !== '' ? beautifyPastDateRelativeToNow(date) : '';
const exactCreatedAt = date !== '' ? beautifyExactDateTime(date) : '';
const dateElementId = `date-id-${uuidV4()}`;
const inputFileRef = useRef<HTMLInputElement>(null);
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) onUploadPicture?.(e.target.files[0]);
};
const handleAvatarClick = () => {
inputFileRef?.current?.click?.();
};
return (
<StyledShowPageSummaryCard>
<StyledAvatarWrapper>
<Avatar
avatarUrl={logoOrAvatar}
onClick={onUploadPicture ? handleAvatarClick : undefined}
size="xl"
colorId={id}
placeholder={title}
type={avatarType}
/>
<StyledFileInput
ref={inputFileRef}
onChange={onFileChange}
type="file"
/>
</StyledAvatarWrapper>
<StyledInfoContainer>
<StyledTitle>
{renderTitleEditComponent ? (
renderTitleEditComponent()
) : (
<OverflowingTextWithTooltip text={title} />
)}
</StyledTitle>
<StyledDate id={dateElementId}>Added {beautifiedCreatedAt}</StyledDate>
<StyledTooltip
anchorSelect={`#${dateElementId}`}
content={exactCreatedAt}
clickable
noArrow
place="right"
/>
</StyledInfoContainer>
</StyledShowPageSummaryCard>
);
};
@@ -0,0 +1,3 @@
import { createContext } from 'react';
export const ShowPageRecoilScopeContext = createContext<string | null>(null);
@@ -0,0 +1,78 @@
import * as React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
type TabProps = {
id: string;
title: string;
Icon?: IconComponent;
active?: boolean;
className?: string;
onClick?: () => void;
disabled?: boolean;
};
const StyledTab = styled.div<{ active?: boolean; disabled?: boolean }>`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-color: ${({ theme, active }) =>
active ? theme.border.color.inverted : 'transparent'};
color: ${({ theme, active, disabled }) =>
active
? theme.font.color.primary
: disabled
? theme.font.color.light
: theme.font.color.secondary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
margin-bottom: -1px;
padding: ${({ theme }) => theme.spacing(2) + ' 0'};
pointer-events: ${({ disabled }) => (disabled ? 'none' : '')};
`;
const StyledHover = styled.span`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
&:hover {
background: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm};
}
&:active {
background: ${({ theme }) => theme.background.quaternary};
}
`;
export const Tab = ({
id,
title,
Icon,
active = false,
onClick,
className,
disabled,
}: TabProps) => {
const theme = useTheme();
return (
<StyledTab
onClick={onClick}
active={active}
className={className}
disabled={disabled}
data-testid={'tab-' + id}
>
<StyledHover>
{Icon && <Icon size={theme.icon.size.md} />}
{title}
</StyledHover>
</StyledTab>
);
};
@@ -0,0 +1,65 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { activeTabIdScopedState } from '../states/activeTabIdScopedState';
import { Tab } from './Tab';
type SingleTabProps = {
title: string;
Icon?: IconComponent;
id: string;
hide?: boolean;
disabled?: boolean;
};
type TabListProps = {
tabs: SingleTabProps[];
context: React.Context<string | null>;
};
const StyledContainer = styled.div`
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: 40px;
padding-left: ${({ theme }) => theme.spacing(2)};
user-select: none;
`;
export const TabList = ({ tabs, context }: TabListProps) => {
const initialActiveTabId = tabs[0].id;
const [activeTabId, setActiveTabId] = useRecoilScopedState(
activeTabIdScopedState,
context,
);
React.useEffect(() => {
setActiveTabId(initialActiveTabId);
}, [initialActiveTabId, setActiveTabId]);
return (
<StyledContainer>
{tabs
.filter((tab) => !tab.hide)
.map((tab) => (
<Tab
id={tab.id}
key={tab.id}
title={tab.title}
Icon={tab.Icon}
active={tab.id === activeTabId}
onClick={() => {
setActiveTabId(tab.id);
}}
disabled={tab.disabled}
/>
))}
</StyledContainer>
);
};
@@ -0,0 +1,64 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { Tab } from '../Tab';
const meta: Meta<typeof Tab> = {
title: 'UI/Layout/Tab/Tab',
component: Tab,
};
export default meta;
type Story = StoryObj<typeof Tab>;
export const Default: Story = {
args: {
title: 'Tab title',
active: false,
Icon: IconCheckbox,
disabled: false,
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof Tab> = {
args: { title: 'Tab title', Icon: IconCheckbox },
argTypes: {
active: { control: false },
disabled: { control: false },
onClick: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.active'] },
catalog: {
dimensions: [
{
name: 'states',
values: ['default', 'hover', 'active'],
props: (state: string) =>
state === 'default' ? {} : { className: state },
},
{
name: 'Active',
values: ['true', 'false'],
labels: (active: string) =>
active === 'true' ? 'active' : 'inactive',
props: (active: string) => ({ active: active === 'true' }),
},
{
name: 'Disabled',
values: ['true', 'false'],
labels: (disabled: string) =>
disabled === 'true' ? 'disabled' : 'enabled',
props: (disabled: string) => ({ disabled: disabled === 'true' }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,68 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { IconCheckbox } from '@/ui/display/icon';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { TabList } from '../TabList';
const tabs = [
{
id: '1',
title: 'Tab1',
Icon: IconCheckbox,
hide: true,
},
{
id: '2',
title: 'Tab2',
Icon: IconCheckbox,
hide: false,
},
{
id: '3',
title: 'Tab3',
Icon: IconCheckbox,
hide: false,
disabled: true,
},
{
id: '4',
title: 'Tab4',
Icon: IconCheckbox,
hide: false,
disabled: false,
},
];
const meta: Meta<typeof TabList> = {
title: 'UI/Layout/Tab/TabList',
component: TabList,
args: {
tabs: tabs,
},
decorators: [
(Story) => (
<RecoilScope>
<Story />
</RecoilScope>
),
ComponentDecorator,
],
};
export default meta;
type Story = StoryObj<typeof TabList>;
export const TabListDisplay: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const submitButton = canvas.queryByText('Tab1');
expect(submitButton).toBeNull();
expect(await canvas.findByText('Tab2')).toBeInTheDocument();
expect(await canvas.findByText('Tab3')).toBeInTheDocument();
expect(await canvas.findByText('Tab4')).toBeInTheDocument();
},
};
@@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const activeTabIdScopedState = atomFamily<string | null, string>({
key: 'activeTabIdScopedState',
default: null,
});
@@ -0,0 +1,5 @@
import styled from '@emotion/styled';
const StyledTable = styled.div``;
export { StyledTable as Table };
@@ -0,0 +1,10 @@
import styled from '@emotion/styled';
const StyledTableBody = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
padding: ${({ theme }) => theme.spacing(2)} 0;
`;
export { StyledTableBody as TableBody };
@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
type TableCellProps = {
align?: 'left' | 'center' | 'right';
color?: string;
};
const StyledTableCell = styled.div<TableCellProps>`
align-items: center;
color: ${({ color, theme }) => color || theme.font.color.secondary};
display: flex;
height: ${({ theme }) => theme.spacing(8)};
justify-content: ${({ align }) =>
align === 'right'
? 'flex-end'
: align === 'center'
? 'center'
: 'flex-start'};
padding: 0 ${({ theme }) => theme.spacing(2)};
text-align: ${({ align }) => align ?? 'left'};
`;
export { StyledTableCell as TableCell };
@@ -0,0 +1,20 @@
import styled from '@emotion/styled';
const StyledTableHeader = styled.div<{ align?: 'left' | 'center' | 'right' }>`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: ${({ theme }) => theme.spacing(8)};
justify-content: ${({ align }) =>
align === 'right'
? 'flex-end'
: align === 'center'
? 'center'
: 'flex-start'};
padding: 0 ${({ theme }) => theme.spacing(2)};
text-align: ${({ align }) => align ?? 'left'};
`;
export { StyledTableHeader as TableHeader };
@@ -0,0 +1,24 @@
import styled from '@emotion/styled';
const StyledTableRow = styled.div<{
isSelected?: boolean;
onClick?: () => void;
}>`
background-color: ${({ isSelected, theme }) =>
isSelected ? theme.accent.quaternary : 'transparent'};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
transition: background-color
${({ theme }) => theme.animation.duration.normal}s;
width: 100%;
&:hover {
background-color: ${({ onClick, theme }) =>
onClick ? theme.background.transparent.light : 'transparent'};
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')};
}
`;
export { StyledTableRow as TableRow };
@@ -0,0 +1,73 @@
import { ReactNode, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronDown, IconChevronUp } from '@/ui/display/icon';
import { TableBody } from './TableBody';
type TableSectionProps = {
children: ReactNode;
isInitiallyExpanded?: boolean;
title: string;
};
const StyledSectionHeader = styled.div<{ isExpanded: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.light};
cursor: pointer;
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
height: ${({ theme }) => theme.spacing(6)};
justify-content: space-between;
padding: 0 ${({ theme }) => theme.spacing(2)};
text-align: left;
text-transform: uppercase;
`;
const StyledSection = styled.div<{ isExpanded: boolean }>`
max-height: ${({ isExpanded }) => (isExpanded ? '1000px' : 0)};
opacity: ${({ isExpanded }) => (isExpanded ? 1 : 0)};
overflow: hidden;
transition:
max-height ${({ theme }) => theme.animation.duration.normal}s,
opacity ${({ theme }) => theme.animation.duration.normal}s;
`;
const StyledSectionContent = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
export const TableSection = ({
children,
isInitiallyExpanded = true,
title,
}: TableSectionProps) => {
const theme = useTheme();
const [isExpanded, setIsExpanded] = useState(isInitiallyExpanded);
const handleToggleSection = () =>
setIsExpanded((previousIsExpanded) => !previousIsExpanded);
return (
<>
<StyledSectionHeader
isExpanded={isExpanded}
onClick={handleToggleSection}
>
{title}
{isExpanded ? (
<IconChevronUp size={theme.icon.size.sm} />
) : (
<IconChevronDown size={theme.icon.size.sm} />
)}
</StyledSectionHeader>
<StyledSection isExpanded={isExpanded}>
<StyledSectionContent>{children}</StyledSectionContent>
</StyledSection>
</>
);
};
@@ -0,0 +1,53 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Table } from '../Table';
import { TableCell } from '../TableCell';
import { TableHeader } from '../TableHeader';
import { TableRow } from '../TableRow';
import { TableSection } from '../TableSection';
const meta: Meta<typeof Table> = {
title: 'UI/Layout/Table/Table',
component: Table,
decorators: [ComponentDecorator],
argTypes: {
as: { table: { disable: true } },
theme: { table: { disable: true } },
},
};
export default meta;
type Story = StoryObj<typeof Table>;
export const Default: Story = {
render: () => (
<Table>
<TableRow>
<TableHeader>Header 1</TableHeader>
<TableHeader>Header 2</TableHeader>
<TableHeader align="right">Numbers</TableHeader>
</TableRow>
<TableSection title="Section 1">
<TableRow>
<TableCell>Cell 1</TableCell>
<TableCell>Cell 2</TableCell>
<TableCell align="right">3</TableCell>
</TableRow>
<TableRow>
<TableCell>Cell 4</TableCell>
<TableCell>Cell 5</TableCell>
<TableCell align="right">6</TableCell>
</TableRow>
</TableSection>
<TableSection title="Section 2">
<TableRow>
<TableCell>Lorem ipsum dolor sit amet</TableCell>
<TableCell>Lorem ipsum</TableCell>
<TableCell align="right">42</TableCell>
</TableRow>
</TableSection>
</Table>
),
};
@@ -0,0 +1,57 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
type TopBarProps = {
className?: string;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
bottomComponent?: ReactNode;
displayBottomBorder?: boolean;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledTopBar = styled.div<{ displayBottomBorder: boolean }>`
align-items: center;
border-bottom: ${({ displayBottomBorder, theme }) =>
displayBottomBorder ? `1px solid ${theme.border.color.light}` : 'none'};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
flex-direction: row;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 39px;
justify-content: space-between;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
z-index: 5;
`;
const StyledLeftSection = styled.div`
display: flex;
`;
const StyledRightSection = styled.div`
display: flex;
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.betweenSiblingsGap};
`;
export const TopBar = ({
className,
leftComponent,
rightComponent,
bottomComponent,
displayBottomBorder = true,
}: TopBarProps) => (
<StyledContainer className={className}>
<StyledTopBar displayBottomBorder={displayBottomBorder}>
<StyledLeftSection>{leftComponent}</StyledLeftSection>
<StyledRightSection>{rightComponent}</StyledRightSection>
</StyledTopBar>
{bottomComponent}
</StyledContainer>
);