@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.
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { cn } from "./cn.js";
4
+ import { OverlayPortal, overlayPositionProps, useOverlayPresence, useOverlayTheme } from "./overlay.js";
5
+ import { frequentMotionClass, overlaySurfaceClass } from "./overlay-style.js";
6
+ import { renderElement } from "./render.js";
7
+ import { jsx } from "react/jsx-runtime";
8
+ import { createContext, createElement, use, useMemo, useRef } from "react";
9
+ import { mergeProps, mergeRefs, useOverlayPosition, useTooltip, useTooltipTrigger } from "react-aria";
10
+ import { useTooltipTriggerState } from "react-stately";
11
+
12
+ // src/Tooltip.tsx
13
+ const TooltipContext = createContext(null);
14
+ function useTooltipContext(part) {
15
+ const context = use(TooltipContext);
16
+ if (!context) throw new Error(`${part}는 Tooltip 안에 둔다.`);
17
+ return context;
18
+ }
19
+ /**
20
+ * 트리거와 설명을 잇는 열림 상태. DOM 요소를 만들지 않는다.
21
+ * 안에 `TooltipTrigger` 하나와 `TooltipContent` 하나를 둔다.
22
+ */
23
+ function Tooltip({ open, defaultOpen, onOpenChange, delay = 500, closeDelay = 300, disabled, children }) {
24
+ const options = {
25
+ isOpen: open,
26
+ defaultOpen,
27
+ onOpenChange,
28
+ delay,
29
+ closeDelay,
30
+ isDisabled: disabled
31
+ };
32
+ const state = useTooltipTriggerState(options);
33
+ const triggerRef = useRef(null);
34
+ const { triggerProps, tooltipProps } = useTooltipTrigger(options, state, triggerRef);
35
+ const value = useMemo(() => ({
36
+ state,
37
+ triggerRef,
38
+ triggerProps,
39
+ tooltipId: tooltipProps.id
40
+ }), [
41
+ state,
42
+ triggerProps,
43
+ tooltipProps.id
44
+ ]);
45
+ return /* @__PURE__ */ jsx(TooltipContext, {
46
+ value,
47
+ children
48
+ });
49
+ }
50
+ /**
51
+ * Tooltip을 여는 요소. 마우스를 올리면 `delay` 뒤에, 키보드로 포커스하면 바로 열린다.
52
+ * 누르면(마우스, 터치, 키) 닫힌다. 열린 동안 `aria-describedby`에 설명 id가 더해진다.
53
+ */
54
+ function TooltipTrigger({ render, ref, className, "aria-describedby": describedBy, ...props }) {
55
+ const { triggerRef, triggerProps } = useTooltipContext("TooltipTrigger");
56
+ const { tabIndex: _tabIndex, "aria-describedby": tooltipDescribedBy, ...ariaProps } = triggerProps;
57
+ const mergedRef = useMemo(() => mergeRefs(ref, triggerRef), [ref, triggerRef]);
58
+ const merged = {
59
+ ...mergeProps(props, ariaProps),
60
+ "aria-describedby": [describedBy, tooltipDescribedBy].filter(Boolean).join(" ") || void 0,
61
+ ref: mergedRef
62
+ };
63
+ if (!render) return createElement("button", {
64
+ type: "button",
65
+ ...merged,
66
+ className
67
+ });
68
+ return renderElement({
69
+ defaultElement: "button",
70
+ render,
71
+ baseClassName: "",
72
+ props: {
73
+ ...merged,
74
+ className
75
+ }
76
+ });
77
+ }
78
+ const contentClass = `isolate z-tooltip max-w-60 px-2 py-1 text-body-sm ${overlaySurfaceClass} ${frequentMotionClass}`;
79
+ /**
80
+ * 트리거에 붙는 설명(`role="tooltip"`). 열릴 때만 `document.body`에 portal로 렌더하고 `z-tooltip`으로 쌓는다.
81
+ * 바로 나타나고, 닫히면 opacity만 `fast`(150ms)로 사라진다. 조작 요소(버튼, 링크)는 넣지 않는다.
82
+ * 열려 있으면 `data-open`이, 트리거 기준 방향이 `data-placement`(`top`/`bottom`/`left`/`right`)로 붙는다.
83
+ */
84
+ function TooltipContent(props) {
85
+ const context = useTooltipContext("TooltipContent");
86
+ const tooltipRef = useRef(null);
87
+ if (!useOverlayPresence(context.state.isOpen, tooltipRef)) return null;
88
+ return /* @__PURE__ */ jsx(OverlayPortal, {
89
+ isExiting: !context.state.isOpen,
90
+ focusManagement: false,
91
+ children: /* @__PURE__ */ jsx(TooltipContentInner, {
92
+ ...props,
93
+ context,
94
+ tooltipRef
95
+ })
96
+ });
97
+ }
98
+ /** DOM에 있는 동안만 portal 안에서 렌더한다. 위치 hook은 요소가 있다고 보고 동작한다. */
99
+ function TooltipContentInner({ context, tooltipRef, placement = "top", offset = 4, ref, className, children, ...props }) {
100
+ const { state, triggerRef, tooltipId } = context;
101
+ useOverlayTheme(triggerRef, tooltipRef);
102
+ const position = useOverlayPosition({
103
+ targetRef: triggerRef,
104
+ overlayRef: tooltipRef,
105
+ placement,
106
+ offset,
107
+ isOpen: state.isOpen,
108
+ onClose: () => state.close(true)
109
+ });
110
+ const overlayProps = overlayPositionProps(position.overlayProps, position.placement, placement);
111
+ const { tooltipProps } = useTooltip({}, state);
112
+ const mergedRef = useMemo(() => mergeRefs(ref, tooltipRef), [ref, tooltipRef]);
113
+ const { style: _style, ...rest } = mergeProps(props, overlayProps, tooltipProps);
114
+ return /* @__PURE__ */ jsx("div", {
115
+ ...rest,
116
+ id: tooltipId,
117
+ ref: mergedRef,
118
+ "data-open": state.isOpen ? "" : void 0,
119
+ "data-react-aria-top-layer": "",
120
+ className: cn(contentClass, !state.isOpen && state.shouldSkipAnimation && "transition-none", className),
121
+ style: {
122
+ ...overlayProps.style,
123
+ ...props.style
124
+ },
125
+ children
126
+ });
127
+ }
128
+
129
+ export { Tooltip, TooltipContent, TooltipTrigger };
package/dist/index.d.ts CHANGED
@@ -23,6 +23,15 @@ import { Progress, ProgressProps } from "./Progress.js";
23
23
  import { Table, TableBody, TableBodyProps, TableCell, TableCellProps, TableHead, TableHeadProps, TableHeaderCell, TableHeaderCellProps, TableProps, TableRow, TableRowProps } from "./Table.js";
