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