@tide-tds/components 0.3.0 → 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.
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import { cn } from "./cn.js";
4
+ import { OverlayPortal, useOverlayPresence, useOverlayTheme, usePopoverOverlay } from "./overlay.js";
5
+ import { overlaySurfaceClass, popoverMotionClass } from "./overlay-style.js";
6
+ import { renderElement } from "./render.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { createContext, createElement, use, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
9
+ import { DismissButton, mergeProps, mergeRefs, useDialog, useOverlayTrigger, usePress } from "react-aria";
10
+ import { useOverlayTriggerState } from "react-stately";
11
+
12
+ // src/Popover.tsx
13
+ const PopoverContext = createContext(null);
14
+ function usePopoverContext(part) {
15
+ const context = use(PopoverContext);
16
+ if (!context) throw new Error(`${part}는 Popover 안에 둔다.`);
17
+ return context;
18
+ }
19
+ /**
20
+ * 트리거와 내용을 잇는 열림 상태. DOM 요소를 만들지 않는다.
21
+ * 안에 `PopoverTrigger` 하나와 `PopoverContent` 하나를 둔다.
22
+ */
23
+ function Popover({ open, defaultOpen, onOpenChange, children }) {
24
+ const state = useOverlayTriggerState({
25
+ isOpen: open,
26
+ defaultOpen,
27
+ onOpenChange
28
+ });
29
+ const triggerRef = useRef(null);
30
+ const { triggerProps, overlayProps } = useOverlayTrigger({ type: "dialog" }, state, triggerRef);
31
+ const generatedTriggerId = useId();
32
+ const [triggerId, setTriggerId] = useState(generatedTriggerId);
33
+ const value = useMemo(() => ({
34
+ state,
35
+ triggerRef,
36
+ triggerProps,
37
+ contentId: overlayProps.id,
38
+ triggerId,
39
+ setTriggerId
40
+ }), [
41
+ state,
42
+ triggerProps,
43
+ overlayProps.id,
44
+ triggerId
45
+ ]);
46
+ return /* @__PURE__ */ jsx(PopoverContext, {
47
+ value,
48
+ children
49
+ });
50
+ }
51
+ /**
52
+ * Popover를 여닫는 버튼. 누르면(마우스, 터치, Enter, Space) 열림 상태를 뒤집는다.
53
+ * `aria-expanded`, 열렸을 때 `aria-controls`, 열렸을 때 `data-open`이 붙는다.
54
+ */
55
+ function PopoverTrigger({ render, ref, id, className, ...props }) {
56
+ const { state, triggerRef, triggerProps, triggerId, setTriggerId } = usePopoverContext("PopoverTrigger");
57
+ const { onPress, ...ariaProps } = triggerProps;
58
+ const { pressProps } = usePress({
59
+ onPress,
60
+ ref: triggerRef
61
+ });
62
+ const mergedRef = useMemo(() => mergeRefs(ref, triggerRef), [ref, triggerRef]);
63
+ const resolvedId = id ?? triggerId;
64
+ useLayoutEffect(() => {
65
+ if (resolvedId !== triggerId) setTriggerId(resolvedId);
66
+ }, [
67
+ resolvedId,
68
+ triggerId,
69
+ setTriggerId
70
+ ]);
71
+ const merged = {
72
+ ...mergeProps(props, ariaProps, pressProps),
73
+ id: resolvedId,
74
+ ref: mergedRef,
75
+ "data-open": state.isOpen ? "" : void 0
76
+ };
77
+ if (!render) return createElement("button", {
78
+ type: "button",
79
+ ...merged,
80
+ className
81
+ });
82
+ return renderElement({
83
+ defaultElement: "button",
84
+ render,
85
+ baseClassName: "",
86
+ props: {
87
+ ...merged,
88
+ className
89
+ }
90
+ });
91
+ }
92
+ const contentClass = `isolate z-popover max-w-80 overflow-auto p-4 text-body-md outline-none ${overlaySurfaceClass} ${popoverMotionClass}`;
93
+ /**
94
+ * 트리거 옆에 뜨는 내용(`role="dialog"`). 열릴 때만 `document.body`에 portal로 렌더하고 `z-popover`로 쌓는다.
95
+ * 등장은 opacity + blur 4px + 4px 이동(`normal`), 퇴장은 그 절반(`fast`)이다. 움직임 줄이기에서는 opacity만 바뀐다.
96
+ * 열려 있으면 `data-open`이, 트리거 기준 방향이 `data-placement`(`top`/`bottom`/`left`/`right`)로 붙는다.
97
+ */
98
+ function PopoverContent(props) {
99
+ const context = usePopoverContext("PopoverContent");
100
+ const popoverRef = useRef(null);
101
+ if (!useOverlayPresence(context.state.isOpen, popoverRef)) return null;
102
+ return /* @__PURE__ */ jsx(OverlayPortal, {
103
+ isExiting: !context.state.isOpen,
104
+ children: /* @__PURE__ */ jsx(PopoverContentInner, {
105
+ ...props,
106
+ context,
107
+ popoverRef
108
+ })
109
+ });
110
+ }
111
+ /** DOM에 있는 동안만 portal 안에서 렌더한다. React Aria 오버레이 hook은 요소가 있다고 보고 동작한다. */
112
+ function PopoverContentInner({ context, popoverRef, placement = "bottom", offset = 8, ref, className, children, ...props }) {
113
+ const { state, triggerRef, contentId, triggerId } = context;
114
+ useOverlayTheme(triggerRef, popoverRef);
115
+ const { overlayProps } = usePopoverOverlay({
116
+ triggerRef,
117
+ popoverRef,
118
+ placement,
119
+ offset
120
+ }, state);
121
+ const { dialogProps } = useDialog({
122
+ "aria-label": props["aria-label"],
123
+ "aria-labelledby": props["aria-labelledby"]
124
+ }, popoverRef);
125
+ const labelledBy = dialogProps["aria-labelledby"] ?? (props["aria-label"] ? void 0 : triggerId);
126
+ const mergedRef = useMemo(() => mergeRefs(ref, popoverRef), [ref, popoverRef]);
127
+ const { style: _style, ...rest } = mergeProps(props, overlayProps, dialogProps);
128
+ return /* @__PURE__ */ jsxs("div", {
129
+ ...rest,
130
+ id: contentId,
131
+ ref: mergedRef,
132
+ "aria-labelledby": labelledBy,
133
+ "data-open": state.isOpen ? "" : void 0,
134
+ className: cn(contentClass, className),
135
+ style: {
136
+ ...overlayProps.style,
137
+ ...props.style
138
+ },
139
+ children: [
140
+ /* @__PURE__ */ jsx(DismissButton, { onDismiss: state.close }),
141
+ children,
142
+ /* @__PURE__ */ jsx(DismissButton, { onDismiss: state.close })
143
+ ]
144
+ });
145
+ }
146
+
147
+ export { Popover, PopoverContent, PopoverTrigger };
@@ -0,0 +1,62 @@
1
+ import { InputSize } from "./input-style.js";
2
+ import { ListBoxOptionProps, ListBoxSectionProps } from "./listbox.js";
3
+ import { ComponentProps, ReactElement, ReactNode } from "react";
4
+ export interface SelectProps extends Omit<ComponentProps<"button">, "value" | "defaultValue" | "onChange" | "type" | "size" | "children" | "name" | "form"> {
5
+ /** 고른 값(제어). 고른 옵션의 `value`다. `null`이면 아무것도 고르지 않은 상태다. 주면 `onValueChange`로 바꾼다. */
6
+ value?: string | null;
7
+ /** 처음 고른 값(비제어). 주지 않으면 아무것도 고르지 않은 상태로 시작한다. 폼을 reset하면 이 값으로 돌아간다. */
8
+ defaultValue?: string;
9
+ /** 사용자가 다른 옵션을 고르면 그 옵션의 `value`로 부른다. */
10
+ onValueChange?: (value: string) => void;
11
+ /** 목록 열림 상태(제어). 주면 `onOpenChange`로 바꾼다. */
12
+ open?: boolean;
13
+ /** 처음 목록 열림 상태(비제어). 기본값은 `false`다. */
14
+ defaultOpen?: boolean;
15
+ /** 목록 열림 상태가 바뀌려 할 때 새 값으로 부른다. 옵션을 골라 닫힐 때도 부른다. */
16
+ onOpenChange?: (open: boolean) => void;
17
+ /** 폼 제출 이름. 숨은 `<select>`에 붙어 고른 값이 이 이름으로 제출된다(트리거 버튼에는 붙지 않는다). */
18
+ name?: string;
19
+ /** 숨은 `<select>`가 속할 `<form>`의 id. 폼 밖에 둘 때 쓴다. */
20
+ form?: string;
21
+ /** 브라우저 자동 완성 힌트. 숨은 `<select>`에 붙는다. */
22
+ autoComplete?: string;
23
+ /** 아무것도 고르지 않았을 때 트리거에 보이는 글자. 기본값은 `"선택하세요"`다. */
24
+ placeholder?: string;
25
+ /** 높이 `sm` 32px, `md` 40px, `lg` 48px. 같은 size의 Input·Button과 높이가 같다. 기본값은 `md`다. */
26
+ size?: InputSize;
27
+ /**
28
+ * 오류 상태. 트리거에 `aria-invalid="true"`와 `data-invalid`가 붙고 경계가 danger 색이 된다.
29
+ * Field 안에서는 Field의 값을 따르고, 여기에 준 값이 이긴다.
30
+ */
31
+ invalid?: boolean;
32
+ /**
33
+ * 필수. 고르지 않고 폼을 제출하면 제출이 막히고 트리거로 포커스가 오며 오류 상태가 된다(브라우저 오류 말풍선은
34
+ * 없다). 메시지는 Field의 `error`로 앱이 보여 준다. Field 안에서는 Field의 값을 따른다.
35
+ */
36
+ required?: boolean;
37
+ /** `SelectItem`과 `SelectSection`. 이 요소들을 바로 아래(또는 Fragment·배열 안)에 둔다. */
38
+ children?: ReactNode;
39
+ }
40
+ /**
41
+ * 값 하나를 고르는 입력. 트리거를 누르거나(마우스는 누르는 순간) Enter·Space·↓·↑로 목록을 열고, 화살표·Home/End·
42
+ * 영문 글자로 옵션을 옮겨 Enter·Space·누름으로 고른다. 닫힌 채로 글자를 치면 바로 그 옵션을 고른다.
43
+ * 고르면 닫히고 포커스는 트리거로 돌아온다. Esc·바깥 누름·Tab으로도 닫힌다.
44
+ * Field 안에 두면 label·설명·오류가 자동으로 연결되고, `name`을 주면 고른 값이 폼에 제출된다.
45
+ * 상태는 `data-open`(트리거·목록), `data-invalid`(트리거), 네이티브 `:disabled`(트리거),
46
+ * `data-highlighted`·`data-selected`·`data-disabled`(옵션)로 노출한다.
47
+ */
48
+ export declare function Select({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, name, form, autoComplete, placeholder, size, invalid, disabled, required, id, ref, className, children, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }: SelectProps): ReactElement;
49
+ export interface SelectItemProps extends ListBoxOptionProps {}
50
+ /**
51
+ * Select의 옵션(`role="option"`). `Select`나 `SelectSection` 바로 안에 둔다. `value`가 고른 값이고 폼에 제출된다.
52
+ * 내용(아이콘과 글자)은 고르면 트리거에도 보인다. 키보드 위치와 마우스를 올린 옵션은 highlight(primary subtle 배경,
53
+ * primary 글자), 고른 옵션은 neutral subtle-active 배경과 끝의 체크 아이콘이다.
54
+ * 상태는 `data-highlighted`, `data-selected`, `data-disabled`로 노출한다.
55
+ */
56
+ export declare function SelectItem(_props: SelectItemProps): null;
57
+ export interface SelectSectionProps extends ListBoxSectionProps {}
58
+ /**
59
+ * 옵션 묶음(`role="group"`). `heading`이 화면에 보이는 제목이자 묶음의 이름이고, 없으면 `aria-label`이 이름이다.
60
+ * 앞뒤 다른 옵션·묶음과의 사이에 구분선이 그려진다.
61
+ */
62
+ export declare function SelectSection(_props: SelectSectionProps): null;
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 {};