24
24
  import { Breadcrumb, BreadcrumbCurrent, BreadcrumbCurrentProps, BreadcrumbItem, BreadcrumbItemProps, BreadcrumbLink, BreadcrumbLinkProps, BreadcrumbProps } from "./Breadcrumb.js";
25
25
  import { Pagination, PaginationEllipsis, PaginationEllipsisProps, PaginationItem, PaginationItemProps, PaginationLink, PaginationLinkProps, PaginationNext, PaginationNextProps, PaginationPrevious, PaginationPreviousProps, PaginationProps } from "./Pagination.js";
26
+ import { Menu, MenuContent, MenuContentProps, MenuItem, MenuItemProps, MenuPlacement, MenuProps, MenuSection, MenuSectionProps, MenuTrigger, MenuTriggerProps } from "./Menu.js";
26
27
  import { Popover, PopoverContent, PopoverContentProps, PopoverPlacement, PopoverProps, PopoverTrigger, PopoverTriggerProps } from "./Popover.js";
28
+ import { Combobox, ComboboxItem, ComboboxItemProps, ComboboxProps, ComboboxSection, ComboboxSectionProps } from "./Combobox.js";
29
+ import { Select, SelectItem, SelectItemProps, SelectProps, SelectSection, SelectSectionProps } from "./Select.js";
30
+ import { AlertDialog, AlertDialogProps } from "./AlertDialog.js";
31
+ import { Dialog, DialogClose, DialogCloseProps, DialogContent, DialogContentProps, DialogDescription, DialogDescriptionProps, DialogFooter, DialogFooterProps, DialogProps, DialogSize, DialogTitle, DialogTitleProps, DialogTrigger, DialogTriggerProps } from "./Dialog.js";
32
+ import { Accordion, AccordionHeadingLevel, AccordionItem, AccordionItemProps, AccordionPanel, AccordionPanelProps, AccordionProps, AccordionTrigger, AccordionTriggerProps } from "./Accordion.js";
33
+ import { Tab, TabList, TabListProps, TabPanel, TabPanelProps, TabProps, Tabs, TabsActivationMode, TabsOrientation, TabsProps } from "./Tabs.js";
34
+ import { Tag, TagGroup, TagGroupProps, TagProps } from "./Tag.js";
35
+ import { Tooltip, TooltipContent, TooltipContentProps, TooltipPlacement, TooltipProps, TooltipTrigger, TooltipTriggerProps } from "./Tooltip.js";
27
36
  import { Color, ColorFamilyName, ColorFamilyRegistry, FillVariant, Role, RoleRegistry, Size } from "./types.js";
