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,30 @@
import { useTheme } from '@emotion/react';
import { motion } from 'framer-motion';
import { AnimationDuration } from '@/ui/theme/constants/animation';
type AnimatedEaseInProps = Omit<
React.ComponentProps<typeof motion.div>,
'initial' | 'animated' | 'transition'
> & {
duration?: AnimationDuration;
};
export const AnimatedEaseIn = ({
children,
duration = 'normal',
}: AnimatedEaseInProps) => {
const theme = useTheme();
const initial = { opacity: 0 };
const animate = { opacity: 1 };
const transition = {
ease: 'linear',
duration: theme.animation.duration[duration],
};
return (
<motion.div initial={initial} animate={animate} transition={transition}>
{children}
</motion.div>
);
};

View File

@ -0,0 +1,47 @@
import { useTheme } from '@emotion/react';
import { AnimatePresence, motion } from 'framer-motion';
import { AnimationDuration } from '@/ui/theme/constants/animation';
type AnimatedEaseInOutProps = {
isOpen: boolean;
children: React.ReactNode;
duration?: AnimationDuration;
marginBottom?: string;
marginTop?: string;
initial?: boolean;
};
export const AnimatedEaseInOut = ({
children,
isOpen,
marginBottom,
marginTop,
duration = 'normal',
initial = true,
}: AnimatedEaseInOutProps) => {
const theme = useTheme();
return (
<AnimatePresence initial={initial}>
{isOpen && (
<motion.div
initial={{
marginBottom: marginBottom ?? 0,
marginTop: marginTop ?? 0,
height: 0,
opacity: 0,
}}
animate={{ height: 'fit-content', opacity: 1 }}
exit={{ height: 0, opacity: 0, marginBottom: 0, marginTop: 0 }}
transition={{
duration: theme.animation.duration[duration],
ease: 'easeInOut',
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};

View File

@ -0,0 +1,42 @@
import { useTheme } from '@emotion/react';
import { AnimatePresence, motion } from 'framer-motion';
import { AnimationDuration } from '@/ui/theme/constants/animation';
type AnimatedFadeOutProps = {
isOpen: boolean;
children: React.ReactNode;
duration?: AnimationDuration;
marginBottom?: string;
marginTop?: string;
};
export const AnimatedFadeOut = ({
isOpen,
children,
duration = 'normal',
marginBottom,
marginTop,
}: AnimatedFadeOutProps) => {
const theme = useTheme();
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{
opacity: 1,
marginBottom: marginBottom ?? 0,
marginTop: marginTop ?? 0,
}}
exit={{ opacity: 0, height: 0, marginBottom: 0, marginTop: 0 }}
transition={{
duration: theme.animation.duration[duration],
ease: 'easeOut',
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};

View File

@ -0,0 +1,72 @@
import React, { useMemo } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
const StyledContainer = styled(motion.div)`
display: flex;
overflow: hidden;
`;
const StyledWord = styled(motion.span)`
white-space: pre;
`;
type AnimatedTextWordProps = Omit<
React.ComponentProps<typeof motion.div>,
'children'
> & {
text: string;
};
const containerAnimation = {
hidden: { opacity: 0 },
visible: (i = 1) => ({
opacity: 1,
transition: { staggerChildren: 0.12, delayChildren: 0.04 * i },
}),
};
const childAnimation = {
visible: {
opacity: 1,
x: 0,
transition: {
type: 'spring',
damping: 12,
stiffness: 100,
},
},
hidden: {
opacity: 0,
x: 20,
transition: {
type: 'spring',
damping: 12,
stiffness: 100,
},
},
};
export const AnimatedTextWord = ({ text = '' }: AnimatedTextWordProps) => {
const words = useMemo(() => {
const words = text.split(' ');
return words.map((value, index) =>
index === words.length - 1 ? value : value + ' ',
);
}, [text]);
return (
<StyledContainer
variants={containerAnimation}
initial="hidden"
animate="visible"
>
{words.map((word, index) => (
<StyledWord variants={childAnimation} key={index}>
{word}
</StyledWord>
))}
</StyledContainer>
);
};

View File

@ -0,0 +1,44 @@
import { Profiler } from 'react';
import { Interaction } from 'scheduler/tracing';
import { logDebug } from '~/utils/logDebug';
type TimingProfilerProps = {
id: string;
children: React.ReactNode;
};
export const TimingProfiler = ({ id, children }: TimingProfilerProps) => {
const handleRender = (
id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number,
interactions: Set<Interaction>,
) => {
logDebug(
'TimingProfiler',
JSON.stringify(
{
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
interactions,
},
null,
2,
),
);
};
return (
<Profiler id={id} onRender={handleRender}>
{children}
</Profiler>
);
};

View File

@ -0,0 +1,52 @@
import { ReactNode, useLayoutEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
type ComputeNodeDimensionsProps = {
children: (
dimensions: { height: number; width: number } | undefined,
) => ReactNode;
node?: ReactNode;
};
const StyledNodeWrapper = styled.span`
pointer-events: none;
position: fixed;
visibility: hidden;
`;
export const ComputeNodeDimensions = ({
children,
node = children(undefined),
}: ComputeNodeDimensionsProps) => {
const nodeWrapperRef = useRef<HTMLSpanElement>(null);
const [nodeDimensions, setNodeDimensions] = useState<
| {
width: number;
height: number;
}
| undefined
>(undefined);
useLayoutEffect(() => {
if (!nodeWrapperRef.current) {
return;
}
const resizeObserver = new ResizeObserver(() => {
if (nodeWrapperRef.current) {
setNodeDimensions({
width: nodeWrapperRef.current.offsetWidth,
height: nodeWrapperRef.current.offsetHeight,
});
}
});
resizeObserver.observe(nodeWrapperRef.current);
return () => resizeObserver.disconnect();
}, [nodeWrapperRef]);
return (
<>
<StyledNodeWrapper ref={nodeWrapperRef}>{node}</StyledNodeWrapper>
{nodeDimensions && children(nodeDimensions)}
</>
);
};

View File

@ -0,0 +1,72 @@
import { RefObject } from 'react';
import {
boxesIntersect,
useSelectionContainer,
} from '@air/react-drag-to-select';
import { useTheme } from '@emotion/react';
import { rgba } from '@/ui/theme/constants/colors';
import { useDragSelect } from '../hooks/useDragSelect';
type DragSelectProps = {
dragSelectable: RefObject<HTMLElement>;
onDragSelectionChange: (id: string, selected: boolean) => void;
onDragSelectionStart?: () => void;
};
export const DragSelect = ({
dragSelectable,
onDragSelectionChange,
onDragSelectionStart,
}: DragSelectProps) => {
const theme = useTheme();
const { isDragSelectionStartEnabled } = useDragSelect();
const { DragSelection } = useSelectionContainer({
shouldStartSelecting: (target) => {
if (!isDragSelectionStartEnabled()) {
return false;
}
if (target instanceof HTMLElement || target instanceof SVGElement) {
let el = target;
while (el.parentElement && !el.dataset.selectDisable) {
el = el.parentElement;
}
return el.dataset.selectDisable !== 'true';
}
return true;
},
onSelectionStart: onDragSelectionStart,
onSelectionChange: (box) => {
const scrollAwareBox = {
...box,
top: box.top + window.scrollY,
left: box.left + window.scrollX,
};
Array.from(
dragSelectable.current?.querySelectorAll('[data-selectable-id]') ?? [],
).forEach((item) => {
const id = item.getAttribute('data-selectable-id');
if (!id) {
return;
}
if (boxesIntersect(scrollAwareBox, item.getBoundingClientRect())) {
onDragSelectionChange(id, true);
} else {
onDragSelectionChange(id, false);
}
});
},
selectionProps: {
style: {
border: `1px solid ${theme.color.blue10}`,
background: rgba(theme.color.blue30, 0.4),
position: `absolute`,
zIndex: 99,
},
},
});
return <DragSelection />;
};

View File

@ -0,0 +1,28 @@
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDragSelectionStartEnabledState } from '../states/internal/isDragSelectionStartEnabledState';
export const useDragSelect = () => {
const setIsDragSelectionStartEnabled = useSetRecoilState(
isDragSelectionStartEnabledState,
);
const setDragSelectionStartEnabled = (isEnabled: boolean) => {
setIsDragSelectionStartEnabled(isEnabled);
};
const isDragSelectionStartEnabled = useRecoilCallback(
({ snapshot }) =>
() => {
return snapshot
.getLoadable(isDragSelectionStartEnabledState)
.getValue();
},
[],
);
return {
isDragSelectionStartEnabled,
setDragSelectionStartEnabled,
};
};

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isDragSelectionStartEnabledState = atom({
key: 'drag-select/isDragSelectionStartEnabledState',
default: true,
});

View File

@ -0,0 +1,22 @@
import { Keys } from 'react-hotkeys-hook';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
type HotkeyEffectProps = {
hotkey: {
key: Keys;
scope: string;
};
onHotkeyTriggered: () => void;
};
export const HotkeyEffect = ({
hotkey,
onHotkeyTriggered,
}: HotkeyEffectProps) => {
useScopedHotkeys(hotkey.key, () => onHotkeyTriggered(), hotkey.scope, [
onHotkeyTriggered,
]);
return <></>;
};

View File

@ -0,0 +1,18 @@
import { AppHotkeyScope } from '../types/AppHotkeyScope';
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
import { HotkeyScope } from '../types/HotkeyScope';
export const DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES: CustomHotkeyScopes = {
commandMenu: true,
goto: false,
keyboardShortcutMenu: false,
};
export const INITIAL_HOTKEYS_SCOPE: HotkeyScope = {
scope: AppHotkeyScope.App,
customScopes: {
commandMenu: true,
goto: true,
keyboardShortcutMenu: true,
},
};

View File

@ -0,0 +1,25 @@
import { Keys } from 'react-hotkeys-hook/dist/types';
import { useNavigate } from 'react-router-dom';
import { AppHotkeyScope } from '../types/AppHotkeyScope';
import { useSequenceHotkeys } from './useSequenceScopedHotkeys';
export const useGoToHotkeys = (key: Keys, location: string) => {
const navigate = useNavigate();
useSequenceHotkeys(
'g',
key,
() => {
navigate(location);
},
AppHotkeyScope.Goto,
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[navigate],
);
};

View File

@ -0,0 +1,50 @@
import { useRecoilCallback } from 'recoil';
import { currentHotkeyScopeState } from '../states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeState } from '../states/internal/previousHotkeyScopeState';
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
import { useSetHotkeyScope } from './useSetHotkeyScope';
export const usePreviousHotkeyScope = () => {
const setHotkeyScope = useSetHotkeyScope();
const goBackToPreviousHotkeyScope = useRecoilCallback(
({ snapshot, set }) =>
() => {
const previousHotkeyScope = snapshot
.getLoadable(previousHotkeyScopeState)
.valueOrThrow();
if (!previousHotkeyScope) {
return;
}
setHotkeyScope(
previousHotkeyScope.scope,
previousHotkeyScope.customScopes,
);
set(previousHotkeyScopeState, null);
},
[setHotkeyScope],
);
const setHotkeyScopeAndMemorizePreviousScope = useRecoilCallback(
({ snapshot, set }) =>
(scope: string, customScopes?: CustomHotkeyScopes) => {
const currentHotkeyScope = snapshot
.getLoadable(currentHotkeyScopeState)
.valueOrThrow();
setHotkeyScope(scope, customScopes);
set(previousHotkeyScopeState, currentHotkeyScope);
},
[setHotkeyScope],
);
return {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
};
};

View File

@ -0,0 +1,65 @@
import { Hotkey } from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState';
const DEBUG_HOTKEY_SCOPE = true;
export const useScopedHotkeyCallback = () =>
useRecoilCallback(
({ snapshot }) =>
({
callback,
hotkeysEvent,
keyboardEvent,
scope,
preventDefault = true,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
scope: string;
preventDefault?: boolean;
}) => {
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.valueOrThrow();
if (!currentHotkeyScopes.includes(scope)) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`%cI can't call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}]`,
'color: gray; ',
);
}
return;
}
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`%cI can call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}]`,
'color: green;',
);
}
if (preventDefault) {
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
}
return callback(keyboardEvent, hotkeysEvent);
},
[],
);

View File

@ -0,0 +1,52 @@
import { useHotkeys } from 'react-hotkeys-hook';
import {
HotkeyCallback,
Keys,
Options,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
export const useScopedHotkeys = (
keys: Keys,
callback: HotkeyCallback,
scope: string,
dependencies?: OptionsOrDependencyArray,
options: Options = {
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callScopedHotkeyCallback = useScopedHotkeyCallback();
return useHotkeys(
keys,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
keyboardEvent,
hotkeysEvent,
callback: () => {
if (!pendingHotkey) {
callback(keyboardEvent, hotkeysEvent);
return;
}
setPendingHotkey(null);
},
scope,
preventDefault: !!options.preventDefault,
});
},
{
enableOnContentEditable: options.enableOnContentEditable,
enableOnFormTags: options.enableOnFormTags,
},
dependencies,
);
};

