@uniai-fe/uds-primitives 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/styles.css +56 -0
- package/package.json +1 -1
- package/src/components/input/hooks/index.ts +3 -0
- package/src/components/input/hooks/useInputFile.ts +254 -0
- package/src/components/input/hooks/useInputFileContext.ts +244 -0
- package/src/components/input/hooks/useInputFileRHF.ts +105 -0
- package/src/components/input/index.scss +1 -0
- package/src/components/input/markup/file/DragAndDrop.tsx +59 -0
- package/src/components/input/markup/file/UploadButton.tsx +44 -0
- package/src/components/input/markup/file/UploadedChip.tsx +66 -0
- package/src/components/input/markup/file/index.tsx +25 -0
- package/src/components/input/markup/file/list/Body.tsx +55 -0
- package/src/components/input/markup/file/list/Container.tsx +97 -0
- package/src/components/input/markup/file/list/Header.tsx +86 -0
- package/src/components/input/markup/file/list/Item.tsx +35 -0
- package/src/components/input/markup/file/list/Provider.tsx +35 -0
- package/src/components/input/markup/file/list/Remove.tsx +54 -0
- package/src/components/input/markup/file/list/index.tsx +31 -0
- package/src/components/input/markup/foundation/SideSlot.tsx +0 -2
- package/src/components/input/markup/foundation/index.tsx +0 -2
- package/src/components/input/markup/index.tsx +2 -0
- package/src/components/input/markup/text/Search.tsx +0 -2
- package/src/components/input/styles/file.scss +60 -0
- package/src/components/input/types/date.ts +0 -80
- package/src/components/input/types/file.ts +531 -0
- package/src/components/input/types/index.ts +1 -0
- package/src/components/input/utils/file.ts +103 -0
- package/src/components/input/utils/index.tsx +1 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import type { InputFileDragAndDropProps } from "../../types";
|
|
5
|
+
import { useInputFile } from "../../hooks/useInputFile";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input Markup; File DragAndDrop 컴포넌트
|
|
9
|
+
* @component
|
|
10
|
+
* @param {InputFileDragAndDropProps} props drag-and-drop props
|
|
11
|
+
* @param {React.ReactNode} props.children drop zone 내부 콘텐츠
|
|
12
|
+
* @param {InputFileChangeHandler} props.onFilesChange 파일 변경 콜백
|
|
13
|
+
* @param {boolean} [props.disabled] drop 비활성화 여부
|
|
14
|
+
* @param {string} [props.className] root className
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* // DragAndDrop 단독 도입 예시
|
|
18
|
+
* <Input.File.DragAndDrop onFilesChange={files => console.log(files)}>
|
|
19
|
+
* <span>파일을 드래그해 업로드하세요</span>
|
|
20
|
+
* </Input.File.DragAndDrop>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
const InputFileDragAndDrop = ({
|
|
24
|
+
className,
|
|
25
|
+
children,
|
|
26
|
+
onFilesChange,
|
|
27
|
+
disabled = false,
|
|
28
|
+
...restProps
|
|
29
|
+
}: InputFileDragAndDropProps) => {
|
|
30
|
+
// DragAndDrop에서 필요한 이벤트 핸들러만 useInputFile에서 선택적으로 가져온다.
|
|
31
|
+
const { isDragging, onDropFiles, onDragEnter, onDragLeave, onDragOver } =
|
|
32
|
+
useInputFile({
|
|
33
|
+
onFilesChange,
|
|
34
|
+
disabled,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
// 외부 className을 병합하되, 루트 훅 class는 고정한다.
|
|
40
|
+
className={clsx("input-file-drag-and-drop", className)}
|
|
41
|
+
// 시각 상태는 class 추가 대신 data-*로 노출한다.
|
|
42
|
+
data-dragging={isDragging ? "true" : undefined}
|
|
43
|
+
data-disabled={disabled ? "true" : undefined}
|
|
44
|
+
// DnD 이벤트는 훅에서 생성한 핸들러를 그대로 연결한다.
|
|
45
|
+
onDrop={onDropFiles}
|
|
46
|
+
onDragEnter={onDragEnter}
|
|
47
|
+
onDragLeave={onDragLeave}
|
|
48
|
+
onDragOver={onDragOver}
|
|
49
|
+
// div native props(aria-*, id 등)는 외부에서 확장 가능하게 유지한다.
|
|
50
|
+
{...restProps}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
InputFileDragAndDrop.displayName = "InputFileDragAndDrop";
|
|
58
|
+
|
|
59
|
+
export default InputFileDragAndDrop;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "../../../button";
|
|
4
|
+
import type { MouseEvent } from "react";
|
|
5
|
+
import type { InputFileUploadButtonProps } from "../../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input Markup; File Upload 트리거 버튼 컴포넌트
|
|
9
|
+
* @component
|
|
10
|
+
* @param {InputFileUploadButtonProps} props 업로드 버튼 props
|
|
11
|
+
* @param {InputFileOpenDialogHandler} [props.onOpenFileDialog] file dialog open 핸들러
|
|
12
|
+
* @param {ButtonProps} ... Button.Default props 전체를 그대로 전달
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* // 파일 선택창 오픈 동작만 추가된 Button.Default preset
|
|
16
|
+
* <Input.File.UploadButton
|
|
17
|
+
* fill="outlined"
|
|
18
|
+
* size="small"
|
|
19
|
+
* priority="primary"
|
|
20
|
+
* onOpenFileDialog={fileUpload.onOpenFileDialog}
|
|
21
|
+
* >
|
|
22
|
+
* 파일 선택
|
|
23
|
+
* </Input.File.UploadButton>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export default function InputFileUploadButton({
|
|
27
|
+
onOpenFileDialog,
|
|
28
|
+
onClick,
|
|
29
|
+
...buttonProps
|
|
30
|
+
}: InputFileUploadButtonProps) {
|
|
31
|
+
/**
|
|
32
|
+
* Input Markup; File Upload 버튼 클릭 병합 핸들러
|
|
33
|
+
* @param {MouseEvent<HTMLElement>} event 클릭 이벤트
|
|
34
|
+
*/
|
|
35
|
+
const handleButtonClick = (event: MouseEvent<HTMLElement>) => {
|
|
36
|
+
// 1) 업로드 preset 동작을 우선 실행한다.
|
|
37
|
+
onOpenFileDialog?.(event);
|
|
38
|
+
// 2) 소비자 onClick이 있으면 후속 실행한다.
|
|
39
|
+
onClick?.(event as never);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Button.Default의 렌더링/스타일 계약은 그대로 유지한다.
|
|
43
|
+
return <Button.Default {...buttonProps} onClick={handleButtonClick} />;
|
|
44
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { MouseEvent } from "react";
|
|
4
|
+
import { Chip } from "../../../chip";
|
|
5
|
+
import type { InputFileUploadedChipProps } from "../../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input Markup; File Upload 완료 상태 Chip 컴포넌트
|
|
9
|
+
* @component
|
|
10
|
+
* @param {InputFileUploadedChipProps} props 업로드 완료 칩 props
|
|
11
|
+
* @param {string} [props.fileId] 업로드 엔트리 id
|
|
12
|
+
* @param {string} props.fileName 업로드된 파일명
|
|
13
|
+
* @param {(fileId: string) => void} [props.onRemoveFromInput] input file 컨텍스트 제거 핸들러
|
|
14
|
+
* @param {() => void} [props.onClearSelectedFiles] fileId가 없을 때 선택 파일 초기화 핸들러
|
|
15
|
+
* @param {(event: MouseEvent<HTMLButtonElement>) => void} [props.onRemove] 파일 제거 핸들러
|
|
16
|
+
* @param {string} [props.removeButtonLabel="업로드 파일 삭제"] 제거 버튼 aria-label
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* // UploadedChip은 Chip(kind="input") preset으로 동작한다.
|
|
20
|
+
* <Input.File.UploadedChip
|
|
21
|
+
* fileId={meta.id}
|
|
22
|
+
* fileName={meta.name}
|
|
23
|
+
* onRemoveFromInput={onRemoveFileById}
|
|
24
|
+
* onRemove={onTrackRemove}
|
|
25
|
+
* />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export default function InputFileUploadedChip({
|
|
29
|
+
fileId,
|
|
30
|
+
fileName,
|
|
31
|
+
onRemoveFromInput,
|
|
32
|
+
onClearSelectedFiles,
|
|
33
|
+
onRemove,
|
|
34
|
+
removeButtonLabel = "업로드 파일 삭제",
|
|
35
|
+
...chipProps
|
|
36
|
+
}: InputFileUploadedChipProps) {
|
|
37
|
+
/**
|
|
38
|
+
* Input Markup; File Upload Chip 제거 병합 핸들러
|
|
39
|
+
* @param {MouseEvent<HTMLButtonElement>} event 제거 클릭 이벤트
|
|
40
|
+
*/
|
|
41
|
+
const handleRemove = (event: MouseEvent<HTMLButtonElement>) => {
|
|
42
|
+
// 기본 동작: fileId가 있으면 엔트리 제거, 없으면 선택 파일 전체를 초기화한다.
|
|
43
|
+
if (typeof fileId === "string" && fileId.length > 0) {
|
|
44
|
+
onRemoveFromInput?.(fileId);
|
|
45
|
+
} else {
|
|
46
|
+
onClearSelectedFiles?.();
|
|
47
|
+
}
|
|
48
|
+
// 추가 동작: 소비자 콜백을 후속으로 실행한다.
|
|
49
|
+
onRemove?.(event);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Chip
|
|
54
|
+
// 소비자 전달 Chip props(aria/className/data-*)를 유지한다.
|
|
55
|
+
{...chipProps}
|
|
56
|
+
// preset 목적상 kind는 input으로 고정한다.
|
|
57
|
+
kind="input"
|
|
58
|
+
// 기본 제거 동작 + 소비자 후속 콜백을 병합한 핸들러를 주입한다.
|
|
59
|
+
onRemove={handleRemove}
|
|
60
|
+
removeButtonLabel={removeButtonLabel}
|
|
61
|
+
>
|
|
62
|
+
{/* 파일명은 children slot으로 렌더링한다. */}
|
|
63
|
+
{fileName}
|
|
64
|
+
</Chip>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import InputFileDragAndDrop from "./DragAndDrop";
|
|
2
|
+
import InputFileUploadButton from "./UploadButton";
|
|
3
|
+
import InputFileUploadedChip from "./UploadedChip";
|
|
4
|
+
import { InputFileList } from "./list";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Input Markup; File 네임스페이스
|
|
8
|
+
* @desc
|
|
9
|
+
* - DragAndDrop: 드래그 앤 드롭 입력 영역
|
|
10
|
+
* - UploadButton: 파일 선택창 트리거 버튼
|
|
11
|
+
* - UploadedChip: 업로드 완료 파일 표현/제거 preset
|
|
12
|
+
* - List: headless 파일 목록 구조(Container/Header/Body/Item/Remove)
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <Input.File.UploadButton onOpenFileDialog={fileUpload.onOpenFileDialog}>
|
|
16
|
+
* 파일 추가
|
|
17
|
+
* </Input.File.UploadButton>
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export const InputFile = {
|
|
21
|
+
DragAndDrop: InputFileDragAndDrop,
|
|
22
|
+
UploadButton: InputFileUploadButton,
|
|
23
|
+
UploadedChip: InputFileUploadedChip,
|
|
24
|
+
List: InputFileList,
|
|
25
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import type { InputFileListBodyProps } from "../../../types";
|
|
5
|
+
import { useInputFileListContext } from "./Provider";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input Markup; File List 본문 컴포넌트
|
|
9
|
+
* @component
|
|
10
|
+
* @param {InputFileListBodyProps} props list 본문 props
|
|
11
|
+
* @param {ReactNode} props.children 목록 콘텐츠
|
|
12
|
+
* @param {boolean} [props.forceMount=false] 접힘 상태에서도 DOM 유지 여부
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* // forceMount=true면 collapsed 상태에서도 DOM을 유지한다.
|
|
16
|
+
* <Input.File.List.Body>
|
|
17
|
+
* <Input.File.List.Item>...</Input.File.List.Item>
|
|
18
|
+
* </Input.File.List.Body>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
const InputFileListBody = ({
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
forceMount = false,
|
|
25
|
+
...restProps
|
|
26
|
+
}: InputFileListBodyProps) => {
|
|
27
|
+
// List 공유 컨텍스트(접힘 상태)를 조회한다.
|
|
28
|
+
const context = useInputFileListContext();
|
|
29
|
+
// collapsible + collapsed 조합일 때만 실제 접힘 상태로 간주한다.
|
|
30
|
+
const isCollapsed = Boolean(context?.collapsible && context.collapsed);
|
|
31
|
+
|
|
32
|
+
// forceMount=false인 경우 접힘 상태에서는 렌더를 생략한다.
|
|
33
|
+
if (isCollapsed && !forceMount) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
// 루트 class + 외부 className 병합
|
|
40
|
+
className={clsx("input-file-list-body", className)}
|
|
41
|
+
// hidden 속성으로 스크린리더/레이아웃 동작을 일관되게 제어한다.
|
|
42
|
+
hidden={isCollapsed}
|
|
43
|
+
data-collapsed={isCollapsed ? "true" : undefined}
|
|
44
|
+
// div native props(id, aria-*)는 확장 가능하게 전달한다.
|
|
45
|
+
{...restProps}
|
|
46
|
+
>
|
|
47
|
+
{/* 접힘 상태에서도 forceMount 옵션으로 측정/애니메이션 요구를 대응할 수 있다. */}
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
InputFileListBody.displayName = "InputFileListBody";
|
|
54
|
+
|
|
55
|
+
export default InputFileListBody;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import clsx from "clsx";
|
|
5
|
+
import type { InputFileListContainerProps } from "../../../types";
|
|
6
|
+
import { InputFileListContextProvider } from "./Provider";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Input Markup; File List 컨테이너 컴포넌트
|
|
10
|
+
* @component
|
|
11
|
+
* @param {InputFileListContainerProps} props list 컨테이너 props
|
|
12
|
+
* @param {ReactNode} props.children list 하위 슬롯 콘텐츠
|
|
13
|
+
* @param {boolean} [props.collapsible] 헤더 트리거 기반 접기/펼치기 활성화 여부
|
|
14
|
+
* @param {boolean} [props.defaultCollapsed=false] uncontrolled 초기 접힘 상태
|
|
15
|
+
* @param {boolean} [props.collapsed] controlled 접힘 상태
|
|
16
|
+
* @param {(collapsed: boolean) => void} [props.onCollapsedChange] 접힘 상태 변경 콜백
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* // Header를 trigger로 사용해 List Body를 접고 펼친다.
|
|
20
|
+
* <Input.File.List.Container collapsible>
|
|
21
|
+
* <Input.File.List.Header asTrigger>첨부파일 3</Input.File.List.Header>
|
|
22
|
+
* <Input.File.List.Body>
|
|
23
|
+
* {fileContext.fileMetas.map(meta => (
|
|
24
|
+
* // 렌더링 key는 domain/category/item depth 포맷을 사용한다.
|
|
25
|
+
* <Input.File.List.Item key={`input/file/list/item/${meta.id}`}>
|
|
26
|
+
* {meta.name}
|
|
27
|
+
* </Input.File.List.Item>
|
|
28
|
+
* ))}
|
|
29
|
+
* </Input.File.List.Body>
|
|
30
|
+
* </Input.File.List.Container>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
const InputFileListContainer = ({
|
|
34
|
+
className,
|
|
35
|
+
children,
|
|
36
|
+
collapsible = false,
|
|
37
|
+
defaultCollapsed = false,
|
|
38
|
+
collapsed,
|
|
39
|
+
onCollapsedChange,
|
|
40
|
+
...restProps
|
|
41
|
+
}: InputFileListContainerProps) => {
|
|
42
|
+
// uncontrolled 모드에서만 사용하는 내부 접힘 상태다.
|
|
43
|
+
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
|
|
44
|
+
// collapsed prop 제공 여부로 controlled/uncontrolled 모드를 구분한다.
|
|
45
|
+
const isControlled = typeof collapsed === "boolean";
|
|
46
|
+
// 최종 접힘 상태는 controlled 우선으로 계산한다.
|
|
47
|
+
const resolvedCollapsed = isControlled ? collapsed : internalCollapsed;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Input Markup; File List 접힘 상태 토글 핸들러
|
|
51
|
+
*/
|
|
52
|
+
const onToggleCollapsed = useCallback(() => {
|
|
53
|
+
// collapsible=false면 토글 동작을 비활성화한다.
|
|
54
|
+
if (!collapsible) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// 현재 상태를 반전한 다음 상태를 계산한다.
|
|
58
|
+
const nextCollapsed = !resolvedCollapsed;
|
|
59
|
+
// uncontrolled 모드일 때만 내부 상태를 갱신한다.
|
|
60
|
+
if (!isControlled) {
|
|
61
|
+
setInternalCollapsed(nextCollapsed);
|
|
62
|
+
}
|
|
63
|
+
// 외부 콜백이 있으면 상태 변경을 통지한다.
|
|
64
|
+
onCollapsedChange?.(nextCollapsed);
|
|
65
|
+
}, [collapsible, isControlled, onCollapsedChange, resolvedCollapsed]);
|
|
66
|
+
|
|
67
|
+
// Provider value를 메모화해 불필요한 하위 리렌더를 줄인다.
|
|
68
|
+
const contextValue = useMemo(
|
|
69
|
+
() => ({
|
|
70
|
+
collapsible,
|
|
71
|
+
collapsed: resolvedCollapsed,
|
|
72
|
+
onToggleCollapsed,
|
|
73
|
+
}),
|
|
74
|
+
[collapsible, resolvedCollapsed, onToggleCollapsed],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<InputFileListContextProvider value={contextValue}>
|
|
79
|
+
<section
|
|
80
|
+
// 루트 클래스는 고정하고 외부 className을 병합한다.
|
|
81
|
+
className={clsx("input-file-list-container", className)}
|
|
82
|
+
// 상태는 data-*로 노출해 SCSS에서 조건 스타일을 처리한다.
|
|
83
|
+
data-collapsible={collapsible ? "true" : undefined}
|
|
84
|
+
data-collapsed={resolvedCollapsed ? "true" : undefined}
|
|
85
|
+
// section native props(id, aria-*)는 확장 가능하게 전달한다.
|
|
86
|
+
{...restProps}
|
|
87
|
+
>
|
|
88
|
+
{/* List 루트 상태를 data-* 속성으로 고정해 외부 SCSS 제어를 단순화한다. */}
|
|
89
|
+
{children}
|
|
90
|
+
</section>
|
|
91
|
+
</InputFileListContextProvider>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
InputFileListContainer.displayName = "InputFileListContainer";
|
|
96
|
+
|
|
97
|
+
export default InputFileListContainer;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import type { KeyboardEvent, MouseEvent } from "react";
|
|
5
|
+
import type { InputFileListHeaderProps } from "../../../types";
|
|
6
|
+
import { useInputFileListContext } from "./Provider";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Input Markup; File List 헤더 컴포넌트
|
|
10
|
+
* @component
|
|
11
|
+
* @param {InputFileListHeaderProps} props list 헤더 props
|
|
12
|
+
* @param {ReactNode} props.children 헤더 콘텐츠
|
|
13
|
+
* @param {boolean} [props.asTrigger=false] 클릭/키보드 트리거로 접기/펼치기 제어 여부
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* // Header 클릭/키보드 Enter, Space로 collapsed 상태를 전환한다.
|
|
17
|
+
* <Input.File.List.Header asTrigger>
|
|
18
|
+
* <span>첨부파일 3</span>
|
|
19
|
+
* </Input.File.List.Header>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
const InputFileListHeader = ({
|
|
23
|
+
className,
|
|
24
|
+
children,
|
|
25
|
+
asTrigger = false,
|
|
26
|
+
onClick,
|
|
27
|
+
onKeyDown,
|
|
28
|
+
...restProps
|
|
29
|
+
}: InputFileListHeaderProps) => {
|
|
30
|
+
// Provider에서 공유한 collapsible/collapsed 컨텍스트를 조회한다.
|
|
31
|
+
const context = useInputFileListContext();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Input Markup; File List 헤더 클릭 핸들러
|
|
35
|
+
* @param {MouseEvent<HTMLDivElement>} event 헤더 클릭 이벤트
|
|
36
|
+
*/
|
|
37
|
+
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
38
|
+
// 1) 소비자 onClick을 우선 실행한다.
|
|
39
|
+
onClick?.(event);
|
|
40
|
+
// 2) preventDefault되지 않았고 trigger 모드면 collapsed를 토글한다.
|
|
41
|
+
if (!event.defaultPrevented && asTrigger) {
|
|
42
|
+
context?.onToggleCollapsed();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Input Markup; File List 헤더 키보드 핸들러
|
|
48
|
+
* @param {KeyboardEvent<HTMLDivElement>} event 키보드 이벤트
|
|
49
|
+
*/
|
|
50
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
51
|
+
// 1) 소비자 onKeyDown을 우선 실행한다.
|
|
52
|
+
onKeyDown?.(event);
|
|
53
|
+
// 2) trigger가 아니거나 preventDefault면 토글을 수행하지 않는다.
|
|
54
|
+
if (event.defaultPrevented || !asTrigger) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// 3) Enter/Space에서 button과 유사한 접근성 동작을 제공한다.
|
|
58
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
context?.onToggleCollapsed();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
// 루트 class + 외부 className 병합
|
|
67
|
+
className={clsx("input-file-list-header", className)}
|
|
68
|
+
// 헤더의 트리거/상태 정보를 data-*로 노출한다.
|
|
69
|
+
data-trigger={asTrigger ? "true" : undefined}
|
|
70
|
+
data-collapsible={context?.collapsible ? "true" : undefined}
|
|
71
|
+
data-collapsed={context?.collapsed ? "true" : undefined}
|
|
72
|
+
// trigger 모드면 div를 button처럼 접근 가능하게 설정한다.
|
|
73
|
+
role={asTrigger ? "button" : undefined}
|
|
74
|
+
tabIndex={asTrigger ? 0 : restProps.tabIndex}
|
|
75
|
+
onClick={handleClick}
|
|
76
|
+
onKeyDown={handleKeyDown}
|
|
77
|
+
{...restProps}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
InputFileListHeader.displayName = "InputFileListHeader";
|
|
85
|
+
|
|
86
|
+
export default InputFileListHeader;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import clsx from "clsx";
|
|
2
|
+
import type { InputFileListItemProps } from "../../../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Input Markup; File List 항목 컴포넌트
|
|
6
|
+
* @component
|
|
7
|
+
* @param {InputFileListItemProps} props list 항목 props
|
|
8
|
+
* @param {ReactNode} props.children 파일 항목 콘텐츠
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* // Item 내부 배치는 서비스/도메인에서 자유롭게 구성한다.
|
|
12
|
+
* fileContext.fileMetas.map(meta => (
|
|
13
|
+
* <Input.File.List.Item key={`input/file/list/item/${meta.id}`}>
|
|
14
|
+
* <span>{meta.name}</span>
|
|
15
|
+
* <span>{meta.sizeLabel}</span>
|
|
16
|
+
* </Input.File.List.Item>
|
|
17
|
+
* ));
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
const InputFileListItem = ({
|
|
21
|
+
className,
|
|
22
|
+
children,
|
|
23
|
+
...restProps
|
|
24
|
+
}: InputFileListItemProps) => {
|
|
25
|
+
return (
|
|
26
|
+
// Item은 레이아웃 뼈대만 제공하고 콘텐츠/행동은 children으로 위임한다.
|
|
27
|
+
<div className={clsx("input-file-list-item", className)} {...restProps}>
|
|
28
|
+
{children}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
InputFileListItem.displayName = "InputFileListItem";
|
|
34
|
+
|
|
35
|
+
export default InputFileListItem;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
import type { InputFileListContextValue } from "../../../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Input Markup; File List 컨텍스트 인스턴스
|
|
8
|
+
*/
|
|
9
|
+
const InputFileListContext = createContext<InputFileListContextValue | null>(
|
|
10
|
+
null,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Input Markup; File List 컨텍스트 생성 헬퍼
|
|
15
|
+
*/
|
|
16
|
+
export const InputFileListContextProvider = InputFileListContext.Provider;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Input Hook; File List 컨텍스트 조회 Hook
|
|
20
|
+
* @hook
|
|
21
|
+
* @returns {InputFileListContextValue | null} List 컨텍스트 값(null이면 Container 외부)
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* // Container 내부에서는 컨텍스트 값이 존재한다.
|
|
25
|
+
* const fileListContext = useInputFileListContext();
|
|
26
|
+
* if (!fileListContext) {
|
|
27
|
+
* return null;
|
|
28
|
+
* }
|
|
29
|
+
* return <span>{fileListContext.collapsed ? "닫힘" : "열림"}</span>;
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export const useInputFileListContext = (): InputFileListContextValue | null => {
|
|
33
|
+
// createContext로 생성한 List 컨텍스트를 조회한다.
|
|
34
|
+
return useContext(InputFileListContext);
|
|
35
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import type { MouseEvent } from "react";
|
|
5
|
+
import type { InputFileListRemoveProps } from "../../../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input Markup; File List 제거 액션 컴포넌트
|
|
9
|
+
* @component
|
|
10
|
+
* @param {InputFileListRemoveProps} props 제거 액션 props
|
|
11
|
+
* @param {ReactNode} [props.children] 제거 버튼 콘텐츠
|
|
12
|
+
* @param {(event: MouseEvent<HTMLButtonElement>) => void} [props.onRemove] 제거 핸들러
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* // Remove는 삭제 동작 트리거만 제공하고 라벨/아이콘은 외부에서 주입한다.
|
|
16
|
+
* <Input.File.List.Remove onRemove={() => onRemoveFileById(meta.id)}>
|
|
17
|
+
* 삭제
|
|
18
|
+
* </Input.File.List.Remove>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
const InputFileListRemove = ({
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
onRemove,
|
|
25
|
+
type = "button",
|
|
26
|
+
...restProps
|
|
27
|
+
}: InputFileListRemoveProps) => {
|
|
28
|
+
/**
|
|
29
|
+
* Input Markup; File List 제거 클릭 핸들러
|
|
30
|
+
* @param {MouseEvent<HTMLButtonElement>} event 제거 클릭 이벤트
|
|
31
|
+
*/
|
|
32
|
+
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
33
|
+
// 소비자가 전달한 제거 콜백을 실행한다.
|
|
34
|
+
onRemove?.(event);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
// 제출 버튼 오작동을 방지하기 위해 기본 type은 button으로 고정한다.
|
|
40
|
+
type={type}
|
|
41
|
+
// 루트 class + 외부 className 병합
|
|
42
|
+
className={clsx("input-file-list-remove", className)}
|
|
43
|
+
onClick={handleClick}
|
|
44
|
+
// button native props(aria-*, disabled 등)는 그대로 전달한다.
|
|
45
|
+
{...restProps}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
InputFileListRemove.displayName = "InputFileListRemove";
|
|
53
|
+
|
|
54
|
+
export default InputFileListRemove;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import InputFileListContainer from "./Container";
|
|
2
|
+
import InputFileListHeader from "./Header";
|
|
3
|
+
import InputFileListBody from "./Body";
|
|
4
|
+
import InputFileListItem from "./Item";
|
|
5
|
+
import InputFileListRemove from "./Remove";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input Markup; File List 네임스페이스
|
|
9
|
+
* @desc
|
|
10
|
+
* - Container: collapsible 상태를 관리하는 루트 래퍼
|
|
11
|
+
* - Header: trigger 모드(asTrigger) 지원 헤더
|
|
12
|
+
* - Body: collapsed 상태를 반영하는 목록 본문
|
|
13
|
+
* - Item: 파일 행 레이아웃 뼈대
|
|
14
|
+
* - Remove: 파일 제거 액션 버튼
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <Input.File.List.Container collapsible>
|
|
18
|
+
* <Input.File.List.Header asTrigger>첨부파일 2</Input.File.List.Header>
|
|
19
|
+
* <Input.File.List.Body>
|
|
20
|
+
* <Input.File.List.Item>...</Input.File.List.Item>
|
|
21
|
+
* </Input.File.List.Body>
|
|
22
|
+
* </Input.File.List.Container>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export const InputFileList = {
|
|
26
|
+
Container: InputFileListContainer,
|
|
27
|
+
Header: InputFileListHeader,
|
|
28
|
+
Body: InputFileListBody,
|
|
29
|
+
Item: InputFileListItem,
|
|
30
|
+
Remove: InputFileListRemove,
|
|
31
|
+
};
|
|
@@ -2,10 +2,12 @@ import { InputFoundation } from "./foundation";
|
|
|
2
2
|
import { InputText } from "./text";
|
|
3
3
|
import { InputDate } from "./date";
|
|
4
4
|
import { InputAddress } from "./address";
|
|
5
|
+
import { InputFile } from "./file";
|
|
5
6
|
|
|
6
7
|
export const Input = {
|
|
7
8
|
...InputFoundation,
|
|
8
9
|
Text: InputText,
|
|
9
10
|
Date: InputDate,
|
|
10
11
|
Address: InputAddress,
|
|
12
|
+
File: InputFile,
|
|
11
13
|
};
|