28
- export { Alert, type AlertProps, Avatar, type AvatarProps, Badge, type BadgeProps, Breadcrumb, BreadcrumbCurrent, type BreadcrumbCurrentProps, BreadcrumbItem, type BreadcrumbItemProps, BreadcrumbLink, type BreadcrumbLinkProps, type BreadcrumbProps, Button, type ButtonProps, Card, type CardProps, Checkbox, CheckboxGroup, type CheckboxGroupProps, type CheckboxProps, type ChoiceOrientation, type Color, type ColorFamilyName, type ColorFamilyRegistry, Field, type FieldProps, type FillVariant, IconButton, type IconButtonProps, Input, type InputProps, Link, type LinkProps, Pagination, PaginationEllipsis, type PaginationEllipsisProps, PaginationItem, type PaginationItemProps, PaginationLink, type PaginationLinkProps, PaginationNext, type PaginationNextProps, PaginationPrevious, type PaginationPreviousProps, type PaginationProps, Popover, PopoverContent, type PopoverContentProps, type PopoverPlacement, type PopoverProps, PopoverTrigger, type PopoverTriggerProps, Progress, type ProgressProps, Radio, RadioGroup, type RadioGroupProps, type RadioProps, type Role, type RoleRegistry, Separator, type SeparatorProps, type Size, Skeleton, type SkeletonProps, Spinner, type SpinnerProps, Switch, type SwitchProps, Table, TableBody, type TableBodyProps, TableCell, type TableCellProps, TableHead, type TableHeadProps, TableHeaderCell, type TableHeaderCellProps, type TableProps, TableRow, type TableRowProps, Textarea, type TextareaProps, Typography, type TypographyProps, VisuallyHidden, type VisuallyHiddenProps, cn };
37
+ export { Accordion, type AccordionHeadingLevel, AccordionItem, type AccordionItemProps, AccordionPanel, type AccordionPanelProps, type AccordionProps, AccordionTrigger, type AccordionTriggerProps, Alert, AlertDialog, type AlertDialogProps, type AlertProps, Avatar, type AvatarProps, Badge, type BadgeProps, Breadcrumb, BreadcrumbCurrent, type BreadcrumbCurrentProps, BreadcrumbItem, type BreadcrumbItemProps, BreadcrumbLink, type BreadcrumbLinkProps, type BreadcrumbProps, Button, type ButtonProps, Card, type CardProps, Checkbox, CheckboxGroup, type CheckboxGroupProps, type CheckboxProps, type ChoiceOrientation, type Color, type ColorFamilyName, type ColorFamilyRegistry, Combobox, ComboboxItem, type ComboboxItemProps, type ComboboxProps, ComboboxSection, type ComboboxSectionProps, Dialog, DialogClose, type DialogCloseProps, DialogContent, type DialogContentProps, DialogDescription, type DialogDescriptionProps, DialogFooter, type DialogFooterProps, type DialogProps, type DialogSize, DialogTitle, type DialogTitleProps, DialogTrigger, type DialogTriggerProps, Field, type FieldProps, type FillVariant, IconButton, type IconButtonProps, Input, type InputProps, Link, type LinkProps, Menu, MenuContent, type MenuContentProps, MenuItem, type MenuItemProps, type MenuPlacement, type MenuProps, MenuSection, type MenuSectionProps, MenuTrigger, type MenuTriggerProps, Pagination, PaginationEllipsis, type PaginationEllipsisProps, PaginationItem, type PaginationItemProps, PaginationLink, type PaginationLinkProps, PaginationNext, type PaginationNextProps, PaginationPrevious, type PaginationPreviousProps, type PaginationProps, Popover, PopoverContent, type PopoverContentProps, type PopoverPlacement, type PopoverProps, PopoverTrigger, type PopoverTriggerProps, Progress, type ProgressProps, Radio, RadioGroup, type RadioGroupProps, type RadioProps, type Role, type RoleRegistry, Select, SelectItem, type SelectItemProps, type SelectProps, SelectSection, type SelectSectionProps, Separator, type SeparatorProps, type Size, Skeleton, type SkeletonProps, Spinner, type SpinnerProps, Switch, type SwitchProps, Tab, TabList, type TabListProps, TabPanel, type TabPanelProps, type TabProps, Table, TableBody, type TableBodyProps, TableCell, type TableCellProps, TableHead, type TableHeadProps, TableHeaderCell, type TableHeaderCellProps, type TableProps, TableRow, type TableRowProps, Tabs, type TabsActivationMode, type TabsOrientation, type TabsProps, Tag, TagGroup, type TagGroupProps, type TagProps, Textarea, type TextareaProps, Tooltip, TooltipContent, type TooltipContentProps, type TooltipPlacement, type TooltipProps, TooltipTrigger, type TooltipTriggerProps, Typography, type TypographyProps, VisuallyHidden, type VisuallyHiddenProps, cn };
package/dist/index.js CHANGED
@@ -23,6 +23,15 @@ import { Progress } from "./Progress.js";
23
23
  import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table.js";
24
24
  import { Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink } from "./Breadcrumb.js";
25
25
  import { Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "./Pagination.js";
26
+ import { Menu, MenuContent, MenuItem, MenuSection, MenuTrigger } from "./Menu.js";
26
27
  import { Popover, PopoverContent, PopoverTrigger } from "./Popover.js";
28
+ import { Combobox, ComboboxItem, ComboboxSection } from "./Combobox.js";
29
+ import { Select, SelectItem, SelectSection } from "./Select.js";
30
+ import { AlertDialog } from "./AlertDialog.js";
31
+ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from "./Dialog.js";
32
+ import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger } from "./Accordion.js";
33
+ import { Tab, TabList, TabPanel, Tabs } from "./Tabs.js";
34
+ import { Tag, TagGroup } from "./Tag.js";
35
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./Tooltip.js";
27
36
 
28
- export { Alert, Avatar, Badge, Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink, Button, Card, Checkbox, CheckboxGroup, Field, IconButton, Input, Link, Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, Popover, PopoverContent, PopoverTrigger, Progress, Radio, RadioGroup, Separator, Skeleton, Spinner, Switch, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Textarea, Typography, VisuallyHidden, cn };
37
+ export { Accordion, AccordionItem, AccordionPanel, AccordionTrigger, Alert, AlertDialog, Avatar, Badge, Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink, Button, Card, Checkbox, CheckboxGroup, Combobox, ComboboxItem, ComboboxSection, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger, Field, IconButton, Input, Link, Menu, MenuContent, MenuItem, MenuSection, MenuTrigger, Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, Popover, PopoverContent, PopoverTrigger, Progress, Radio, RadioGroup, Select, SelectItem, SelectSection, Separator, Skeleton, Spinner, Switch, Tab, TabList, TabPanel, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Tabs, Tag, TagGroup, Textarea, Tooltip, TooltipContent, TooltipTrigger, Typography, VisuallyHidden, cn };
@@ -0,0 +1,3 @@
1
+ import { Size } from "./types.js";
2
+ /** 입력창의 size. `xs`(24px)는 입력하기에 작아서 받지 않는다. Input·Select 트리거가 같다. */
3
+ export type InputSize = Extract<Size, "sm" | "md" | "lg">;
@@ -1,5 +1,11 @@
1
1
  // src/input-style.ts
2
2
  /** 입력창의 공통 모양. size class 뒤에 넣는다. */