View File

@ -0,0 +1,76 @@
import { Options, useHotkeys } from 'react-hotkeys-hook';
import { Keys } from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
export const useSequenceHotkeys = (
firstKey: Keys,
secondKey: Keys,
sequenceCallback: () => void,
scope: string,
options: Options = {
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
deps: any[] = [],
) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callScopedHotkeyCallback = useScopedHotkeyCallback();
useHotkeys(
firstKey,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
keyboardEvent,
hotkeysEvent,
callback: () => {
setPendingHotkey(firstKey);
},
scope,
preventDefault: !!options.preventDefault,
});
},
{
enableOnContentEditable: options.enableOnContentEditable,
enableOnFormTags: options.enableOnFormTags,
},
[setPendingHotkey, scope],
);
useHotkeys(
secondKey,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
keyboardEvent,
hotkeysEvent,
callback: () => {
if (pendingHotkey !== firstKey) {
return;
}
setPendingHotkey(null);
if (options.preventDefault) {
keyboardEvent.stopImmediatePropagation();
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
}
sequenceCallback();
},
scope,
preventDefault: false,
});
},
{
enableOnContentEditable: options.enableOnContentEditable,
enableOnFormTags: options.enableOnFormTags,
},
[pendingHotkey, setPendingHotkey, scope, ...deps],
);
};

