@tide-tds/components 0.3.1 → 0.4.0
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/Accordion.d.ts +66 -0
- package/dist/Accordion.js +127 -0
- package/dist/AlertDialog.d.ts +8 -0
- package/dist/AlertDialog.js +18 -0
- package/dist/Combobox.d.ts +59 -0
- package/dist/Combobox.js +206 -0
- package/dist/Dialog.d.ts +70 -0
- package/dist/Dialog.js +255 -0
- package/dist/Input.d.ts +2 -5
- package/dist/Input.js +2 -8
- package/dist/Menu.d.ts +72 -0
- package/dist/Menu.js +226 -0
- package/dist/Select.d.ts +62 -0
- package/dist/Select.js +169 -0
- package/dist/Tabs.d.ts +66 -0
- package/dist/Tabs.js +159 -0
- package/dist/Tag.d.ts +48 -0
- package/dist/Tag.js +102 -0
- package/dist/Tooltip.d.ts +53 -0
- package/dist/Tooltip.js +129 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +10 -1
- package/dist/input-style.d.ts +3 -0
- package/dist/input-style.js +7 -1
- package/dist/list-collection.d.ts +23 -0
- package/dist/list-collection.js +99 -0
- package/dist/list-style.js +45 -0
- package/dist/listbox.d.ts +15 -0
- package/dist/listbox.js +90 -0
- package/dist/overlay-style.js +50 -1
- package/dist/overlay.js +27 -3
- package/package.json +3 -3
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
2
|
+
/** 트리거를 감싸는 제목 요소의 단계 */
|
|
3
|
+
export type AccordionHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
4
|
+
export interface AccordionProps extends Omit<ComponentProps<"div">, "defaultValue" | "onChange"> {
|
|
5
|
+
/**
|
|
6
|
+
* 펼친 항목의 값 목록(제어). 주면 `onValueChange`로 바꾼다.
|
|
7
|
+
* `multiple`이 아닌데 값이 둘 이상이면 첫 값만 남기며 `onValueChange`를 부른다.
|
|
8
|
+
*/
|
|
9
|
+
value?: readonly string[];
|
|
10
|
+
/** 처음 펼친 항목의 값 목록(비제어). 기본값은 빈 목록(모두 접힘)이다. */
|
|
11
|
+
defaultValue?: readonly string[];
|
|
12
|
+
/** 펼친 항목이 바뀌려 할 때 새 값 목록으로 부른다. 목록 순서는 펼친 순서다. */
|
|
13
|
+
onValueChange?: (value: string[]) => void;
|
|
14
|
+
/**
|
|
15
|
+
* 여러 항목을 함께 펼칠지. 기본값은 `false`로, 한 항목을 펼치면 펼쳐 있던 항목이 접힌다.
|
|
16
|
+
* 어느 쪽이든 펼친 항목을 다시 누르면 접힌다(모두 접을 수 있다).
|
|
17
|
+
*/
|
|
18
|
+
multiple?: boolean;
|
|
19
|
+
/** `AccordionItem`들 */
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 접고 펴는 영역 묶음. 루트는 `<div>`(radius `lg`, 장식용 경계)이고 안에 `AccordionItem`을 둔다.
|
|
24
|
+
* 기본은 한 번에 한 항목만 펼친다(`multiple`이면 여럿).
|
|
25
|
+
*/
|
|
26
|
+
export declare function Accordion({ value, defaultValue, onValueChange, multiple, ref, className, children, ...props }: AccordionProps): ReactElement;
|
|
27
|
+
export interface AccordionItemProps extends Omit<ComponentProps<"div">, "value"> {
|
|
28
|
+
/** 항목을 가리키는 값. `Accordion`의 `value`·`defaultValue` 목록에 이 값을 넣으면 펼친다. */
|
|
29
|
+
value: string;
|
|
30
|
+
/** 펼치거나 접을 수 없게 한다. 트리거가 네이티브 `disabled`가 된다. 펼친 채 비활성이면 펼친 채로 남는다. */
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
/** `AccordionTrigger` 하나와 `AccordionPanel` 하나 */
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 항목 하나. 안에 `AccordionTrigger`와 `AccordionPanel`을 둔다.
|
|
37
|
+
* 펼쳐 있으면 `data-open`, 비활성이면 `data-disabled`가 붙는다.
|
|
38
|
+
*/
|
|
39
|
+
export declare function AccordionItem({ value, disabled, ref, className, children, ...props }: AccordionItemProps): ReactElement;
|
|
40
|
+
export interface AccordionTriggerProps extends Omit<ComponentProps<"button">, "type" | "id" | "disabled" | "aria-expanded" | "aria-controls"> {
|
|
41
|
+
/**
|
|
42
|
+
* 트리거를 감싸는 제목 요소의 단계(`<h1>`~`<h6>`). 기본값은 `3`이다.
|
|
43
|
+
* 페이지 제목 구조에 맞춘다(WAI-ARIA APG Accordion).
|
|
44
|
+
*/
|
|
45
|
+
level?: AccordionHeadingLevel;
|
|
46
|
+
/** 항목 제목. 제목 요소 안에는 버튼만 둔다(APG). */
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 항목 제목 버튼. 제목 요소(`level`, 기본 `<h3>`) 안의 `<button type="button">`이고 끝에 장식용 chevron이 있다.
|
|
51
|
+
* `ref`·`className`·나머지 props는 버튼에 간다. 펼침 상태는 `aria-expanded`와 `data-open`, 비활성은 `:disabled`다.
|
|
52
|
+
* Enter·Space·누름으로 펼치고 접는다.
|
|
53
|
+
*/
|
|
54
|
+
export declare function AccordionTrigger({ level, ref, className, children, ...props }: AccordionTriggerProps): ReactElement;
|
|
55
|
+
export interface AccordionPanelProps extends Omit<ComponentProps<"div">, "id" | "role" | "hidden" | "aria-labelledby"> {
|
|
56
|
+
/**
|
|
57
|
+
* 패널 역할. 기본값은 `region`(트리거 이름이 붙은 영역, APG 예시)이다.
|
|
58
|
+
* 함께 펼칠 수 있는 패널이 많으면(대략 6개 넘음) 영역 랜드마크가 너무 늘지 않게 `group`으로 둔다(APG).
|
|
59
|
+
*/
|
|
60
|
+
role?: "region" | "group";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 항목 내용. 트리거가 `aria-controls`로 가리키고 트리거 글자가 이름이 된다(`aria-labelledby`).
|
|
64
|
+
* 접혀 있으면 숨는다(`hidden`). 늘 렌더하므로 안의 입력 상태는 접어도 남는다.
|
|
65
|
+
*/
|
|
66
|
+
export declare function AccordionPanel({ role, ref, className, ...props }: AccordionPanelProps): ReactElement;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { createContext, use, useMemo, useRef } from "react";
|
|
6
|
+
import { ChevronDownIcon } from "@tide-tds/icons";
|
|
7
|
+
import { mergeProps, mergeRefs, useButton, useDisclosure } from "react-aria";
|
|
8
|
+
import { useDisclosureGroupState, useDisclosureState } from "react-stately";
|
|
9
|
+
|
|
10
|
+
// src/Accordion.tsx
|
|
11
|
+
const AccordionContext = createContext(null);
|
|
12
|
+
const AccordionItemContext = createContext(null);
|
|
13
|
+
function useAccordionItemContext(part) {
|
|
14
|
+
const context = use(AccordionItemContext);
|
|
15
|
+
if (!context) throw new Error(`${part}는 AccordionItem 안에 둔다.`);
|
|
16
|
+
return context;
|
|
17
|
+
}
|
|
18
|
+
const accordionClass = "rounded-lg border border-default text-default";
|
|
19
|
+
const itemClass = "not-first:border-t not-first:border-default";
|
|
20
|
+
const triggerClass = [
|
|
21
|
+
"group flex flex-1 cursor-pointer items-center gap-3 rounded-md px-4 py-3 text-start text-label-lg outline-none select-none",
|
|
22
|
+
"enabled:hover:bg-neutral-subtle-hover focus-visible:focus-ring",
|
|
23
|
+
"disabled:cursor-not-allowed disabled:text-disabled"
|
|
24
|
+
].join(" ");
|
|
25
|
+
const chevronClass = "ms-auto shrink-0 text-muted group-hover:group-enabled:text-default group-disabled:text-disabled group-aria-expanded:rotate-180 motion-safe:transition-[rotate] forced-colors:text-current forced-colors:group-disabled:text-current";
|
|
26
|
+
const panelClass = "px-4 pb-4 text-body-md [&[hidden]]:p-0";
|
|
27
|
+
/**
|
|
28
|
+
* 접고 펴는 영역 묶음. 루트는 `<div>`(radius `lg`, 장식용 경계)이고 안에 `AccordionItem`을 둔다.
|
|
29
|
+
* 기본은 한 번에 한 항목만 펼친다(`multiple`이면 여럿).
|
|
30
|
+
*/
|
|
31
|
+
function Accordion({ value, defaultValue, onValueChange, multiple = false, ref, className, children, ...props }) {
|
|
32
|
+
const state = useDisclosureGroupState({
|
|
33
|
+
allowsMultipleExpanded: multiple,
|
|
34
|
+
expandedKeys: value,
|
|
35
|
+
defaultExpandedKeys: defaultValue,
|
|
36
|
+
onExpandedChange: onValueChange && ((keys) => onValueChange([...keys].map(String)))
|
|
37
|
+
});
|
|
38
|
+
return /* @__PURE__ */ jsx("div", {
|
|
39
|
+
...props,
|
|
40
|
+
ref,
|
|
41
|
+
className: cn(accordionClass, className),
|
|
42
|
+
children: /* @__PURE__ */ jsx(AccordionContext, {
|
|
43
|
+
value: state,
|
|
44
|
+
children
|
|
45
|
+
})
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 항목 하나. 안에 `AccordionTrigger`와 `AccordionPanel`을 둔다.
|
|
50
|
+
* 펼쳐 있으면 `data-open`, 비활성이면 `data-disabled`가 붙는다.
|
|
51
|
+
*/
|
|
52
|
+
function AccordionItem({ value, disabled = false, ref, className, children, ...props }) {
|
|
53
|
+
const group = use(AccordionContext);
|
|
54
|
+
if (!group) throw new Error("AccordionItem는 Accordion 안에 둔다.");
|
|
55
|
+
const panelRef = useRef(null);
|
|
56
|
+
const state = useDisclosureState({
|
|
57
|
+
isExpanded: group.expandedKeys.has(value),
|
|
58
|
+
onExpandedChange: () => group.toggleKey(value)
|
|
59
|
+
});
|
|
60
|
+
const { buttonProps, panelProps } = useDisclosure({ isDisabled: disabled }, state, panelRef);
|
|
61
|
+
const context = useMemo(() => ({
|
|
62
|
+
expanded: state.isExpanded,
|
|
63
|
+
disabled,
|
|
64
|
+
buttonProps,
|
|
65
|
+
panelProps,
|
|
66
|
+
panelRef
|
|
67
|
+
}), [
|
|
68
|
+
state.isExpanded,
|
|
69
|
+
disabled,
|
|
70
|
+
buttonProps,
|
|
71
|
+
panelProps
|
|
72
|
+
]);
|
|
73
|
+
return /* @__PURE__ */ jsx("div", {
|
|
74
|
+
...props,
|
|
75
|
+
ref,
|
|
76
|
+
"data-open": state.isExpanded ? "" : void 0,
|
|
77
|
+
"data-disabled": disabled ? "" : void 0,
|
|
78
|
+
className: cn(itemClass, className),
|
|
79
|
+
children: /* @__PURE__ */ jsx(AccordionItemContext, {
|
|
80
|
+
value: context,
|
|
81
|
+
children
|
|
82
|
+
})
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 항목 제목 버튼. 제목 요소(`level`, 기본 `<h3>`) 안의 `<button type="button">`이고 끝에 장식용 chevron이 있다.
|
|
87
|
+
* `ref`·`className`·나머지 props는 버튼에 간다. 펼침 상태는 `aria-expanded`와 `data-open`, 비활성은 `:disabled`다.
|
|
88
|
+
* Enter·Space·누름으로 펼치고 접는다.
|
|
89
|
+
*/
|
|
90
|
+
function AccordionTrigger({ level = 3, ref, className, children, ...props }) {
|
|
91
|
+
const item = useAccordionItemContext("AccordionTrigger");
|
|
92
|
+
const buttonRef = useRef(null);
|
|
93
|
+
const { buttonProps } = useButton(item.buttonProps, buttonRef);
|
|
94
|
+
const mergedRef = useMemo(() => mergeRefs(ref, buttonRef), [ref, buttonRef]);
|
|
95
|
+
const Heading = `h${level}`;
|
|
96
|
+
return /* @__PURE__ */ jsx(Heading, {
|
|
97
|
+
className: "flex",
|
|
98
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
99
|
+
...mergeProps(props, buttonProps),
|
|
100
|
+
ref: mergedRef,
|
|
101
|
+
type: "button",
|
|
102
|
+
"data-open": item.expanded ? "" : void 0,
|
|
103
|
+
className: cn(triggerClass, className),
|
|
104
|
+
children: [children, /* @__PURE__ */ jsx(ChevronDownIcon, {
|
|
105
|
+
size: 16,
|
|
106
|
+
className: chevronClass
|
|
107
|
+
})]
|
|
108
|
+
})
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 항목 내용. 트리거가 `aria-controls`로 가리키고 트리거 글자가 이름이 된다(`aria-labelledby`).
|
|
113
|
+
* 접혀 있으면 숨는다(`hidden`). 늘 렌더하므로 안의 입력 상태는 접어도 남는다.
|
|
114
|
+
*/
|
|
115
|
+
function AccordionPanel({ role = "region", ref, className, ...props }) {
|
|
116
|
+
const item = useAccordionItemContext("AccordionPanel");
|
|
117
|
+
const mergedRef = useMemo(() => mergeRefs(ref, item.panelRef), [ref, item.panelRef]);
|
|
118
|
+
return /* @__PURE__ */ jsx("div", {
|
|
119
|
+
...mergeProps(props, item.panelProps),
|
|
120
|
+
ref: mergedRef,
|
|
121
|
+
role,
|
|
122
|
+
"data-open": item.expanded ? "" : void 0,
|
|
123
|
+
className: cn(panelClass, className)
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { Accordion, AccordionItem, AccordionPanel, AccordionTrigger };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { DialogProps } from "./Dialog.js";
|
|
2
|
+
import { ReactElement } from "react";
|
|
3
|
+
export type AlertDialogProps = DialogProps;
|
|
4
|
+
/**
|
|
5
|
+
* 삭제 확인처럼 응답이 꼭 필요한 모달의 열림 상태. DOM 요소를 만들지 않는다.
|
|
6
|
+
* 안에 `DialogTrigger`와 `DialogContent`를 두면 내용이 `role="alertdialog"`가 된다.
|
|
7
|
+
*/
|
|
8
|
+
export declare function AlertDialog(props: AlertDialogProps): ReactElement;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { DialogRoot } from "./Dialog.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
|
|
6
|
+
// src/AlertDialog.tsx
|
|
7
|
+
/**
|
|
8
|
+
* 삭제 확인처럼 응답이 꼭 필요한 모달의 열림 상태. DOM 요소를 만들지 않는다.
|
|
9
|
+
* 안에 `DialogTrigger`와 `DialogContent`를 두면 내용이 `role="alertdialog"`가 된다.
|
|
10
|
+
*/
|
|
11
|
+
function AlertDialog(props) {
|
|
12
|
+
return /* @__PURE__ */ jsx(DialogRoot, {
|
|
13
|
+
...props,
|
|
14
|
+
alert: true
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { AlertDialog };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { InputSize } from "./input-style.js";
|
|
2
|
+
import { ListBoxOptionProps, ListBoxSectionProps } from "./listbox.js";
|
|
3
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
4
|
+
export interface ComboboxProps extends Omit<ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "type" | "size" | "children" | "name" | "form" | "list"> {
|
|
5
|
+
/** 고른 값(제어). 고른 옵션의 `value`다. `null`이면 아무것도 고르지 않은 상태다. 주면 `onValueChange`로 바꾼다. */
|
|
6
|
+
value?: string | null;
|
|
7
|
+
/** 처음 고른 값(비제어). 입력에 그 옵션의 글자가 보인다. 폼을 reset하면 이 값으로 돌아간다. */
|
|
8
|
+
defaultValue?: string;
|
|
9
|
+
/**
|
|
10
|
+
* 고른 값이 바뀌면 부른다. 옵션을 고르면 그 `value`로, 사용자가 입력을 모두 지우면 `null`로 부른다.
|
|
11
|
+
*/
|
|
12
|
+
onValueChange?: (value: string | null) => void;
|
|
13
|
+
/** 목록이 열리거나 닫힐 때 새 상태로 부른다. 열림 상태는 입력이 정하므로 제어할 수 없다. */
|
|
14
|
+
onOpenChange?: (open: boolean) => void;
|
|
15
|
+
/** 거른 결과가 없을 때 목록에 보이는 글자. 기본값은 `"결과가 없습니다"`다. */
|
|
16
|
+
emptyMessage?: ReactNode;
|
|
17
|
+
/** 폼 제출 이름. 숨은 input에 붙어 고른 옵션의 `value`가 이 이름으로 제출된다(입력한 글자는 제출되지 않는다). */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** 숨은 input이 속할 `<form>`의 id. 폼 밖에 둘 때 쓴다. */
|
|
20
|
+
form?: string;
|
|
21
|
+
/** 높이 `sm` 32px, `md` 40px, `lg` 48px. 같은 size의 Input·Select·Button과 높이가 같다. 기본값은 `md`다. */
|
|
22
|
+
size?: InputSize;
|
|
23
|
+
/**
|
|
24
|
+
* 오류 상태. 입력에 `aria-invalid="true"`와 `data-invalid`가 붙고 경계가 danger 색이 된다.
|
|
25
|
+
* Field 안에서는 Field의 값을 따르고, 여기에 준 값이 이긴다.
|
|
26
|
+
*/
|
|
27
|
+
invalid?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* 필수. 비어 있으면 폼 제출이 막히고 입력으로 포커스가 오며 오류 상태가 된다(브라우저 오류 말풍선은 없다).
|
|
30
|
+
* 메시지는 Field의 `error`로 앱이 보여 준다. Field 안에서는 Field의 값을 따른다.
|
|
31
|
+
*/
|
|
32
|
+
required?: boolean;
|
|
33
|
+
/** `ComboboxItem`과 `ComboboxSection`. 이 요소들을 바로 아래(또는 Fragment·배열 안)에 둔다. */
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 글자를 쳐서 옵션을 거르고 하나를 고르는 입력. 치면 목록이 열리고 옵션 글자에 친 글자가 들어 있는 옵션만 남는다
|
|
38
|
+
* (대소문자·악센트 무시, 현재 locale 기준). ↓·↑로 목록을 열고 옵션을 옮겨 Enter로 고르며, chevron 버튼을 누르면 거르지
|
|
39
|
+
* 않은 전체 목록이 열린다. 고르면 닫히고 입력에 그 옵션의 글자가 들어간다. Esc는 닫고 입력을 고른 옵션의 글자로 되돌린다.
|
|
40
|
+
* 포커스가 나가면 고르지 않은 글자는 지워진다(자유 입력 없음). 거른 결과가 없으면 목록에 `emptyMessage`가 보인다.
|
|
41
|
+
* Field 안에 두면 label·설명·오류가 자동으로 연결되고, `name`을 주면 고른 옵션의 `value`가 폼에 제출된다.
|
|
42
|
+
* 상태는 `data-open`·`data-invalid`·네이티브 `:disabled`(입력), `data-open`(목록),
|
|
43
|
+
* `data-highlighted`·`data-selected`·`data-disabled`(옵션)로 노출한다.
|
|
44
|
+
*/
|
|
45
|
+
export declare function Combobox({ value, defaultValue, onValueChange, onOpenChange, emptyMessage, name, form, size, invalid, disabled, required, readOnly, id, ref, className, children, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }: ComboboxProps): ReactElement;
|
|
46
|
+
export interface ComboboxItemProps extends ListBoxOptionProps {}
|
|
47
|
+
/**
|
|
48
|
+
* Combobox의 옵션(`role="option"`). `Combobox`나 `ComboboxSection` 바로 안에 둔다. `value`가 고른 값이고 폼에
|
|
49
|
+
* 제출된다. 거르기와 고른 뒤 입력에 들어가는 글자는 `textValue`(기본은 내용의 글자)다. 키보드 위치와 마우스를 올린
|
|
50
|
+
* 옵션은 highlight, 고른 옵션은 neutral subtle-active 배경과 끝의 체크 아이콘이다(Select 옵션과 같다).
|
|
51
|
+
* 상태는 `data-highlighted`, `data-selected`, `data-disabled`로 노출한다.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ComboboxItem(_props: ComboboxItemProps): null;
|
|
54
|
+
export interface ComboboxSectionProps extends ListBoxSectionProps {}
|
|
55
|
+
/**
|
|
56
|
+
* 옵션 묶음(`role="group"`). `heading`이 화면에 보이는 제목이자 묶음의 이름이고, 없으면 `aria-label`이 이름이다.
|
|
57
|
+
* 거른 결과 묶음 안에 남은 옵션이 없으면 묶음도 빠진다.
|
|
58
|
+
*/
|
|
59
|
+
export declare function ComboboxSection(_props: ComboboxSectionProps): null;
|
package/dist/Combobox.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { FieldContext, useFieldControl } from "./field-context.js";
|
|
5
|
+
import { inputBaseClass, inputSizeClass } from "./input-style.js";
|
|
6
|
+
import { asCollectionComponent, resolveCollectionChildren, sectionCollectionNode, valueItemCollectionNode } from "./list-collection.js";
|
|
7
|
+
import { listPopupClass } from "./list-style.js";
|
|
8
|
+
import { ListBoxNodes } from "./listbox.js";
|
|
9
|
+
import { OverlayPortal, useOverlayPresence, useOverlayTheme, usePopoverOverlay } from "./overlay.js";
|
|
10
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
|
+
import { use, useLayoutEffect, useMemo, useRef } from "react";
|
|
12
|
+
import { ChevronDownIcon } from "@tide-tds/icons";
|
|
13
|
+
import { mergeProps, mergeRefs, useButton, useComboBox, useFilter, useListBox } from "react-aria";
|
|
14
|
+
import { useComboBoxState } from "react-stately";
|
|
15
|
+
|
|
16
|
+
// src/Combobox.tsx
|
|
17
|
+
const wrapperClass = "flex w-full";
|
|
18
|
+
const inputEndClass = {
|
|
19
|
+
sm: "pe-[34px]",
|
|
20
|
+
md: "pe-9",
|
|
21
|
+
lg: "pe-10"
|
|
22
|
+
};
|
|
23
|
+
const buttonSizeClass = {
|
|
24
|
+
sm: "-ms-[35px] w-[35px] pe-[11px]",
|
|
25
|
+
md: "-ms-[37px] w-[37px] pe-[13px]",
|
|
26
|
+
lg: "-ms-[41px] w-[41px] pe-[17px]"
|
|
27
|
+
};
|
|
28
|
+
const buttonClass = "inline-flex shrink-0 cursor-pointer items-center justify-end text-muted outline-none disabled:cursor-not-allowed disabled:text-disabled";
|
|
29
|
+
/**
|
|
30
|
+
* 거른 결과가 없을 때의 글자. 옵션과 같은 여백·글자 크기라 목록 높이가 옵션 하나와 같다. `text-muted`.
|
|
31
|
+
* listbox 밖(같은 목록 표면 안)에 둔다. listbox 자식은 option·group만 될 수 있어서다. 결과 개수(0개)는 React Aria가
|
|
32
|
+
* live region으로 알린다.
|
|
33
|
+
*/
|
|
34
|
+
const emptyClass = "px-2 py-1.5 text-body-md text-muted select-none";
|
|
35
|
+
/**
|
|
36
|
+
* 한글 IME 조합 중의 keydown인지. 조합 중 Enter·화살표·Esc는 IME가 쓰므로 목록 동작에 쓰지 않는다.
|
|
37
|
+
* Chromium·Firefox는 `isComposing`이 `true`다. Safari는 조합을 끝내는 Enter의 keydown이 compositionend 뒤에
|
|
38
|
+
* `isComposing: false`로 오지만 `keyCode`가 229다(조합 처리 중인 키). 그래서 둘 다 본다.
|
|
39
|
+
*/
|
|
40
|
+
const isComposingKey = (event) => event.nativeEvent.isComposing || event.keyCode === 229;
|
|
41
|
+
/**
|
|
42
|
+
* 글자를 쳐서 옵션을 거르고 하나를 고르는 입력. 치면 목록이 열리고 옵션 글자에 친 글자가 들어 있는 옵션만 남는다
|
|
43
|
+
* (대소문자·악센트 무시, 현재 locale 기준). ↓·↑로 목록을 열고 옵션을 옮겨 Enter로 고르며, chevron 버튼을 누르면 거르지
|
|
44
|
+
* 않은 전체 목록이 열린다. 고르면 닫히고 입력에 그 옵션의 글자가 들어간다. Esc는 닫고 입력을 고른 옵션의 글자로 되돌린다.
|
|
45
|
+
* 포커스가 나가면 고르지 않은 글자는 지워진다(자유 입력 없음). 거른 결과가 없으면 목록에 `emptyMessage`가 보인다.
|
|
46
|
+
* Field 안에 두면 label·설명·오류가 자동으로 연결되고, `name`을 주면 고른 옵션의 `value`가 폼에 제출된다.
|
|
47
|
+
* 상태는 `data-open`·`data-invalid`·네이티브 `:disabled`(입력), `data-open`(목록),
|
|
48
|
+
* `data-highlighted`·`data-selected`·`data-disabled`(옵션)로 노출한다.
|
|
49
|
+
*/
|
|
50
|
+
function Combobox({ value, defaultValue, onValueChange, onOpenChange, emptyMessage = "결과가 없습니다", name, form, size = "md", invalid, disabled, required, readOnly, id, ref, className, children, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }) {
|
|
51
|
+
const field = use(FieldContext);
|
|
52
|
+
const control = useFieldControl({
|
|
53
|
+
id,
|
|
54
|
+
invalid,
|
|
55
|
+
disabled,
|
|
56
|
+
required,
|
|
57
|
+
"aria-describedby": ariaDescribedBy,
|
|
58
|
+
"aria-invalid": ariaInvalid
|
|
59
|
+
});
|
|
60
|
+
const isDisabled = control.disabled ?? false;
|
|
61
|
+
const isRequired = control.required ?? false;
|
|
62
|
+
const items = useMemo(() => resolveCollectionChildren(children), [children]);
|
|
63
|
+
const { contains } = useFilter({ sensitivity: "base" });
|
|
64
|
+
const state = useComboBoxState({
|
|
65
|
+
children: items,
|
|
66
|
+
defaultFilter: contains,
|
|
67
|
+
value,
|
|
68
|
+
defaultValue,
|
|
69
|
+
onChange: (key) => onValueChange?.(key == null ? null : String(key)),
|
|
70
|
+
onOpenChange: (open) => onOpenChange?.(open),
|
|
71
|
+
allowsEmptyCollection: true,
|
|
72
|
+
isDisabled,
|
|
73
|
+
isReadOnly: readOnly,
|
|
74
|
+
isRequired,
|
|
75
|
+
validationBehavior: "native"
|
|
76
|
+
});
|
|
77
|
+
const inputRef = useRef(null);
|
|
78
|
+
const buttonRef = useRef(null);
|
|
79
|
+
const popoverRef = useRef(null);
|
|
80
|
+
const listBoxRef = useRef(null);
|
|
81
|
+
const { inputProps, buttonProps, listBoxProps, isInvalid } = useComboBox({
|
|
82
|
+
inputRef,
|
|
83
|
+
buttonRef,
|
|
84
|
+
popoverRef,
|
|
85
|
+
listBoxRef,
|
|
86
|
+
id: control.id,
|
|
87
|
+
"aria-label": ariaLabel,
|
|
88
|
+
"aria-labelledby": ariaLabelledBy ?? field?.labelId,
|
|
89
|
+
isDisabled,
|
|
90
|
+
isReadOnly: readOnly,
|
|
91
|
+
isRequired,
|
|
92
|
+
validationBehavior: "native"
|
|
93
|
+
}, state);
|
|
94
|
+
const { buttonProps: chevronProps } = useButton(buttonProps, buttonRef);
|
|
95
|
+
const mergedRef = useMemo(() => mergeRefs(ref, inputRef), [ref, inputRef]);
|
|
96
|
+
const showInvalid = control["data-invalid"] === "" || isInvalid;
|
|
97
|
+
const { onKeyDown: ariaKeyDown, ...ariaInputProps } = inputProps;
|
|
98
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
99
|
+
className: wrapperClass,
|
|
100
|
+
children: [
|
|
101
|
+
/* @__PURE__ */ jsx("input", {
|
|
102
|
+
...mergeProps(props, ariaInputProps),
|
|
103
|
+
ref: mergedRef,
|
|
104
|
+
onKeyDown: (event) => {
|
|
105
|
+
props.onKeyDown?.(event);
|
|
106
|
+
if (!isComposingKey(event)) ariaKeyDown?.(event);
|
|
107
|
+
},
|
|
108
|
+
"aria-describedby": control["aria-describedby"],
|
|
109
|
+
"aria-invalid": showInvalid ? true : control["aria-invalid"],
|
|
110
|
+
"data-invalid": showInvalid ? "" : void 0,
|
|
111
|
+
"data-open": state.isOpen ? "" : void 0,
|
|
112
|
+
className: cn(inputSizeClass[size], inputBaseClass, inputEndClass[size], className)
|
|
113
|
+
}),
|
|
114
|
+
/* @__PURE__ */ jsx("button", {
|
|
115
|
+
...chevronProps,
|
|
116
|
+
ref: buttonRef,
|
|
117
|
+
className: cn(buttonClass, buttonSizeClass[size]),
|
|
118
|
+
children: /* @__PURE__ */ jsx(ChevronDownIcon, { size: 16 })
|
|
119
|
+
}),
|
|
120
|
+
name !== void 0 && /* @__PURE__ */ jsx("input", {
|
|
121
|
+
type: "hidden",
|
|
122
|
+
name,
|
|
123
|
+
form,
|
|
124
|
+
value: state.selectedKey == null ? "" : String(state.selectedKey),
|
|
125
|
+
disabled: isDisabled
|
|
126
|
+
}),
|
|
127
|
+
/* @__PURE__ */ jsx(ComboboxPopover, {
|
|
128
|
+
state,
|
|
129
|
+
inputRef,
|
|
130
|
+
popoverRef,
|
|
131
|
+
listBoxRef,
|
|
132
|
+
listBoxProps,
|
|
133
|
+
emptyMessage
|
|
134
|
+
})
|
|
135
|
+
]
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 목록을 DOM에 둘지 정하고 portal한다. 닫혀도 퇴장 전환이 끝날 때까지 남긴다(ADR 0018).
|
|
140
|
+
* 포커스는 입력에 머물므로 FocusScope로 감싸지 않는다(`focusManagement={false}`). 감싸면 바깥 빈 곳을 눌러 포커스가
|
|
141
|
+
* body로 간 뒤 목록이 빠질 때 FocusScope가 입력으로 포커스를 되돌린다(react-aria 3.53.1 FocusScope restoreFocus).
|
|
142
|
+
*/
|
|
143
|
+
function ComboboxPopover(props) {
|
|
144
|
+
if (!useOverlayPresence(props.state.isOpen, props.popoverRef)) return null;
|
|
145
|
+
return /* @__PURE__ */ jsx(OverlayPortal, {
|
|
146
|
+
isExiting: !props.state.isOpen,
|
|
147
|
+
focusManagement: false,
|
|
148
|
+
children: /* @__PURE__ */ jsx(ComboboxPopoverInner, { ...props })
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 목록 표면(`listPopupClass`)과 그 안의 `role="listbox"`. 입력 아래 시작 끝에 4px 떨어져 붙고(Select와 같다),
|
|
153
|
+
* 폭은 입력보다 좁지 않다. 거른 결과가 없으면 listbox 뒤에 `emptyMessage`를 보인다.
|
|
154
|
+
*/
|
|
155
|
+
function ComboboxPopoverInner({ state, inputRef, popoverRef, listBoxRef, listBoxProps, emptyMessage }) {
|
|
156
|
+
useOverlayTheme(inputRef, popoverRef);
|
|
157
|
+
useLayoutEffect(() => {
|
|
158
|
+
const popover = popoverRef.current;
|
|
159
|
+
const width = inputRef.current?.offsetWidth;
|
|
160
|
+
if (popover && width) popover.style.minWidth = `min(${width}px, 100vw - 24px)`;
|
|
161
|
+
}, [inputRef, popoverRef]);
|
|
162
|
+
const { overlayProps } = usePopoverOverlay({
|
|
163
|
+
triggerRef: inputRef,
|
|
164
|
+
popoverRef,
|
|
165
|
+
placement: "bottom start",
|
|
166
|
+
offset: 4,
|
|
167
|
+
nonModal: true
|
|
168
|
+
}, state);
|
|
169
|
+
const { listBoxProps: listProps } = useListBox(listBoxProps, state, listBoxRef);
|
|
170
|
+
const empty = state.collection.size === 0;
|
|
171
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
172
|
+
...overlayProps,
|
|
173
|
+
ref: popoverRef,
|
|
174
|
+
"data-open": state.isOpen ? "" : void 0,
|
|
175
|
+
className: listPopupClass,
|
|
176
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
177
|
+
...listProps,
|
|
178
|
+
ref: listBoxRef,
|
|
179
|
+
className: "outline-none",
|
|
180
|
+
children: /* @__PURE__ */ jsx(ListBoxNodes, { state })
|
|
181
|
+
}), empty && /* @__PURE__ */ jsx("div", {
|
|
182
|
+
className: emptyClass,
|
|
183
|
+
children: emptyMessage
|
|
184
|
+
})]
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Combobox의 옵션(`role="option"`). `Combobox`나 `ComboboxSection` 바로 안에 둔다. `value`가 고른 값이고 폼에
|
|
189
|
+
* 제출된다. 거르기와 고른 뒤 입력에 들어가는 글자는 `textValue`(기본은 내용의 글자)다. 키보드 위치와 마우스를 올린
|
|
190
|
+
* 옵션은 highlight, 고른 옵션은 neutral subtle-active 배경과 끝의 체크 아이콘이다(Select 옵션과 같다).
|
|
191
|
+
* 상태는 `data-highlighted`, `data-selected`, `data-disabled`로 노출한다.
|
|
192
|
+
*/
|
|
193
|
+
function ComboboxItem(_props) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
asCollectionComponent(ComboboxItem, valueItemCollectionNode);
|
|
197
|
+
/**
|
|
198
|
+
* 옵션 묶음(`role="group"`). `heading`이 화면에 보이는 제목이자 묶음의 이름이고, 없으면 `aria-label`이 이름이다.
|
|
199
|
+
* 거른 결과 묶음 안에 남은 옵션이 없으면 묶음도 빠진다.
|
|
200
|
+
*/
|
|
201
|
+
function ComboboxSection(_props) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
asCollectionComponent(ComboboxSection, sectionCollectionNode);
|
|
205
|
+
|
|
206
|
+
export { Combobox, ComboboxItem, ComboboxSection };
|
package/dist/Dialog.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
2
|
+
export interface DialogProps {
|
|
3
|
+
/** 열림 상태(제어). 주면 `onOpenChange`로 바꾼다. */
|
|
4
|
+
open?: boolean;
|
|
5
|
+
/** 처음 열림 상태(비제어). 기본값은 `false`다. */
|
|
6
|
+
defaultOpen?: boolean;
|
|
7
|
+
/** 열림 상태가 바뀌려 할 때 새 값으로 부른다. */
|
|
8
|
+
onOpenChange?: (open: boolean) => void;
|
|
9
|
+
/** `DialogTrigger`와 `DialogContent` */
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 트리거와 내용을 잇는 열림 상태. DOM 요소를 만들지 않는다.
|
|
14
|
+
* 안에 `DialogTrigger`(없어도 된다. `open`으로 제어해 Menu 항목 등에서 열 수 있다)와 `DialogContent`를 둔다.
|
|
15
|
+
*/
|
|
16
|
+
export declare function Dialog(props: DialogProps): ReactElement;
|
|
17
|
+
export interface DialogTriggerProps extends ComponentProps<"button"> {
|
|
18
|
+
/**
|
|
19
|
+
* 다른 요소로 렌더한다. 예: `render={<Button variant="outline" />}`, `render={<IconButton aria-label="삭제" />}`.
|
|
20
|
+
* props, ref, className, 이벤트 핸들러를 그 요소에 병합한다. 기본은 class 없는 `<button type="button">`이다.
|
|
21
|
+
*/
|
|
22
|
+
render?: ReactElement;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Dialog를 여는 버튼. 누르면(마우스, 터치, Enter, Space) 연다. 열린 동안은 바깥이 inert라 다시 누를 수 없다.
|
|
26
|
+
* `aria-haspopup="dialog"`, `aria-expanded`, 열렸을 때 `aria-controls`와 `data-open`이 붙는다.
|
|
27
|
+
*/
|
|
28
|
+
export declare function DialogTrigger({ render, ref, id, className, ...props }: DialogTriggerProps): ReactElement;
|
|
29
|
+
/** Dialog 최대 폭. `sm` 24rem(384px), `md` 32rem(512px), `lg` 40rem(640px)이다. */
|
|
30
|
+
export type DialogSize = "sm" | "md" | "lg";
|
|
31
|
+
export interface DialogContentProps extends Omit<ComponentProps<"div">, "id" | "role"> {
|
|
32
|
+
/** 최대 폭. 기본값은 `md`(32rem)다. 화면이 좁으면 화면 폭 − 32px까지 줄어든다. */
|
|
33
|
+
size?: DialogSize;
|
|
34
|
+
/** 닫기 버튼(IconButton)의 이름. 기본값은 `"닫기"`다. AlertDialog에는 닫기 버튼이 없어 쓰지 않는다. */
|
|
35
|
+
closeLabel?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 화면 가운데 뜨는 모달 내용(`role="dialog"`, AlertDialog 안에서는 `role="alertdialog"`).
|
|
39
|
+
* 열릴 때만 `document.body`에 portal하고, 반투명 막(`bg-overlay`)과 함께 `z-modal`로 쌓는다.
|
|
40
|
+
* 등장은 opacity + scale 0.96 → 1(`normal`), 퇴장은 opacity + scale 0.98(`fast`)이다. 움직임 줄이기에서는 opacity만 바뀐다.
|
|
41
|
+
* Dialog에는 끝 위쪽에 닫기 버튼(IconButton)이 있고 바깥 막을 누르면 닫힌다. AlertDialog는 둘 다 없다(Esc는 닫는다).
|
|
42
|
+
*/
|
|
43
|
+
export declare function DialogContent(props: DialogContentProps): ReactElement | null;
|
|
44
|
+
export type DialogTitleProps = ComponentProps<"h2">;
|
|
45
|
+
/**
|
|
46
|
+
* Dialog 제목(`<h2>`, `text-heading-sm`). 있으면 dialog의 `aria-labelledby`가 가리켜 이름이 된다.
|
|
47
|
+
* Dialog에서는 끝쪽 닫기 버튼과 겹치지 않게 끝 여백(36px)을 둔다.
|
|
48
|
+
*/
|
|
49
|
+
export declare function DialogTitle({ id, className, ...props }: DialogTitleProps): ReactElement;
|
|
50
|
+
export type DialogDescriptionProps = ComponentProps<"p">;
|
|
51
|
+
/**
|
|
52
|
+
* Dialog 설명(`<p>`, `text-muted`). 있으면 dialog의 `aria-describedby`가 가리킨다. AlertDialog에서는 알릴 내용을 여기에 쓴다.
|
|
53
|
+
* 목록·여러 문단처럼 구조가 있는 내용은 한 줄로 읽히므로 이 요소 밖에 둔다(APG Dialog).
|
|
54
|
+
*/
|
|
55
|
+
export declare function DialogDescription({ id, className, ...props }: DialogDescriptionProps): ReactElement;
|
|
56
|
+
export type DialogFooterProps = ComponentProps<"div">;
|
|
57
|
+
/** 동작 버튼 줄. 끝쪽에 맞추고 버튼 사이는 8px이다. 앞에 덜 위험한 동작(취소)을, 끝에 주 동작을 둔다. */
|
|
58
|
+
export declare function DialogFooter({ className, ...props }: DialogFooterProps): ReactElement;
|
|
59
|
+
export interface DialogCloseProps extends ComponentProps<"button"> {
|
|
60
|
+
/**
|
|
61
|
+
* 다른 요소로 렌더한다. 예: `render={<Button variant="ghost" color="neutral" />}`.
|
|
62
|
+
* 기본은 class 없는 `<button type="button">`이다.
|
|
63
|
+
*/
|
|
64
|
+
render?: ReactElement;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 누르면 Dialog를 닫는 버튼. `onClick`을 먼저 부르고, 그 안에서 `event.preventDefault()`를 부르면 닫지 않는다.
|
|
68
|
+
* 닫히면 포커스는 열기 전 요소로 돌아간다.
|
|
69
|
+
*/
|
|
70
|
+
export declare function DialogClose({ render, onClick, className, ...props }: DialogCloseProps): ReactElement;
|