3
3
  const inputBaseClass = "w-full min-w-0 rounded-md border border-strong bg-canvas font-normal text-default transition-colors placeholder:text-muted aria-invalid:border-danger focus:focus-field disabled:cursor-not-allowed disabled:border-default disabled:bg-disabled disabled:text-disabled disabled:placeholder:text-disabled";
4
+ /** 높이, 좌우 padding, 텍스트 스타일(spec size 표). radius는 모든 size에서 `md`다. */
5
+ const inputSizeClass = {
6
+ sm: "h-8 px-2.5 text-label-sm",
7
+ md: "h-10 px-3 text-label-md",
8
+ lg: "h-12 px-4 text-label-lg"
9
+ };
4
10
 
5
- export { inputBaseClass };
11
+ export { inputBaseClass, inputSizeClass };
@@ -0,0 +1,23 @@
1
+ import { ReactNode } from "react";
2
+ /** 항목 Component의 공통 props. Tide 이름(`disabled`)을 React Stately 이름(`isDisabled`)으로 바꿔 node에 넣는다. */
3
+ export interface CollectionItemProps {
4
+ /** `true`면 고르거나 실행할 수 없고 키보드 위치도 건너뛴다. */
5
+ disabled?: boolean;
6
+ /** 타입어헤드(글자를 쳐서 항목으로 옮겨 가기)에 쓰는 글자. 없으면 children의 글자를 모은다. */
7
+ textValue?: string;
8
+ "aria-label"?: string;
9
+ children?: ReactNode;
10
+ }
11
+ /** 항목 묶음 Component의 공통 props */
12
+ export interface CollectionSectionProps {
13
+ /** 묶음 제목. 화면에 보이고 묶음(`group`)의 이름이 된다. */
14
+ heading?: ReactNode;
15
+ /** 제목이 없을 때 묶음의 이름 */
16
+ "aria-label"?: string;
17
+ children?: ReactNode;
18
+ }
19
+ /** 값을 고르는 옵션 Component(Select·Combobox 옵션)의 공통 props. `value`가 컬렉션 key가 된다. */
20
+ export interface CollectionValueItemProps extends CollectionItemProps {
21
+ /** 옵션의 값. 고르면 이 값이 목록의 값이 되고 폼에 제출된다. 같은 목록 안에서 겹치지 않아야 한다. */
22
+ value: string;
23
+ }
@@ -0,0 +1,99 @@
1
+ import { Children, Fragment, createElement, isValidElement } from "react";
2
+
3
+ // src/list-collection.ts
4
+ const lazyType = Symbol.for("react.lazy");
5
+ const isLazy = (type) => typeof type === "object" && type !== null && type.$$typeof === lazyType;
6
+ /**
7
+ * children 안의 React node에서 글자만 모은다. 항목의 기본 타입어헤드 글자다. 아이콘(글자 없는 요소)은 빠진다.
8
+ */
9
+ function textOf(node) {
10
+ if (typeof node === "string" || typeof node === "number") return String(node);
11
+ if (Array.isArray(node)) return node.map((child) => textOf(child)).join("");
12
+ if (isValidElement(node)) return textOf(node.props.children);
13
+ return "";
14
+ }
15
+ /**
16
+ * 목록에 넘길 children을 컬렉션 빌더가 읽을 수 있게 고친다.
17
+ *
18
+ * - 서버 컴포넌트가 만든 요소는 client Component type이 React lazy 참조(RSC payload의 `$L`)로 온다. 컬렉션 빌더는
19
+ * type의 `getCollectionNode`를 직접 보므로 lazy를 풀어 실제 Component로 바꾼다. 목록 Component와 항목 Component는
20
+ * 같은 모듈이라 목록이 렌더되는 시점에는 이미 불러와져 있다(풀 때 기다리지 않는다).
21
+ * - Fragment는 펼친다. 배열·Fragment 안의 lazy도 같이 푼다.
22
+ * - 묶음 안의 항목은 묶음의 `getCollectionNode`(`sectionCollectionNode`)가 같은 함수로 푼다.
23
+ */
24
+ function resolveCollectionChildren(children) {
25
+ const resolved = [];
26
+ Children.forEach(children, (child) => {
27
+ if (!isValidElement(child)) {
28
+ if (child != null && typeof child !== "boolean") resolved.push(child);
29
+ return;
30
+ }
31
+ if (child.type === Fragment) {
32
+ resolved.push(...resolveCollectionChildren(child.props.children));
33
+ return;
34
+ }
35
+ if (isLazy(child.type)) {
36
+ const type = child.type._init(child.type._payload);
37
+ resolved.push(createElement(type, {
38
+ ...child.props,
39
+ key: child.key
40
+ }));
41
+ return;
42
+ }
43
+ resolved.push(child);
44
+ });
45
+ return resolved;
46
+ }
47
+ /**
48
+ * 항목 Component의 `getCollectionNode`. node의 props에 `isDisabled`를 더한다(React Stately `SelectionManager`·
49
+ * 키보드 이동이 `props.isDisabled`를 읽는다). 렌더는 목록 Component가 node.props(항목에 준 props 그대로)로 한다.
50
+ */
51
+ function* itemCollectionNode(props) {
52
+ yield {
53
+ type: "item",
54
+ props: {
55
+ ...props,
56
+ isDisabled: props.disabled ?? false
57
+ },
58
+ rendered: props.children,
59
+ textValue: props.textValue ?? textOf(props.children).trim(),
60
+ "aria-label": props["aria-label"],
61
+ hasChildNodes: false
62
+ };
63
+ }
64
+ /**
65
+ * 옵션 Component의 `getCollectionNode`. `itemCollectionNode`와 같고, `value`를 node의 key로 yield한다.
66
+ * 컬렉션 빌더는 yield한 node에 key가 있으면 요소 key·자리 번호 대신 그 값을 쓴다(react-stately 3.51.0
67
+ * `CollectionBuilder.getFullNode`). 그래서 목록 상태의 선택 key가 곧 앱의 값이고, 묶음 안에 있어도 앞에 묶음 key가
68
+ * 붙지 않는다(묶음은 key 없는 부분 node로 항목을 넘긴다).
69
+ */
70
+ function* valueItemCollectionNode(props) {
71
+ for (const node of itemCollectionNode(props)) yield {
72
+ ...node,
73
+ key: props.value
74
+ };
75
+ }
76
+ /** 묶음 Component의 `getCollectionNode`. 안의 항목도 `resolveCollectionChildren`으로 고쳐 넘긴다. */
77
+ function* sectionCollectionNode(props) {
78
+ yield {
79
+ type: "section",
80
+ props,
81
+ rendered: props.heading,
82
+ "aria-label": props["aria-label"],
83
+ hasChildNodes: true,
84
+ *childNodes() {
85
+ for (const child of resolveCollectionChildren(props.children)) if (isValidElement(child)) yield {
86
+ type: "item",
87
+ element: child
88
+ };
89
+ }
90
+ };
91
+ }
92
+ /**
93
+ * 렌더하지 않는 표시 Component에 컬렉션 node 생성 함수를 붙인다. 공개 타입에는 드러나지 않는다.
94
+ */
95
+ function asCollectionComponent(component, getCollectionNode) {
96
+ component.getCollectionNode = getCollectionNode;
97
+ }
98
+
99
+ export { asCollectionComponent, itemCollectionNode, resolveCollectionChildren, sectionCollectionNode, textOf, valueItemCollectionNode };
@@ -0,0 +1,45 @@
1
+ import { frequentMotionClass, overlaySurfaceClass } from "./overlay-style.js";
2
+
3
+ // src/list-style.ts
4
+ /**
5
+ * 목록 오버레이(Menu 목록, Select·Combobox 목록)의 루트. 요소에 `data-open`이 있어야 한다(`frequentMotionClass`).
6
+ *
7
+ * - 표면: raised + `shadow-md` + radius `lg` + `border-raised`(overlaySurfaceClass).
8
+ * - 여백: 3px. 바깥 radius 8px = 항목 radius 4px + 여백 3px + border 1px(spec 중첩 규칙).
9
+ * - 크기: 폭은 내용에 맞추되 10rem(`min-w-40`)~20rem(`max-w-80`). 높이는 React Aria가 `max-height`로 화면 안에
10
+ * 맞추고 넘치면 스크롤한다(`overflow-auto`).
11
+ * - 쌓임: `z-popover`(300)라 Dialog(`z-modal`) 위에 보인다. 루트 `isolate`.
12
+ * - 포커스: 목록 자신이 포커스를 받을 때(마우스로 연 직후)는 표시가 없다. 위치 표시는 항목의 highlight가 맡는다.
13
+ * - motion: 「자주 씀」. 바로 나타나고 사라질 때만 opacity `fast`.
14
+ */
15
+ const listPopupClass = `isolate z-popover min-w-40 max-w-80 overflow-auto p-[3px] outline-none ${overlaySurfaceClass} ${frequentMotionClass}`;
16
+ /**
17
+ * 목록 안 항목(Menu 항목, Select·Combobox 옵션). 상태는 boolean `data-*`로 받는다.
18
+ *
19
+ * - `data-highlighted`: 키보드 위치(React Aria가 옮긴 포커스 위치)이고, 마우스를 올리면 React Aria가 그 항목으로
20
+ * 위치를 옮기므로 hover도 같은 모양이다. highlight = primary subtle 배경 + primary text 글자 + 투명 outline 2px.
21
+ * 투명 outline은 평소에는 보이지 않고 forced-colors에서 시스템 색으로 그려져 위치가 남는다(그 모드는 배경을 덮는다).
22
+ * 기본 `outline-none`이 outline 모양 변수를 none으로 두므로 `outline-solid`를 함께 쓴다.
23
+ * 항목 안쪽(`-outline-offset-2`)에 그려 이웃 항목·목록 경계와 겹치지 않는다.
24
+ * - `hover`(highlight가 아닐 때): raised 위 항목 규칙의 `subtle-hover`(neutral). 키보드로 옮긴 위치가 따로 있을 때
25
+ * 마우스가 다른 항목 위에 있는 경우다.
26
+ * - `data-selected`(highlight가 아닐 때): 선택 유지 `subtle-active`(neutral). Select·Combobox의 고른 옵션이 쓴다.
27
+ * - `data-disabled`: `text-disabled`, `cursor-not-allowed`. hover 배경이 없다.
28
+ * - 상태끼리 겹칠 때 순서에 기대지 않도록 `not-data-highlighted:`로 서로 뺀다.
29
+ * - transition이 없다. 반복되는 목록 항목은 바로 바뀐다(spec 「motion 적용」).
30
+ * - 크기: 세로 6px·가로 8px 여백, `text-body-md`(16/24)라 높이 36px. 바로 안의 아이콘(svg, 장식용)은 16px이고
31
+ * 아이콘과 글자 사이는 8px다(Button md와 같은 아이콘 크기).
32
+ */
33
+ const listItemClass = [
34
+ "flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-body-md text-default outline-none select-none [&>svg]:size-4 [&>svg]:shrink-0",
35
+ "hover:not-data-highlighted:not-data-disabled:bg-neutral-subtle-hover",
36
+ "data-selected:not-data-highlighted:bg-neutral-subtle-active",
37
+ "data-highlighted:bg-primary-subtle data-highlighted:text-primary data-highlighted:outline-2 data-highlighted:outline-solid data-highlighted:-outline-offset-2 data-highlighted:outline-transparent",
38
+ "data-disabled:cursor-not-allowed data-disabled:text-disabled"
39
+ ].join(" ");
40
+ /** 항목 묶음의 제목. `text-label-sm`(12px), `text-muted`. 항목과 같은 가로 여백이라 글자 시작이 맞는다. */
41
+ const listHeadingClass = "px-2 pt-1.5 pb-1 text-label-sm text-muted select-none";
42
+ /** 묶음 사이 구분선. 장식용 `border-default` 1px, 위아래 4px. */
43
+ const listSeparatorClass = "my-1 h-0 border-t border-default";
44
+
45
+ export { listHeadingClass, listItemClass, listPopupClass, listSeparatorClass };
@@ -0,0 +1,15 @@
1
+ import { CollectionSectionProps, CollectionValueItemProps } from "./list-collection.js";
2
+ import { ComponentProps, ReactNode } from "react";
3
+ import "react-stately";
4
+ /** 옵션 Component(Select·Combobox 옵션)의 props. 옵션 요소는 `<div role="option">`이다. */
5
+ export interface ListBoxOptionProps extends Omit<ComponentProps<"div">, "role" | "children">, CollectionValueItemProps {
6
+ /** 옵션 내용. 아이콘(장식용)과 글자를 함께 둘 수 있다. Select 트리거에도 이 내용이 보인다. */
7
+ children?: ReactNode;
8
+ }
9
+ /** 옵션 묶음 Component의 props */
10
+ export interface ListBoxSectionProps extends CollectionSectionProps {
11
+ /** 묶음 감싸개(`<div role="presentation">`)에 붙는 class */
12
+ className?: string;
13
+ /** 옵션. 바로 아래(또는 Fragment·배열 안)에 둔다. */
14
+ children?: ReactNode;
15
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { cn } from "./cn.js";
4
+ import { listHeadingClass, listItemClass, listSeparatorClass } from "./list-style.js";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { useMemo, useRef } from "react";
7
+ import { CheckIcon } from "@tide-tds/icons";
8
+ import { mergeProps, mergeRefs, useListBoxSection, useOption } from "react-aria";
9
+
10
+ // src/listbox.tsx
11
+ /**
12
+ * 고른 옵션 끝의 체크 아이콘. 장식용이다(선택은 `aria-selected`가 알린다). 배경(subtle-active)이 지워지는 고대비
13
+ * 모드에서도 고른 옵션이 보이게 한다. 글자와의 간격은 항목의 gap(8px)이고 `ms-auto`로 오른쪽 끝에 붙는다.
14
+ */
15
+ const checkClass = "ms-auto";
16
+ /**
17
+ * 목록 상태의 옵션·묶음을 그린다. 묶음과 이웃한 옵션·묶음 사이에 장식용 구분선을 둔다. 목록의 처음·끝과 이어진
18
+ * 구분선은 그리지 않는다(Menu와 같다). listbox의 자식은 option·group만 될 수 있어(WAI-ARIA, axe
19
+ * aria-required-children) Menu와 달리 구분선은 `separator` 역할 없이 보조기술에서 숨긴다. 묶음 경계는 group이 알린다.
20
+ */
21
+ function ListBoxNodes({ state }) {
22
+ const nodes = [...state.collection];
23
+ return nodes.map((node, index) => {
24
+ const previous = nodes[index - 1];
25
+ const separator = previous !== void 0 && (node.type === "section" || previous.type === "section");
26
+ const element = node.type === "section" ? /* @__PURE__ */ jsx(ListBoxSectionView, {
27
+ node,
28
+ state
29
+ }, node.key) : /* @__PURE__ */ jsx(ListBoxOptionView, {
30
+ node,
31
+ state
32
+ }, node.key);
33
+ if (!separator) return element;
34
+ return [/* @__PURE__ */ jsx("div", {
35
+ role: "presentation",
36
+ "aria-hidden": "true",
37
+ className: listSeparatorClass
38
+ }, `${String(node.key)}-separator`), element];
39
+ });
40
+ }
41
+ /** 옵션 하나를 그린다. node.props는 앱이 옵션에 준 props(와 컬렉션이 더한 isDisabled)다. */
42
+ function ListBoxOptionView({ node, state }) {
43
+ const optionRef = useRef(null);
44
+ const { ref, className, value: _value, disabled: _disabled, textValue: _textValue, isDisabled: _isDisabled, children: _children, id: _id, ...props } = node.props;
45
+ const { optionProps, isFocused, isSelected, isDisabled } = useOption({
46
+ key: node.key,
47
+ "aria-label": props["aria-label"]
48
+ }, state, optionRef);
49
+ const mergedRef = useMemo(() => mergeRefs(ref, optionRef), [ref, optionRef]);
50
+ const merged = mergeProps(props, optionProps);
51
+ return /* @__PURE__ */ jsxs("div", {
52
+ ...merged,
53
+ ref: mergedRef,
54
+ "aria-labelledby": props["aria-labelledby"],
55
+ "aria-describedby": props["aria-describedby"],
56
+ "data-highlighted": isFocused ? "" : void 0,
57
+ "data-selected": isSelected ? "" : void 0,
58
+ "data-disabled": isDisabled ? "" : void 0,
59
+ className: cn(listItemClass, className),
60
+ children: [node.rendered, isSelected ? /* @__PURE__ */ jsx(CheckIcon, {
61
+ size: 16,
62
+ className: checkClass
63
+ }) : null]
64
+ });
65
+ }
66
+ /** 묶음 하나를 그린다. 제목은 보조기술에서 숨기고(`presentation`) 묶음의 이름으로만 쓴다(useListBoxSection). */
67
+ function ListBoxSectionView({ node, state }) {
68
+ const { className } = node.props;
69
+ const { itemProps, headingProps, groupProps } = useListBoxSection({
70
+ heading: node.rendered,
71
+ "aria-label": node["aria-label"]
72
+ });
73
+ return /* @__PURE__ */ jsxs("div", {
74
+ ...itemProps,
75
+ className,
76
+ children: [node.rendered != null && /* @__PURE__ */ jsx("div", {
77
+ ...headingProps,
78
+ className: "px-2 pt-1.5 pb-1 text-label-sm text-muted select-none",
79
+ children: node.rendered
80
+ }), /* @__PURE__ */ jsx("div", {
81
+ ...groupProps,
82
+ children: [...node.childNodes].map((child) => /* @__PURE__ */ jsx(ListBoxOptionView, {
83
+ node: child,
84
+ state
85
+ }, child.key))
86
+ })]
87
+ });
88
+ }
89
+
90
+ export { ListBoxNodes };
@@ -31,5 +31,54 @@ const popoverMotionClass = [
31
31
  "motion-safe:data-[placement=bottom]:starting:-translate-y-1 motion-safe:data-[placement=top]:starting:translate-y-1",
32
32
  "motion-safe:data-[placement=right]:starting:-translate-x-1 motion-safe:data-[placement=left]:starting:translate-x-1"
33
33
  ].join(" ");
34
+ /**
35
+ * 「자주 씀」 오버레이(Tooltip, Menu, Select·Combobox 목록)의 등장·퇴장(spec 「motion 적용」, 티켓 26).
36
+ * 요소에 `data-open`(열림)이 있어야 한다.
37
+ *
38
+ * - 등장: 바로 나타난다. 열린 동안은 `transition-none`이라 퇴장 중에 다시 열려도 바로 불투명해진다.
39
+ * 새로 넣은 요소는 이전 스타일이 없어 전환이 생기지 않으므로 `starting:`도 두지 않는다.
40
+ * - 퇴장: `data-open`이 빠지면 opacity만 `fast`(150ms), out으로 사라진다. 전환 대상은 opacity와 display다.
41
+ * duration·전환 대상은 도착 상태의 값을 쓰므로 닫힌 상태에서만 전환이 있다.
42
+ * - 닫힌 상태는 `display: none`(`hidden`)이고 `transition-discrete`로 퇴장이 끝날 때 사라진다(popoverMotionClass와 같다).
43
+ * DOM에서 빼는 시점은 `useOverlayPresence`가 정한다. 퇴장 중에는 `pointer-events-none`이다.
44
+ * - reduced-motion: 이동·blur·확대가 없으므로 바꿀 것이 없다. opacity 전환은 그대로 둔다.
45
+ */
46
+ const frequentMotionClass = ["transition-[opacity,display] transition-discrete duration-fast ease-out data-open:transition-none", "not-data-open:pointer-events-none not-data-open:hidden not-data-open:opacity-0"].join(" ");
47
+ /**
48
+ * Dialog류(Dialog, AlertDialog) 표면(spec radius·Elevation, 티켓 30). 가운데에 뜨는 모달이라 트리거에 붙는
49
+ * 오버레이보다 한 단계 높다. raised 표면(`bg-raised`), `shadow-lg`, radius `xl`, 경계 `border-raised`.
50
+ * 글자·경계·forced-colors 처리는 `overlaySurfaceClass`와 같다(`text-default × bg-raised` 4.5:1 등록, `border-raised`는 장식용).
51
+ */
52
+ const dialogSurfaceClass = "rounded-xl border border-raised bg-raised text-default shadow-lg";
53
+ /**
54
+ * 모달 뒤를 가리는 반투명 막(backdrop, `bg-overlay`)의 등장·퇴장(티켓 30). 요소에 `data-open`이 있어야 한다.
55
+ *
56
+ * - 등장: `starting:`에서 opacity 0으로 시작해 `normal`(300ms)로 나타난다. 퇴장: `data-open`이 빠지면 `fast`(150ms)로 사라진다.
57
+ * - 닫힌 상태는 `display: none`(`hidden`) + `transition-discrete`로 퇴장이 끝날 때 사라지고, 퇴장 중에는
58
+ * `pointer-events-none`이다(popoverMotionClass와 같은 구조). 안의 Dialog도 이 값을 물려받아 눌리지 않는다.
59
+ * - opacity만 바뀌므로 움직임 줄이기에서도 같다.
60
+ * - `useOverlayPresence`는 이 요소(막)의 전환이 끝나기를 기다린다. 안의 Dialog 전환(`dialogMotionClass`)도 같은
61
+ * duration이라 함께 끝난다.
62
+ */
63
+ const backdropMotionClass = [
64
+ "transition-[opacity,display] transition-discrete duration-fast ease-out data-open:duration-normal",
65
+ "not-data-open:pointer-events-none not-data-open:hidden not-data-open:opacity-0",
66
+ "starting:opacity-0"
67
+ ].join(" ");
68
+ /**
69
+ * Dialog류의 등장·퇴장(spec 「motion 적용」 「가끔 씀」 Dialog류, 티켓 30). 요소에 `data-open`이 있어야 한다.
70
+ *
71
+ * - 등장: `starting:`에서 opacity 0 + scale 0.96(`scale-96`)으로 시작해 `normal`(300ms), out으로 제자리에 온다.
72
+ * - 퇴장: `data-open`이 빠지면 같은 조합을 약하게(scale 0.98, `scale-98`) `fast`(150ms)로 사라진다.
73
+ * duration은 도착 상태의 값을 쓰므로 기본 `duration-fast`에 열림만 `data-open:duration-normal`이다.
74
+ * - 화면에서 빼는 일(display)은 바깥 막(`backdropMotionClass`)이 한다. 이 요소는 opacity·scale만 전환한다.
75
+ * - reduced-motion: 확대(scale)와 그 전환 대상은 `motion-safe:`일 때만 둔다. 움직임 줄이기에서는 opacity만 바뀐다.
76
+ * - `transition-all`은 쓰지 않는다.
77
+ */
78
+ const dialogMotionClass = [
79
+ "transition-[opacity] duration-fast ease-out data-open:duration-normal motion-safe:transition-[opacity,scale]",
80
+ "not-data-open:opacity-0 motion-safe:not-data-open:scale-98",
81
+ "starting:opacity-0 motion-safe:starting:scale-96"
82
+ ].join(" ");
34
83
 
35
- export { overlaySurfaceClass, popoverMotionClass };
84
+ export { backdropMotionClass, dialogMotionClass, dialogSurfaceClass, frequentMotionClass, overlaySurfaceClass, popoverMotionClass };
package/dist/overlay.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { jsx } from "react/jsx-runtime";
4
- import { useLayoutEffect, useState } from "react";
4
+ import { useEffect, useLayoutEffect, useState } from "react";
5
5
  import { Overlay, usePopover } from "react-aria";
6
6
 
7
7
  // src/overlay.tsx
@@ -92,15 +92,39 @@ function useOverlayTheme(triggerRef, overlayRef) {
92
92
  }, [triggerRef, overlayRef]);