View File

@ -0,0 +1,81 @@
import { useRecoilCallback } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES } from '../constants';
import { currentHotkeyScopeState } from '../states/internal/currentHotkeyScopeState';
import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState';
import { AppHotkeyScope } from '../types/AppHotkeyScope';
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
import { HotkeyScope } from '../types/HotkeyScope';
const isCustomScopesEqual = (
customScopesA: CustomHotkeyScopes | undefined,
customScopesB: CustomHotkeyScopes | undefined,
) => {
return (
customScopesA?.commandMenu === customScopesB?.commandMenu &&
customScopesA?.goto === customScopesB?.goto &&
customScopesA?.keyboardShortcutMenu === customScopesB?.keyboardShortcutMenu
);
};
export const useSetHotkeyScope = () =>
useRecoilCallback(
({ snapshot, set }) =>
async (hotkeyScopeToSet: string, customScopes?: CustomHotkeyScopes) => {
const currentHotkeyScope = snapshot
.getLoadable(currentHotkeyScopeState)
.valueOrThrow();
if (currentHotkeyScope.scope === hotkeyScopeToSet) {
if (!isDefined(customScopes)) {
if (
isCustomScopesEqual(
currentHotkeyScope?.customScopes,
DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES,
)
) {
return;
}
} else {
if (
isCustomScopesEqual(
currentHotkeyScope?.customScopes,
customScopes,
)
) {
return;
}
}
}
const newHotkeyScope: HotkeyScope = {
scope: hotkeyScopeToSet,
customScopes: {
commandMenu: customScopes?.commandMenu ?? true,
goto: customScopes?.goto ?? false,
keyboardShortcutMenu: customScopes?.keyboardShortcutMenu ?? false,
},
};
const scopesToSet: string[] = [];
if (newHotkeyScope.customScopes?.commandMenu) {
scopesToSet.push(AppHotkeyScope.CommandMenu);
}
if (newHotkeyScope?.customScopes?.goto) {
scopesToSet.push(AppHotkeyScope.Goto);
}
if (newHotkeyScope?.customScopes?.keyboardShortcutMenu) {
scopesToSet.push(AppHotkeyScope.KeyboardShortcutMenu);
}
scopesToSet.push(newHotkeyScope.scope);
set(internalHotkeysEnabledScopesState, scopesToSet);
set(currentHotkeyScopeState, newHotkeyScope);
},
[],
);

