@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
package/dist/Select.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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, useCloseOnTab, useOverlayPresence, useOverlayTheme, usePopoverOverlay } from "./overlay.js";
|
|
10
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
11
|
+
import { use, useLayoutEffect, useMemo, useRef } from "react";
|
|
12
|
+
import { ChevronDownIcon } from "@tide-tds/icons";
|
|
13
|
+
import { DismissButton, HiddenSelect, mergeProps, mergeRefs, useButton, useListBox, useSelect } from "react-aria";
|
|
14
|
+
import { useSelectState } from "react-stately";
|
|
15
|
+
|
|
16
|
+
// src/Select.tsx
|
|
17
|
+
const triggerClass = "group inline-flex items-center justify-between gap-2 text-start";
|
|
18
|
+
const valueClass = "min-w-0 flex-1 truncate [&>svg]:me-2 [&>svg]:inline-block [&>svg]:size-4 [&>svg]:align-[-0.125em]";
|
|
19
|
+
const placeholderClass = "text-muted group-disabled:text-disabled";
|
|
20
|
+
const chevronClass = "shrink-0 text-muted group-disabled:text-disabled";
|
|
21
|
+
/**
|
|
22
|
+
* 값 하나를 고르는 입력. 트리거를 누르거나(마우스는 누르는 순간) Enter·Space·↓·↑로 목록을 열고, 화살표·Home/End·
|
|
23
|
+
* 영문 글자로 옵션을 옮겨 Enter·Space·누름으로 고른다. 닫힌 채로 글자를 치면 바로 그 옵션을 고른다.
|
|
24
|
+
* 고르면 닫히고 포커스는 트리거로 돌아온다. Esc·바깥 누름·Tab으로도 닫힌다.
|
|
25
|
+
* Field 안에 두면 label·설명·오류가 자동으로 연결되고, `name`을 주면 고른 값이 폼에 제출된다.
|
|
26
|
+
* 상태는 `data-open`(트리거·목록), `data-invalid`(트리거), 네이티브 `:disabled`(트리거),
|
|
27
|
+
* `data-highlighted`·`data-selected`·`data-disabled`(옵션)로 노출한다.
|
|
28
|
+
*/
|
|
29
|
+
function Select({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, name, form, autoComplete, placeholder = "선택하세요", size = "md", invalid, disabled, required, id, ref, className, children, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }) {
|
|
30
|
+
const field = use(FieldContext);
|
|
31
|
+
const control = useFieldControl({
|
|
32
|
+
id,
|
|
33
|
+
invalid,
|
|
34
|
+
disabled,
|
|
35
|
+
required,
|
|
36
|
+
"aria-describedby": ariaDescribedBy,
|
|
37
|
+
"aria-invalid": ariaInvalid
|
|
38
|
+
});
|
|
39
|
+
const isDisabled = control.disabled ?? false;
|
|
40
|
+
const isRequired = control.required ?? false;
|
|
41
|
+
const items = useMemo(() => resolveCollectionChildren(children), [children]);
|
|
42
|
+
const state = useSelectState({
|
|
43
|
+
children: items,
|
|
44
|
+
value,
|
|
45
|
+
defaultValue,
|
|
46
|
+
onChange: (key) => {
|
|
47
|
+
if (key != null) onValueChange?.(String(key));
|
|
48
|
+
},
|
|
49
|
+
isOpen: open,
|
|
50
|
+
defaultOpen,
|
|
51
|
+
onOpenChange,
|
|
52
|
+
isDisabled,
|
|
53
|
+
isRequired,
|
|
54
|
+
validationBehavior: "native"
|
|
55
|
+
});
|
|
56
|
+
const triggerRef = useRef(null);
|
|
57
|
+
const { triggerProps, valueProps, menuProps, hiddenSelectProps, isInvalid } = useSelect({
|
|
58
|
+
id: control.id,
|
|
59
|
+
"aria-label": ariaLabel,
|
|
60
|
+
"aria-labelledby": ariaLabelledBy ?? field?.labelId,
|
|
61
|
+
isDisabled,
|
|
62
|
+
isRequired,
|
|
63
|
+
validationBehavior: "native",
|
|
64
|
+
name,
|
|
65
|
+
form,
|
|
66
|
+
autoComplete
|
|
67
|
+
}, state, triggerRef);
|
|
68
|
+
const { buttonProps } = useButton(triggerProps, triggerRef);
|
|
69
|
+
const mergedRef = useMemo(() => mergeRefs(ref, triggerRef), [ref, triggerRef]);
|
|
70
|
+
const showInvalid = control["data-invalid"] === "" || isInvalid;
|
|
71
|
+
const selected = state.selectedItem;
|
|
72
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
73
|
+
/* @__PURE__ */ jsxs("button", {
|
|
74
|
+
...mergeProps(props, buttonProps),
|
|
75
|
+
ref: mergedRef,
|
|
76
|
+
"aria-describedby": control["aria-describedby"],
|
|
77
|
+
"aria-invalid": showInvalid ? true : control["aria-invalid"],
|
|
78
|
+
"data-invalid": showInvalid ? "" : void 0,
|
|
79
|
+
"data-open": state.isOpen ? "" : void 0,
|
|
80
|
+
className: cn(inputSizeClass[size], inputBaseClass, triggerClass, className),
|
|
81
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
82
|
+
...valueProps,
|
|
83
|
+
className: cn(valueClass, !selected && placeholderClass),
|
|
84
|
+
children: selected ? selected.rendered : placeholder
|
|
85
|
+
}), /* @__PURE__ */ jsx(ChevronDownIcon, {
|
|
86
|
+
size: 16,
|
|
87
|
+
className: chevronClass
|
|
88
|
+
})]
|
|
89
|
+
}),
|
|
90
|
+
/* @__PURE__ */ jsx(HiddenSelect, {
|
|
91
|
+
...hiddenSelectProps,
|
|
92
|
+
autoComplete
|
|
93
|
+
}),
|
|
94
|
+
/* @__PURE__ */ jsx(SelectPopover, {
|
|
95
|
+
state,
|
|
96
|
+
triggerRef,
|
|
97
|
+
menuProps
|
|
98
|
+
})
|
|
99
|
+
] });
|
|
100
|
+
}
|
|
101
|
+
/** 목록을 DOM에 둘지 정하고 portal한다. 닫혀도 퇴장 전환이 끝날 때까지 남긴다(ADR 0018). */
|
|
102
|
+
function SelectPopover(props) {
|
|
103
|
+
const popoverRef = useRef(null);
|
|
104
|
+
if (!useOverlayPresence(props.state.isOpen, popoverRef)) return null;
|
|
105
|
+
return /* @__PURE__ */ jsx(OverlayPortal, {
|
|
106
|
+
isExiting: !props.state.isOpen,
|
|
107
|
+
children: /* @__PURE__ */ jsx(SelectPopoverInner, {
|
|
108
|
+
...props,
|
|
109
|
+
popoverRef
|
|
110
|
+
})
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 목록 표면(`listPopupClass`)과 그 안의 `role="listbox"`. 트리거 아래 시작 끝에 4px 떨어져 붙고(Menu와 같다),
|
|
115
|
+
* 폭은 트리거보다 좁지 않다.
|
|
116
|
+
*/
|
|
117
|
+
function SelectPopoverInner({ state, triggerRef, menuProps, popoverRef }) {
|
|
118
|
+
const listRef = useRef(null);
|
|
119
|
+
useOverlayTheme(triggerRef, popoverRef);
|
|
120
|
+
useLayoutEffect(() => {
|
|
121
|
+
const popover = popoverRef.current;
|
|
122
|
+
const width = triggerRef.current?.offsetWidth;
|
|
123
|
+
if (popover && width) popover.style.minWidth = `${width}px`;
|
|
124
|
+
}, [triggerRef, popoverRef]);
|
|
125
|
+
const { overlayProps } = usePopoverOverlay({
|
|
126
|
+
triggerRef,
|
|
127
|
+
popoverRef,
|
|
128
|
+
placement: "bottom start",
|
|
129
|
+
offset: 4
|
|
130
|
+
}, state);
|
|
131
|
+
const { listBoxProps } = useListBox(menuProps, state, listRef);
|
|
132
|
+
useCloseOnTab(popoverRef, state.close);
|
|
133
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
134
|
+
...overlayProps,
|
|
135
|
+
ref: popoverRef,
|
|
136
|
+
"data-open": state.isOpen ? "" : void 0,
|
|
137
|
+
className: listPopupClass,
|
|
138
|
+
children: [
|
|
139
|
+
/* @__PURE__ */ jsx(DismissButton, { onDismiss: state.close }),
|
|
140
|
+
/* @__PURE__ */ jsx("div", {
|
|
141
|
+
...listBoxProps,
|
|
142
|
+
ref: listRef,
|
|
143
|
+
className: "outline-none",
|
|
144
|
+
children: /* @__PURE__ */ jsx(ListBoxNodes, { state })
|
|
145
|
+
}),
|
|
146
|
+
/* @__PURE__ */ jsx(DismissButton, { onDismiss: state.close })
|
|
147
|
+
]
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Select의 옵션(`role="option"`). `Select`나 `SelectSection` 바로 안에 둔다. `value`가 고른 값이고 폼에 제출된다.
|
|
152
|
+
* 내용(아이콘과 글자)은 고르면 트리거에도 보인다. 키보드 위치와 마우스를 올린 옵션은 highlight(primary subtle 배경,
|
|
153
|
+
* primary 글자), 고른 옵션은 neutral subtle-active 배경과 끝의 체크 아이콘이다.
|
|
154
|
+
* 상태는 `data-highlighted`, `data-selected`, `data-disabled`로 노출한다.
|
|
155
|
+
*/
|
|
156
|
+
function SelectItem(_props) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
asCollectionComponent(SelectItem, valueItemCollectionNode);
|
|
160
|
+
/**
|
|
161
|
+
* 옵션 묶음(`role="group"`). `heading`이 화면에 보이는 제목이자 묶음의 이름이고, 없으면 `aria-label`이 이름이다.
|
|
162
|
+
* 앞뒤 다른 옵션·묶음과의 사이에 구분선이 그려진다.
|
|
163
|
+
*/
|
|
164
|
+
function SelectSection(_props) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
asCollectionComponent(SelectSection, sectionCollectionNode);
|
|
168
|
+
|
|
169
|
+
export { Select, SelectItem, SelectSection };
|
package/dist/Tabs.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CollectionValueItemProps } from "./list-collection.js";
|
|
2
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
/** 탭 목록의 방향. 세로면 ↓↑로 옮기고 탭 목록이 패널의 시작쪽에 선다. */
|
|
4
|
+
export type TabsOrientation = "horizontal" | "vertical";
|
|
5
|
+
/** 키보드로 탭을 옮겼을 때 바로 고를지(`automatic`), Enter·Space로 고를지(`manual`). */
|
|
6
|
+
export type TabsActivationMode = "automatic" | "manual";
|
|
7
|
+
type TabsValueProps = {
|
|
8
|
+
/** 고른 탭의 값(제어). 주면 `onValueChange`로 바꾼다. */
|
|
9
|
+
value: string;
|
|
10
|
+
defaultValue?: undefined;
|
|
11
|
+
} | {
|
|
12
|
+
value?: undefined;
|
|
13
|
+
/** 처음 고른 탭의 값(비제어). 서버 렌더에서도 그 패널이 바로 보이도록 `value`나 `defaultValue` 하나는 꼭 준다. */
|
|
14
|
+
defaultValue: string;
|
|
15
|
+
};
|
|
16
|
+
interface TabsBaseProps extends Omit<ComponentProps<"div">, "defaultValue" | "onChange"> {
|
|
17
|
+
/**
|
|
18
|
+
* 고른 탭이 바뀌려 할 때 새 값으로 부른다. 이미 고른 탭을 다시 눌러도 부르지 않는다.
|
|
19
|
+
* 준 값과 같은 탭이 없으면 첫 활성 탭을 고르며 부른다.
|
|
20
|
+
*/
|
|
21
|
+
onValueChange?: (value: string) => void;
|
|
22
|
+
/** 탭 목록의 방향. 기본값은 `horizontal`이다. 세로면 ↓↑로 옮기고 탭 목록이 패널의 시작쪽에 선다. */
|
|
23
|
+
orientation?: TabsOrientation;
|
|
24
|
+
/**
|
|
25
|
+
* 키보드로 탭을 옮겼을 때 바로 고를지(`automatic`, 기본값), Enter·Space로 고를지(`manual`)다.
|
|
26
|
+
* 패널을 그리는 데 시간이 걸리면(데이터를 불러오는 패널) `manual`을 쓴다(WAI-ARIA APG).
|
|
27
|
+
*/
|
|
28
|
+
activationMode?: TabsActivationMode;
|
|
29
|
+
/** `TabList` 하나와 `TabPanel`들 */
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
export type TabsProps = TabsBaseProps & TabsValueProps;
|
|
33
|
+
/**
|
|
34
|
+
* 같은 영역의 여러 패널 중 하나를 고르는 탭 묶음. 루트는 `<div>`이고 방향이 `data-orientation`으로 붙는다.
|
|
35
|
+
* 안에 `TabList` 하나와 탭마다 `TabPanel`을 둔다.
|
|
36
|
+
*/
|
|
37
|
+
export declare function Tabs({ value: valueProp, defaultValue, onValueChange, orientation, activationMode, ref, className, children, ...props }: TabsProps): ReactElement;
|
|
38
|
+
export interface TabListProps extends Omit<ComponentProps<"div">, "role" | "children"> {
|
|
39
|
+
/** `Tab`. 바로 아래(또는 Fragment·배열 안)에 둔다. */
|
|
40
|
+
children?: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 탭 목록(`role="tablist"`). 화살표로 탭을 옮기고(끝에서 처음으로 돈다) Home·End로 처음·마지막 탭에 간다.
|
|
44
|
+
* 비활성 탭은 건너뛴다. 이름은 `aria-label`이나 `aria-labelledby`로 준다.
|
|
45
|
+
*/
|
|
46
|
+
export declare function TabList({ ref, className, children, ...props }: TabListProps): ReactElement;
|
|
47
|
+
export interface TabProps extends Omit<ComponentProps<"button">, "value" | "type" | "role" | "id" | "disabled" | "children">, Omit<CollectionValueItemProps, "textValue"> {
|
|
48
|
+
/** 탭 글자. 아이콘(장식용)과 글자를 함께 둘 수 있다. */
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 탭 하나(`role="tab"`). `TabList` 바로 안에 두고, 같은 `value`의 `TabPanel`과 이어진다.
|
|
53
|
+
* 고른 탭은 `data-selected`와 `aria-selected="true"`이고 primary 글자와 아래(세로면 끝쪽) 막대로 보인다.
|
|
54
|
+
* `disabled`면 네이티브 `disabled`라 고를 수 없고 키보드 이동도 건너뛴다.
|
|
55
|
+
*/
|
|
56
|
+
export declare function Tab(_props: TabProps): null;
|
|
57
|
+
export interface TabPanelProps extends Omit<ComponentProps<"div">, "id" | "role"> {
|
|
58
|
+
/** 이어지는 `Tab`의 값. 그 탭을 고른 동안만 렌더한다. */
|
|
59
|
+
value: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 탭 하나의 내용(`role="tabpanel"`). 같은 `value`의 탭을 고른 동안만 렌더하고, 그 탭이 이름이 된다(`aria-labelledby`).
|
|
63
|
+
* 탭 다음 Tab 순서에 들어간다(`tabIndex` 기본 `0`). 첫 내용이 조작 요소면 `tabIndex={-1}`로 뺄 수 있다.
|
|
64
|
+
*/
|
|
65
|
+
export declare function TabPanel({ value, ref, className, tabIndex, ...props }: TabPanelProps): ReactElement | null;
|
|
66
|
+
export {};
|
package/dist/Tabs.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { asCollectionComponent, resolveCollectionChildren, valueItemCollectionNode } from "./list-collection.js";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
import { createContext, isValidElement, use, useCallback, useId, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { mergeProps, mergeRefs, useTab, useTabList } from "react-aria";
|
|
8
|
+
import { useTabListState } from "react-stately";
|
|
9
|
+
|
|
10
|
+
// src/Tabs.tsx
|
|
11
|
+
const TabsContext = createContext(null);
|
|
12
|
+
function useTabsContext(part) {
|
|
13
|
+
const context = use(TabsContext);
|
|
14
|
+
if (!context) throw new Error(`${part}는 Tabs 안에 둔다.`);
|
|
15
|
+
return context;
|
|
16
|
+
}
|
|
17
|
+
const tabsOrientationClass = {
|
|
18
|
+
horizontal: "flex flex-col gap-4",
|
|
19
|
+
vertical: "flex flex-row gap-6"
|
|
20
|
+
};
|
|
21
|
+
const tabListOrientationClass = {
|
|
22
|
+
horizontal: "flex shrink-0 gap-1 border-b border-default",
|
|
23
|
+
vertical: "flex shrink-0 flex-col gap-1 border-e border-default"
|
|
24
|
+
};
|
|
25
|
+
const tabBaseClass = [
|
|
26
|
+
"relative flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-label-md whitespace-nowrap text-muted outline-none select-none [&>svg]:size-4 [&>svg]:shrink-0",
|
|
27
|
+
"hover:not-data-selected:enabled:text-default data-selected:text-primary focus-visible:focus-ring",
|
|
28
|
+
"disabled:cursor-not-allowed disabled:text-disabled",
|
|
29
|
+
"after:pointer-events-none after:absolute after:border-primary after:opacity-0 after:transition-opacity data-selected:after:opacity-100",
|
|
30
|
+
"forced-colors:after:forced-color-adjust-none forced-colors:after:border-[Highlight]"
|
|
31
|
+
].join(" ");
|
|
32
|
+
const tabOrientationClass = {
|
|
33
|
+
horizontal: "after:inset-x-0 after:-bottom-px after:border-b-2 motion-safe:after:scale-x-0 motion-safe:after:transition-[opacity,scale] motion-safe:data-selected:after:scale-x-100",
|
|
34
|
+
vertical: "after:inset-y-0 after:-end-px after:border-e-2 motion-safe:after:scale-y-0 motion-safe:after:transition-[opacity,scale] motion-safe:data-selected:after:scale-y-100"
|
|
35
|
+
};
|
|
36
|
+
const tabPanelClass = "min-w-0 flex-1 rounded-md outline-none focus-visible:focus-ring";
|
|
37
|
+
/** id에 쓸 수 없는 공백을 뺀다(React Aria 탭 id와 같은 규칙). */
|
|
38
|
+
const idPart = (value) => value.replace(/\s+/g, "");
|
|
39
|
+
/**
|
|
40
|
+
* 같은 영역의 여러 패널 중 하나를 고르는 탭 묶음. 루트는 `<div>`이고 방향이 `data-orientation`으로 붙는다.
|
|
41
|
+
* 안에 `TabList` 하나와 탭마다 `TabPanel`을 둔다.
|
|
42
|
+
*/
|
|
43
|
+
function Tabs({ value: valueProp, defaultValue, onValueChange, orientation = "horizontal", activationMode = "automatic", ref, className, children, ...props }) {
|
|
44
|
+
const [internal, setInternal] = useState(defaultValue ?? "");
|
|
45
|
+
const controlled = valueProp !== void 0;
|
|
46
|
+
const value = controlled ? valueProp : internal;
|
|
47
|
+
const setValue = useCallback((next) => {
|
|
48
|
+
if (next === value) return;
|
|
49
|
+
if (!controlled) setInternal(next);
|
|
50
|
+
onValueChange?.(next);
|
|
51
|
+
}, [
|
|
52
|
+
controlled,
|
|
53
|
+
onValueChange,
|
|
54
|
+
value
|
|
55
|
+
]);
|
|
56
|
+
const baseId = useId();
|
|
57
|
+
const context = useMemo(() => ({
|
|
58
|
+
value,
|
|
59
|
+
setValue,
|
|
60
|
+
orientation,
|
|
61
|
+
activationMode,
|
|
62
|
+
tabId: (tab) => `${baseId}-tab-${idPart(tab)}`,
|
|
63
|
+
panelId: (tab) => `${baseId}-tabpanel-${idPart(tab)}`
|
|
64
|
+
}), [
|
|
65
|
+
value,
|
|
66
|
+
setValue,
|
|
67
|
+
orientation,
|
|
68
|
+
activationMode,
|
|
69
|
+
baseId
|
|
70
|
+
]);
|
|
71
|
+
return /* @__PURE__ */ jsx("div", {
|
|
72
|
+
...props,
|
|
73
|
+
ref,
|
|
74
|
+
"data-orientation": orientation,
|
|
75
|
+
className: cn(tabsOrientationClass[orientation], className),
|
|
76
|
+
children: /* @__PURE__ */ jsx(TabsContext, {
|
|
77
|
+
value: context,
|
|
78
|
+
children
|
|
79
|
+
})
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 탭 목록(`role="tablist"`). 화살표로 탭을 옮기고(끝에서 처음으로 돈다) Home·End로 처음·마지막 탭에 간다.
|
|
84
|
+
* 비활성 탭은 건너뛴다. 이름은 `aria-label`이나 `aria-labelledby`로 준다.
|
|
85
|
+
*/
|
|
86
|
+
function TabList({ ref, className, children, ...props }) {
|
|
87
|
+
const context = useTabsContext("TabList");
|
|
88
|
+
const listRef = useRef(null);
|
|
89
|
+
const items = useMemo(() => resolveCollectionChildren(children), [children]);
|
|
90
|
+
const known = items.some((item) => isValidElement(item) && item.props.value === context.value);
|
|
91
|
+
const state = useTabListState({
|
|
92
|
+
children: items,
|
|
93
|
+
selectedKey: known ? context.value : null,
|
|
94
|
+
onSelectionChange: (key) => context.setValue(String(key))
|
|
95
|
+
});
|
|
96
|
+
const { tabListProps } = useTabList({
|
|
97
|
+
orientation: context.orientation,
|
|
98
|
+
keyboardActivation: context.activationMode,
|
|
99
|
+
"aria-label": props["aria-label"],
|
|
100
|
+
"aria-labelledby": props["aria-labelledby"]
|
|
101
|
+
}, state, listRef);
|
|
102
|
+
const mergedRef = useMemo(() => mergeRefs(ref, listRef), [ref, listRef]);
|
|
103
|
+
return /* @__PURE__ */ jsx("div", {
|
|
104
|
+
...mergeProps(props, tabListProps),
|
|
105
|
+
ref: mergedRef,
|
|
106
|
+
className: cn(tabListOrientationClass[context.orientation], className),
|
|
107
|
+
children: [...state.collection].map((node) => /* @__PURE__ */ jsx(TabView, {
|
|
108
|
+
node,
|
|
109
|
+
state,
|
|
110
|
+
context
|
|
111
|
+
}, node.key))
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 탭 하나(`role="tab"`). `TabList` 바로 안에 두고, 같은 `value`의 `TabPanel`과 이어진다.
|
|
116
|
+
* 고른 탭은 `data-selected`와 `aria-selected="true"`이고 primary 글자와 아래(세로면 끝쪽) 막대로 보인다.
|
|
117
|
+
* `disabled`면 네이티브 `disabled`라 고를 수 없고 키보드 이동도 건너뛴다.
|
|
118
|
+
*/
|
|
119
|
+
function Tab(_props) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
asCollectionComponent(Tab, valueItemCollectionNode);
|
|
123
|
+
/** 탭 하나를 그린다. node.props는 앱이 Tab에 준 props(와 컬렉션이 더한 isDisabled)다. */
|
|
124
|
+
function TabView({ node, state, context }) {
|
|
125
|
+
const tabRef = useRef(null);
|
|
126
|
+
const { ref, className, value, disabled: _disabled, isDisabled: _isDisabled, children: _children, onFocus: _onFocus, onBlur: _onBlur, onKeyDown: _onKeyDown, onKeyUp: _onKeyUp, autoFocus: _autoFocus, ...props } = node.props;
|
|
127
|
+
const { tabProps, isSelected, isDisabled } = useTab({ key: node.key }, state, tabRef);
|
|
128
|
+
const mergedRef = useMemo(() => mergeRefs(ref, tabRef), [ref, tabRef]);
|
|
129
|
+
return /* @__PURE__ */ jsx("button", {
|
|
130
|
+
...mergeProps(props, tabProps),
|
|
131
|
+
ref: mergedRef,
|
|
132
|
+
type: "button",
|
|
133
|
+
id: context.tabId(value),
|
|
134
|
+
"aria-controls": isSelected ? context.panelId(value) : void 0,
|
|
135
|
+
disabled: isDisabled || void 0,
|
|
136
|
+
"data-selected": isSelected ? "" : void 0,
|
|
137
|
+
className: cn(tabBaseClass, tabOrientationClass[context.orientation], className),
|
|
138
|
+
children: node.rendered
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 탭 하나의 내용(`role="tabpanel"`). 같은 `value`의 탭을 고른 동안만 렌더하고, 그 탭이 이름이 된다(`aria-labelledby`).
|
|
143
|
+
* 탭 다음 Tab 순서에 들어간다(`tabIndex` 기본 `0`). 첫 내용이 조작 요소면 `tabIndex={-1}`로 뺄 수 있다.
|
|
144
|
+
*/
|
|
145
|
+
function TabPanel({ value, ref, className, tabIndex = 0, ...props }) {
|
|
146
|
+
const context = useTabsContext("TabPanel");
|
|
147
|
+
if (context.value !== value) return null;
|
|
148
|
+
return /* @__PURE__ */ jsx("div", {
|
|
149
|
+
...props,
|
|
150
|
+
ref,
|
|
151
|
+
role: "tabpanel",
|
|
152
|
+
id: context.panelId(value),
|
|
153
|
+
"aria-labelledby": context.tabId(value),
|
|
154
|
+
tabIndex,
|
|
155
|
+
className: cn(tabPanelClass, className)
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { Tab, TabList, TabPanel, Tabs };
|
package/dist/Tag.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CollectionValueItemProps } from "./list-collection.js";
|
|
2
|
+
import { ColorFamilyName } from "./types.js";
|
|
3
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
4
|
+
type TagGroupNameProps = {
|
|
5
|
+
/** 묶음 이름(예: `"고른 필터"`). 화면에 보이는 제목이 있으면 `aria-labelledby`를 쓴다. */
|
|
6
|
+
"aria-label": string;
|
|
7
|
+
"aria-labelledby"?: string;
|
|
8
|
+
} | {
|
|
9
|
+
"aria-label"?: string;
|
|
10
|
+
/** 묶음 이름이 되는 요소의 id. 이름(`aria-label`이나 `aria-labelledby`) 하나는 꼭 준다. */
|
|
11
|
+
"aria-labelledby": string;
|
|
12
|
+
};
|
|
13
|
+
interface TagGroupBaseProps extends Omit<ComponentProps<"div">, "role" | "children" | "aria-label" | "aria-labelledby"> {
|
|
14
|
+
/**
|
|
15
|
+
* 태그를 지우려 할 때 지울 태그의 `value`로 부른다. 지우기 버튼을 누르거나 태그에서 Delete·Backspace를 누르면 불린다.
|
|
16
|
+
* 주면 태그마다 지우기 버튼이 생긴다. 태그를 목록에서 빼는 일은 앱이 한다.
|
|
17
|
+
*/
|
|
18
|
+
onRemove?: (value: string) => void;
|
|
19
|
+
/**
|
|
20
|
+
* 지우기 버튼 이름의 앞부분. 기본값은 `"제거"`다. 버튼 이름은 이 글자와 태그 글자를 이어 읽는다(예: 「제거 디자인」).
|
|
21
|
+
*/
|
|
22
|
+
removeLabel?: string;
|
|
23
|
+
/** `Tag`. 바로 아래(또는 Fragment·배열 안)에 둔다. */
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
export type TagGroupProps = TagGroupBaseProps & TagGroupNameProps;
|
|
27
|
+
/**
|
|
28
|
+
* 고르거나 입력한 값을 보여 주는 태그 묶음(`role="grid"`). ←→로 태그를 옮기고(끝에서 처음으로 돈다) Home·End로
|
|
29
|
+
* 처음·마지막 태그에 간다. `onRemove`를 주면 Delete·Backspace와 지우기 버튼으로 태그를 지운다.
|
|
30
|
+
* 지운 뒤 포커스는 옆 태그로 옮겨 간다. 이름은 `aria-label`이나 `aria-labelledby`로 꼭 준다.
|
|
31
|
+
*/
|
|
32
|
+
export declare function TagGroup({ onRemove, removeLabel, ref, className, children, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, ...props }: TagGroupProps): ReactElement;
|
|
33
|
+
export interface TagProps extends Omit<ComponentProps<"div">, "color" | "role" | "id" | "tabIndex" | "children">, CollectionValueItemProps {
|
|
34
|
+
/** 태그 글자. 필수다. 아이콘(장식용)을 앞에 함께 둘 수 있다. */
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* 색 계열 이름. Tide 색 계열 6개와 앱이 `ColorFamilyRegistry`에 더한 계열을 받는다. 기본값은 `gray`다.
|
|
38
|
+
* 분류를 구분하는 뜻 없는 색이라 Role(`primary`·`danger` 등)은 받지 않는다(ADR 0016·0017 후속).
|
|
39
|
+
* 루트에 `data-color`로 붙고 바깥 요소의 색을 물려받지 않는다.
|
|
40
|
+
*/
|
|
41
|
+
color?: ColorFamilyName;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 태그 하나(`role="row"`). `TagGroup` 바로 안에 둔다. `value`가 묶음 안의 key이고 `onRemove`에 넘어오는 값이다.
|
|
45
|
+
* `disabled`면 지울 수 없고 키보드 이동도 건너뛴다(`aria-disabled`).
|
|
46
|
+
*/
|
|
47
|
+
export declare function Tag(_props: TagProps): null;
|
|
48
|
+
export {};
|
package/dist/Tag.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { IconButton } from "./IconButton.js";
|
|
5
|
+
import { asCollectionComponent, resolveCollectionChildren, valueItemCollectionNode } from "./list-collection.js";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { createContext, use, useMemo, useRef } from "react";
|
|
8
|
+
import { XIcon } from "@tide-tds/icons";
|
|
9
|
+
import { mergeProps, mergeRefs, useTag, useTagGroup } from "react-aria";
|
|
10
|
+
import { useListState } from "react-stately";
|
|
11
|
+
|
|
12
|
+
// src/Tag.tsx
|
|
13
|
+
const TagGroupContext = createContext(null);
|
|
14
|
+
const tagGroupClass = "flex flex-wrap items-center gap-2 outline-none";
|
|
15
|
+
const tagClass = [
|
|
16
|
+
"inline-flex h-8 max-w-full min-w-0 shrink-0 items-center rounded-sm border border-transparent bg-accent-subtle ps-2.5 align-middle text-label-md whitespace-nowrap text-accent outline-none select-none",
|
|
17
|
+
"[--tag-remove-hover:var(--tide-accent-subtle-hover)] [--tag-remove-active:var(--tide-accent-subtle-active)]",
|
|
18
|
+
"aria-disabled:bg-disabled aria-disabled:text-disabled focus-visible:focus-ring"
|
|
19
|
+
].join(" ");
|
|
20
|
+
const tagCellClass = "flex min-w-0 items-center gap-1";
|
|
21
|
+
const tagLabelClass = "inline-flex min-w-0 items-center gap-1 [&>svg]:size-4 [&>svg]:shrink-0";
|
|
22
|
+
const removeClass = "rounded-sm text-current";
|
|
23
|
+
const removeInteractionClass = "hover:bg-(--tag-remove-hover) active:bg-(--tag-remove-active)";
|
|
24
|
+
/**
|
|
25
|
+
* 고르거나 입력한 값을 보여 주는 태그 묶음(`role="grid"`). ←→로 태그를 옮기고(끝에서 처음으로 돈다) Home·End로
|
|
26
|
+
* 처음·마지막 태그에 간다. `onRemove`를 주면 Delete·Backspace와 지우기 버튼으로 태그를 지운다.
|
|
27
|
+
* 지운 뒤 포커스는 옆 태그로 옮겨 간다. 이름은 `aria-label`이나 `aria-labelledby`로 꼭 준다.
|
|
28
|
+
*/
|
|
29
|
+
function TagGroup({ onRemove, removeLabel = "제거", ref, className, children, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, ...props }) {
|
|
30
|
+
const groupRef = useRef(null);
|
|
31
|
+
const items = useMemo(() => resolveCollectionChildren(children), [children]);
|
|
32
|
+
const state = useListState({ children: items });
|
|
33
|
+
const { gridProps } = useTagGroup({
|
|
34
|
+
id,
|
|
35
|
+
"aria-label": ariaLabel,
|
|
36
|
+
"aria-labelledby": ariaLabelledby,
|
|
37
|
+
"aria-describedby": ariaDescribedby,
|
|
38
|
+
onRemove: onRemove ? (keys) => keys.forEach((key) => onRemove(String(key))) : void 0
|
|
39
|
+
}, state, groupRef);
|
|
40
|
+
const mergedRef = useMemo(() => mergeRefs(ref, groupRef), [ref, groupRef]);
|
|
41
|
+
const context = useMemo(() => ({
|
|
42
|
+
onRemove,
|
|
43
|
+
removeLabel
|
|
44
|
+
}), [onRemove, removeLabel]);
|
|
45
|
+
return /* @__PURE__ */ jsx("div", {
|
|
46
|
+
...mergeProps(props, gridProps),
|
|
47
|
+
ref: mergedRef,
|
|
48
|
+
className: cn(tagGroupClass, className),
|
|
49
|
+
children: /* @__PURE__ */ jsx(TagGroupContext, {
|
|
50
|
+
value: context,
|
|
51
|
+
children: [...state.collection].map((node) => /* @__PURE__ */ jsx(TagView, {
|
|
52
|
+
node,
|
|
53
|
+
state
|
|
54
|
+
}, node.key))
|
|
55
|
+
})
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 태그 하나(`role="row"`). `TagGroup` 바로 안에 둔다. `value`가 묶음 안의 key이고 `onRemove`에 넘어오는 값이다.
|
|
60
|
+
* `disabled`면 지울 수 없고 키보드 이동도 건너뛴다(`aria-disabled`).
|
|
61
|
+
*/
|
|
62
|
+
function Tag(_props) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
asCollectionComponent(Tag, valueItemCollectionNode);
|
|
66
|
+
/** 태그 하나를 그린다. node.props는 앱이 Tag에 준 props(와 컬렉션이 더한 isDisabled)다. */
|
|
67
|
+
function TagView({ node, state }) {
|
|
68
|
+
const context = use(TagGroupContext);
|
|
69
|
+
const rowRef = useRef(null);
|
|
70
|
+
const { ref, className, color = "gray", value: _value, disabled: _disabled, isDisabled: _isDisabled, textValue: _textValue, children: _children, "aria-label": _ariaLabel, onFocus: _onFocus, onBlur: _onBlur, onKeyDown: _onKeyDown, onKeyUp: _onKeyUp, autoFocus: _autoFocus, ...props } = node.props;
|
|
71
|
+
const { rowProps, gridCellProps, removeButtonProps, allowsRemoving, isDisabled } = useTag({ item: node }, state, rowRef);
|
|
72
|
+
const mergedRef = useMemo(() => mergeRefs(ref, rowRef), [ref, rowRef]);
|
|
73
|
+
const onRemove = context?.onRemove;
|
|
74
|
+
return /* @__PURE__ */ jsx("div", {
|
|
75
|
+
...mergeProps(props, rowProps),
|
|
76
|
+
ref: mergedRef,
|
|
77
|
+
"data-color": color,
|
|
78
|
+
className: cn(tagClass, allowsRemoving ? "pe-0.75" : "pe-2.5", className),
|
|
79
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
80
|
+
...gridCellProps,
|
|
81
|
+
className: tagCellClass,
|
|
82
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
83
|
+
className: tagLabelClass,
|
|
84
|
+
children: node.rendered
|
|
85
|
+
}), allowsRemoving && onRemove ? /* @__PURE__ */ jsx(IconButton, {
|
|
86
|
+
"aria-label": context.removeLabel,
|
|
87
|
+
"aria-labelledby": removeButtonProps["aria-labelledby"],
|
|
88
|
+
id: removeButtonProps.id,
|
|
89
|
+
variant: "ghost",
|
|
90
|
+
color: "neutral",
|
|
91
|
+
size: "xs",
|
|
92
|
+
disabled: isDisabled,
|
|
93
|
+
tabIndex: -1,
|
|
94
|
+
onClick: () => onRemove(String(node.key)),
|
|
95
|
+
className: cn(removeClass, isDisabled ? void 0 : removeInteractionClass),
|
|
96
|
+
children: /* @__PURE__ */ jsx(XIcon, {})
|
|
97
|
+
}) : null]
|
|
98
|
+
})
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { Tag, TagGroup };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { OverlayPlacement } from "./overlay.js";
|
|
2
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
/** Tooltip 설명의 위치. 앞은 트리거의 어느 쪽, 뒤는 그 변에서 맞출 끝이다. 공간이 모자라면 반대쪽으로 뒤집힌다. */
|
|
4
|
+
export type TooltipPlacement = OverlayPlacement;
|
|
5
|
+
export interface TooltipProps {
|
|
6
|
+
/** 열림 상태(제어). 주면 `onOpenChange`로 바꾼다. */
|
|
7
|
+
open?: boolean;
|
|
8
|
+
/** 처음 열림 상태(비제어). 기본값은 `false`다. */
|
|
9
|
+
defaultOpen?: boolean;
|
|
10
|
+
/** 열림 상태가 바뀌려 할 때 새 값으로 부른다. */
|
|
11
|
+
onOpenChange?: (open: boolean) => void;
|
|
12
|
+
/**
|
|
13
|
+
* 마우스를 올린 뒤 열릴 때까지 기다리는 시간(ms). 기본값은 `500`이다. 키보드 포커스는 기다리지 않는다.
|
|
14
|
+
* 한 Tooltip이 열린 뒤 잠깐(0.5초) 안에 다른 트리거로 옮기면 기다리지 않고 바로 연다.
|
|
15
|
+
*/
|
|
16
|
+
delay?: number;
|
|
17
|
+
/** 포인터가 트리거·설명을 떠난 뒤 닫힐 때까지 기다리는 시간(ms). 기본값은 `300`이다. 설명으로 옮겨 갈 틈이다. */
|
|
18
|
+
closeDelay?: number;
|
|
19
|
+
/** `true`면 열리지 않는다. 트리거는 그대로 동작한다. */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** `TooltipTrigger`와 `TooltipContent` */
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 트리거와 설명을 잇는 열림 상태. DOM 요소를 만들지 않는다.
|
|
26
|
+
* 안에 `TooltipTrigger` 하나와 `TooltipContent` 하나를 둔다.
|
|
27
|
+
*/
|
|
28
|
+
export declare function Tooltip({ open, defaultOpen, onOpenChange, delay, closeDelay, disabled, children }: TooltipProps): ReactElement;
|
|
29
|
+
export interface TooltipTriggerProps extends ComponentProps<"button"> {
|
|
30
|
+
/**
|
|
31
|
+
* 다른 요소로 렌더한다. 예: `render={<IconButton aria-label="복사" />}`, `render={<Button variant="ghost" />}`.
|
|
32
|
+
* props, ref, className, 이벤트 핸들러를 그 요소에 병합한다. 기본은 class 없는 `<button type="button">`이다.
|
|
33
|
+
* 포커스를 받을 수 있는 요소여야 키보드 사용자도 설명을 본다.
|
|
34
|
+
*/
|
|
35
|
+
render?: ReactElement;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Tooltip을 여는 요소. 마우스를 올리면 `delay` 뒤에, 키보드로 포커스하면 바로 열린다.
|
|
39
|
+
* 누르면(마우스, 터치, 키) 닫힌다. 열린 동안 `aria-describedby`에 설명 id가 더해진다.
|
|
40
|
+
*/
|
|
41
|
+
export declare function TooltipTrigger({ render, ref, className, "aria-describedby": describedBy, ...props }: TooltipTriggerProps): ReactElement;
|
|
42
|
+
export interface TooltipContentProps extends Omit<ComponentProps<"div">, "id" | "role"> {
|
|
43
|
+
/** 트리거 기준 위치. 기본값은 `top`(위, 가운데 맞춤)이다. 공간이 모자라면 반대쪽으로 뒤집힌다. */
|
|
44
|
+
placement?: TooltipPlacement;
|
|
45
|
+
/** 트리거와의 간격(px). 기본값은 `4`다. */
|
|
46
|
+
offset?: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 트리거에 붙는 설명(`role="tooltip"`). 열릴 때만 `document.body`에 portal로 렌더하고 `z-tooltip`으로 쌓는다.
|
|
50
|
+
* 바로 나타나고, 닫히면 opacity만 `fast`(150ms)로 사라진다. 조작 요소(버튼, 링크)는 넣지 않는다.
|
|
51
|
+
* 열려 있으면 `data-open`이, 트리거 기준 방향이 `data-placement`(`top`/`bottom`/`left`/`right`)로 붙는다.
|
|
52
|
+
*/
|
|
53
|
+
export declare function TooltipContent(props: TooltipContentProps): ReactElement | null;
|