93
93
  }
94
94
  /**
95
+ * 오버레이 안에서 Tab을 누르면 기본 동작을 막고 닫는다(포커스는 `OverlayPortal`의 FocusScope가 트리거로 돌린다).
96
+ * 목록 오버레이(Menu, Select)가 쓴다(WAI-ARIA APG Menu Button·Select-Only Combobox: Tab은 목록을 닫는다).
97
+ *
98
+ * React Aria 목록 hook(useMenu·useListBox)은 Tab으로 닫지 않고, 항목의 keydown 처리가 전파를 멈추며 FocusScope가
99
+ * document capture에서 Tab을 먼저 다룬다. 열린 동안 바깥은 inert라 브라우저 기본 Tab은 갈 곳이 없다. 그래서 그보다
100
+ * 앞인 window capture에서 받는다(티켓 27에서 Menu 안에 두었다가 티켓 28에서 이곳으로 옮겼다).
101
+ * 전파도 멈춘다. listbox(useListBox 안의 useSelectableCollection)는 Tab에서 포커스를 목록 안 요소로 옮기는데, 그러면
102
+ * 닫힌 뒤 포커스가 트리거로 돌아오지 않았다(Select 테스트로 확인).
103
+ */
104
+ function useCloseOnTab(overlayRef, close) {
105
+ useEffect(() => {
106
+ const onKeyDown = (event) => {
107
+ if (event.key !== "Tab" || !overlayRef.current?.contains(event.target)) return;
108
+ event.preventDefault();
109
+ event.stopPropagation();
110
+ close();
111
+ };
112
+ window.addEventListener("keydown", onKeyDown, true);
113
+ return () => window.removeEventListener("keydown", onKeyDown, true);
114
+ }, [close, overlayRef]);
115
+ }
116
+ /**
95
117
  * 오버레이를 `document.body`에 portal한다(React Aria `Overlay`). top-layer(`<dialog>`, popover API)는 쓰지 않는다.
96
118
  * 안을 `FocusScope`(`restoreFocus`)로 감싸서, DOM에서 빠질 때 포커스가 열기 전 요소(보통 트리거)로 돌아간다.
97
119
  * 역할 hook이 포커스 가두기를 요청하면(`useDialog` 등) Tab이 안에서 돈다. 서버 렌더에서는 아무것도 그리지 않는다.
120
+ * `focusManagement={false}`면 FocusScope 없이 portal만 한다(Tooltip).
98
121
  */
99
- function OverlayPortal({ isExiting, children }) {
122
+ function OverlayPortal({ isExiting, focusManagement = true, children }) {
100
123
  return /* @__PURE__ */ jsx(Overlay, {
101
124
  isExiting,
125
+ disableFocusManagement: !focusManagement,
102
126
  children
103
127
  });
104
128
  }
105
129
 
106
- export { OverlayPortal, overlayPositionProps, useOverlayPresence, useOverlayTheme, usePopoverOverlay };
130
+ export { OverlayPortal, overlayPositionProps, useCloseOnTab, useOverlayPresence, useOverlayTheme, usePopoverOverlay };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tide-tds/components",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Tide 디자인 시스템의 React Component",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,8 +23,8 @@
23
23
  "./styles.css": "./dist/styles.css"
24
24
  },
25
25
  "dependencies": {
26
- "@tide-tds/foundation": "0.3.1",
27
- "@tide-tds/icons": "0.3.1",
26
+ "@tide-tds/foundation": "0.4.0",
27
+ "@tide-tds/icons": "0.4.0",
28
28
  "clsx": "^2.1.1",
29
29
  "react-aria": "^3.53.1",
30
30
  "react-stately": "^3.51.0",