View File

@ -0,0 +1,9 @@
import { atom } from 'recoil';
import { INITIAL_HOTKEYS_SCOPE } from '../../constants';
import { HotkeyScope } from '../../types/HotkeyScope';
export const currentHotkeyScopeState = atom<HotkeyScope>({
key: 'currentHotkeyScopeState',
default: INITIAL_HOTKEYS_SCOPE,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const internalHotkeysEnabledScopesState = atom<string[]>({
key: 'internalHotkeysEnabledScopesState',
default: [],
});

View File

@ -0,0 +1,7 @@
import { Keys } from 'react-hotkeys-hook/dist/types';
import { atom } from 'recoil';
export const pendingHotkeyState = atom<Keys | null>({
key: 'pendingHotkeyState',
default: null,
});

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { HotkeyScope } from '../../types/HotkeyScope';
export const previousHotkeyScopeState = atom<HotkeyScope | null>({
key: 'previousHotkeyScopeState',
default: null,
});

View File

@ -0,0 +1,6 @@
export enum AppHotkeyScope {
App = 'app',
Goto = 'goto',
CommandMenu = 'command-menu',
KeyboardShortcutMenu = 'keyboard-shortcut-menu',
}

View File

@ -0,0 +1,5 @@
export type CustomHotkeyScopes = {
goto?: boolean;
commandMenu?: boolean;
keyboardShortcutMenu?: boolean;
};

View File

@ -0,0 +1,6 @@
import { CustomHotkeyScopes } from './CustomHotkeyScope';
export type HotkeyScope = {
scope: string;
customScopes?: CustomHotkeyScopes;
};

View File

@ -0,0 +1,57 @@
export const isNonTextWritingKey = (key: string) => {
const nonTextWritingKeys = [
'Enter',
'Tab',
'Shift',
'Escape',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'Delete',
'Backspace',
'F1',
'F2',
'F3',
'F4',
'F5',
'F6',
'F7',
'F8',
'F9',
'F10',
'F11',
'F12',
'Meta',
'Alt',
'Control',
'CapsLock',
'NumLock',
'ScrollLock',
'Pause',
'Insert',
'Home',
'PageUp',
'Delete',
'End',
'PageDown',
'ContextMenu',
'PrintScreen',
'BrowserBack',
'BrowserForward',
'BrowserRefresh',
'BrowserStop',
'BrowserSearch',
'BrowserFavorites',
'BrowserHome',
'VolumeMute',
'VolumeDown',
'VolumeUp',
'MediaTrackNext',
'MediaTrackPrevious',
'MediaStop',
'MediaPlayPause',
];
return nonTextWritingKeys.includes(key);
};

View File

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

View File

@ -0,0 +1,13 @@
import { Helmet } from 'react-helmet-async';
type PageTitleProps = {
title: string;
};
export const PageTitle = (props: PageTitleProps) => {
return (
<Helmet>
<title>{props.title}</title>
</Helmet>
);
};

View File

@ -0,0 +1,39 @@
import { useRef } from 'react';
import { fireEvent, render } from '@testing-library/react';
import { useListenClickOutside } from '../useListenClickOutside';
const onOutsideClick = jest.fn();
const TestComponentDomMode = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
const buttonRef2 = useRef<HTMLButtonElement>(null);
useListenClickOutside({
refs: [buttonRef, buttonRef2],
callback: onOutsideClick,
});
return (
<div>
<span>Outside</span>
<button ref={buttonRef}>Inside</button>
<button ref={buttonRef2}>Inside 2</button>
</div>
);
};
test('useListenClickOutside web-hook works in dom mode', async () => {
const { getByText } = render(<TestComponentDomMode />);
const inside = getByText('Inside');
const inside2 = getByText('Inside 2');
const outside = getByText('Outside');
fireEvent.click(inside);
expect(onOutsideClick).toHaveBeenCalledTimes(0);
fireEvent.click(inside2);
expect(onOutsideClick).toHaveBeenCalledTimes(0);
fireEvent.click(outside);
expect(onOutsideClick).toHaveBeenCalledTimes(1);
});

View File

@ -0,0 +1,195 @@
import React, { useEffect, useState } from 'react';
export enum ClickOutsideMode {
comparePixels = 'comparePixels',
compareHTMLRef = 'compareHTMLRef',
}
export const useListenClickOutside = <T extends Element>({
refs,
callback,
mode = ClickOutsideMode.compareHTMLRef,
enabled = true,
}: {
refs: Array<React.RefObject<T>>;
callback: (event: MouseEvent | TouchEvent) => void;
mode?: ClickOutsideMode;
enabled?: boolean;
}) => {
const [isMouseDownInside, setIsMouseDownInside] = useState(false);
useEffect(() => {
const handleMouseDown = (event: MouseEvent | TouchEvent) => {
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
setIsMouseDownInside(clickedOnAtLeastOneRef);
}
if (mode === ClickOutsideMode.comparePixels) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => {
if (!ref.current) {
return false;
}
const { x, y, width, height } = ref.current.getBoundingClientRect();
const clientX =
'clientX' in event
? event.clientX
: event.changedTouches[0].clientX;
const clientY =
'clientY' in event
? event.clientY
: event.changedTouches[0].clientY;
if (
clientX < x ||
clientX > x + width ||
clientY < y ||
clientY > y + height
) {
return false;
}
return true;
});
setIsMouseDownInside(clickedOnAtLeastOneRef);
}
};
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (!clickedOnAtLeastOneRef && !isMouseDownInside) {
callback(event);
}
}
if (mode === ClickOutsideMode.comparePixels) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => {
if (!ref.current) {
return false;
}
const { x, y, width, height } = ref.current.getBoundingClientRect();
const clientX =
'clientX' in event
? event.clientX
: event.changedTouches[0].clientX;
const clientY =
'clientY' in event
? event.clientY
: event.changedTouches[0].clientY;
if (
clientX < x ||
clientX > x + width ||
clientY < y ||
clientY > y + height
) {
return false;
}
return true;
});
if (!clickedOnAtLeastOneRef && !isMouseDownInside) {
callback(event);
}
}
};
if (enabled) {
document.addEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.addEventListener('click', handleClickOutside, { capture: true });
document.addEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.addEventListener('touchend', handleClickOutside, {
capture: true,
});
return () => {
document.removeEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.removeEventListener('click', handleClickOutside, {
capture: true,
});
document.removeEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.removeEventListener('touchend', handleClickOutside, {
capture: true,
});
};
}
}, [refs, callback, mode, enabled, isMouseDownInside]);
};
export const useListenClickOutsideByClassName = ({
classNames,
excludeClassNames,
callback,
}: {
classNames: string[];
excludeClassNames?: string[];
callback: () => void;
}) => {
useEffect(() => {
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (!(event.target instanceof Node)) return;
const clickedElement = event.target as HTMLElement;
let isClickedInside = false;
let isClickedOnExcluded = false;
let currentElement: HTMLElement | null = clickedElement;
while (currentElement) {
const currentClassList = currentElement.classList;
isClickedInside = classNames.some((className) =>
currentClassList.contains(className),
);
isClickedOnExcluded =
excludeClassNames?.some((className) =>
currentClassList.contains(className),
) ?? false;
if (isClickedInside || isClickedOnExcluded) {
break;
}
currentElement = currentElement.parentElement;
}
if (!isClickedInside && !isClickedOnExcluded) {
callback();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside, {
capture: true,
});
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside, {
capture: true,
});
};
}, [callback, classNames, excludeClassNames]);
};

