@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.
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;
@@ -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,5 +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";
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";
26
36
  import { Color, ColorFamilyName, ColorFamilyRegistry, FillVariant, Role, RoleRegistry, Size } from "./types.js";
27
- 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, 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,5 +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";
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";
26
36
 
27
- export { Alert, Avatar, Badge, Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink, Button, Card, Checkbox, CheckboxGroup, Field, IconButton, Input, Link, Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, 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 };