@tide-tds/components 0.2.1 → 0.3.1
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/Alert.d.ts +32 -0
- package/dist/Alert.js +47 -0
- package/dist/Avatar.d.ts +27 -0
- package/dist/Avatar.js +44 -0
- package/dist/Breadcrumb.d.ts +26 -0
- package/dist/Breadcrumb.js +53 -0
- package/dist/Pagination.d.ts +46 -0
- package/dist/Pagination.js +79 -0
- package/dist/Popover.d.ts +43 -0
- package/dist/Popover.js +147 -0
- package/dist/Progress.d.ts +32 -0
- package/dist/Progress.js +40 -0
- package/dist/Skeleton.d.ts +24 -0
- package/dist/Skeleton.js +29 -0
- package/dist/Table.d.ts +31 -0
- package/dist/Table.js +53 -0
- package/dist/alert-style.js +40 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +9 -1
- package/dist/overlay-style.js +35 -0
- package/dist/overlay.d.ts +7 -0
- package/dist/overlay.js +106 -0
- package/package.json +3 -3
package/dist/Alert.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Role } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
export interface AlertProps extends Omit<ComponentProps<"div">, "color" | "title" | "role" | "onClose"> {
|
|
4
|
+
/**
|
|
5
|
+
* Role. Tide Role 6개와 앱이 `RoleRegistry`에 더한 Role을 받는다. 기본값은 `info`다.
|
|
6
|
+
* 상태 알림은 `info`·`success`·`warning`·`danger`를 쓰고, 상태마다 모양이 다른 아이콘이 붙는다.
|
|
7
|
+
* `neutral`·`primary`·앱 Role은 일반 알림이라 `info`와 같은 아이콘이다.
|
|
8
|
+
* 색 계열 이름(`blue` 등)은 받지 않는다. 알림의 뜻을 색으로 전하기 때문이다(ADR 0016).
|
|
9
|
+
* 루트에 `data-color`로 붙고 바깥 요소의 색을 물려받지 않는다.
|
|
10
|
+
*/
|
|
11
|
+
color?: Role;
|
|
12
|
+
/** 제목. 굵은 글자로 본문 위에 놓인다. HTML `title` 속성(툴팁)은 받지 않는다. */
|
|
13
|
+
title?: ReactNode;
|
|
14
|
+
/** 본문. 문장, 목록, 같은 Role의 `Link`를 넣는다. */
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
/**
|
|
17
|
+
* live region 역할. 기본값은 없음(정적 표시)이다.
|
|
18
|
+
* 처음부터 화면에 있는 Alert는 스크린 리더가 알리지 않으므로 역할을 붙이지 않는다.
|
|
19
|
+
* 사용자 동작 뒤에 새로 나타나 바로 알려야 하면 `"alert"`(급하고 중요함, 읽던 것을 끊음),
|
|
20
|
+
* 끊지 않고 알리면 되면 `"status"`(정중하게 알림)를 준다.
|
|
21
|
+
*/
|
|
22
|
+
role?: "alert" | "status";
|
|
23
|
+
/** 닫기 버튼을 눌렀을 때 부른다. 주면 끝쪽에 닫기 버튼(IconButton)이 생긴다. Alert를 빼는 일은 앱이 한다. */
|
|
24
|
+
onClose?: () => void;
|
|
25
|
+
/** 닫기 버튼 이름. 기본값은 `"닫기"`다. 같은 화면에 Alert가 여럿이면 `"결제 알림 닫기"`처럼 구분한다. */
|
|
26
|
+
closeLabel?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 화면 안에서 상태(info·success·warning·danger)나 알릴 내용을 보여 주는 표면.
|
|
30
|
+
* 상태 아이콘(장식용), 제목, 본문, 닫기 버튼으로 이뤄진다. 서버 컴포넌트로 쓸 수 있다.
|
|
31
|
+
*/
|
|
32
|
+
export declare function Alert({ color, title, children, onClose, closeLabel, className, ...props }: AlertProps): ReactElement;
|
package/dist/Alert.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { alertCloseClass, alertContentClass, alertDescriptionClass, alertIconClass, alertLayoutClass, alertStatusIcon, alertSurfaceClass, alertTitleClass } from "./alert-style.js";
|
|
2
|
+
import { cn } from "./cn.js";
|
|
3
|
+
import { IconButton } from "./IconButton.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { XIcon } from "@tide-tds/icons";
|
|
6
|
+
|
|
7
|
+
// src/Alert.tsx
|
|
8
|
+
/**
|
|
9
|
+
* 화면 안에서 상태(info·success·warning·danger)나 알릴 내용을 보여 주는 표면.
|
|
10
|
+
* 상태 아이콘(장식용), 제목, 본문, 닫기 버튼으로 이뤄진다. 서버 컴포넌트로 쓸 수 있다.
|
|
11
|
+
*/
|
|
12
|
+
function Alert({ color = "info", title, children, onClose, closeLabel = "닫기", className, ...props }) {
|
|
13
|
+
const StatusIcon = alertStatusIcon(color);
|
|
14
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
15
|
+
...props,
|
|
16
|
+
"data-color": color,
|
|
17
|
+
className: cn(alertLayoutClass, alertSurfaceClass, className),
|
|
18
|
+
children: [
|
|
19
|
+
/* @__PURE__ */ jsx(StatusIcon, {
|
|
20
|
+
size: 20,
|
|
21
|
+
"aria-hidden": "true",
|
|
22
|
+
className: alertIconClass
|
|
23
|
+
}),
|
|
24
|
+
/* @__PURE__ */ jsxs("div", {
|
|
25
|
+
className: alertContentClass,
|
|
26
|
+
children: [title !== void 0 && /* @__PURE__ */ jsx("div", {
|
|
27
|
+
className: "text-body-sm font-semibold",
|
|
28
|
+
children: title
|
|
29
|
+
}), children !== void 0 && /* @__PURE__ */ jsx("div", {
|
|
30
|
+
className: "text-body-sm",
|
|
31
|
+
children
|
|
32
|
+
})]
|
|
33
|
+
}),
|
|
34
|
+
onClose !== void 0 && /* @__PURE__ */ jsx(IconButton, {
|
|
35
|
+
"aria-label": closeLabel,
|
|
36
|
+
variant: "ghost",
|
|
37
|
+
color,
|
|
38
|
+
size: "xs",
|
|
39
|
+
onClick: onClose,
|
|
40
|
+
className: "-my-0.5 -me-1",
|
|
41
|
+
children: /* @__PURE__ */ jsx(XIcon, {})
|
|
42
|
+
})
|
|
43
|
+
]
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { Alert };
|
package/dist/Avatar.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ColorFamilyName, Size } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement } from "react";
|
|
3
|
+
/** Avatar의 size. 공통 size 4개를 모두 받는다. 높이는 spec size 표와 같다(24·32·40·48px). */
|
|
4
|
+
type AvatarSize = Extract<Size, "xs" | "sm" | "md" | "lg">;
|
|
5
|
+
export interface AvatarProps extends Omit<ComponentProps<"span">, "color" | "children" | "role" | "aria-label"> {
|
|
6
|
+
/** 사용자 이미지 주소. 없거나 빈 문자열이거나 불러오지 못하면 fallback 아이콘이 보인다. */
|
|
7
|
+
src?: string;
|
|
8
|
+
/**
|
|
9
|
+
* 대체 텍스트(보통 사람 이름). 필수다. 주면 루트가 `role="img"`가 되고 이 텍스트가 이름이 된다.
|
|
10
|
+
* 옆에 이름이 이미 적혀 있는 장식용이면 `""`를 주어 읽히지 않게 한다. 이미지가 없어도 같다.
|
|
11
|
+
*/
|
|
12
|
+
alt: string;
|
|
13
|
+
/** `xs` 24px, `sm` 32px, `md` 40px, `lg` 48px. 기본값은 `md`다. */
|
|
14
|
+
size?: AvatarSize;
|
|
15
|
+
/**
|
|
16
|
+
* fallback 배경과 아이콘의 색 계열(Tide 색 계열 6개와 앱이 `ColorFamilyRegistry`에 더한 계열).
|
|
17
|
+
* 뜻이 없는 장식 색이라 Role은 받지 않는다(ADR 0016·0017 후속). 기본값은 `gray`다.
|
|
18
|
+
* 루트에 `data-color`로 붙고 바깥 요소의 색을 물려받지 않는다.
|
|
19
|
+
*/
|
|
20
|
+
color?: ColorFamilyName;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 사용자 이미지를 원 안에 보여 준다. 이미지가 없거나 불러오지 못하면 fallback 아이콘이 보인다.
|
|
24
|
+
* 누르는 동작이 없다. 클릭이 필요하면 Button이나 IconButton 안에 넣는다. 서버 컴포넌트로 쓸 수 있다.
|
|
25
|
+
*/
|
|
26
|
+
export declare function Avatar({ src, alt, size, color, className, ...props }: AvatarProps): ReactElement;
|
|
27
|
+
export {};
|
package/dist/Avatar.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { cn } from "./cn.js";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { UserIcon } from "@tide-tds/icons";
|
|
4
|
+
|
|
5
|
+
// src/Avatar.tsx
|
|
6
|
+
/** 원 크기와 fallback 아이콘 크기(원의 약 60%). */
|
|
7
|
+
const sizeClass = {
|
|
8
|
+
xs: "size-6 [&_svg]:size-4",
|
|
9
|
+
sm: "size-8 [&_svg]:size-5",
|
|
10
|
+
md: "size-10 [&_svg]:size-6",
|
|
11
|
+
lg: "size-12 [&_svg]:size-7"
|
|
12
|
+
};
|
|
13
|
+
const baseClass = "relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent-subtle text-accent align-middle select-none [&_svg]:shrink-0";
|
|
14
|
+
/** 이미지 층. 아이콘 위에 원 전체를 덮고, `object-fit: cover`처럼 가운데를 채운다. */
|
|
15
|
+
const imageClass = "absolute inset-0 bg-cover bg-center bg-no-repeat";
|
|
16
|
+
/**
|
|
17
|
+
* CSS `url("…")` 값을 만든다. 따옴표·역슬래시·줄바꿈을 CSS escape(`\HH `)로 바꿔 문자열 밖으로 나가지 못하게 한다.
|
|
18
|
+
*/
|
|
19
|
+
function cssUrl(src) {
|
|
20
|
+
return `url("${src.replace(/["\\\n\r\f]/g, (char) => `\\${char.charCodeAt(0).toString(16)} `)}")`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 사용자 이미지를 원 안에 보여 준다. 이미지가 없거나 불러오지 못하면 fallback 아이콘이 보인다.
|
|
24
|
+
* 누르는 동작이 없다. 클릭이 필요하면 Button이나 IconButton 안에 넣는다. 서버 컴포넌트로 쓸 수 있다.
|
|
25
|
+
*/
|
|
26
|
+
function Avatar({ src, alt, size = "md", color = "gray", className, ...props }) {
|
|
27
|
+
const named = alt !== "";
|
|
28
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
29
|
+
...props,
|
|
30
|
+
...named ? {
|
|
31
|
+
role: "img",
|
|
32
|
+
"aria-label": alt
|
|
33
|
+
} : {},
|
|
34
|
+
"data-color": color,
|
|
35
|
+
className: cn(baseClass, sizeClass[size], className),
|
|
36
|
+
children: [/* @__PURE__ */ jsx(UserIcon, { "aria-hidden": "true" }), src ? /* @__PURE__ */ jsx("span", {
|
|
37
|
+
"aria-hidden": "true",
|
|
38
|
+
className: imageClass,
|
|
39
|
+
style: { backgroundImage: cssUrl(src) }
|
|
40
|
+
}) : null]
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { Avatar };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ComponentProps, ReactElement } from "react";
|
|
2
|
+
export interface BreadcrumbProps extends ComponentProps<"nav"> {
|
|
3
|
+
/** 탐색 영역 이름. 기본값은 `"이동 경로"`다. 한 화면에 같은 이름의 탐색 영역이 겹치지 않게 한다. */
|
|
4
|
+
"aria-label"?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface BreadcrumbItemProps extends ComponentProps<"li"> {}
|
|
7
|
+
export interface BreadcrumbLinkProps extends Omit<ComponentProps<"a">, "color"> {
|
|
8
|
+
/**
|
|
9
|
+
* 다른 요소로 렌더한다. 예: `render={<NextLink href="/docs" />}`.
|
|
10
|
+
* props, ref, className을 그 요소에 병합한다.
|
|
11
|
+
*/
|
|
12
|
+
render?: ReactElement;
|
|
13
|
+
}
|
|
14
|
+
export interface BreadcrumbCurrentProps extends ComponentProps<"span"> {}
|
|
15
|
+
/**
|
|
16
|
+
* 현재 페이지까지의 경로를 보여 주는 탐색 영역. `<nav>` 안의 `<ol>`이다.
|
|
17
|
+
* 자식은 `BreadcrumbItem`이고, 그 안에 `BreadcrumbLink`(상위 페이지)나 `BreadcrumbCurrent`(현재 페이지)를 둔다.
|
|
18
|
+
* `ref`·`className`은 `<nav>`에 붙는다.
|
|
19
|
+
*/
|
|
20
|
+
export declare function Breadcrumb({ "aria-label": ariaLabel, children, ...props }: BreadcrumbProps): ReactElement;
|
|
21
|
+
/** 경로의 한 항목 `<li>`. 앞에 장식용 구분자(›)를 붙이고, 목록의 첫 항목에서는 숨긴다. */
|
|
22
|
+
export declare function BreadcrumbItem({ className, children, ...props }: BreadcrumbItemProps): ReactElement;
|
|
23
|
+
/** 상위 페이지 링크 `<a>`. 밑줄이 있고 hover에 짙어진다. Next.js에서는 `render={<NextLink href="/x" />}`를 쓴다. */
|
|
24
|
+
export declare function BreadcrumbLink({ render, ...props }: BreadcrumbLinkProps): ReactElement;
|
|
25
|
+
/** 현재 페이지 `<span aria-current="page">`. 링크가 아니다. 경로의 마지막 항목에 둔다. */
|
|
26
|
+
export declare function BreadcrumbCurrent({ className, ...props }: BreadcrumbCurrentProps): ReactElement;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { cn } from "./cn.js";
|
|
2
|
+
import { renderElement } from "./render.js";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { ChevronRightIcon } from "@tide-tds/icons";
|
|
5
|
+
|
|
6
|
+
// src/Breadcrumb.tsx
|
|
7
|
+
const listClass = "flex flex-wrap items-center gap-1.5 text-body-sm text-muted";
|
|
8
|
+
const itemClass = "inline-flex items-center gap-1.5";
|
|
9
|
+
const separatorClass = "size-4 shrink-0 [li:first-child>&]:hidden";
|
|
10
|
+
const linkClass = "cursor-pointer rounded-sm underline decoration-1 underline-offset-2 hover:text-default hover:decoration-2 focus-visible:focus-ring";
|
|
11
|
+
const currentClass = "text-label-md text-default";
|
|
12
|
+
/**
|
|
13
|
+
* 현재 페이지까지의 경로를 보여 주는 탐색 영역. `<nav>` 안의 `<ol>`이다.
|
|
14
|
+
* 자식은 `BreadcrumbItem`이고, 그 안에 `BreadcrumbLink`(상위 페이지)나 `BreadcrumbCurrent`(현재 페이지)를 둔다.
|
|
15
|
+
* `ref`·`className`은 `<nav>`에 붙는다.
|
|
16
|
+
*/
|
|
17
|
+
function Breadcrumb({ "aria-label": ariaLabel = "이동 경로", children, ...props }) {
|
|
18
|
+
return /* @__PURE__ */ jsx("nav", {
|
|
19
|
+
...props,
|
|
20
|
+
"aria-label": ariaLabel,
|
|
21
|
+
children: /* @__PURE__ */ jsx("ol", {
|
|
22
|
+
className: listClass,
|
|
23
|
+
children
|
|
24
|
+
})
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** 경로의 한 항목 `<li>`. 앞에 장식용 구분자(›)를 붙이고, 목록의 첫 항목에서는 숨긴다. */
|
|
28
|
+
function BreadcrumbItem({ className, children, ...props }) {
|
|
29
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
30
|
+
...props,
|
|
31
|
+
className: cn(itemClass, className),
|
|
32
|
+
children: [/* @__PURE__ */ jsx(ChevronRightIcon, { className: separatorClass }), children]
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** 상위 페이지 링크 `<a>`. 밑줄이 있고 hover에 짙어진다. Next.js에서는 `render={<NextLink href="/x" />}`를 쓴다. */
|
|
36
|
+
function BreadcrumbLink({ render, ...props }) {
|
|
37
|
+
return renderElement({
|
|
38
|
+
defaultElement: "a",
|
|
39
|
+
render,
|
|
40
|
+
baseClassName: linkClass,
|
|
41
|
+
props
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** 현재 페이지 `<span aria-current="page">`. 링크가 아니다. 경로의 마지막 항목에 둔다. */
|
|
45
|
+
function BreadcrumbCurrent({ className, ...props }) {
|
|
46
|
+
return /* @__PURE__ */ jsx("span", {
|
|
47
|
+
"aria-current": "page",
|
|
48
|
+
...props,
|
|
49
|
+
className: cn(currentClass, className)
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { IconButtonProps } from "./IconButton.js";
|
|
2
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
export interface PaginationProps extends ComponentProps<"nav"> {
|
|
4
|
+
/** 탐색 영역 이름. 기본값은 `"페이지 이동"`이다. 한 화면에 목록이 여럿이면 `"댓글 페이지"`처럼 구분한다. */
|
|
5
|
+
"aria-label"?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PaginationItemProps extends ComponentProps<"li"> {}
|
|
8
|
+
export interface PaginationLinkProps extends Omit<ComponentProps<"a">, "color"> {
|
|
9
|
+
/** 현재 페이지인지. `aria-current="page"`를 붙이고 subtle 채움으로 보인다. 기본값은 `false`다. */
|
|
10
|
+
current?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* 다른 요소로 렌더한다. 예: `render={<NextLink href="?page=2" />}`.
|
|
13
|
+
* 상태로 페이지를 바꾸면 `render={<button type="button" onClick={...} />}`를 쓴다. props, ref, className을 병합한다.
|
|
14
|
+
*/
|
|
15
|
+
render?: ReactElement;
|
|
16
|
+
}
|
|
17
|
+
/** 이전/다음 항목 props. IconButton의 props에서 모양(variant·color·size)과 아이콘·로딩을 고정했다. */
|
|
18
|
+
export interface PaginationArrowProps extends Omit<IconButtonProps, "aria-label" | "children" | "variant" | "color" | "size" | "loading" | "loadingLabel"> {
|
|
19
|
+
/** 버튼 이름. 기본값은 이전 `"이전 페이지"`, 다음 `"다음 페이지"`다. */
|
|
20
|
+
"aria-label"?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PaginationPreviousProps extends PaginationArrowProps {}
|
|
23
|
+
export interface PaginationNextProps extends PaginationArrowProps {}
|
|
24
|
+
export interface PaginationEllipsisProps extends Omit<ComponentProps<"span">, "children"> {
|
|
25
|
+
/** 스크린리더가 읽는 텍스트. 화면에는 「…」 아이콘만 보인다. 기본값은 `"페이지 생략"`이다. */
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 페이지 이동 탐색 영역. `<nav>` 안의 `<ul>`이다.
|
|
30
|
+
* 자식은 `PaginationItem`이고, 그 안에 `PaginationPrevious`·`PaginationLink`·`PaginationEllipsis`·`PaginationNext`를 둔다.
|
|
31
|
+
* `ref`·`className`은 `<nav>`에 붙는다.
|
|
32
|
+
*/
|
|
33
|
+
export declare function Pagination({ "aria-label": ariaLabel, children, ...props }: PaginationProps): ReactElement;
|
|
34
|
+
/** 목록의 한 항목 `<li>`. 기본 class가 없다. */
|
|
35
|
+
export declare function PaginationItem(props: PaginationItemProps): ReactElement;
|
|
36
|
+
/**
|
|
37
|
+
* 페이지 번호 링크. 기본은 `<a>`이고 Button(ghost, 32px) 모양이다. 현재 페이지는 `current`를 준다.
|
|
38
|
+
* Next.js에서는 `render={<NextLink href="?page=2" />}`를 쓴다.
|
|
39
|
+
*/
|
|
40
|
+
export declare function PaginationLink({ current, render, className, ...props }: PaginationLinkProps): ReactElement;
|
|
41
|
+
/** 이전 페이지로 가는 IconButton(ghost, 32px, ‹). 첫 페이지에서는 `disabled`를 준다. */
|
|
42
|
+
export declare function PaginationPrevious({ "aria-label": ariaLabel, ...props }: PaginationPreviousProps): ReactElement;
|
|
43
|
+
/** 다음 페이지로 가는 IconButton(ghost, 32px, ›). 마지막 페이지에서는 `disabled`를 준다. */
|
|
44
|
+
export declare function PaginationNext({ "aria-label": ariaLabel, ...props }: PaginationNextProps): ReactElement;
|
|
45
|
+
/** 생략된 번호 자리 `<span>`. 화면에는 「…」 아이콘, 스크린리더에는 텍스트(기본 `"페이지 생략"`)를 보인다. */
|
|
46
|
+
export declare function PaginationEllipsis({ className, children, ...props }: PaginationEllipsisProps): ReactElement;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Button } from "./Button.js";
|
|
2
|
+
import { cn } from "./cn.js";
|
|
3
|
+
import { IconButton } from "./IconButton.js";
|
|
4
|
+
import { VisuallyHidden } from "./VisuallyHidden.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { ChevronLeftIcon, ChevronRightIcon, EllipsisIcon } from "@tide-tds/icons";
|
|
7
|
+
|
|
8
|
+
// src/Pagination.tsx
|
|
9
|
+
/** 페이지 번호·이전/다음 항목의 고정 size. 둘 다 32×32px다(24×24 이상). */
|
|
10
|
+
const itemSize = "sm";
|
|
11
|
+
const listClass = "flex flex-wrap items-center gap-1";
|
|
12
|
+
const linkClass = "min-w-8 px-2 tabular-nums";
|
|
13
|
+
const ellipsisClass = "inline-flex size-8 items-center justify-center text-muted";
|
|
14
|
+
/**
|
|
15
|
+
* 페이지 이동 탐색 영역. `<nav>` 안의 `<ul>`이다.
|
|
16
|
+
* 자식은 `PaginationItem`이고, 그 안에 `PaginationPrevious`·`PaginationLink`·`PaginationEllipsis`·`PaginationNext`를 둔다.
|
|
17
|
+
* `ref`·`className`은 `<nav>`에 붙는다.
|
|
18
|
+
*/
|
|
19
|
+
function Pagination({ "aria-label": ariaLabel = "페이지 이동", children, ...props }) {
|
|
20
|
+
return /* @__PURE__ */ jsx("nav", {
|
|
21
|
+
...props,
|
|
22
|
+
"aria-label": ariaLabel,
|
|
23
|
+
children: /* @__PURE__ */ jsx("ul", {
|
|
24
|
+
className: listClass,
|
|
25
|
+
children
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** 목록의 한 항목 `<li>`. 기본 class가 없다. */
|
|
30
|
+
function PaginationItem(props) {
|
|
31
|
+
return /* @__PURE__ */ jsx("li", { ...props });
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 페이지 번호 링크. 기본은 `<a>`이고 Button(ghost, 32px) 모양이다. 현재 페이지는 `current`를 준다.
|
|
35
|
+
* Next.js에서는 `render={<NextLink href="?page=2" />}`를 쓴다.
|
|
36
|
+
*/
|
|
37
|
+
function PaginationLink({ current = false, render, className, ...props }) {
|
|
38
|
+
return /* @__PURE__ */ jsx(Button, {
|
|
39
|
+
...props,
|
|
40
|
+
render: render ?? /* @__PURE__ */ jsx("a", {}),
|
|
41
|
+
variant: current ? "subtle" : "ghost",
|
|
42
|
+
color: current ? "primary" : "neutral",
|
|
43
|
+
size: itemSize,
|
|
44
|
+
"aria-current": current ? "page" : props["aria-current"],
|
|
45
|
+
className: cn(linkClass, className)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** 이전 페이지로 가는 IconButton(ghost, 32px, ‹). 첫 페이지에서는 `disabled`를 준다. */
|
|
49
|
+
function PaginationPrevious({ "aria-label": ariaLabel = "이전 페이지", ...props }) {
|
|
50
|
+
return /* @__PURE__ */ jsx(IconButton, {
|
|
51
|
+
...props,
|
|
52
|
+
"aria-label": ariaLabel,
|
|
53
|
+
variant: "ghost",
|
|
54
|
+
color: "neutral",
|
|
55
|
+
size: itemSize,
|
|
56
|
+
children: /* @__PURE__ */ jsx(ChevronLeftIcon, {})
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** 다음 페이지로 가는 IconButton(ghost, 32px, ›). 마지막 페이지에서는 `disabled`를 준다. */
|
|
60
|
+
function PaginationNext({ "aria-label": ariaLabel = "다음 페이지", ...props }) {
|
|
61
|
+
return /* @__PURE__ */ jsx(IconButton, {
|
|
62
|
+
...props,
|
|
63
|
+
"aria-label": ariaLabel,
|
|
64
|
+
variant: "ghost",
|
|
65
|
+
color: "neutral",
|
|
66
|
+
size: itemSize,
|
|
67
|
+
children: /* @__PURE__ */ jsx(ChevronRightIcon, {})
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** 생략된 번호 자리 `<span>`. 화면에는 「…」 아이콘, 스크린리더에는 텍스트(기본 `"페이지 생략"`)를 보인다. */
|
|
71
|
+
function PaginationEllipsis({ className, children = "페이지 생략", ...props }) {
|
|
72
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
73
|
+
...props,
|
|
74
|
+
className: cn(ellipsisClass, className),
|
|
75
|
+
children: [/* @__PURE__ */ jsx(EllipsisIcon, {}), /* @__PURE__ */ jsx(VisuallyHidden, { children })]
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { OverlayPlacement } from "./overlay.js";
|
|
2
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
/** Popover 내용의 위치. 앞은 트리거의 어느 쪽, 뒤는 그 변에서 맞출 끝이다. 공간이 모자라면 반대쪽으로 뒤집힌다. */
|
|
4
|
+
export type PopoverPlacement = OverlayPlacement;
|
|
5
|
+
export interface PopoverProps {
|
|
6
|
+
/** 열림 상태(제어). 주면 `onOpenChange`로 바꾼다. */
|
|
7
|
+
open?: boolean;
|
|
8
|
+
/** 처음 열림 상태(비제어). 기본값은 `false`다. */
|
|
9
|
+
defaultOpen?: boolean;
|
|
10
|
+
/** 열림 상태가 바뀌려 할 때 새 값으로 부른다. */
|
|
11
|
+
onOpenChange?: (open: boolean) => void;
|
|
12
|
+
/** `PopoverTrigger`와 `PopoverContent` */
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 트리거와 내용을 잇는 열림 상태. DOM 요소를 만들지 않는다.
|
|
17
|
+
* 안에 `PopoverTrigger` 하나와 `PopoverContent` 하나를 둔다.
|
|
18
|
+
*/
|
|
19
|
+
export declare function Popover({ open, defaultOpen, onOpenChange, children }: PopoverProps): ReactElement;
|
|
20
|
+
export interface PopoverTriggerProps extends ComponentProps<"button"> {
|
|
21
|
+
/**
|
|
22
|
+
* 다른 요소로 렌더한다. 예: `render={<Button variant="outline" />}`, `render={<IconButton aria-label="설정" />}`.
|
|
23
|
+
* props, ref, className, 이벤트 핸들러를 그 요소에 병합한다. 기본은 class 없는 `<button type="button">`이다.
|
|
24
|
+
*/
|
|
25
|
+
render?: ReactElement;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Popover를 여닫는 버튼. 누르면(마우스, 터치, Enter, Space) 열림 상태를 뒤집는다.
|
|
29
|
+
* `aria-expanded`, 열렸을 때 `aria-controls`, 열렸을 때 `data-open`이 붙는다.
|
|
30
|
+
*/
|
|
31
|
+
export declare function PopoverTrigger({ render, ref, id, className, ...props }: PopoverTriggerProps): ReactElement;
|
|
32
|
+
export interface PopoverContentProps extends Omit<ComponentProps<"div">, "id" | "role"> {
|
|
33
|
+
/** 트리거 기준 위치. 기본값은 `bottom`(아래, 가운데 맞춤)이다. 공간이 모자라면 반대쪽으로 뒤집힌다. */
|
|
34
|
+
placement?: PopoverPlacement;
|
|
35
|
+
/** 트리거와의 간격(px). 기본값은 `8`이다. */
|
|
36
|
+
offset?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 트리거 옆에 뜨는 내용(`role="dialog"`). 열릴 때만 `document.body`에 portal로 렌더하고 `z-popover`로 쌓는다.
|
|
40
|
+
* 등장은 opacity + blur 4px + 4px 이동(`normal`), 퇴장은 그 절반(`fast`)이다. 움직임 줄이기에서는 opacity만 바뀐다.
|
|
41
|
+
* 열려 있으면 `data-open`이, 트리거 기준 방향이 `data-placement`(`top`/`bottom`/`left`/`right`)로 붙는다.
|
|
42
|
+
*/
|
|
43
|
+
export declare function PopoverContent(props: PopoverContentProps): ReactElement | null;
|
package/dist/Popover.js
ADDED
|
@@ -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,32 @@
|
|
|
1
|
+
import { Role, Size } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement } from "react";
|
|
3
|
+
/** Progress의 size. 막대 굵기다. */
|
|
4
|
+
type ProgressSize = Extract<Size, "sm" | "md">;
|
|
5
|
+
/** 접근 가능한 이름. `aria-label`이나 `aria-labelledby` 중 하나는 꼭 준다(WAI-ARIA progressbar는 이름이 필요하다). */
|
|
6
|
+
type ProgressName = {
|
|
7
|
+
"aria-label": string;
|
|
8
|
+
"aria-labelledby"?: string;
|
|
9
|
+
} | {
|
|
10
|
+
"aria-label"?: string;
|
|
11
|
+
"aria-labelledby": string;
|
|
12
|
+
};
|
|
13
|
+
interface ProgressOwnProps extends Omit<ComponentProps<"div">, "color" | "children" | "role" | "aria-label" | "aria-labelledby" | "aria-valuenow" | "aria-valuemin" | "aria-valuemax"> {
|
|
14
|
+
/** 현재 값. 0 이상 `max` 이하로 잘라서 쓴다. 값을 모르는 진행은 Progress가 아니라 Spinner로 보여 준다. */
|
|
15
|
+
value: number;
|
|
16
|
+
/** 끝 값. 기본값은 `100`이다. 시작 값은 항상 0이다. */
|
|
17
|
+
max?: number;
|
|
18
|
+
/** 막대 굵기. `sm` 4px, `md` 8px. 기본값은 `md`다. */
|
|
19
|
+
size?: ProgressSize;
|
|
20
|
+
/**
|
|
21
|
+
* 막대 색. 진행 상태는 뜻이 있는 표시라 Role만 받는다(Tide Role 6개와 앱이 `RoleRegistry`에 더한 Role, ADR 0016).
|
|
22
|
+
* 기본값은 `primary`다. 루트에 `data-color`로 붙고 바깥 요소의 색을 물려받지 않는다.
|
|
23
|
+
*/
|
|
24
|
+
color?: Role;
|
|
25
|
+
}
|
|
26
|
+
export type ProgressProps = ProgressOwnProps & ProgressName;
|
|
27
|
+
/**
|
|
28
|
+
* 작업이 얼마나 진행됐는지 막대로 보여 준다. 역할은 `progressbar`이고 값은 `aria-valuenow`로 나간다.
|
|
29
|
+
* 움직임 줄이기에서도 진행 표시는 그대로다. 서버 컴포넌트로 쓸 수 있다.
|
|
30
|
+
*/
|
|
31
|
+
export declare function Progress({ value, max, size, color, className, ...props }: ProgressProps): ReactElement;
|
|
32
|
+
export {};
|
package/dist/Progress.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cn } from "./cn.js";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
|
|
4
|
+
// src/Progress.tsx
|
|
5
|
+
const sizeClass = {
|
|
6
|
+
sm: "h-1",
|
|
7
|
+
md: "h-2"
|
|
8
|
+
};
|
|
9
|
+
const trackClass = "relative block w-full overflow-hidden rounded-full bg-accent-subtle forced-colors:forced-color-adjust-none forced-colors:border forced-colors:border-[CanvasText] forced-colors:bg-[Canvas]";
|
|
10
|
+
const barClass = "block h-full rounded-full bg-current text-accent motion-safe:transition-[width] forced-colors:text-[Highlight]";
|
|
11
|
+
/** 0 이상 max 이하로 자른다. max가 0 이하이거나 숫자가 아니면 0으로 본다. */
|
|
12
|
+
function clamp(value, max) {
|
|
13
|
+
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0) return 0;
|
|
14
|
+
return Math.min(Math.max(value, 0), max);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 작업이 얼마나 진행됐는지 막대로 보여 준다. 역할은 `progressbar`이고 값은 `aria-valuenow`로 나간다.
|
|
18
|
+
* 움직임 줄이기에서도 진행 표시는 그대로다. 서버 컴포넌트로 쓸 수 있다.
|
|
19
|
+
*/
|
|
20
|
+
function Progress({ value, max = 100, size = "md", color = "primary", className, ...props }) {
|
|
21
|
+
const safeMax = Number.isFinite(max) && max > 0 ? max : 100;
|
|
22
|
+
const now = clamp(value, safeMax);
|
|
23
|
+
const percent = now / safeMax * 100;
|
|
24
|
+
return /* @__PURE__ */ jsx("div", {
|
|
25
|
+
...props,
|
|
26
|
+
role: "progressbar",
|
|
27
|
+
"aria-valuenow": now,
|
|
28
|
+
"aria-valuemin": 0,
|
|
29
|
+
"aria-valuemax": safeMax,
|
|
30
|
+
"data-color": color,
|
|
31
|
+
className: cn(trackClass, sizeClass[size], className),
|
|
32
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
33
|
+
"aria-hidden": "true",
|
|
34
|
+
className: barClass,
|
|
35
|
+
style: { width: `${percent}%` }
|
|
36
|
+
})
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { Progress };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Size } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement } from "react";
|
|
3
|
+
/** 원의 size. Avatar size와 같은 24·32·40·48px다(Avatar 자리를 그대로 채운다). */
|
|
4
|
+
type SkeletonCircleSize = Extract<Size, "xs" | "sm" | "md" | "lg">;
|
|
5
|
+
interface SkeletonBaseProps extends Omit<ComponentProps<"div">, "children" | "aria-hidden"> {}
|
|
6
|
+
/** `variant`에 따라 받는 props가 다르다. `size`는 원(`circle`)에만 있다. */
|
|
7
|
+
export type SkeletonProps = SkeletonBaseProps & ({
|
|
8
|
+
/**
|
|
9
|
+
* 모양. `text`는 글 한 줄(높이 1em, 너비 100%), `rect`는 사각형 영역(너비 100%, 높이는 `className`으로 준다),
|
|
10
|
+
* `circle`은 원(크기는 `size`). 기본값은 `text`다.
|
|
11
|
+
*/
|
|
12
|
+
variant?: "text" | "rect";
|
|
13
|
+
size?: never;
|
|
14
|
+
} | {
|
|
15
|
+
variant: "circle";
|
|
16
|
+
/** 원 크기. `xs` 24px, `sm` 32px, `md` 40px, `lg` 48px(Avatar와 같다). 기본값은 `md`다. */
|
|
17
|
+
size?: SkeletonCircleSize;
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* 내용이 오기 전 자리를 표시한다. 스크린 리더에는 읽히지 않는다(`aria-hidden`).
|
|
21
|
+
* 불러오는 중이라는 사실은 앱이 영역의 `aria-busy`나 보이는 문구로 알린다. 서버 컴포넌트로 쓸 수 있다.
|
|
22
|
+
*/
|
|
23
|
+
export declare function Skeleton({ variant, size, className, ...props }: SkeletonProps): ReactElement;
|
|
24
|
+
export {};
|
package/dist/Skeleton.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cn } from "./cn.js";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
|
|
4
|
+
// src/Skeleton.tsx
|
|
5
|
+
const variantClass = {
|
|
6
|
+
text: "h-[1em] w-full rounded-sm",
|
|
7
|
+
rect: "w-full rounded-md",
|
|
8
|
+
circle: "shrink-0 rounded-full"
|
|
9
|
+
};
|
|
10
|
+
const circleSizeClass = {
|
|
11
|
+
xs: "size-6",
|
|
12
|
+
sm: "size-8",
|
|
13
|
+
md: "size-10",
|
|
14
|
+
lg: "size-12"
|
|
15
|
+
};
|
|
16
|
+
const baseClass = "block border border-transparent bg-neutral-subtle-active motion-safe:animate-pulse";
|
|
17
|
+
/**
|
|
18
|
+
* 내용이 오기 전 자리를 표시한다. 스크린 리더에는 읽히지 않는다(`aria-hidden`).
|
|
19
|
+
* 불러오는 중이라는 사실은 앱이 영역의 `aria-busy`나 보이는 문구로 알린다. 서버 컴포넌트로 쓸 수 있다.
|
|
20
|
+
*/
|
|
21
|
+
function Skeleton({ variant = "text", size, className, ...props }) {
|
|
22
|
+
return /* @__PURE__ */ jsx("div", {
|
|
23
|
+
...props,
|
|
24
|
+
"aria-hidden": "true",
|
|
25
|
+
className: cn(baseClass, variantClass[variant], variant === "circle" ? circleSizeClass[size ?? "md"] : void 0, className)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { Skeleton };
|
package/dist/Table.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ComponentProps, ReactElement } from "react";
|
|
2
|
+
export interface TableProps extends ComponentProps<"table"> {}
|
|
3
|
+
export interface TableHeadProps extends ComponentProps<"thead"> {}
|
|
4
|
+
export interface TableBodyProps extends ComponentProps<"tbody"> {}
|
|
5
|
+
export interface TableRowProps extends ComponentProps<"tr"> {}
|
|
6
|
+
export interface TableHeaderCellProps extends ComponentProps<"th"> {
|
|
7
|
+
/**
|
|
8
|
+
* 이 머리 칸이 맡는 범위. 기본값은 `"col"`(아래 열)이다. 본문 행의 첫 칸처럼 행을 맡으면 `"row"`를 준다.
|
|
9
|
+
* 열 그룹·행 그룹 머리는 `"colgroup"`·`"rowgroup"`이다.
|
|
10
|
+
*/
|
|
11
|
+
scope?: "col" | "row" | "colgroup" | "rowgroup";
|
|
12
|
+
}
|
|
13
|
+
export interface TableCellProps extends ComponentProps<"td"> {}
|
|
14
|
+
/**
|
|
15
|
+
* 시맨틱 `<table>`. 행·열로 된 데이터를 보여 준다. 정렬·행 선택·가상화는 없다. 서버 컴포넌트로 쓸 수 있다.
|
|
16
|
+
* 표의 이름은 `<caption>`이나 `aria-label`·`aria-labelledby`로 준다.
|
|
17
|
+
*/
|
|
18
|
+
export declare function Table({ className, ...props }: TableProps): ReactElement;
|
|
19
|
+
/** 머리 행 묶음 `<thead>`. 안의 행은 hover로 바뀌지 않는다. */
|
|
20
|
+
export declare function TableHead(props: TableHeadProps): ReactElement;
|
|
21
|
+
/** 본문 행 묶음 `<tbody>`. 바로 아래 `TableRow`에 마우스를 올리면 배경이 바로 바뀐다. */
|
|
22
|
+
export declare function TableBody(props: TableBodyProps): ReactElement;
|
|
23
|
+
/** 행 `<tr>`. 아래 경계가 있고, `TableBody` 안에서는 hover 배경이 transition 없이 바로 바뀐다. */
|
|
24
|
+
export declare function TableRow({ className, ...props }: TableRowProps): ReactElement;
|
|
25
|
+
/**
|
|
26
|
+
* 머리 칸 `<th>`. 기본은 `scope="col"`(열 머리, columnheader)이다. 본문 행의 행 머리는 `scope="row"`(rowheader)를 준다.
|
|
27
|
+
* 복잡한 표(여러 줄 머리)는 `id`와 칸의 `headers`로 직접 잇는다.
|
|
28
|
+
*/
|
|
29
|
+
export declare function TableHeaderCell({ scope, className, ...props }: TableHeaderCellProps): ReactElement;
|
|
30
|
+
/** 데이터 칸 `<td>`. 숫자 열은 `className="tabular-nums text-end"`로 자릿수를 맞춘다. */
|
|
31
|
+
export declare function TableCell({ className, ...props }: TableCellProps): ReactElement;
|
package/dist/Table.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { cn } from "./cn.js";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
|
|
4
|
+
// src/Table.tsx
|
|
5
|
+
const tableClass = "w-full border-collapse text-body-sm text-default";
|
|
6
|
+
const rowClass = "border-b border-default [:where(tbody>&)]:hover:bg-neutral-subtle-hover";
|
|
7
|
+
const headerCellClass = "px-3 py-2.5 text-start align-middle text-label-md";
|
|
8
|
+
const cellClass = "px-3 py-2.5 text-start align-middle";
|
|
9
|
+
/**
|
|
10
|
+
* 시맨틱 `<table>`. 행·열로 된 데이터를 보여 준다. 정렬·행 선택·가상화는 없다. 서버 컴포넌트로 쓸 수 있다.
|
|
11
|
+
* 표의 이름은 `<caption>`이나 `aria-label`·`aria-labelledby`로 준다.
|
|
12
|
+
*/
|
|
13
|
+
function Table({ className, ...props }) {
|
|
14
|
+
return /* @__PURE__ */ jsx("table", {
|
|
15
|
+
...props,
|
|
16
|
+
className: cn(tableClass, className)
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** 머리 행 묶음 `<thead>`. 안의 행은 hover로 바뀌지 않는다. */
|
|
20
|
+
function TableHead(props) {
|
|
21
|
+
return /* @__PURE__ */ jsx("thead", { ...props });
|
|
22
|
+
}
|
|
23
|
+
/** 본문 행 묶음 `<tbody>`. 바로 아래 `TableRow`에 마우스를 올리면 배경이 바로 바뀐다. */
|
|
24
|
+
function TableBody(props) {
|
|
25
|
+
return /* @__PURE__ */ jsx("tbody", { ...props });
|
|
26
|
+
}
|
|
27
|
+
/** 행 `<tr>`. 아래 경계가 있고, `TableBody` 안에서는 hover 배경이 transition 없이 바로 바뀐다. */
|
|
28
|
+
function TableRow({ className, ...props }) {
|
|
29
|
+
return /* @__PURE__ */ jsx("tr", {
|
|
30
|
+
...props,
|
|
31
|
+
className: cn(rowClass, className)
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 머리 칸 `<th>`. 기본은 `scope="col"`(열 머리, columnheader)이다. 본문 행의 행 머리는 `scope="row"`(rowheader)를 준다.
|
|
36
|
+
* 복잡한 표(여러 줄 머리)는 `id`와 칸의 `headers`로 직접 잇는다.
|
|
37
|
+
*/
|
|
38
|
+
function TableHeaderCell({ scope = "col", className, ...props }) {
|
|
39
|
+
return /* @__PURE__ */ jsx("th", {
|
|
40
|
+
...props,
|
|
41
|
+
scope,
|
|
42
|
+
className: cn(headerCellClass, className)
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** 데이터 칸 `<td>`. 숫자 열은 `className="tabular-nums text-end"`로 자릿수를 맞춘다. */
|
|
46
|
+
function TableCell({ className, ...props }) {
|
|
47
|
+
return /* @__PURE__ */ jsx("td", {
|
|
48
|
+
...props,
|
|
49
|
+
className: cn(cellClass, className)
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CircleCheckIcon, InfoIcon, OctagonExclamationPointIcon, TriangleExclamationPointIcon } from "@tide-tds/icons";
|
|
2
|
+
|
|
3
|
+
// src/alert-style.ts
|
|
4
|
+
/**
|
|
5
|
+
* 배치. 아이콘·내용·닫기 버튼을 한 줄로 놓고 위에 맞춘다. 여백은 16px, radius는 담는 요소의 lg다(spec radius 「쓰는 곳」).
|
|
6
|
+
* border 1px는 항상 둔다. forced-colors에서 브라우저가 경계를 CanvasText로 덮어 배경 없이도 영역이 남는다.
|
|
7
|
+
*/
|
|
8
|
+
const alertLayoutClass = "flex items-start gap-3 rounded-lg border p-4";
|
|
9
|
+
/**
|
|
10
|
+
* Alert의 subtle 표면. 모두 등록된 대비 쌍이다.
|
|
11
|
+
* - 글자·아이콘: text-accent × bg-accent-subtle 4.5:1(Role `text-<role>` × `bg-<role>-subtle`, neutral은 text-default ×
|
|
12
|
+
* bg-neutral-subtle, 앱 Role은 색 이름 규칙 text × subtle)
|
|
13
|
+
* - 경계: border-accent × 바깥 표면 층 3:1(`border-<role>` × 표면 층, neutral은 border-strong)과
|
|
14
|
+
* × 안쪽 bg-accent-subtle 3:1(`border-<role>` × `bg-<role>-subtle`, neutral은 border-strong × bg-neutral-subtle)
|
|
15
|
+
*/
|
|
16
|
+
const alertSurfaceClass = "border-accent bg-accent-subtle text-accent";
|
|
17
|
+
/** 상태 아이콘 자리. 글자 첫 줄 높이(body-sm 20px)와 같은 20px라 첫 줄 가운데에 맞는다. */
|
|
18
|
+
const alertIconClass = "size-5 shrink-0";
|
|
19
|
+
/** 제목과 본문을 세로로 쌓는다. 긴 단어가 닫기 버튼을 밀어내지 않게 min-w-0을 둔다. */
|
|
20
|
+
const alertContentClass = "flex min-w-0 flex-1 flex-col gap-1";
|
|
21
|
+
/** 제목. 본문과 같은 크기에 굵기만 올린다. 제목 수준(h1~h6)은 화면마다 다르므로 heading 요소를 쓰지 않는다. */
|
|
22
|
+
const alertTitleClass = "text-body-sm font-semibold";
|
|
23
|
+
/** 본문 */
|
|
24
|
+
const alertDescriptionClass = "text-body-sm";
|
|
25
|
+
/** 닫기 버튼(IconButton xs, 24px). 위아래 2px를 겹쳐 첫 줄 높이(20px)를 늘리지 않고, 끝쪽 4px를 여백에 겹친다. */
|
|
26
|
+
const alertCloseClass = "-my-0.5 -me-1";
|
|
27
|
+
const statusIcons = {
|
|
28
|
+
neutral: InfoIcon,
|
|
29
|
+
primary: InfoIcon,
|
|
30
|
+
info: InfoIcon,
|
|
31
|
+
success: CircleCheckIcon,
|
|
32
|
+
warning: TriangleExclamationPointIcon,
|
|
33
|
+
danger: OctagonExclamationPointIcon
|
|
34
|
+
};
|
|
35
|
+
/** Role의 상태 아이콘. 앱이 더한 Role(예: `brand`)은 일반 알림으로 보고 i 아이콘을 쓴다. */
|
|
36
|
+
function alertStatusIcon(color) {
|
|
37
|
+
return Object.hasOwn(statusIcons, color) ? statusIcons[color] : InfoIcon;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { alertCloseClass, alertContentClass, alertDescriptionClass, alertIconClass, alertLayoutClass, alertStatusIcon, alertSurfaceClass, alertTitleClass };
|
package/dist/index.d.ts
CHANGED
|
@@ -13,8 +13,16 @@ import { CheckboxGroup, CheckboxGroupProps, ChoiceOrientation } from "./Checkbox
|
|
|
13
13
|
import { Radio, RadioProps } from "./Radio.js";
|
|
14
14
|
import { RadioGroup, RadioGroupProps } from "./RadioGroup.js";
|
|
15
15
|
import { Switch, SwitchProps } from "./Switch.js";
|
|
16
|
+
import { Alert, AlertProps } from "./Alert.js";
|
|
17
|
+
import { Avatar, AvatarProps } from "./Avatar.js";
|
|
16
18
|
import { Badge, BadgeProps } from "./Badge.js";
|
|
17
19
|
import { Card, CardProps } from "./Card.js";
|
|
18
20
|
import { Separator, SeparatorProps } from "./Separator.js";
|
|
21
|
+
import { Skeleton, SkeletonProps } from "./Skeleton.js";
|
|
22
|
+
import { Progress, ProgressProps } from "./Progress.js";
|
|
23
|
+
import { Table, TableBody, TableBodyProps, TableCell, TableCellProps, TableHead, TableHeadProps, TableHeaderCell, TableHeaderCellProps, TableProps, TableRow, TableRowProps } from "./Table.js";
|
|
24
|
+
import { Breadcrumb, BreadcrumbCurrent, BreadcrumbCurrentProps, BreadcrumbItem, BreadcrumbItemProps, BreadcrumbLink, BreadcrumbLinkProps, BreadcrumbProps } from "./Breadcrumb.js";
|
|
25
|
+
import { Pagination, PaginationEllipsis, PaginationEllipsisProps, PaginationItem, PaginationItemProps, PaginationLink, PaginationLinkProps, PaginationNext, PaginationNextProps, PaginationPrevious, PaginationPreviousProps, PaginationProps } from "./Pagination.js";
|
|
26
|
+
import { Popover, PopoverContent, PopoverContentProps, PopoverPlacement, PopoverProps, PopoverTrigger, PopoverTriggerProps } from "./Popover.js";
|
|
19
27
|
import { Color, ColorFamilyName, ColorFamilyRegistry, FillVariant, Role, RoleRegistry, Size } from "./types.js";
|
|
20
|
-
export { Badge, type BadgeProps, 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, Radio, RadioGroup, type RadioGroupProps, type RadioProps, type Role, type RoleRegistry, Separator, type SeparatorProps, type Size, Spinner, type SpinnerProps, Switch, type SwitchProps, Textarea, type TextareaProps, Typography, type TypographyProps, VisuallyHidden, type VisuallyHiddenProps, cn };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -13,8 +13,16 @@ import { CheckboxGroup } from "./CheckboxGroup.js";
|
|
|
13
13
|
import { Radio } from "./Radio.js";
|
|
14
14
|
import { RadioGroup } from "./RadioGroup.js";
|
|
15
15
|
import { Switch } from "./Switch.js";
|
|
16
|
+
import { Alert } from "./Alert.js";
|
|
17
|
+
import { Avatar } from "./Avatar.js";
|
|
16
18
|
import { Badge } from "./Badge.js";
|
|
17
19
|
import { Card } from "./Card.js";
|
|
18
20
|
import { Separator } from "./Separator.js";
|
|
21
|
+
import { Skeleton } from "./Skeleton.js";
|
|
22
|
+
import { Progress } from "./Progress.js";
|
|
23
|
+
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table.js";
|
|
24
|
+
import { Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink } from "./Breadcrumb.js";
|
|
25
|
+
import { Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "./Pagination.js";
|
|
26
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./Popover.js";
|
|
19
27
|
|
|
20
|
-
export { Badge, Button, Card, Checkbox, CheckboxGroup, Field, IconButton, Input, Link, Radio, RadioGroup, Separator, Spinner, Switch, Textarea, Typography, VisuallyHidden, cn };
|
|
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 };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/overlay-style.ts
|
|
2
|
+
/**
|
|
3
|
+
* 오버레이 표면. raised 표면(`bg-raised`), `shadow-md`, radius `lg`, 경계 `border-raised`(light 투명 / dark gray-800).
|
|
4
|
+
* - 글자는 `text-default`다. `text-default × bg-raised` 4.5:1이 등록되어 있다.
|
|
5
|
+
* - `border-raised`는 장식용 경계라 대비 검사에서 빠진다. light의 투명 border는 forced-colors에서 CanvasText로 칠해져
|
|
6
|
+
* 그림자 없이도 경계가 남는다(spec Elevation, 사용자 이야기 37). forced-colors에서는 브라우저가 box-shadow를 지운다.
|
|
7
|
+
*/
|
|
8
|
+
const overlaySurfaceClass = "rounded-lg border border-raised bg-raised text-default shadow-md";
|
|
9
|
+
/**
|
|
10
|
+
* 「가끔 씀」 오버레이의 등장·퇴장(spec 「motion 적용」). 요소에 `data-open`(열림)과 `data-placement`(트리거 기준 방향,
|
|
11
|
+
* `top`·`bottom`·`left`·`right`)가 있어야 한다.
|
|
12
|
+
*
|
|
13
|
+
* - 등장: `@starting-style`(`starting:`)에서 opacity 0 + blur 4px(`blur-xs`) + 트리거 쪽으로 4px(`*-translate-*-1`)
|
|
14
|
+
* 떨어진 곳에서 시작해 `normal`(300ms)로 제자리에 온다.
|
|
15
|
+
* - 퇴장: `data-open`이 빠지면 같은 조합의 절반(blur 2px, 2px 이동)으로 `fast`(150ms)에 사라진다.
|
|
16
|
+
* duration은 도착 상태의 값을 쓰므로 기본 `duration-fast`에 열림만 `data-open:duration-normal`이다. easing은 둘 다 out이다.
|
|
17
|
+
* - 닫힌 상태는 `display: none`(`hidden`)이고 `transition-discrete`(`transition-behavior: allow-discrete`)로 display도
|
|
18
|
+
* 전환 대상에 넣어, 퇴장 전환이 끝날 때 사라진다. 이 둘을 지원하지 않는 브라우저에서는 바로 나타나고 사라진다.
|
|
19
|
+
* 요소를 DOM에서 빼는 시점은 `useOverlayPresence`가 전환이 끝나기를 기다려 정한다.
|
|
20
|
+
* - 퇴장 중에는 `pointer-events-none`으로 눌리지 않는다.
|
|
21
|
+
* - reduced-motion: 이동·blur와 그 전환 대상(filter, translate)은 `motion-safe:`일 때만 둔다. 움직임 줄이기에서는
|
|
22
|
+
* opacity(와 display)만 같은 시간으로 바뀐다.
|
|
23
|
+
* - `transition-all`은 쓰지 않는다. 전환 대상을 적는다.
|
|
24
|
+
*/
|
|
25
|
+
const popoverMotionClass = [
|
|
26
|
+
"transition-[opacity,display] transition-discrete duration-fast ease-out data-open:duration-normal motion-safe:transition-[opacity,filter,translate,display]",
|
|
27
|
+
"not-data-open:pointer-events-none not-data-open:hidden not-data-open:opacity-0 motion-safe:not-data-open:blur-[2px]",
|
|
28
|
+
"motion-safe:not-data-open:data-[placement=bottom]:-translate-y-0.5 motion-safe:not-data-open:data-[placement=top]:translate-y-0.5",
|
|
29
|
+
"motion-safe:not-data-open:data-[placement=right]:-translate-x-0.5 motion-safe:not-data-open:data-[placement=left]:translate-x-0.5",
|
|
30
|
+
"starting:opacity-0 motion-safe:starting:blur-xs",
|
|
31
|
+
"motion-safe:data-[placement=bottom]:starting:-translate-y-1 motion-safe:data-[placement=top]:starting:translate-y-1",
|
|
32
|
+
"motion-safe:data-[placement=right]:starting:-translate-x-1 motion-safe:data-[placement=left]:starting:translate-x-1"
|
|
33
|
+
].join(" ");
|
|
34
|
+
|
|
35
|
+
export { overlaySurfaceClass, popoverMotionClass };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import "react";
|
|
2
|
+
import "react-stately";
|
|
3
|
+
/**
|
|
4
|
+
* 트리거 기준 위치. 앞은 트리거의 어느 쪽에 놓을지(주축), 뒤는 그 변에서 어디에 맞출지(교차축)다.
|
|
5
|
+
* 공간이 모자라면 반대쪽으로 뒤집는다(React Aria `shouldFlip` 기본값).
|
|
6
|
+
*/
|
|
7
|
+
export type OverlayPlacement = "top" | "top start" | "top end" | "bottom" | "bottom start" | "bottom end" | "left" | "left top" | "left bottom" | "right" | "right top" | "right bottom";
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
import { useLayoutEffect, useState } from "react";
|
|
5
|
+
import { Overlay, usePopover } from "react-aria";
|
|
6
|
+
|
|
7
|
+
// src/overlay.tsx
|
|
8
|
+
const isAxis = (value) => value === "top" || value === "bottom" || value === "left" || value === "right";
|
|
9
|
+
/**
|
|
10
|
+
* 열림 상태가 꺼져도 퇴장 전환이 끝날 때까지 오버레이를 DOM에 남긴다. DOM에 둘지(`true`)를 돌려준다.
|
|
11
|
+
*
|
|
12
|
+
* 닫힌 뒤 요소의 `getAnimations()`(CSS transition 포함)가 모두 끝나면 뺀다. 전환이 없으면(지원하지 않는 브라우저,
|
|
13
|
+
* 전환이 없는 오버레이) 바로 뺀다. 퇴장 중에 다시 열리면 빼지 않는다. React Aria hooks에는 퇴장을 기다리는 hook이
|
|
14
|
+
* 없어서(react-aria-components의 `isExiting`은 Component 층에만 있다) 이 hook이 그 일을 한다.
|
|
15
|
+
*/
|
|
16
|
+
function useOverlayPresence(isOpen, ref) {
|
|
17
|
+
const [present, setPresent] = useState(isOpen);
|
|
18
|
+
if (isOpen && !present) setPresent(true);
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
if (isOpen || !present) return;
|
|
21
|
+
const element = ref.current;
|
|
22
|
+
if (!element || typeof element.getAnimations !== "function") {
|
|
23
|
+
setPresent(false);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
let cancelled = false;
|
|
27
|
+
const waitForExit = () => {
|
|
28
|
+
const animations = element.getAnimations();
|
|
29
|
+
if (animations.length === 0) {
|
|
30
|
+
setPresent(false);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
|
|
34
|
+
if (!cancelled) waitForExit();
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
waitForExit();
|
|
38
|
+
return () => {
|
|
39
|
+
cancelled = true;
|
|
40
|
+
};
|
|
41
|
+
}, [
|
|
42
|
+
isOpen,
|
|
43
|
+
present,
|
|
44
|
+
ref
|
|
45
|
+
]);
|
|
46
|
+
return isOpen || present;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* React Aria가 위치 style에 넣는 `zIndex: 100000`을 빼고 주축 방향을 `data-placement`로 붙인다.
|
|
50
|
+
* 쌓임은 쌓임 단계 class(`z-popover` 등)로만 정한다(spec 「오버레이」).
|
|
51
|
+
*
|
|
52
|
+
* `placement`가 아직 없으면(처음 렌더) 요청한 placement의 주축을 쓴다. 등장 전환의 시작 방향이 첫 스타일 계산에서
|
|
53
|
+
* 정해지기 때문이다. 공간이 없어 뒤집힌 경우에만 첫 프레임의 시작 방향이 요청한 쪽이 된다(4px).
|
|
54
|
+
*/
|
|
55
|
+
function overlayPositionProps(props, placement, requested) {
|
|
56
|
+
const { zIndex: _zIndex, ...style } = props.style ?? {};
|
|
57
|
+
const fallback = requested.split(" ")[0];
|
|
58
|
+
return {
|
|
59
|
+
...props,
|
|
60
|
+
style,
|
|
61
|
+
"data-placement": isAxis(placement) ? placement : fallback
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 트리거에 붙는 오버레이(Popover, Menu, Select·Combobox 목록)의 위치·닫기 동작. React Aria `usePopover`를 감싼다.
|
|
66
|
+
* - 닫기: Esc, 바깥 누름, 포커스가 바깥으로 나감(`usePopover` 기본). 위에 쌓인 오버레이부터 닫힌다.
|
|
67
|
+
* - 모달: 기본은 바깥을 `inert`로 막고 스크롤을 잠근다. `nonModal`이면 둘 다 하지 않는다.
|
|
68
|
+
* - 위치: 공간이 모자라면 반대쪽으로 뒤집고, 주축 길이는 `max-height`로 줄여 트리거를 덮지 않는다(WCAG 2.4.11, Comments 참고).
|
|
69
|
+
* 돌려받은 `overlayProps`(style 포함)를 오버레이 요소에 펼친다. 쌓임 단계 class는 부르는 쪽이 붙인다.
|
|
70
|
+
*/
|
|
71
|
+
function usePopoverOverlay({ triggerRef, popoverRef, placement, offset, nonModal = false }, state) {
|
|
72
|
+
const { popoverProps, placement: actual } = usePopover({
|
|
73
|
+
triggerRef,
|
|
74
|
+
popoverRef,
|
|
75
|
+
placement,
|
|
76
|
+
offset,
|
|
77
|
+
isNonModal: nonModal
|
|
78
|
+
}, state);
|
|
79
|
+
return { overlayProps: overlayPositionProps(popoverProps, actual, placement) };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 트리거가 놓인 Theme(`data-theme`)을 portal로 나간 오버레이에 옮긴다. Theme을 `<html>`이 아닌 일부 영역에
|
|
83
|
+
* 붙인 앱에서도 오버레이가 같은 색이 된다. 오버레이에 이미 `data-theme`이 있으면 두고, 트리거 위에 Theme이 없으면
|
|
84
|
+
* 붙이지 않는다(`:root` 기본값을 따른다). 위치를 계산하기 전(첫 스타일 계산 전)에 붙도록 위치 hook보다 먼저 부른다.
|
|
85
|
+
*/
|
|
86
|
+
function useOverlayTheme(triggerRef, overlayRef) {
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
const overlay = overlayRef.current;
|
|
89
|
+
if (!overlay || overlay.hasAttribute("data-theme")) return;
|
|
90
|
+
const theme = triggerRef.current?.closest("[data-theme]")?.getAttribute("data-theme");
|
|
91
|
+
if (theme) overlay.setAttribute("data-theme", theme);
|
|
92
|
+
}, [triggerRef, overlayRef]);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 오버레이를 `document.body`에 portal한다(React Aria `Overlay`). top-layer(`<dialog>`, popover API)는 쓰지 않는다.
|
|
96
|
+
* 안을 `FocusScope`(`restoreFocus`)로 감싸서, DOM에서 빠질 때 포커스가 열기 전 요소(보통 트리거)로 돌아간다.
|
|
97
|
+
* 역할 hook이 포커스 가두기를 요청하면(`useDialog` 등) Tab이 안에서 돈다. 서버 렌더에서는 아무것도 그리지 않는다.
|
|
98
|
+
*/
|
|
99
|
+
function OverlayPortal({ isExiting, children }) {
|
|
100
|
+
return /* @__PURE__ */ jsx(Overlay, {
|
|
101
|
+
isExiting,
|
|
102
|
+
children
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { OverlayPortal, overlayPositionProps, useOverlayPresence, useOverlayTheme, usePopoverOverlay };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tide-tds/components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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.
|
|
27
|
-
"@tide-tds/icons": "0.
|
|
26
|
+
"@tide-tds/foundation": "0.3.1",
|
|
27
|
+
"@tide-tds/icons": "0.3.1",
|
|
28
28
|
"clsx": "^2.1.1",
|
|
29
29
|
"react-aria": "^3.53.1",
|
|
30
30
|
"react-stately": "^3.51.0",
|