View File

@ -0,0 +1,67 @@
import { useCallback, useEffect } from 'react';
type MouseListener = (positionX: number, positionY: number) => void;
export const useTrackPointer = ({
shouldTrackPointer = true,
onMouseMove,
onMouseDown,
onMouseUp,
}: {
shouldTrackPointer?: boolean;
onMouseMove?: MouseListener;
onMouseDown?: MouseListener;
onMouseUp?: MouseListener;
}) => {
const extractPosition = useCallback((event: MouseEvent | TouchEvent) => {
const clientX =
'clientX' in event ? event.clientX : event.changedTouches[0].clientX;
const clientY =
'clientY' in event ? event.clientY : event.changedTouches[0].clientY;
return { clientX, clientY };
}, []);
const onInternalMouseMove = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseMove?.(clientX, clientY);
},
[onMouseMove, extractPosition],
);
const onInternalMouseDown = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseDown?.(clientX, clientY);
},
[onMouseDown, extractPosition],
);
const onInternalMouseUp = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseUp?.(clientX, clientY);
},
[onMouseUp, extractPosition],
);
useEffect(() => {
if (shouldTrackPointer) {
document.addEventListener('mousemove', onInternalMouseMove);
document.addEventListener('mousedown', onInternalMouseDown);
document.addEventListener('mouseup', onInternalMouseUp);
return () => {
document.removeEventListener('mousemove', onInternalMouseMove);
document.removeEventListener('mousedown', onInternalMouseDown);
document.removeEventListener('mouseup', onInternalMouseUp);
};
}
}, [
shouldTrackPointer,
onInternalMouseMove,
onInternalMouseDown,
onInternalMouseUp,
]);
};

View File

@ -0,0 +1,33 @@
import { useRef } from 'react';
import { v4 } from 'uuid';
import { RecoilScopeContext as RecoilScopeContextType } from '@/types/RecoilScopeContext';
import { RecoilScopeContext } from '../states/RecoilScopeContext';
/**
*
* @deprecated Use a custom scope context instead, see example with DropdownScope
*/
export const RecoilScope = ({
children,
scopeId,
CustomRecoilScopeContext,
}: {
children: React.ReactNode;
scopeId?: string;
CustomRecoilScopeContext?: RecoilScopeContextType;
}) => {
// eslint-disable-next-line twenty/no-state-useref
const currentScopeId = useRef(scopeId ?? v4());
return CustomRecoilScopeContext ? (
<CustomRecoilScopeContext.Provider value={currentScopeId.current}>
{children}
</CustomRecoilScopeContext.Provider>
) : (
<RecoilScopeContext.Provider value={currentScopeId.current}>
{children}
</RecoilScopeContext.Provider>
);
};

