@tide-tds/components 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/dist/Button.d.ts +42 -0
- package/dist/Button.js +76 -0
- package/dist/Checkbox.d.ts +30 -0
- package/dist/Checkbox.js +74 -0
- package/dist/CheckboxGroup.d.ts +19 -0
- package/dist/CheckboxGroup.js +45 -0
- package/dist/Field.d.ts +33 -0
- package/dist/Field.js +74 -0
- package/dist/IconButton.d.ts +42 -0
- package/dist/IconButton.js +63 -0
- package/dist/Input.d.ts +19 -0
- package/dist/Input.js +35 -0
- package/dist/Link.d.ts +20 -0
- package/dist/Link.js +21 -0
- package/dist/Radio.d.ts +27 -0
- package/dist/Radio.js +67 -0
- package/dist/RadioGroup.d.ts +28 -0
- package/dist/RadioGroup.js +50 -0
- package/dist/Spinner.d.ts +20 -0
- package/dist/Spinner.js +35 -0
- package/dist/Switch.d.ts +30 -0
- package/dist/Switch.js +65 -0
- package/dist/Textarea.d.ts +19 -0
- package/dist/Textarea.js +36 -0
- package/dist/Typography.d.ts +36 -0
- package/dist/Typography.js +50 -0
- package/dist/VisuallyHidden.d.ts +4 -0
- package/dist/VisuallyHidden.js +13 -0
- package/dist/choice-group.js +34 -0
- package/dist/choice-style.d.ts +2 -0
- package/dist/choice-style.js +16 -0
- package/dist/cn.d.ts +6 -0
- package/dist/cn.js +15 -0
- package/dist/field-context.js +26 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/input-style.js +5 -0
- package/dist/pressable.js +65 -0
- package/dist/render.js +55 -0
- package/dist/styles.css +5 -0
- package/dist/types.d.ts +9 -0
- package/package.json +57 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chane81
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/Button.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FillVariant, Role, Size } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
/** Button의 채움 방식. 4개를 모두 받는다. */
|
|
4
|
+
type ButtonVariant = Extract<FillVariant, "solid" | "subtle" | "outline" | "ghost">;
|
|
5
|
+
/** Button의 size. 공통 size 4개를 모두 받는다. */
|
|
6
|
+
type ButtonSize = Extract<Size, "xs" | "sm" | "md" | "lg">;
|
|
7
|
+
export interface ButtonProps extends Omit<ComponentProps<"button">, "color"> {
|
|
8
|
+
/** 채움 방식. 기본값은 `solid`다. */
|
|
9
|
+
variant?: ButtonVariant;
|
|
10
|
+
/**
|
|
11
|
+
* Role. Tide Role 6개와 앱이 `RoleRegistry`에 더한 Role을 받는다. 기본값은 `primary`다.
|
|
12
|
+
* 색 계열 이름(`blue` 등)은 받지 않는다. 행동의 뜻을 색으로 전하기 때문이다(ADR 0016).
|
|
13
|
+
* 루트에 `data-color`로 붙어 accent 색을 정한다. 바깥 요소의 색을 물려받지 않는다.
|
|
14
|
+
*/
|
|
15
|
+
color?: Role;
|
|
16
|
+
/** 높이 `xs` 24px, `sm` 32px, `md` 40px, `lg` 48px. 기본값은 `md`다. */
|
|
17
|
+
size?: ButtonSize;
|
|
18
|
+
/** 텍스트 앞 아이콘. 장식용으로 숨겨지고 size에 맞는 크기가 된다. 다른 라이브러리의 아이콘도 받는다. */
|
|
19
|
+
startIcon?: ReactNode;
|
|
20
|
+
/** 텍스트 뒤 아이콘. 장식용으로 숨겨지고 size에 맞는 크기가 된다. */
|
|
21
|
+
endIcon?: ReactNode;
|
|
22
|
+
/**
|
|
23
|
+
* 로딩 중. 앞 아이콘 자리에 Spinner를 보이고 클릭을 막는다.
|
|
24
|
+
* 포커스는 유지하고 `aria-disabled="true"`와 `data-loading`을 붙인다.
|
|
25
|
+
*/
|
|
26
|
+
loading?: boolean;
|
|
27
|
+
/** 로딩 중에 버튼 이름 뒤에 붙는 텍스트. 화면에는 보이지 않는다. 기본값은 `"로딩 중"`이다. */
|
|
28
|
+
loadingLabel?: string;
|
|
29
|
+
/** 누를 때 줄어드는 반응(scale 0.96)을 끈다. 움직임 줄이기 설정에서는 이 값과 관계없이 줄어들지 않는다. */
|
|
30
|
+
noPressScale?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* 다른 요소로 렌더한다. 예: `render={<Link href="/x" />}`.
|
|
33
|
+
* props, ref, className을 그 요소에 병합한다. `button`이 아닌 요소의 `disabled`는 `aria-disabled`로 바뀐다.
|
|
34
|
+
*/
|
|
35
|
+
render?: ReactElement;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 누르면 동작을 실행하는 버튼. 채움 방식(`variant`) × Role(`color`) × `size`로 모양을 고른다.
|
|
39
|
+
* 링크로 렌더해야 하면 `render={<Link href="/x" />}`를 쓴다.
|
|
40
|
+
*/
|
|
41
|
+
export declare function Button({ variant, color, size, startIcon, endIcon, loading, loadingLabel, noPressScale, render, disabled, type, children, onClick, ...props }: ButtonProps): ReactElement;
|
|
42
|
+
export {};
|
package/dist/Button.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { pressableBaseClass, pressableState } from "./pressable.js";
|
|
4
|
+
import { renderElement } from "./render.js";
|
|
5
|
+
import { Spinner } from "./Spinner.js";
|
|
6
|
+
import { VisuallyHidden } from "./VisuallyHidden.js";
|
|
7
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
|
|
9
|
+
// src/Button.tsx
|
|
10
|
+
/** 높이, 좌우 padding, 아이콘과 텍스트 사이 간격, 텍스트 스타일(spec size 표). radius는 모든 size에서 `md`다. */
|
|
11
|
+
const sizeClass = {
|
|
12
|
+
xs: "h-6 gap-1 px-2 text-label-sm",
|
|
13
|
+
sm: "h-8 gap-1.5 px-3 text-label-sm",
|
|
14
|
+
md: "h-10 gap-2 px-4 text-label-md",
|
|
15
|
+
lg: "h-12 gap-2 px-5 text-label-lg"
|
|
16
|
+
};
|
|
17
|
+
/** 텍스트 옆 아이콘 크기(spec 아이콘 크기 규칙). 감싼 span의 크기이고 안의 요소가 꽉 채운다. */
|
|
18
|
+
const iconSizeClass = {
|
|
19
|
+
xs: "size-3.5",
|
|
20
|
+
sm: "size-3.5",
|
|
21
|
+
md: "size-4",
|
|
22
|
+
lg: "size-5"
|
|
23
|
+
};
|
|
24
|
+
function Icon({ size, children }) {
|
|
25
|
+
return /* @__PURE__ */ jsx("span", {
|
|
26
|
+
"aria-hidden": "true",
|
|
27
|
+
className: `inline-flex shrink-0 items-center justify-center *:size-full ${iconSizeClass[size]}`,
|
|
28
|
+
children
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 누르면 동작을 실행하는 버튼. 채움 방식(`variant`) × Role(`color`) × `size`로 모양을 고른다.
|
|
33
|
+
* 링크로 렌더해야 하면 `render={<Link href="/x" />}`를 쓴다.
|
|
34
|
+
*/
|
|
35
|
+
function Button({ variant = "solid", color = "primary", size = "md", startIcon, endIcon, loading = false, loadingLabel = "로딩 중", noPressScale = false, render, disabled = false, type, children, onClick, ...props }) {
|
|
36
|
+
const state = pressableState({
|
|
37
|
+
variant,
|
|
38
|
+
color,
|
|
39
|
+
disabled,
|
|
40
|
+
loading,
|
|
41
|
+
noPressScale,
|
|
42
|
+
render,
|
|
43
|
+
type,
|
|
44
|
+
onClick,
|
|
45
|
+
ariaDisabled: props["aria-disabled"]
|
|
46
|
+
});
|
|
47
|
+
const { showSpinner } = state;
|
|
48
|
+
const label = children ?? (render?.props)?.children;
|
|
49
|
+
const rootProps = {
|
|
50
|
+
...props,
|
|
51
|
+
...state.rootProps,
|
|
52
|
+
children: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
53
|
+
showSpinner ? /* @__PURE__ */ jsx(Spinner, {
|
|
54
|
+
size,
|
|
55
|
+
"aria-hidden": "true"
|
|
56
|
+
}) : startIcon != null ? /* @__PURE__ */ jsx(Icon, {
|
|
57
|
+
size,
|
|
58
|
+
children: startIcon
|
|
59
|
+
}) : null,
|
|
60
|
+
label,
|
|
61
|
+
endIcon != null ? /* @__PURE__ */ jsx(Icon, {
|
|
62
|
+
size,
|
|
63
|
+
children: endIcon
|
|
64
|
+
}) : null,
|
|
65
|
+
showSpinner ? /* @__PURE__ */ jsx(VisuallyHidden, { children: loadingLabel }) : null
|
|
66
|
+
] })
|
|
67
|
+
};
|
|
68
|
+
return renderElement({
|
|
69
|
+
defaultElement: "button",
|
|
70
|
+
render,
|
|
71
|
+
baseClassName: `${pressableBaseClass} ${sizeClass[size]} ${state.className}`,
|
|
72
|
+
props: rootProps
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { Button };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ChoiceSize } from "./choice-style.js";
|
|
2
|
+
import { Role } from "./types.js";
|
|
3
|
+
import { CSSProperties, ComponentProps, ReactElement, ReactNode } from "react";
|
|
4
|
+
export interface CheckboxProps extends Omit<ComponentProps<"input">, "type" | "size" | "color" | "children" | "className" | "style"> {
|
|
5
|
+
/** 상자 옆에 보이는 label. 체크박스의 이름이 된다. 없으면 `aria-label`을 준다. */
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
/** 상자 16px·글자 14px(`sm`) 또는 상자 20px·글자 16px(`md`). 누르는 영역은 둘 다 24×24px이다. 기본값은 `md`다. */
|
|
8
|
+
size?: ChoiceSize;
|
|
9
|
+
/** 체크했을 때 상자 색(Role). 루트에 `data-color`로 붙는다. 기본값은 `primary`다. */
|
|
10
|
+
color?: Role;
|
|
11
|
+
/**
|
|
12
|
+
* 일부만 선택된 상태(「전체 선택」 등). 체크 대신 가로줄이 보이고 보조 기술은 「일부 선택」으로 읽는다.
|
|
13
|
+
* 누르면 브라우저가 이 상태를 지우므로, 앱이 다시 계산해 넘긴다.
|
|
14
|
+
*/
|
|
15
|
+
indeterminate?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* 오류 상태. input에 `aria-invalid="true"`와 `data-invalid`가 붙고 경계가 danger 색이 된다.
|
|
18
|
+
* Field·CheckboxGroup 안에서는 그 값을 따르고, 여기에 준 값이 이긴다.
|
|
19
|
+
*/
|
|
20
|
+
invalid?: boolean;
|
|
21
|
+
/** 루트 label의 class. 사용자 class가 기본 class를 이긴다. 상자 모양은 바꾸지 않는다(`size`·`color`가 맡는다). */
|
|
22
|
+
className?: string;
|
|
23
|
+
/** 루트 label의 style */
|
|
24
|
+
style?: CSSProperties;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 네이티브 체크박스. `checked`/`defaultChecked`/`onChange`를 그대로 받고 form에 그대로 제출된다.
|
|
28
|
+
* 여러 개를 묶어 이름·설명·오류를 붙이려면 `Field group` 안의 `CheckboxGroup`에 둔다.
|
|
29
|
+
*/
|
|
30
|
+
export declare function Checkbox({ children, size, color, indeterminate, invalid, id, disabled, required, className, style, ref, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }: CheckboxProps): ReactElement;
|
package/dist/Checkbox.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckboxGroupContext } from "./choice-group.js";
|
|
4
|
+
import { choiceAreaClass, choiceInputClass, choiceMarkClass, choiceRootClass, choiceTextClass } from "./choice-style.js";
|
|
5
|
+
import { cn } from "./cn.js";
|
|
6
|
+
import { useFieldControl } from "./field-context.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { use, useImperativeHandle, useLayoutEffect, useRef } from "react";
|
|
9
|
+
import { CheckIcon, MinusIcon } from "@tide-tds/icons";
|
|
10
|
+
|
|
11
|
+
// src/Checkbox.tsx
|
|
12
|
+
/** 상자 크기와 모서리. 체크박스는 radius `sm`(4px)이다. */
|
|
13
|
+
const boxClass = {
|
|
14
|
+
sm: "size-4 rounded-sm",
|
|
15
|
+
md: "size-5 rounded-sm"
|
|
16
|
+
};
|
|
17
|
+
/** 상자 안 아이콘 크기(px) */
|
|
18
|
+
const iconSize = {
|
|
19
|
+
sm: 12,
|
|
20
|
+
md: 16
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* 네이티브 체크박스. `checked`/`defaultChecked`/`onChange`를 그대로 받고 form에 그대로 제출된다.
|
|
24
|
+
* 여러 개를 묶어 이름·설명·오류를 붙이려면 `Field group` 안의 `CheckboxGroup`에 둔다.
|
|
25
|
+
*/
|
|
26
|
+
function Checkbox({ children, size = "md", color = "primary", indeterminate = false, invalid, id, disabled, required, className, style, ref, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }) {
|
|
27
|
+
const group = use(CheckboxGroupContext);
|
|
28
|
+
const control = useFieldControl({
|
|
29
|
+
id,
|
|
30
|
+
invalid: invalid ?? group?.invalid,
|
|
31
|
+
disabled: disabled ?? group?.disabled,
|
|
32
|
+
required,
|
|
33
|
+
"aria-describedby": ariaDescribedBy,
|
|
34
|
+
"aria-invalid": ariaInvalid
|
|
35
|
+
});
|
|
36
|
+
const inputRef = useRef(null);
|
|
37
|
+
useImperativeHandle(ref, () => inputRef.current, []);
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
|
|
40
|
+
});
|
|
41
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
42
|
+
"data-color": color,
|
|
43
|
+
"data-disabled": control.disabled ? "" : void 0,
|
|
44
|
+
"data-invalid": control["data-invalid"],
|
|
45
|
+
style,
|
|
46
|
+
className: cn(choiceRootClass, choiceTextClass[size], className),
|
|
47
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
48
|
+
className: choiceAreaClass,
|
|
49
|
+
children: [
|
|
50
|
+
/* @__PURE__ */ jsx("input", {
|
|
51
|
+
...props,
|
|
52
|
+
...control,
|
|
53
|
+
ref: inputRef,
|
|
54
|
+
type: "checkbox",
|
|
55
|
+
className: cn(boxClass[size], choiceInputClass)
|
|
56
|
+
}),
|
|
57
|
+
/* @__PURE__ */ jsx(CheckIcon, {
|
|
58
|
+
size: iconSize[size],
|
|
59
|
+
strokeWidth: 3,
|
|
60
|
+
"data-mark": "check",
|
|
61
|
+
className: cn(choiceMarkClass, "peer-checked:block peer-indeterminate:hidden")
|
|
62
|
+
}),
|
|
63
|
+
/* @__PURE__ */ jsx(MinusIcon, {
|
|
64
|
+
size: iconSize[size],
|
|
65
|
+
strokeWidth: 3,
|
|
66
|
+
"data-mark": "indeterminate",
|
|
67
|
+
className: cn(choiceMarkClass, "peer-indeterminate:block")
|
|
68
|
+
})
|
|
69
|
+
]
|
|
70
|
+
}), children === void 0 || children === null ? null : /* @__PURE__ */ jsx("span", { children })]
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { Checkbox };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ComponentProps, ReactElement } from "react";
|
|
2
|
+
/** 항목을 쌓는 방향 */
|
|
3
|
+
export type ChoiceOrientation = "vertical" | "horizontal";
|
|
4
|
+
export interface CheckboxGroupProps extends ComponentProps<"div"> {
|
|
5
|
+
/** 항목을 세로(`vertical`)로 쌓거나 가로(`horizontal`)로 늘어놓는다. 기본값은 `vertical`이다. */
|
|
6
|
+
orientation?: ChoiceOrientation;
|
|
7
|
+
/**
|
|
8
|
+
* 오류 상태. 안의 Checkbox마다 `aria-invalid="true"`가 붙고, 묶음에 `data-invalid`가 붙는다.
|
|
9
|
+
* Field 안에서는 Field의 값을 따르고, 여기에 준 값이 이긴다.
|
|
10
|
+
*/
|
|
11
|
+
invalid?: boolean;
|
|
12
|
+
/** 안의 Checkbox를 모두 비활성으로 만든다. Field 안에서는 Field의 값을 따른다. */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Checkbox 여러 개를 하나의 이름으로 묶는다. `<Field label group>` 안에 두면 label이 묶음의 이름, 설명·오류가
|
|
17
|
+
* 묶음의 설명이 된다. Field 밖에서는 `aria-label`이나 `aria-labelledby`를 준다.
|
|
18
|
+
*/
|
|
19
|
+
export declare function CheckboxGroup({ orientation, invalid, disabled, id, className, children, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...props }: CheckboxGroupProps): ReactElement;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckboxGroupContext, useChoiceGroup } from "./choice-group.js";
|
|
4
|
+
import { cn } from "./cn.js";
|
|
5
|
+
import { FieldContext } from "./field-context.js";
|
|
6
|
+
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
|
|
8
|
+
// src/CheckboxGroup.tsx
|
|
9
|
+
/** 방향별 배치. 가로는 줄이 넘치면 다음 줄로 간다. */
|
|
10
|
+
const orientationClass = {
|
|
11
|
+
vertical: "flex flex-col gap-2",
|
|
12
|
+
horizontal: "flex flex-row flex-wrap gap-x-6 gap-y-2"
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Checkbox 여러 개를 하나의 이름으로 묶는다. `<Field label group>` 안에 두면 label이 묶음의 이름, 설명·오류가
|
|
16
|
+
* 묶음의 설명이 된다. Field 밖에서는 `aria-label`이나 `aria-labelledby`를 준다.
|
|
17
|
+
*/
|
|
18
|
+
function CheckboxGroup({ orientation = "vertical", invalid, disabled, id, className, children, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...props }) {
|
|
19
|
+
const connection = useChoiceGroup({
|
|
20
|
+
id,
|
|
21
|
+
invalid,
|
|
22
|
+
disabled,
|
|
23
|
+
"aria-labelledby": ariaLabelledBy,
|
|
24
|
+
"aria-describedby": ariaDescribedBy
|
|
25
|
+
});
|
|
26
|
+
const state = {
|
|
27
|
+
invalid: connection.invalid,
|
|
28
|
+
disabled: connection.disabled
|
|
29
|
+
};
|
|
30
|
+
return /* @__PURE__ */ jsx("div", {
|
|
31
|
+
role: "group",
|
|
32
|
+
...props,
|
|
33
|
+
...connection.rootProps,
|
|
34
|
+
className: cn(orientationClass[orientation], className),
|
|
35
|
+
children: /* @__PURE__ */ jsx(FieldContext, {
|
|
36
|
+
value: null,
|
|
37
|
+
children: /* @__PURE__ */ jsx(CheckboxGroupContext, {
|
|
38
|
+
value: state,
|
|
39
|
+
children
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { CheckboxGroup, orientationClass };
|
package/dist/Field.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
2
|
+
export interface FieldProps extends ComponentProps<"div"> {
|
|
3
|
+
/** 입력창의 이름이 되는 label. 화면에 보인다. */
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
/** 입력창 아래에 보이는 설명. 입력창의 `aria-describedby`로 연결된다. */
|
|
6
|
+
description?: ReactNode;
|
|
7
|
+
/** 오류 메시지. 오류 상태일 때만 보이고 입력창의 `aria-describedby`로 연결된다. */
|
|
8
|
+
error?: ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* 오류 상태. 기본값은 `error`가 있으면 `true`다.
|
|
11
|
+
* 입력창에 `aria-invalid="true"`와 `data-invalid`가 붙는다. `false`를 주면 `error`가 있어도 숨긴다.
|
|
12
|
+
*/
|
|
13
|
+
invalid?: boolean;
|
|
14
|
+
/** 필수 입력. 입력창에 네이티브 `required`가 붙고 label 뒤에 `*`가 보인다(스크린리더는 `required`로 읽는다). */
|
|
15
|
+
required?: boolean;
|
|
16
|
+
/** 비활성. 입력창에 네이티브 `disabled`가 붙는다. */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** label이 가리킬 입력창 id. 주지 않으면 자동으로 만든다. 오류 요약에서 `#id`로 링크할 때 쓴다. `group`이면 묶음의 id다. */
|
|
19
|
+
htmlFor?: string;
|
|
20
|
+
/**
|
|
21
|
+
* 안에 둔 것이 묶음(`CheckboxGroup`, `RadioGroup`)일 때 `true`로 준다. 기본값은 `false`다.
|
|
22
|
+
* label이 `<label for>` 대신 `for` 없는 요소가 되고, 묶음이 `aria-labelledby`로 이 label을 이름으로 쓴다.
|
|
23
|
+
*/
|
|
24
|
+
group?: boolean;
|
|
25
|
+
/** 입력 Component 하나(Input, Textarea, CheckboxGroup, RadioGroup 등) */
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 입력 Component에 label, 설명, 오류 메시지를 붙인다. 안의 Input·Textarea에 `id`, `aria-describedby`,
|
|
30
|
+
* `aria-invalid`, `required`, `disabled`가 자동으로 연결된다. 묶음(CheckboxGroup·RadioGroup)은 `group`과 함께 쓴다.
|
|
31
|
+
* 서버 컴포넌트로 쓸 수 있다.
|
|
32
|
+
*/
|
|
33
|
+
export declare function Field({ label, description, error, invalid, required, disabled, htmlFor, group, className, children, ...props }: FieldProps): ReactElement;
|
package/dist/Field.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { cn } from "./cn.js";
|
|
2
|
+
import { FieldContext } from "./field-context.js";
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { useId } from "react";
|
|
5
|
+
import { CircleExclamationPointIcon } from "@tide-tds/icons";
|
|
6
|
+
|
|
7
|
+
// src/Field.tsx
|
|
8
|
+
/** 값이 화면에 그릴 내용인지(null·undefined·false·빈 문자열이 아닌지) */
|
|
9
|
+
const hasContent = (node) => node !== void 0 && node !== null && node !== false && node !== "";
|
|
10
|
+
/**
|
|
11
|
+
* 입력 Component에 label, 설명, 오류 메시지를 붙인다. 안의 Input·Textarea에 `id`, `aria-describedby`,
|
|
12
|
+
* `aria-invalid`, `required`, `disabled`가 자동으로 연결된다. 묶음(CheckboxGroup·RadioGroup)은 `group`과 함께 쓴다.
|
|
13
|
+
* 서버 컴포넌트로 쓸 수 있다.
|
|
14
|
+
*/
|
|
15
|
+
function Field({ label, description, error, invalid, required = false, disabled = false, htmlFor, group = false, className, children, ...props }) {
|
|
16
|
+
const baseId = useId();
|
|
17
|
+
const controlId = htmlFor ?? `${baseId}-control`;
|
|
18
|
+
const labelId = `${baseId}-label`;
|
|
19
|
+
const descriptionId = `${baseId}-description`;
|
|
20
|
+
const errorId = `${baseId}-error`;
|
|
21
|
+
const isInvalid = invalid ?? hasContent(error);
|
|
22
|
+
const showDescription = hasContent(description);
|
|
23
|
+
const showError = isInvalid && hasContent(error);
|
|
24
|
+
const value = {
|
|
25
|
+
controlId,
|
|
26
|
+
labelId,
|
|
27
|
+
describedBy: [showDescription ? descriptionId : void 0, showError ? errorId : void 0].filter(Boolean).join(" ") || void 0,
|
|
28
|
+
invalid: isInvalid,
|
|
29
|
+
disabled,
|
|
30
|
+
required
|
|
31
|
+
};
|
|
32
|
+
const labelContent = /* @__PURE__ */ jsxs(Fragment, { children: [label, required ? /* @__PURE__ */ jsx("span", {
|
|
33
|
+
"aria-hidden": "true",
|
|
34
|
+
className: "ms-0.5 text-danger",
|
|
35
|
+
children: "*"
|
|
36
|
+
}) : null] });
|
|
37
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
38
|
+
...props,
|
|
39
|
+
"data-invalid": isInvalid ? "" : void 0,
|
|
40
|
+
"data-disabled": disabled ? "" : void 0,
|
|
41
|
+
className: cn("flex flex-col gap-1.5", className),
|
|
42
|
+
children: [
|
|
43
|
+
group ? /* @__PURE__ */ jsx("span", {
|
|
44
|
+
id: labelId,
|
|
45
|
+
className: "text-label-md text-default",
|
|
46
|
+
children: labelContent
|
|
47
|
+
}) : /* @__PURE__ */ jsx("label", {
|
|
48
|
+
id: labelId,
|
|
49
|
+
htmlFor: controlId,
|
|
50
|
+
className: "text-label-md text-default",
|
|
51
|
+
children: labelContent
|
|
52
|
+
}),
|
|
53
|
+
/* @__PURE__ */ jsx(FieldContext, {
|
|
54
|
+
value,
|
|
55
|
+
children
|
|
56
|
+
}),
|
|
57
|
+
showDescription ? /* @__PURE__ */ jsx("p", {
|
|
58
|
+
id: descriptionId,
|
|
59
|
+
className: "text-body-sm text-muted",
|
|
60
|
+
children: description
|
|
61
|
+
}) : null,
|
|
62
|
+
showError ? /* @__PURE__ */ jsxs("p", {
|
|
63
|
+
id: errorId,
|
|
64
|
+
className: "flex items-start gap-1 text-body-sm text-danger",
|
|
65
|
+
children: [/* @__PURE__ */ jsx(CircleExclamationPointIcon, {
|
|
66
|
+
size: 16,
|
|
67
|
+
className: "mt-0.5 shrink-0"
|
|
68
|
+
}), /* @__PURE__ */ jsx("span", { children: error })]
|
|
69
|
+
}) : null
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { Field };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FillVariant, Role, Size } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
/** IconButton의 채움 방식. Button과 같은 4개를 받는다. */
|
|
4
|
+
type IconButtonVariant = Extract<FillVariant, "solid" | "subtle" | "outline" | "ghost">;
|
|
5
|
+
/** IconButton의 size. 공통 size 4개를 모두 받는다. 가장 작은 `xs`도 24×24px다. */
|
|
6
|
+
type IconButtonSize = Extract<Size, "xs" | "sm" | "md" | "lg">;
|
|
7
|
+
export interface IconButtonProps extends Omit<ComponentProps<"button">, "color" | "aria-label" | "children"> {
|
|
8
|
+
/** 버튼 이름. 아이콘만 보이므로 반드시 적는다(예: `"설정"`). 스크린 리더가 읽는다. */
|
|
9
|
+
"aria-label": string;
|
|
10
|
+
/** 아이콘. 장식용으로 숨겨지고 size에 맞는 크기가 된다. 다른 라이브러리의 아이콘도 받는다. */
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
/** 채움 방식. 기본값은 Button과 같은 `solid`다. 툴바·닫기 버튼처럼 약한 동작은 `ghost`를 쓴다. */
|
|
13
|
+
variant?: IconButtonVariant;
|
|
14
|
+
/**
|
|
15
|
+
* Role. Tide Role 6개와 앱이 `RoleRegistry`에 더한 Role을 받는다. 기본값은 `primary`다.
|
|
16
|
+
* 색 계열 이름(`blue` 등)은 받지 않는다. 행동의 뜻을 색으로 전하기 때문이다(ADR 0016).
|
|
17
|
+
* 루트에 `data-color`로 붙어 accent 색을 정한다. 바깥 요소의 색을 물려받지 않는다.
|
|
18
|
+
*/
|
|
19
|
+
color?: Role;
|
|
20
|
+
/** 정사각형 크기 `xs` 24px, `sm` 32px, `md` 40px, `lg` 48px. 아이콘은 16/16/20/24px다. 기본값은 `md`다. */
|
|
21
|
+
size?: IconButtonSize;
|
|
22
|
+
/**
|
|
23
|
+
* 로딩 중. 아이콘 자리에 Spinner를 보이고 클릭을 막는다.
|
|
24
|
+
* 포커스는 유지하고 `aria-disabled="true"`와 `data-loading`을 붙인다.
|
|
25
|
+
*/
|
|
26
|
+
loading?: boolean;
|
|
27
|
+
/** 로딩 중에 `aria-label` 뒤에 붙는 텍스트. 기본값은 `"로딩 중"`이다. */
|
|
28
|
+
loadingLabel?: string;
|
|
29
|
+
/** 누를 때 줄어드는 반응(scale 0.96)을 끈다. 움직임 줄이기 설정에서는 이 값과 관계없이 줄어들지 않는다. */
|
|
30
|
+
noPressScale?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* 다른 요소로 렌더한다. 예: `render={<Link href="/x" />}`.
|
|
33
|
+
* props, ref, className을 그 요소에 병합한다. `button`이 아닌 요소의 `disabled`는 `aria-disabled`로 바뀐다.
|
|
34
|
+
*/
|
|
35
|
+
render?: ReactElement;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 아이콘만 있는 버튼. `aria-label`이 필수라 이름이 빠지지 않는다.
|
|
39
|
+
* Button과 같은 채움 방식(`variant`) × Role(`color`) × `size` 축을 쓴다.
|
|
40
|
+
*/
|
|
41
|
+
export declare function IconButton({ variant, color, size, loading, loadingLabel, noPressScale, render, disabled, type, children, onClick, "aria-label": ariaLabel, ...props }: IconButtonProps): ReactElement;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { pressableBaseClass, pressableState } from "./pressable.js";
|
|
4
|
+
import { renderElement } from "./render.js";
|
|
5
|
+
import { Spinner } from "./Spinner.js";
|
|
6
|
+
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
|
|
8
|
+
// src/IconButton.tsx
|
|
9
|
+
/** 정사각형 크기(spec size 표의 높이). padding은 없고 아이콘을 가운데 둔다. 24×24px 밑으로 내려가지 않는다. */
|
|
10
|
+
const sizeClass = {
|
|
11
|
+
xs: "size-6",
|
|
12
|
+
sm: "size-8",
|
|
13
|
+
md: "size-10",
|
|
14
|
+
lg: "size-12"
|
|
15
|
+
};
|
|
16
|
+
/** 아이콘 크기(spec 아이콘 크기 규칙 「IconButton 아이콘」 열). 감싼 span과 Spinner에 쓴다. */
|
|
17
|
+
const iconSizeClass = {
|
|
18
|
+
xs: "size-4",
|
|
19
|
+
sm: "size-4",
|
|
20
|
+
md: "size-5",
|
|
21
|
+
lg: "size-6"
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* 아이콘만 있는 버튼. `aria-label`이 필수라 이름이 빠지지 않는다.
|
|
25
|
+
* Button과 같은 채움 방식(`variant`) × Role(`color`) × `size` 축을 쓴다.
|
|
26
|
+
*/
|
|
27
|
+
function IconButton({ variant = "solid", color = "primary", size = "md", loading = false, loadingLabel = "로딩 중", noPressScale = false, render, disabled = false, type, children, onClick, "aria-label": ariaLabel, ...props }) {
|
|
28
|
+
const state = pressableState({
|
|
29
|
+
variant,
|
|
30
|
+
color,
|
|
31
|
+
disabled,
|
|
32
|
+
loading,
|
|
33
|
+
noPressScale,
|
|
34
|
+
render,
|
|
35
|
+
type,
|
|
36
|
+
onClick,
|
|
37
|
+
ariaDisabled: props["aria-disabled"]
|
|
38
|
+
});
|
|
39
|
+
const { showSpinner } = state;
|
|
40
|
+
const icon = children ?? (render?.props)?.children;
|
|
41
|
+
const rootProps = {
|
|
42
|
+
...props,
|
|
43
|
+
...state.rootProps,
|
|
44
|
+
"aria-label": showSpinner ? `${ariaLabel} ${loadingLabel}` : ariaLabel,
|
|
45
|
+
children: showSpinner ? /* @__PURE__ */ jsx(Spinner, {
|
|
46
|
+
size,
|
|
47
|
+
"aria-hidden": "true",
|
|
48
|
+
className: iconSizeClass[size]
|
|
49
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
50
|
+
"aria-hidden": "true",
|
|
51
|
+
className: `inline-flex shrink-0 items-center justify-center *:size-full ${iconSizeClass[size]}`,
|
|
52
|
+
children: icon
|
|
53
|
+
})
|
|
54
|
+
};
|
|
55
|
+
return renderElement({
|
|
56
|
+
defaultElement: "button",
|
|
57
|
+
render,
|
|
58
|
+
baseClassName: `${pressableBaseClass} p-0 ${sizeClass[size]} ${state.className}`,
|
|
59
|
+
props: rootProps
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export { IconButton };
|
package/dist/Input.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Size } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement } from "react";
|
|
3
|
+
/** Input의 size. `xs`(24px)는 입력하기에 작아서 받지 않는다. */
|
|
4
|
+
type InputSize = Extract<Size, "sm" | "md" | "lg">;
|
|
5
|
+
export interface InputProps extends Omit<ComponentProps<"input">, "size"> {
|
|
6
|
+
/** 높이 `sm` 32px, `md` 40px, `lg` 48px. 같은 size의 Button과 높이가 같다. 기본값은 `md`다. */
|
|
7
|
+
size?: InputSize;
|
|
8
|
+
/**
|
|
9
|
+
* 오류 상태. `aria-invalid="true"`와 `data-invalid`가 붙고 경계가 danger 색이 된다.
|
|
10
|
+
* Field 안에서는 Field의 값을 따르고, 여기에 준 값이 이긴다.
|
|
11
|
+
*/
|
|
12
|
+
invalid?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 한 줄 텍스트를 입력하는 네이티브 `<input>`. `type`(`email`, `password`, `date` 등)을 그대로 받는다.
|
|
16
|
+
* Field 안에 두면 label, 설명, 오류 메시지가 자동으로 연결된다.
|
|
17
|
+
*/
|
|
18
|
+
export declare function Input({ size, invalid, id, disabled, required, className, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }: InputProps): ReactElement;
|
|
19
|
+
export {};
|
package/dist/Input.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { useFieldControl } from "./field-context.js";
|
|
5
|
+
import { inputBaseClass } from "./input-style.js";
|
|
6
|
+
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
|
|
8
|
+
// src/Input.tsx
|
|
9
|
+
/** 높이, 좌우 padding, 텍스트 스타일(spec size 표). radius는 모든 size에서 `md`다. */
|
|
10
|
+
const sizeClass = {
|
|
11
|
+
sm: "h-8 px-2.5 text-label-sm",
|
|
12
|
+
md: "h-10 px-3 text-label-md",
|
|
13
|
+
lg: "h-12 px-4 text-label-lg"
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* 한 줄 텍스트를 입력하는 네이티브 `<input>`. `type`(`email`, `password`, `date` 등)을 그대로 받는다.
|
|
17
|
+
* Field 안에 두면 label, 설명, 오류 메시지가 자동으로 연결된다.
|
|
18
|
+
*/
|
|
19
|
+
function Input({ size = "md", invalid, id, disabled, required, className, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }) {
|
|
20
|
+
const control = useFieldControl({
|
|
21
|
+
id,
|
|
22
|
+
invalid,
|
|
23
|
+
disabled,
|
|
24
|
+
required,
|
|
25
|
+
"aria-describedby": ariaDescribedBy,
|
|
26
|
+
"aria-invalid": ariaInvalid
|
|
27
|
+
});
|
|
28
|
+
return /* @__PURE__ */ jsx("input", {
|
|
29
|
+
...props,
|
|
30
|
+
...control,
|
|
31
|
+
className: cn(sizeClass[size], inputBaseClass, className)
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { Input };
|
package/dist/Link.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Role } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement } from "react";
|
|
3
|
+
export interface LinkProps extends Omit<ComponentProps<"a">, "color"> {
|
|
4
|
+
/**
|
|
5
|
+
* Role. Tide Role 6개와 앱이 `RoleRegistry`에 더한 Role을 받는다. 기본값은 `primary`다.
|
|
6
|
+
* 색 계열 이름(`blue` 등)은 받지 않는다. 행동의 뜻을 색으로 전하기 때문이다(ADR 0016).
|
|
7
|
+
* 루트에 `data-color`로 붙어 글자색(`text-accent`)을 정한다. 바깥 요소의 색을 물려받지 않는다.
|
|
8
|
+
*/
|
|
9
|
+
color?: Role;
|
|
10
|
+
/**
|
|
11
|
+
* 다른 요소로 렌더한다. 예: `render={<NextLink href="/x" />}`.
|
|
12
|
+
* props, ref, className을 그 요소에 병합한다.
|
|
13
|
+
*/
|
|
14
|
+
render?: ReactElement;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 문장 안에서 다른 페이지나 위치로 이동하는 텍스트 링크.
|
|
18
|
+
* Next.js에서는 `render={<NextLink href="/x" />}`로 클라이언트 이동을 쓴다.
|
|
19
|
+
*/
|
|
20
|
+
export declare function Link({ color, render, ...props }: LinkProps): ReactElement;
|
package/dist/Link.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { renderElement } from "./render.js";
|
|
2
|
+
|
|
3
|
+
// src/Link.tsx
|
|
4
|
+
const baseClass = "cursor-pointer rounded-sm text-accent underline decoration-1 underline-offset-2 hover:decoration-2 focus-visible:focus-ring";
|
|
5
|
+
/**
|
|
6
|
+
* 문장 안에서 다른 페이지나 위치로 이동하는 텍스트 링크.
|
|
7
|
+
* Next.js에서는 `render={<NextLink href="/x" />}`로 클라이언트 이동을 쓴다.
|
|
8
|
+
*/
|
|
9
|
+
function Link({ color = "primary", render, ...props }) {
|
|
10
|
+
return renderElement({
|
|
11
|
+
defaultElement: "a",
|
|
12
|
+
render,
|
|
13
|
+
baseClassName: baseClass,
|
|
14
|
+
props: {
|
|
15
|
+
...props,
|
|
16
|
+
"data-color": color
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { Link };
|
package/dist/Radio.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ChoiceSize } from "./choice-style.js";
|
|
2
|
+
import { Role } from "./types.js";
|
|
3
|
+
import { CSSProperties, ComponentProps, ReactElement, ReactNode } from "react";
|
|
4
|
+
export interface RadioProps extends Omit<ComponentProps<"input">, "type" | "size" | "color" | "children" | "className" | "style" | "value"> {
|
|
5
|
+
/** 이 라디오의 값. RadioGroup의 `value`·`onValueChange`가 이 값을 쓰고, form에 제출된다. */
|
|
6
|
+
value: string;
|
|
7
|
+
/** 원 옆에 보이는 label. 라디오의 이름이 된다. 없으면 `aria-label`을 준다. */
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
/** 원 16px·글자 14px(`sm`) 또는 원 20px·글자 16px(`md`). 누르는 영역은 둘 다 24×24px이다. 기본값은 `md`다. */
|
|
10
|
+
size?: ChoiceSize;
|
|
11
|
+
/** 선택했을 때 원 색(Role). 루트에 `data-color`로 붙는다. 기본값은 `primary`다. */
|
|
12
|
+
color?: Role;
|
|
13
|
+
/**
|
|
14
|
+
* 오류 상태. input에 `aria-invalid="true"`와 `data-invalid`가 붙고 경계가 danger 색이 된다.
|
|
15
|
+
* Field·RadioGroup 안에서는 그 값을 따르고, 여기에 준 값이 이긴다.
|
|
16
|
+
*/
|
|
17
|
+
invalid?: boolean;
|
|
18
|
+
/** 루트 label의 class. 사용자 class가 기본 class를 이긴다. 상자 모양은 바꾸지 않는다(`size`·`color`가 맡는다). */
|
|
19
|
+
className?: string;
|
|
20
|
+
/** 루트 label의 style */
|
|
21
|
+
style?: CSSProperties;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 네이티브 라디오 버튼. 보통 `RadioGroup` 안에 두고, 묶음이 `name`과 선택 값을 정한다.
|
|
25
|
+
* 묶음 밖에서는 `name`·`checked`/`defaultChecked`·`onChange`를 직접 준다.
|
|
26
|
+
*/
|
|
27
|
+
export declare function Radio({ value, children, size, color, invalid, id, disabled, required, name, checked, defaultChecked, onChange, className, style, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }: RadioProps): ReactElement;
|