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

View File

@ -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 };

View File

@ -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 = {};

View File

@ -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 = {};

View File

@ -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 = {};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
});

View File

@ -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>
);
};

View File

@ -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>
));

View File

@ -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;

View File

@ -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;
};

View File

@ -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);
}
`;

View File

@ -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%;
`;

View File

@ -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);
}
`;

View File

@ -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,
};

View File

@ -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 },
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
]);
};

View File

@ -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>
);
};

View File

@ -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>();

View File

@ -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,
});

View File

@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const dropdownWidthScopedState = createScopedState<number | undefined>({
key: 'dropdownWidthScopedState',
defaultValue: 160,
});

View File

@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const isDropdownOpenScopedState = createScopedState<boolean>({
key: 'isDropdownOpenScopedState',
defaultValue: false,
});

View File

@ -0,0 +1,6 @@
import { useLocation } from 'react-router-dom';
export const useIsMenuNavbarDisplayed = () => {
const currentPath = useLocation().pathname;
return currentPath.match(/^\/companies(\/.*)?$/) !== null;
};

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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,
};

View File

@ -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 },
},
};

View File

@ -0,0 +1,3 @@
export enum ModalHotkeyScope {
Default = 'default',
}

View File

@ -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>
);
};

View File

@ -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"
/>
);

View File

@ -0,0 +1 @@
export { RightDrawerContainer as PageBody } from './RightDrawerContainer';

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
export { StyledContainer as PageContainer };

View File

@ -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}
/>
);

View File

@ -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>
);
};

View File

@ -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 <></>;
};

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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"
/>
);
};

View File

@ -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}
/>
);
};

View File

@ -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 = {};

View File

@ -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,
};
};

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isRightDrawerExpandedState = atom<boolean>({
key: 'isRightDrawerExpandedState',
default: false,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isRightDrawerOpenState = atom<boolean>({
key: 'ui/layout/is-right-drawer-open',
default: false,
});

View File

@ -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,
});

View File

@ -0,0 +1,3 @@
export enum RightDrawerHotkeyScope {
RightDrawer = 'right-drawer',
}

View File

@ -0,0 +1,4 @@
export enum RightDrawerPages {
CreateActivity = 'create-activity',
EditActivity = 'edit-activity',
}

View File

@ -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>
);

View File

@ -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>;
};

View File

@ -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>
);
};

View File

@ -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 <></>;
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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>
);
};

View File

@ -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>();

View File

@ -0,0 +1,9 @@
import { createScopedFamilyState } from '@/ui/utilities/recoil-scope/utils/createScopedFamilyState';
export const isSelectedItemIdMapScopedFamilyState = createScopedFamilyState<
boolean,
string
>({
key: 'isSelectedItemIdMapScopedFamilyState',
defaultValue: false,
});

View File

@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const selectableItemIdsScopedState = createScopedState<string[][]>({
key: 'selectableItemIdsScopedState',
defaultValue: [[]],
});

View File

@ -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,
});

View File

@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const selectedItemIdScopedState = createScopedState<string | null>({
key: 'selectedItemIdScopedState',
defaultValue: null,
});

View File

@ -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,
),
});

View File

@ -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,
};
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const ShowPageRecoilScopeContext = createContext<string | null>(null);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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],
};

View File

@ -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();
},
};

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const activeTabIdScopedState = atomFamily<string | null, string>({
key: 'activeTabIdScopedState',
default: null,
});

View File

@ -0,0 +1,5 @@
import styled from '@emotion/styled';
const StyledTable = styled.div``;
export { StyledTable as Table };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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>
</>
);
};

View File

@ -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>
),
};

View File

@ -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>
);