View File

@ -0,0 +1,16 @@
import { Context, useContext } from 'react';
/**
* @deprecated use a custom scope instead and desctructure the scope id from the scope context
* Get the scope context with useScopeInternalContext
*/
export const useContextScopeId = (SpecificContext: Context<string | null>) => {
const recoilScopeId = useContext(SpecificContext);
if (!recoilScopeId)
throw new Error(
`Using useContextScopedId outside of the specified context : ${SpecificContext.displayName}, verify that you are using a RecoilScope with the specific context you want to use.`,
);
return recoilScopeId;
};

View File

@ -0,0 +1,17 @@
import { useContext } from 'react';
import { RecoilScopeContext } from '@/types/RecoilScopeContext';
/**
* @deprecated Use a custom scope instead and desctructure the scope id from the scope context
*/
export const useRecoilScopeId = (RecoilScopeContext: RecoilScopeContext) => {
const recoilScopeId = useContext(RecoilScopeContext);
if (!recoilScopeId)
throw new Error(
`Using useRecoilScopeId outside of the specified context : ${RecoilScopeContext.displayName}, verify that you are using a RecoilScope with the specific context you want to use.`,
);
return recoilScopeId;
};

View File

@ -0,0 +1,27 @@
import { RecoilState, SerializableParam, useRecoilState } from 'recoil';
import { ScopedFamilyStateKey } from '../scopes-internal/types/ScopedFamilyStateKey';
export const useRecoilScopedFamilyState = <
StateType,
FamilyKey extends SerializableParam,
>(
recoilState: (
scopedFamilyKey: ScopedFamilyStateKey<FamilyKey>,
) => RecoilState<StateType>,
scopeId: string,
familyKey?: FamilyKey,
) => {
const familyState = useRecoilState<StateType>(
recoilState({
scopeId,
familyKey: familyKey || ('' as FamilyKey),
}),
);
if (!familyKey) {
return [undefined, undefined];
}
return familyState;
};

View File

@ -0,0 +1,22 @@
import { Context, useContext } from 'react';
import { RecoilState, useRecoilState } from 'recoil';
import { RecoilScopeContext } from '../states/RecoilScopeContext';
export const useRecoilScopedState = <StateType>(
recoilState: (param: string) => RecoilState<StateType>,
CustomRecoilScopeContext?: Context<string | null>,
) => {
const recoilScopeId = useContext(
CustomRecoilScopeContext ?? RecoilScopeContext,
);
if (!recoilScopeId)
throw new Error(
`Using a scoped atom without a RecoilScope : ${
recoilState('').key
}, verify that you are using a RecoilScope with a specific context if you intended to do so.`,
);
return useRecoilState<StateType>(recoilState(recoilScopeId));
};

View File

@ -0,0 +1,14 @@
import { RecoilState, useRecoilState } from 'recoil';
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
export const useRecoilScopedStateV2 = <StateType>(
recoilState: (scopedKey: ScopedStateKey) => RecoilState<StateType>,
scopeId: string,
) => {
return useRecoilState<StateType>(
recoilState({
scopeId,
}),
);
};

View File

@ -0,0 +1,25 @@
import { Context, useContext } from 'react';
import { RecoilState, RecoilValueReadOnly, useRecoilValue } from 'recoil';
import { RecoilScopeContext } from '../states/RecoilScopeContext';
/**
* @deprecated use useRecoilScopedStateV2 instead
*/
export const useRecoilScopedValue = <T>(
recoilState: (param: string) => RecoilState<T> | RecoilValueReadOnly<T>,
CustomRecoilScopeContext?: Context<string | null>,
) => {
const recoilScopeId = useContext(
CustomRecoilScopeContext ?? RecoilScopeContext,
);
if (!recoilScopeId)
throw new Error(
`Using a scoped atom without a RecoilScope : ${
recoilState('').key
}, verify that you are using a RecoilScope with a specific context if you intended to do so.`,
);
return useRecoilValue<T>(recoilState(recoilScopeId));
};

View File

@ -0,0 +1,14 @@
import { RecoilState, useRecoilValue } from 'recoil';
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
export const useRecoilScopedValueV2 = <StateType>(
recoilState: (scopedKey: ScopedStateKey) => RecoilState<StateType>,
scopeId: string,
) => {
return useRecoilValue<StateType>(
recoilState({
scopeId,
}),
);
};

View File

@ -0,0 +1,27 @@
import { RecoilState, SerializableParam, useSetRecoilState } from 'recoil';
import { ScopedFamilyStateKey } from '../scopes-internal/types/ScopedFamilyStateKey';
export const useSetRecoilScopedFamilyState = <
StateType,
FamilyKey extends SerializableParam,
>(
recoilState: (
scopedFamilyKey: ScopedFamilyStateKey<FamilyKey>,
) => RecoilState<StateType>,
scopeId: string,
familyKey?: FamilyKey,
) => {
const familyState = useSetRecoilState<StateType>(
recoilState({
scopeId,
familyKey: familyKey || ('' as FamilyKey),
}),
);
if (!familyKey) {
return;
}
return familyState;
};

