Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#127] Add UserChip Component #147

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/packages/mr-c.app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest",
"lint": "next lint"
},
"dependencies": {
Expand Down
37 changes: 37 additions & 0 deletions ui/packages/mr-c.app/src/components/user-chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import Text from '@/components/atomic/text';
import { useOutsideClick } from '@/hooks/use-outside-click';
import { useRef, useState } from 'react';

export default function UserChip({ nickname, tag }: { nickname: string; tag: string }) {
const [isDropdownOpen, setDropdownOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

const handleClick = () => setDropdownOpen((prev) => !prev);
useOutsideClick({ ref, handler: () => setDropdownOpen(false) });

return (
<div ref={ref} className="relative">
<div
className="flex w-fit items-center rounded bg-gray-100 px-1 hover:bg-gray-200"
onClick={handleClick}
>
<Text weight="medium" nowrap>
{nickname}
</Text>
<Text size="sm" weight="light" nowrap>
{tag}
</Text>
</div>
{isDropdownOpen && (
// TODO: replace with a normalized Dropdown with a proper event handler
<div className="absolute right-0 top-7 flex items-center rounded-lg border bg-white px-2 py-1 opacity-70 hover:bg-gray-200">
<Text color="black" size="sm">
작성글 보기
</Text>
</div>
)}
</div>
);
}
105 changes: 105 additions & 0 deletions ui/packages/mr-c.app/src/hooks/use-outside-click.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useRef } from 'react';
import { useOutsideClick } from '@/hooks/use-outside-click';
import { cleanup, click, clickRight, pointerDown, pointerUp, render } from '@/lib/test-utils';

function OutsideClicker({ onOutsideClick }: { onOutsideClick: () => void }) {
const ref = useRef<HTMLDivElement>(null);

useOutsideClick({
ref,
handler: onOutsideClick,
});

return (
<>
<div ref={ref}>Element</div>
<div>Outside</div>
</>
);
}

describe('useOutsideClick', () => {
let outsideClickCount: number;
let element: HTMLElement;
let outsideElement: HTMLElement;

beforeEach(() => {
outsideClickCount = 0;
});

beforeAll(() => {
const { getByText } = render(<OutsideClicker onOutsideClick={() => outsideClickCount++} />);

element = getByText('Element');
outsideElement = getByText('Outside');
});

afterAll(() => {
cleanup();
});

test('should register clicks on other elements, the body, and the document', () => {
expect(outsideClickCount).toEqual(0);

click(element);
expect(outsideClickCount).toEqual(0);

click(outsideElement);
expect(outsideClickCount).toEqual(1);

click(document);
expect(outsideClickCount).toEqual(2);

click(document.body);
expect(outsideClickCount).toEqual(3);
});

test('should register right clicks on other elements, the body, and the document', () => {
expect(outsideClickCount).toEqual(0);

clickRight(element);
expect(outsideClickCount).toEqual(0);

clickRight(outsideElement);
expect(outsideClickCount).toEqual(1);

clickRight(document);
expect(outsideClickCount).toEqual(2);

clickRight(document.body);
expect(outsideClickCount).toEqual(3);
});

test('should fire once on one click sequnce: mouse Down -> Up', () => {
expect(outsideClickCount).toEqual(0);

pointerDown(outsideElement);
expect(outsideClickCount).toEqual(0);
pointerUp(outsideElement);
expect(outsideClickCount).toEqual(1);

pointerDown(outsideElement);
pointerDown(outsideElement);
expect(outsideClickCount).toEqual(1);

pointerUp(outsideElement);
pointerUp(outsideElement);
expect(outsideClickCount).toEqual(2);
});

test('should fire for the click that both mouse Down/Up happen outside', () => {
expect(outsideClickCount).toEqual(0);

pointerDown(element);
pointerUp(outsideElement);
expect(outsideClickCount).toEqual(0);

pointerDown(outsideElement);
pointerUp(element);
expect(outsideClickCount).toEqual(0);

pointerDown(outsideElement);
pointerUp(outsideElement);
expect(outsideClickCount).toEqual(1);
});
});
42 changes: 42 additions & 0 deletions ui/packages/mr-c.app/src/hooks/use-outside-click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react';

interface UseOutsideClickProps {
ref: React.RefObject<HTMLElement>;
handler?: (e: Event) => void;
}

export function useOutsideClick({ ref, handler }: UseOutsideClickProps) {
const stateRef = useRef({
isPointerDown: false,
});

const state = stateRef.current;

useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (isValidEvent(event, ref)) {
state.isPointerDown = true;
}
};

const onPointerUp = (event: PointerEvent) => {
if (state.isPointerDown && handler && isValidEvent(event, ref)) {
state.isPointerDown = false;
handler(event);
}
};

document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointerup', onPointerUp, true);

return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('pointerup', onPointerUp, true);
};
}, [handler, ref, state]);
}

function isValidEvent(event: Event, ref: React.RefObject<HTMLElement>) {
const target = event.target;
return target instanceof Node && !ref.current?.contains(target);
}
19 changes: 19 additions & 0 deletions ui/packages/mr-c.app/src/lib/test-utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { fireEvent } from '@testing-library/react/pure';
export { cleanup, render } from '@testing-library/react/pure';
// why use pure?
// to avoid cleanup afterEach
// so we can render once beforeAll
// https://github.com/testing-library/react-testing-library/issues/541

export function click(el: Node) {
fireEvent.pointerDown(el);
fireEvent.pointerUp(el);
}

export function clickRight(el: Node) {
fireEvent.pointerDown(el, { button: 2 });
fireEvent.pointerUp(el, { button: 2 });
}

export const pointerDown = (el: Node) => fireEvent.pointerDown(el);
export const pointerUp = (el: Node) => fireEvent.pointerUp(el);