Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,128 @@
import { useRef } from 'react';
import { Keys } from 'react-hotkeys-hook';
import {
autoUpdate,
flip,
offset,
Placement,
useFloating,
} from '@floating-ui/react';
import { Key } from 'ts-key-enum';
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useDropdown } from '../hooks/useDropdown';
import { useInternalHotkeyScopeManagement } from '../hooks/useInternalHotkeyScopeManagement';
import { DropdownMenu } from './DropdownMenu';
import { DropdownOnToggleEffect } from './DropdownOnToggleEffect';
type DropdownProps = {
clickableComponent?: JSX.Element | JSX.Element[];
dropdownComponents: JSX.Element | JSX.Element[];
hotkey?: {
key: Keys;
scope: string;
};
dropdownHotkeyScope: HotkeyScope;
dropdownPlacement?: Placement;
dropdownMenuWidth?: number;
dropdownOffset?: { x?: number; y?: number };
onClickOutside?: () => void;
onClose?: () => void;
onOpen?: () => void;
};
export const Dropdown = ({
clickableComponent,
dropdownComponents,
dropdownMenuWidth,
hotkey,
dropdownHotkeyScope,
dropdownPlacement = 'bottom-end',
dropdownOffset = { x: 0, y: 0 },
onClickOutside,
onClose,
onOpen,
}: DropdownProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { isDropdownOpen, toggleDropdown, closeDropdown, dropdownWidth } =
useDropdown();
const offsetMiddlewares = [];
if (dropdownOffset.x) {
offsetMiddlewares.push(offset({ crossAxis: dropdownOffset.x }));
}
if (dropdownOffset.y) {
offsetMiddlewares.push(offset({ mainAxis: dropdownOffset.y }));
}
const { refs, floatingStyles } = useFloating({
placement: dropdownPlacement,
middleware: [flip(), ...offsetMiddlewares],
whileElementsMounted: autoUpdate,
});
const handleHotkeyTriggered = () => {
toggleDropdown();
};
useListenClickOutside({
refs: [containerRef],
callback: () => {
onClickOutside?.();
if (isDropdownOpen) {
closeDropdown();
}
},
});
useInternalHotkeyScopeManagement({
dropdownHotkeyScopeFromParent: dropdownHotkeyScope,
});
useScopedHotkeys(
Key.Escape,
() => {
closeDropdown();
},
dropdownHotkeyScope.scope,
[closeDropdown],
);
return (
<div ref={containerRef}>
{clickableComponent && (
<div ref={refs.setReference} onClick={toggleDropdown}>
{clickableComponent}
</div>
)}
{hotkey && (
<HotkeyEffect
hotkey={hotkey}
onHotkeyTriggered={handleHotkeyTriggered}
/>
)}
{isDropdownOpen && (
<DropdownMenu
width={dropdownMenuWidth ?? dropdownWidth}
data-select-disable
ref={refs.setFloating}
style={floatingStyles}
>
{dropdownComponents}
</DropdownMenu>
)}
<DropdownOnToggleEffect
onDropdownClose={onClose}
onDropdownOpen={onOpen}
/>
</div>
);
};

View File

@ -0,0 +1,23 @@
import styled from '@emotion/styled';
const StyledDropdownMenu = styled.div<{
disableBlur?: boolean;
width?: `${string}px` | 'auto' | number;
}>`
backdrop-filter: ${({ disableBlur }) =>
disableBlur ? 'none' : 'blur(20px)'};
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
width: ${({ width }) =>
width ? `${typeof width === 'number' ? `${width}px` : width}` : '160px'};
`;
export const DropdownMenu = StyledDropdownMenu;

View File

@ -0,0 +1,66 @@
import { ComponentProps, MouseEvent } from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
const StyledHeader = styled.li`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding: ${({ theme }) => theme.spacing(1)};
user-select: none;
`;
const StyledChildrenWrapper = styled.span`
padding: 0 ${({ theme }) => theme.spacing(1)};
`;
const StyledLightIconButton = styled(LightIconButton)`
display: inline-flex;
margin-left: auto;
margin-right: 0;
`;
type DropdownMenuHeaderProps = ComponentProps<'li'> & {
StartIcon?: IconComponent;
EndIcon?: IconComponent;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
testId?: string;
};
export const DropdownMenuHeader = ({
children,
StartIcon,
EndIcon,
onClick,
testId,
}: DropdownMenuHeaderProps) => {
return (
<StyledHeader data-testid={testId}>
{StartIcon && (
<LightIconButton
testId="dropdown-menu-header-end-icon"
Icon={StartIcon}
onClick={onClick}
accent="tertiary"
size="small"
/>
)}
<StyledChildrenWrapper>{children}</StyledChildrenWrapper>
{EndIcon && (
<StyledLightIconButton
testId="dropdown-menu-header-end-icon"
Icon={EndIcon}
onClick={onClick}
accent="tertiary"
size="small"
/>
)}
</StyledHeader>
);
};