View File

@ -0,0 +1,14 @@
import { RecoilState, useSetRecoilState } from 'recoil';
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
export const useSetRecoilScopedStateV2 = <StateType>(
recoilState: (scopedKey: ScopedStateKey) => RecoilState<StateType>,
scopeId: string,
) => {
return useSetRecoilState<StateType>(
recoilState({
scopeId,
}),
);
};

View File

@ -0,0 +1,20 @@
import { ScopeInternalContext } from '../types/ScopeInternalContext';
import { useScopeInternalContext } from './useScopeInternalContext';
export const useAvailableScopeIdOrThrow = <T extends { scopeId: string }>(
Context: ScopeInternalContext<T>,
scopeIdFromProps?: string,
): string => {
const scopeInternalContext = useScopeInternalContext(Context);
const scopeIdFromContext = scopeInternalContext?.scopeId;
if (scopeIdFromProps) {
return scopeIdFromProps;
} else if (scopeIdFromContext) {
return scopeIdFromContext;
} else {
throw new Error('Scope id is not provided and cannot be found in context.');
}
};

View File

@ -0,0 +1,11 @@
import { useContext } from 'react';
import { ScopeInternalContext } from '../types/ScopeInternalContext';
export const useScopeInternalContext = <T extends { scopeId: string }>(
Context: ScopeInternalContext<T>,
) => {
const context = useContext(Context);
return context;
};

View File

@ -0,0 +1,19 @@
import { useContext } from 'react';
import { isDefined } from '~/utils/isDefined';
import { ScopeInternalContext } from '../types/ScopeInternalContext';
export const useScopeInternalContextOrThrow = <T extends { scopeId: string }>(
Context: ScopeInternalContext<T>,
) => {
const context = useContext(Context);
if (!isDefined(context)) {
throw new Error(
`Using a scope context without a ScopeInternalContext.Provider wrapper for context : ${Context.displayName}.`,
);
}
return context;
};

View File

@ -0,0 +1,4 @@
import { Context } from 'react';
export type ScopeInternalContext<T extends { scopeId: string }> =
Context<T | null>;

View File

@ -0,0 +1,6 @@
import { SerializableParam } from 'recoil';
export type ScopedFamilyStateKey<FamilyKey extends SerializableParam> = {
scopeId: string;
familyKey: FamilyKey;
};

View File

@ -0,0 +1,3 @@
export type ScopedStateKey = {
scopeId: string;
};

View File

@ -0,0 +1,13 @@
import { Context, createContext } from 'react';
import { ScopedStateKey } from '../types/ScopedStateKey';
type ScopeInternalContext<T extends ScopedStateKey> = Context<T | null>;
export const createScopeInternalContext = <T extends ScopedStateKey>(
initialValue?: T,
) => {
return createContext<T | null>(
initialValue ?? null,
) as ScopeInternalContext<T>;
};

View File

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

View File

@ -0,0 +1,7 @@
import { RecoilValueReadOnly } from 'recoil';
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
export type RecoilScopedSelector<StateType> = (
scopedKey: ScopedStateKey,
) => RecoilValueReadOnly<StateType>;

View File

@ -0,0 +1,7 @@
import { RecoilState } from 'recoil';
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
export type RecoilScopedState<StateType> = (
scopedKey: ScopedStateKey,
) => RecoilState<StateType>;

View File

@ -0,0 +1,19 @@
import { atomFamily, SerializableParam } from 'recoil';
import { ScopedFamilyStateKey } from '../scopes-internal/types/ScopedFamilyStateKey';
export const createScopedFamilyState = <
ValueType,
FamilyKey extends SerializableParam,
>({
key,
defaultValue,
}: {
key: string;
defaultValue: ValueType;
}) => {
return atomFamily<ValueType, ScopedFamilyStateKey<FamilyKey>>({
key,
default: defaultValue,
});
};

View File

@ -0,0 +1,30 @@
import {
GetCallback,
GetRecoilValue,
Loadable,
RecoilValue,
selectorFamily,
WrappedValue,
} from 'recoil';
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
type SelectorGetter<T, P> = (
param: P,
) => (opts: {
get: GetRecoilValue;
getCallback: GetCallback;
}) => Promise<T> | RecoilValue<T> | Loadable<T> | WrappedValue<T> | T;
export const createScopedSelector = <ValueType>({
key,
get,
}: {
key: string;
get: SelectorGetter<ValueType, ScopedStateKey>;
}) => {
return selectorFamily<ValueType, ScopedStateKey>({
key,
get,
});
};

View File

@ -0,0 +1,14 @@
import { atomFamily } from 'recoil';
export const createScopedState = <ValueType>({
key,
defaultValue,
}: {
key: string;
defaultValue: ValueType;
}) => {
return atomFamily<ValueType, { scopeId: string }>({
key,
default: defaultValue,
});
};

View File

@ -0,0 +1,19 @@
import { RecoilState, SerializableParam } from 'recoil';
import { ScopedFamilyStateKey } from '../scopes-internal/types/ScopedFamilyStateKey';
export const getScopedFamilyState = <
StateType,
FamilyKey extends SerializableParam,
>(
recoilState: (
scopedFamilyKey: ScopedFamilyStateKey<FamilyKey>,
) => RecoilState<StateType>,
scopeId: string,
familyKey: FamilyKey,
) => {
return recoilState({
scopeId,
familyKey: familyKey || ('' as FamilyKey),
});
};

