Skip to content

Commit

Permalink
Merge pull request #147 from 2wheeh/feature/issue-127/user-chip
Browse files Browse the repository at this point in the history
[#127] Add UserChip Component
  • Loading branch information
2wheeh authored Feb 2, 2024
2 parents c032e59 + 26f345a commit 1ee7470
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 0 deletions.
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);

0 comments on commit 1ee7470

Please sign in to comment.