View File

@ -0,0 +1,44 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { rgba } from '@/ui/theme/constants/colors';
import { textInputStyle } from '@/ui/theme/constants/effects';
const StyledInput = styled.input`
${textInputStyle}
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 32px;
position: relative;
width: 100%;
&:focus {
border-color: ${({ theme }) => theme.color.blue};
box-shadow: 0px 0px 0px 3px ${({ theme }) => rgba(theme.color.blue, 0.1)};
}
`;
const StyledInputContainer = styled.div`
box-sizing: border-box;
padding: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
export const DropdownMenuInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(({ autoFocus, defaultValue, placeholder }, ref) => {
return (
<StyledInputContainer>
<StyledInput
autoFocus={autoFocus}
defaultValue={defaultValue}
placeholder={placeholder}
ref={ref}
/>
</StyledInputContainer>
);
});

View File

@ -0,0 +1,55 @@
import styled from '@emotion/styled';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledDropdownMenuItemsExternalContainer = styled.div<{
hasMaxHeight?: boolean;
}>`
--padding: ${({ theme }) => theme.spacing(1)};
align-items: flex-start;
display: flex;
flex-direction: column;
gap: 2px;
height: 100%;
max-height: ${({ hasMaxHeight }) => (hasMaxHeight ? '180px' : 'none')};
overflow-y: auto;
padding: var(--padding);
padding-right: 0;
width: calc(100% - 1 * var(--padding));
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
width: 100%;
`;
const StyledDropdownMenuItemsInternalContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: 2px;
height: 100%;
width: 100%;
`;
export const DropdownMenuItemsContainer = ({
children,
hasMaxHeight,
}: {
children: React.ReactNode;
hasMaxHeight?: boolean;
}) => {
return (
<StyledDropdownMenuItemsExternalContainer hasMaxHeight={hasMaxHeight}>
<StyledScrollWrapper>
<StyledDropdownMenuItemsInternalContainer>
{children}
</StyledDropdownMenuItemsInternalContainer>
</StyledScrollWrapper>
</StyledDropdownMenuItemsExternalContainer>
);
};

View File

@ -0,0 +1,47 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
const StyledDropdownMenuSearchInputContainer = styled.div`
--vertical-padding: ${({ theme }) => theme.spacing(1)};
align-items: center;
display: flex;
flex-direction: row;
height: calc(36px - 2 * var(--vertical-padding));
padding: var(--vertical-padding) 0;
width: 100%;
`;
const StyledInput = styled.input`
${textInputStyle}
font-size: ${({ theme }) => theme.font.size.sm};
width: 100%;
&[type='number']::-webkit-outer-spin-button,
&[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type='number'] {
-moz-appearance: textfield;
}
`;
export const DropdownMenuSearchInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(({ value, onChange, autoFocus, placeholder = 'Search', type }, ref) => (
<StyledDropdownMenuSearchInputContainer>
<StyledInput
autoComplete="off"
{...{ autoFocus, onChange, placeholder, type, value }}
ref={ref}
/>
</StyledDropdownMenuSearchInputContainer>
));

View File

@ -0,0 +1,10 @@
import styled from '@emotion/styled';
const StyledDropdownMenuSeparator = styled.div`
background-color: ${({ theme }) => theme.border.color.light};
height: 1px;
width: 100%;
`;
export const DropdownMenuSeparator = StyledDropdownMenuSeparator;

View File

@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const DropdownOnToggleEffect = ({
onDropdownClose,
onDropdownOpen,
}: {
onDropdownClose?: () => void;
onDropdownOpen?: () => void;
}) => {
const { isDropdownOpen } = useDropdown();
useEffect(() => {
if (isDropdownOpen) {
onDropdownOpen?.();
} else {
onDropdownClose?.();
}
}, [isDropdownOpen, onDropdownClose, onDropdownOpen]);
return null;
};

View File