View File

@ -0,0 +1,10 @@
import { RecoilScopedSelector } from '../types/RecoilScopedSelector';
export const getScopedSelector = <StateType>(
recoilScopedState: RecoilScopedSelector<StateType>,
scopeId: string,
) => {
return recoilScopedState({
scopeId,
});
};

View File

@ -0,0 +1,10 @@
import { RecoilScopedState } from '../types/RecoilScopedState';
export const getScopedState = <StateType>(
recoilScopedState: RecoilScopedState<StateType>,
scopeId: string,
) => {
return recoilScopedState({
scopeId,
});
};

View File

@ -0,0 +1,15 @@
import { Snapshot } from 'recoil';
import { RecoilScopedSelector } from '../types/RecoilScopedSelector';
import { getScopedSelector } from './getScopedSelector';
export const getSnapshotScopedSelector = <StateType>(
snapshot: Snapshot,
scopedState: RecoilScopedSelector<StateType>,
scopeId: string,
) => {
return snapshot
.getLoadable(getScopedSelector(scopedState, scopeId))
.getValue();
};

View File

@ -0,0 +1,13 @@
import { Snapshot } from 'recoil';
import { RecoilScopedState } from '../types/RecoilScopedState';
import { getScopedState } from './getScopedState';
export const getSnapshotScopedValue = <StateType>(
snapshot: Snapshot,
scopedState: RecoilScopedState<StateType>,
scopeId: string,
) => {
return snapshot.getLoadable(getScopedState(scopedState, scopeId)).getValue();
};

View File

@ -0,0 +1,8 @@
import { RecoilState, RecoilValueReadOnly, Snapshot } from 'recoil';
export const getSnapshotValue = <StateType>(
snapshot: Snapshot,
state: RecoilState<StateType> | RecoilValueReadOnly<StateType>,
) => {
return snapshot.getLoadable(state).getValue();
};

View File

@ -0,0 +1,6 @@
import { useMediaQuery } from 'react-responsive';
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
export const useIsMobile = () =>
useMediaQuery({ query: `(max-width: ${MOBILE_VIEWPORT}px)` });

View File

@ -0,0 +1,41 @@
import { createContext, RefObject, useRef } from 'react';
import styled from '@emotion/styled';
import { useListenScroll } from '../hooks/useListenScroll';
export const ScrollWrapperContext = createContext<RefObject<HTMLDivElement>>({
current: null,
});
const StyledScrollWrapper = styled.div`
display: flex;
height: 100%;
overflow: auto;
scrollbar-gutter: stable;
width: 100%;
&.scrolling::-webkit-scrollbar-thumb {
background-color: ${({ theme }) => theme.border.color.medium};
}
`;
export type ScrollWrapperProps = {
children: React.ReactNode;
className?: string;
};
export const ScrollWrapper = ({ children, className }: ScrollWrapperProps) => {
const scrollableRef = useRef<HTMLDivElement>(null);
useListenScroll({
scrollableRef,
});
return (
<ScrollWrapperContext.Provider value={scrollableRef}>
<StyledScrollWrapper ref={scrollableRef} className={className}>
{children}
</StyledScrollWrapper>
</ScrollWrapperContext.Provider>
);
};

View File

@ -0,0 +1,45 @@
import { useEffect } from 'react';
import debounce from 'lodash.debounce';
import { useRecoilCallback } from 'recoil';
import { isScrollingState } from '../states/isScrollingState';
export const useListenScroll = <T extends Element>({
scrollableRef,
}: {
scrollableRef: React.RefObject<T>;
}) => {
const hideScrollBarsCallback = useRecoilCallback(({ snapshot }) => () => {
const isScrolling = snapshot.getLoadable(isScrollingState).getValue();
if (!isScrolling) {
scrollableRef.current?.classList.remove('scrolling');
}
});
const handleScrollStart = useRecoilCallback(({ set }) => () => {
set(isScrollingState, true);
scrollableRef.current?.classList.add('scrolling');
});
const handleScrollEnd = useRecoilCallback(({ set }) => () => {
set(isScrollingState, false);
debounce(hideScrollBarsCallback, 1000)();
});
useEffect(() => {
const refTarget = scrollableRef.current;
refTarget?.addEventListener('scrollend', handleScrollEnd);
refTarget?.addEventListener('scroll', handleScrollStart);
return () => {
refTarget?.removeEventListener('scrollend', handleScrollEnd);
refTarget?.removeEventListener('scroll', handleScrollStart);
};
}, [
hideScrollBarsCallback,
handleScrollStart,
handleScrollEnd,
scrollableRef,
]);
};

View File

@ -0,0 +1,14 @@
import { useContext } from 'react';
import { ScrollWrapperContext } from '../components/ScrollWrapper';
export const useScrollWrapperScopedRef = () => {
const scrollWrapperRef = useContext(ScrollWrapperContext);
if (!scrollWrapperRef)
throw new Error(
`Using a scoped ref without a ScrollWrapper : verify that you are using a ScrollWrapper if you intended to do so.`,
);
return scrollWrapperRef;
};

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isScrollingState = atom({
key: 'scroll/isScollingState',
default: false,
});