@ -0,0 +1,27 @@
import styled from '@emotion/styled';
type StyledDropdownButtonProps = {
isUnfolded: boolean;
isActive?: boolean;
};
export const StyledDropdownButtonContainer = styled.div<StyledDropdownButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ isActive, theme, color }) =>
color ?? (isActive ? theme.color.blue : 'none')};
cursor: pointer;
display: flex;
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
padding: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
user-select: none;
&:hover {
filter: brightness(0.95);
}
`;

View File

@ -0,0 +1,11 @@
import styled from '@emotion/styled';
export const StyledDropdownMenuSubheader = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xxs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
text-transform: uppercase;
width: 100%;
`;

View File

@ -0,0 +1,27 @@
import styled from '@emotion/styled';
type StyledDropdownButtonProps = {
isUnfolded?: boolean;
isActive?: boolean;
};
export const StyledHeaderDropdownButton = styled.div<StyledDropdownButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ isActive, theme, color }) =>
color ?? (isActive ? theme.color.blue : theme.font.color.secondary)};
cursor: pointer;
display: flex;
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
padding: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
user-select: none;
&:hover {
filter: brightness(0.95);
}
`;

View File

@ -0,0 +1,319 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { PlayFunction } from '@storybook/types';
import { Button } from '@/ui/input/button/components/Button';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar';
import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar';
import { Avatar } from '@/users/components/Avatar';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DropdownScope } from '../../scopes/DropdownScope';
import { Dropdown } from '../Dropdown';
import { DropdownMenuHeader } from '../DropdownMenuHeader';
import { DropdownMenuInput } from '../DropdownMenuInput';
import { DropdownMenuItemsContainer } from '../DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '../DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '../DropdownMenuSeparator';
import { StyledDropdownMenuSubheader } from '../StyledDropdownMenuSubheader';
const meta: Meta<typeof Dropdown> = {
title: 'UI/Layout/Dropdown/Dropdown',
component: Dropdown,
decorators: [
ComponentDecorator,
(Story) => (
<DropdownScope dropdownScopeId="testDropdownMenu">
<Story />
</DropdownScope>
),
],
args: {
clickableComponent: <Button title="Open Dropdown" />,
dropdownHotkeyScope: { scope: 'testDropdownMenu' },
dropdownOffset: { x: 0, y: 8 },
},
argTypes: {
clickableComponent: { control: false },
dropdownHotkeyScope: { control: false },
dropdownOffset: { control: false },
dropdownComponents: { control: false },
},
};
export default meta;
type Story = StoryObj<typeof Dropdown>;
const StyledContainer = styled.div`
height: 600px;
position: relative;
width: 300px;
`;
const StyledMenuAbsolutePositionWrapper = styled.div`
height: fit-content;
width: fit-content;
`;
const WithContentBelowDecorator: Decorator = (Story) => (
<StyledContainer>
<StyledMenuAbsolutePositionWrapper>
<Story />
</StyledMenuAbsolutePositionWrapper>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</StyledContainer>
);
const StyledEmptyDropdownContent = styled.div`
height: 400px;
width: 100%;
`;
export const Empty: Story = {
args: {
dropdownComponents: (
<StyledEmptyDropdownContent data-testid="dropdown-content" />
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole('button');
userEvent.click(button);
await waitFor(async () => {
const fakeMenu = await canvas.findByTestId('dropdown-content');
expect(fakeMenu).toBeInTheDocument();
});
userEvent.click(button);
await waitFor(async () => {
const fakeMenu = await canvas.findByTestId('dropdown-content');
expect(fakeMenu).not.toBeInTheDocument();
});
userEvent.click(button);
await waitFor(async () => {
const fakeMenu = await canvas.findByTestId('dropdown-content');
expect(fakeMenu).toBeInTheDocument();
});
},
};
const avatarUrl =
'https://s3-alpha-sig.figma.com/img/bbb5/4905/f0a52cc2b9aaeb0a82a360d478dae8bf?Expires=1687132800&Signature=iVBr0BADa3LHoFVGbwqO-wxC51n1o~ZyFD-w7nyTyFP4yB-Y6zFawL-igewaFf6PrlumCyMJThDLAAc-s-Cu35SBL8BjzLQ6HymzCXbrblUADMB208PnMAvc1EEUDq8TyryFjRO~GggLBk5yR0EXzZ3zenqnDEGEoQZR~TRqS~uDF-GwQB3eX~VdnuiU2iittWJkajIDmZtpN3yWtl4H630A3opQvBnVHZjXAL5YPkdh87-a-H~6FusWvvfJxfNC2ZzbrARzXofo8dUFtH7zUXGCC~eUk~hIuLbLuz024lFQOjiWq2VKyB7dQQuGFpM-OZQEV8tSfkViP8uzDLTaCg__&Key-Pair-Id=APKAQ4GOSFWCVNEHN3O4';
const optionsMock = [
{
id: '1',
name: 'Company A',
avatarUrl,
},
{
id: '2',
name: 'Company B',
avatarUrl,
},
{
id: '3',
name: 'Company C',
avatarUrl,
},
{
id: '4',
name: 'Person 2',
avatarUrl,
},
{
id: '5',
name: 'Company D',
avatarUrl,
},
{
id: '6',
name: 'Person 1',
avatarUrl,
},
];
const FakeSelectableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
const [selectedItem, setSelectedItem] = useState<string | null>(null);
return (
<>
{optionsMock.map((item) => (
<MenuItemSelectAvatar
key={item.id}
selected={selectedItem === item.id}
onClick={() => setSelectedItem(item.id)}
avatar={
hasAvatar ? (
<Avatar
placeholder="A"
avatarUrl={item.avatarUrl}
size="md"
type="squared"
/>
) : undefined
}
text={item.name}
/>
))}
</>
);
};
const FakeCheckableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
const [selectedItemsById, setSelectedItemsById] = useState<
Record<string, boolean>
>({});
return (
<>
{optionsMock.map((item) => (
<MenuItemMultiSelectAvatar
key={item.id}
selected={selectedItemsById[item.id]}
onSelectChange={(checked) =>
setSelectedItemsById((previous) => ({
...previous,
[item.id]: checked,
}))
}
avatar={
hasAvatar ? (
<Avatar
placeholder="A"
avatarUrl={item.avatarUrl}
size="md"
type="squared"
/>
) : undefined
}
text={item.name}
/>
))}
</>
);
};
const playInteraction: PlayFunction<any, any> = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole('button');
userEvent.click(button);
await waitFor(async () => {
expect(canvas.getByText('Company A')).toBeInTheDocument();
});
};
export const WithHeaders: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<>
<DropdownMenuHeader>Header</DropdownMenuHeader>
<DropdownMenuSeparator />
<StyledDropdownMenuSubheader>Subheader 1</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer hasMaxHeight>
<>
{optionsMock.slice(0, 3).map(({ name }) => (
<MenuItem text={name} />
))}
</>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<StyledDropdownMenuSubheader>Subheader 2</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer>
{optionsMock.slice(3).map(({ name }) => (
<MenuItem text={name} />
))}
</DropdownMenuItemsContainer>
</>
),
},
play: playInteraction,
};
export const SearchWithLoadingMenu: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<>
<DropdownMenuSearchInput value="query" autoFocus />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuSkeletonItem />
</DropdownMenuItemsContainer>
</>
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole('button');
await waitFor(() => {
userEvent.click(button);
expect(canvas.getByDisplayValue('query')).toBeInTheDocument();
});
await waitFor(() => {
userEvent.click(button);
expect(canvas.queryByDisplayValue('query')).not.toBeInTheDocument();
});
},
};
export const WithInput: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<>
<DropdownMenuInput defaultValue="Lorem ipsum" autoFocus />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{optionsMock.map(({ name }) => (
<MenuItem text={name} />
))}
</DropdownMenuItemsContainer>
</>
),
},
play: playInteraction,
};
export const SelectableMenuItemWithAvatar: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<DropdownMenuItemsContainer hasMaxHeight>
<FakeSelectableMenuItemList hasAvatar />
</DropdownMenuItemsContainer>
),
},
play: playInteraction,
};
export const CheckableMenuItemWithAvatar: Story = {
decorators: [WithContentBelowDecorator],
args: {
dropdownComponents: (
<DropdownMenuItemsContainer hasMaxHeight>
<FakeCheckableMenuItemList hasAvatar />
</DropdownMenuItemsContainer>
),
},
play: playInteraction,
};

View File

@ -0,0 +1,21 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DropdownMenuInput } from '../DropdownMenuInput';
const meta: Meta<typeof DropdownMenuInput> = {
title: 'UI/Layout/Dropdown/DropdownMenuInput',
component: DropdownMenuInput,
decorators: [ComponentDecorator],
args: { defaultValue: 'Lorem ipsum' },
};
export default meta;
type Story = StoryObj<typeof DropdownMenuInput>;
export const Default: Story = {};
export const Focused: Story = {
args: { autoFocus: true },
};

View File

@ -0,0 +1,69 @@
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { DropdownScopeInternalContext } from '../scopes/scope-internal-context/DropdownScopeInternalContext';
import { useDropdownStates } from './useDropdownStates';
type UseDropdownProps = {
dropdownScopeId?: string;
};
export const useDropdown = (props?: UseDropdownProps) => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const scopeId = useAvailableScopeIdOrThrow(
DropdownScopeInternalContext,
props?.dropdownScopeId,
);
const {
dropdownHotkeyScope,
setDropdownHotkeyScope,
isDropdownOpen,
setIsDropdownOpen,
dropdownWidth,
setDropdownWidth,
} = useDropdownStates({
scopeId,
});
const closeDropdownButton = () => {
goBackToPreviousHotkeyScope();
setIsDropdownOpen(false);
};
const openDropdownButton = () => {
setIsDropdownOpen(true);
if (dropdownHotkeyScope) {
setHotkeyScopeAndMemorizePreviousScope(
dropdownHotkeyScope.scope,
dropdownHotkeyScope.customScopes,
);
}
};
const toggleDropdownButton = () => {
if (isDropdownOpen) {
closeDropdownButton();
} else {
openDropdownButton();
}
};
return {
scopeId,
isDropdownOpen: isDropdownOpen,
closeDropdown: closeDropdownButton,
toggleDropdown: toggleDropdownButton,
openDropdown: openDropdownButton,
dropdownHotkeyScope,
setDropdownHotkeyScope,
dropdownWidth,
setDropdownWidth,
};
};

View File

@ -0,0 +1,31 @@
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
import { dropdownHotkeyScopeScopedState } from '../states/dropdownHotkeyScopeScopedState';
import { dropdownWidthScopedState } from '../states/dropdownWidthScopedState';
import { isDropdownOpenScopedState } from '../states/isDropdownOpenScopedState';
export const useDropdownStates = ({ scopeId }: { scopeId: string }) => {
const [isDropdownOpen, setIsDropdownOpen] = useRecoilScopedStateV2(
isDropdownOpenScopedState,
scopeId,
);
const [dropdownHotkeyScope, setDropdownHotkeyScope] = useRecoilScopedStateV2(
dropdownHotkeyScopeScopedState,
scopeId,
);
const [dropdownWidth, setDropdownWidth] = useRecoilScopedStateV2(
dropdownWidthScopedState,
scopeId,
);
return {
isDropdownOpen,
setIsDropdownOpen,
dropdownHotkeyScope,
setDropdownHotkeyScope,
dropdownWidth,
setDropdownWidth,
};
};

View File

@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useDropdown } from './useDropdown';
export const useInternalHotkeyScopeManagement = ({
dropdownHotkeyScopeFromParent,
}: {
dropdownHotkeyScopeFromParent?: HotkeyScope;
}) => {
const { dropdownHotkeyScope, setDropdownHotkeyScope } = useDropdown();
useEffect(() => {
if (!isDeeplyEqual(dropdownHotkeyScopeFromParent, dropdownHotkeyScope)) {
setDropdownHotkeyScope(dropdownHotkeyScopeFromParent);
}
}, [
dropdownHotkeyScope,
dropdownHotkeyScopeFromParent,
setDropdownHotkeyScope,
]);
};

View File

@ -0,0 +1,19 @@
import { ReactNode } from 'react';
import { DropdownScopeInternalContext } from './scope-internal-context/DropdownScopeInternalContext';
type DropdownScopeProps = {
children: ReactNode;
dropdownScopeId: string;
};
export const DropdownScope = ({
children,
dropdownScopeId,
}: DropdownScopeProps) => {
return (
<DropdownScopeInternalContext.Provider value={{ scopeId: dropdownScopeId }}>
{children}
</DropdownScopeInternalContext.Provider>
);
};

View File

@ -0,0 +1,7 @@
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
type DropdownScopeInternalContextProps = ScopedStateKey;
export const DropdownScopeInternalContext =
createScopeInternalContext<DropdownScopeInternalContextProps>();

View File

@ -0,0 +1,9 @@
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const dropdownHotkeyScopeScopedState = createScopedState<
HotkeyScope | null | undefined
>({
key: 'dropdownHotkeyScopeScopedState',
defaultValue: null,
});

View File

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

View File

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