@tide-tds/components 0.1.0 → 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/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/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/choice-group.js +34 -0
- package/dist/choice-style.d.ts +2 -0
- package/dist/choice-style.js +16 -0
- package/dist/field-context.js +26 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +12 -1
- package/dist/input-style.js +5 -0
- package/dist/pressable.js +65 -0
- package/dist/types.d.ts +8 -1
- package/package.json +4 -3
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;
|
package/dist/Radio.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { RadioGroupContext } 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 } from "react";
|
|
9
|
+
import { CircleIcon } from "@tide-tds/icons";
|
|
10
|
+
|
|
11
|
+
// src/Radio.tsx
|
|
12
|
+
const boxClass = {
|
|
13
|
+
sm: "size-4 rounded-full",
|
|
14
|
+
md: "size-5 rounded-full"
|
|
15
|
+
};
|
|
16
|
+
/** 가운데 점 크기(px). 원 아이콘을 채워 그리고 선 두께 2px이 더해진다. */
|
|
17
|
+
const dotSize = {
|
|
18
|
+
sm: 6,
|
|
19
|
+
md: 8
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* 네이티브 라디오 버튼. 보통 `RadioGroup` 안에 두고, 묶음이 `name`과 선택 값을 정한다.
|
|
23
|
+
* 묶음 밖에서는 `name`·`checked`/`defaultChecked`·`onChange`를 직접 준다.
|
|
24
|
+
*/
|
|
25
|
+
function Radio({ value, children, size = "md", color = "primary", invalid, id, disabled, required, name, checked, defaultChecked, onChange, className, style, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }) {
|
|
26
|
+
const group = use(RadioGroupContext);
|
|
27
|
+
const control = useFieldControl({
|
|
28
|
+
id,
|
|
29
|
+
invalid: invalid ?? group?.invalid,
|
|
30
|
+
disabled: disabled ?? group?.disabled,
|
|
31
|
+
required: required ?? (group?.required || void 0),
|
|
32
|
+
"aria-describedby": ariaDescribedBy,
|
|
33
|
+
"aria-invalid": ariaInvalid
|
|
34
|
+
});
|
|
35
|
+
const selection = group?.value !== void 0 ? { checked: group.value === value } : group?.defaultValue !== void 0 ? { defaultChecked: group.defaultValue === value } : checked !== void 0 ? { checked } : { defaultChecked };
|
|
36
|
+
const handleChange = (event) => {
|
|
37
|
+
onChange?.(event);
|
|
38
|
+
if (event.target.checked) group?.onValueChange?.(value);
|
|
39
|
+
};
|
|
40
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
41
|
+
"data-color": color,
|
|
42
|
+
"data-disabled": control.disabled ? "" : void 0,
|
|
43
|
+
"data-invalid": control["data-invalid"],
|
|
44
|
+
style,
|
|
45
|
+
className: cn(choiceRootClass, choiceTextClass[size], className),
|
|
46
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
47
|
+
className: choiceAreaClass,
|
|
48
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
49
|
+
...props,
|
|
50
|
+
...control,
|
|
51
|
+
...selection,
|
|
52
|
+
type: "radio",
|
|
53
|
+
name: name ?? group?.name,
|
|
54
|
+
value,
|
|
55
|
+
onChange: handleChange,
|
|
56
|
+
className: cn(boxClass[size], choiceInputClass)
|
|
57
|
+
}), /* @__PURE__ */ jsx(CircleIcon, {
|
|
58
|
+
size: dotSize[size],
|
|
59
|
+
fill: "currentColor",
|
|
60
|
+
"data-mark": "dot",
|
|
61
|
+
className: cn(choiceMarkClass, "peer-checked:block")
|
|
62
|
+
})]
|
|
63
|
+
}), children === void 0 || children === null ? null : /* @__PURE__ */ jsx("span", { children })]
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { Radio };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ChoiceOrientation } from "./CheckboxGroup.js";
|
|
2
|
+
import { ComponentProps, ReactElement } from "react";
|
|
3
|
+
export interface RadioGroupProps extends Omit<ComponentProps<"div">, "defaultValue"> {
|
|
4
|
+
/** 안의 Radio가 함께 쓰는 `name`. form에 이 이름으로 제출된다. 주지 않으면 자동으로 만든다. */
|
|
5
|
+
name?: string;
|
|
6
|
+
/** 선택된 Radio의 값(제어). `onValueChange`와 함께 쓴다. */
|
|
7
|
+
value?: string;
|
|
8
|
+
/** 처음 선택된 Radio의 값(비제어). */
|
|
9
|
+
defaultValue?: string;
|
|
10
|
+
/** 선택이 바뀌면 새 값 하나로 불린다. */
|
|
11
|
+
onValueChange?: (value: string) => void;
|
|
12
|
+
/** 항목을 세로(`vertical`)로 쌓거나 가로(`horizontal`)로 늘어놓는다. 기본값은 `vertical`이다. 화살표 키는 둘 다 동작한다. */
|
|
13
|
+
orientation?: ChoiceOrientation;
|
|
14
|
+
/**
|
|
15
|
+
* 오류 상태. 안의 Radio마다 `aria-invalid="true"`가 붙고, 묶음에 `data-invalid`가 붙는다.
|
|
16
|
+
* Field 안에서는 Field의 값을 따르고, 여기에 준 값이 이긴다.
|
|
17
|
+
*/
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
/** 안의 Radio를 모두 비활성으로 만든다. Field 안에서는 Field의 값을 따른다. */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** 하나를 꼭 골라야 한다. 안의 Radio에 네이티브 `required`가 붙고 묶음에 `aria-required`가 붙는다. */
|
|
22
|
+
required?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Radio 여러 개를 하나의 이름과 선택 값으로 묶는다. `<Field label group>` 안에 두면 label이 묶음의 이름,
|
|
26
|
+
* 설명·오류가 묶음의 설명이 된다. Field 밖에서는 `aria-label`이나 `aria-labelledby`를 준다.
|
|
27
|
+
*/
|
|
28
|
+
export declare function RadioGroup({ name, value, defaultValue, onValueChange, orientation, invalid, disabled, required, id, className, children, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...props }: RadioGroupProps): ReactElement;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { orientationClass } from "./CheckboxGroup.js";
|
|
4
|
+
import { RadioGroupContext, useChoiceGroup } from "./choice-group.js";
|
|
5
|
+
import { cn } from "./cn.js";
|
|
6
|
+
import { FieldContext } from "./field-context.js";
|
|
7
|
+
import { jsx } from "react/jsx-runtime";
|
|
8
|
+
import { useId } from "react";
|
|
9
|
+
|
|
10
|
+
// src/RadioGroup.tsx
|
|
11
|
+
/**
|
|
12
|
+
* Radio 여러 개를 하나의 이름과 선택 값으로 묶는다. `<Field label group>` 안에 두면 label이 묶음의 이름,
|
|
13
|
+
* 설명·오류가 묶음의 설명이 된다. Field 밖에서는 `aria-label`이나 `aria-labelledby`를 준다.
|
|
14
|
+
*/
|
|
15
|
+
function RadioGroup({ name, value, defaultValue, onValueChange, orientation = "vertical", invalid, disabled, required, id, className, children, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...props }) {
|
|
16
|
+
const autoName = useId();
|
|
17
|
+
const connection = useChoiceGroup({
|
|
18
|
+
id,
|
|
19
|
+
invalid,
|
|
20
|
+
disabled,
|
|
21
|
+
required,
|
|
22
|
+
"aria-labelledby": ariaLabelledBy,
|
|
23
|
+
"aria-describedby": ariaDescribedBy
|
|
24
|
+
});
|
|
25
|
+
const state = {
|
|
26
|
+
name: name ?? autoName,
|
|
27
|
+
value,
|
|
28
|
+
defaultValue,
|
|
29
|
+
onValueChange,
|
|
30
|
+
invalid: connection.invalid,
|
|
31
|
+
disabled: connection.disabled,
|
|
32
|
+
required: connection.required
|
|
33
|
+
};
|
|
34
|
+
return /* @__PURE__ */ jsx("div", {
|
|
35
|
+
role: "radiogroup",
|
|
36
|
+
"aria-required": connection.required || void 0,
|
|
37
|
+
...props,
|
|
38
|
+
...connection.rootProps,
|
|
39
|
+
className: cn(orientationClass[orientation], className),
|
|
40
|
+
children: /* @__PURE__ */ jsx(FieldContext, {
|
|
41
|
+
value: null,
|
|
42
|
+
children: /* @__PURE__ */ jsx(RadioGroupContext, {
|
|
43
|
+
value: state,
|
|
44
|
+
children
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { RadioGroup };
|
package/dist/Switch.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Role, Size } from "./types.js";
|
|
2
|
+
import { CSSProperties, ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
/** Switch의 size. `xs`는 track이 너무 작아 받지 않는다. */
|
|
4
|
+
type SwitchSize = Extract<Size, "sm" | "md" | "lg">;
|
|
5
|
+
export interface SwitchProps extends Omit<ComponentProps<"input">, "type" | "size" | "color" | "role" | "children"> {
|
|
6
|
+
/** 스위치 옆에 보이는 label. 스위치의 이름이 된다. Field 안에서는 Field의 `label`을 쓰고 비워 둔다. */
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
/** track 크기 `sm` 28×16px, `md` 36×20px, `lg` 44×24px. label 텍스트 스타일도 함께 바뀐다. 기본값은 `md`다. */
|
|
9
|
+
size?: SwitchSize;
|
|
10
|
+
/**
|
|
11
|
+
* 켜졌을 때 track 색. Role(Tide Role 6개와 앱이 `RoleRegistry`에 더한 Role)을 받는다. 기본값은 `primary`다.
|
|
12
|
+
* 루트에 `data-color`로 붙어 accent 색을 정한다. 바깥 요소의 색을 물려받지 않는다.
|
|
13
|
+
*/
|
|
14
|
+
color?: Role;
|
|
15
|
+
/**
|
|
16
|
+
* 오류 상태. `aria-invalid="true"`와 `data-invalid`가 붙고 꺼진 track 경계가 danger 색이 된다.
|
|
17
|
+
* Field 안에서는 Field의 값을 따르고, 여기에 준 값이 이긴다.
|
|
18
|
+
*/
|
|
19
|
+
invalid?: boolean;
|
|
20
|
+
/** 루트 `<label>`의 class. 기본 class를 이긴다. track과 thumb 모양은 바꾸지 않는다. */
|
|
21
|
+
className?: string;
|
|
22
|
+
/** 루트 `<label>`의 style */
|
|
23
|
+
style?: CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 켜고 끄는 설정을 바꾸는 스위치. 네이티브 체크박스라 form 제출이 그대로 되고, 스크린리더는 「스위치」로 읽는다.
|
|
27
|
+
* `children`이 label이 된다. Field 안에 두면 Field의 label·설명·오류가 연결된다.
|
|
28
|
+
*/
|
|
29
|
+
export declare function Switch({ children, size, color, invalid, id, disabled, required, className, style, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }: SwitchProps): ReactElement;
|
|
30
|
+
export {};
|
package/dist/Switch.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { useFieldControl } from "./field-context.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
|
|
7
|
+
// src/Switch.tsx
|
|
8
|
+
/** 루트 높이는 최소 24px라 track이 작아도 누르는 영역이 24×24px 이상이다. */
|
|
9
|
+
const rootSizeClass = {
|
|
10
|
+
sm: "gap-1.5 text-label-sm",
|
|
11
|
+
md: "gap-2 text-label-md",
|
|
12
|
+
lg: "gap-2.5 text-label-lg"
|
|
13
|
+
};
|
|
14
|
+
/** track 크기(border 1px 포함). thumb은 track 안쪽에서 2px 떨어진다. */
|
|
15
|
+
const trackSizeClass = {
|
|
16
|
+
sm: "h-4 w-7",
|
|
17
|
+
md: "h-5 w-9",
|
|
18
|
+
lg: "h-6 w-11"
|
|
19
|
+
};
|
|
20
|
+
/** thumb 크기와 켜졌을 때 이동 거리(track 폭 - border 2px - 여백 4px - thumb 크기). RTL에서는 반대로 움직인다. */
|
|
21
|
+
const thumbSizeClass = {
|
|
22
|
+
sm: "size-2.5 peer-checked:translate-x-3 rtl:peer-checked:-translate-x-3",
|
|
23
|
+
md: "size-3.5 peer-checked:translate-x-4 rtl:peer-checked:-translate-x-4",
|
|
24
|
+
lg: "size-4.5 peer-checked:translate-x-5 rtl:peer-checked:-translate-x-5"
|
|
25
|
+
};
|
|
26
|
+
const trackClass = "peer m-0 shrink-0 cursor-pointer appearance-none rounded-full border border-strong bg-transparent transition-colors aria-invalid:border-danger focus-visible:focus-ring checked:enabled:border-accent checked:enabled:bg-accent-solid group-hover/switch:checked:enabled:bg-accent-solid-hover group-active/switch:checked:enabled:bg-accent-solid-active group-hover/switch:not-checked:enabled:bg-accent-subtle group-active/switch:not-checked:enabled:bg-accent-subtle-active disabled:cursor-not-allowed disabled:border-default disabled:bg-disabled";
|
|
27
|
+
const thumbClass = "pointer-events-none absolute start-0.75 top-0.75 rounded-full bg-current text-muted shadow-sm transition-colors motion-safe:transition-[color,background-color,translate] peer-checked:peer-enabled:text-accent-on-solid group-hover/switch:peer-enabled:peer-not-checked:text-accent group-active/switch:peer-enabled:peer-not-checked:text-accent peer-disabled:text-disabled";
|
|
28
|
+
const trackForcedClass = "forced-colors:forced-color-adjust-none forced-colors:[--tide-border-strong:CanvasText] forced-colors:[--tide-border-danger:CanvasText] forced-colors:[--tide-accent-border:Highlight] forced-colors:[--tide-accent-solid:Highlight] forced-colors:[--tide-accent-solid-hover:Highlight] forced-colors:[--tide-accent-solid-active:Highlight] forced-colors:[--tide-accent-subtle:Canvas] forced-colors:[--tide-accent-subtle-active:Canvas] forced-colors:[--tide-border-default:GrayText] forced-colors:[--tide-bg-disabled:Canvas] forced-colors:[--tide-outline-focus:Highlight]";
|
|
29
|
+
const thumbForcedClass = "forced-colors:forced-color-adjust-none forced-colors:[--tide-text-muted:CanvasText] forced-colors:[--tide-accent-text:CanvasText] forced-colors:[--tide-accent-on-solid:HighlightText] forced-colors:[--tide-text-disabled:GrayText]";
|
|
30
|
+
/**
|
|
31
|
+
* 켜고 끄는 설정을 바꾸는 스위치. 네이티브 체크박스라 form 제출이 그대로 되고, 스크린리더는 「스위치」로 읽는다.
|
|
32
|
+
* `children`이 label이 된다. Field 안에 두면 Field의 label·설명·오류가 연결된다.
|
|
33
|
+
*/
|
|
34
|
+
function Switch({ children, size = "md", color = "primary", invalid, id, disabled, required, className, style, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }) {
|
|
35
|
+
const control = useFieldControl({
|
|
36
|
+
id,
|
|
37
|
+
invalid,
|
|
38
|
+
disabled,
|
|
39
|
+
required,
|
|
40
|
+
"aria-describedby": ariaDescribedBy,
|
|
41
|
+
"aria-invalid": ariaInvalid
|
|
42
|
+
});
|
|
43
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
44
|
+
"data-color": color,
|
|
45
|
+
"data-disabled": control.disabled ? "" : void 0,
|
|
46
|
+
"data-invalid": control["data-invalid"],
|
|
47
|
+
style,
|
|
48
|
+
className: cn("group/switch inline-flex min-h-6 cursor-pointer items-center text-default select-none has-disabled:cursor-not-allowed has-disabled:text-disabled", rootSizeClass[size], className),
|
|
49
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
50
|
+
className: "relative inline-flex shrink-0",
|
|
51
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
52
|
+
...props,
|
|
53
|
+
...control,
|
|
54
|
+
type: "checkbox",
|
|
55
|
+
role: "switch",
|
|
56
|
+
className: cn(trackSizeClass[size], trackClass, trackForcedClass)
|
|
57
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
58
|
+
"aria-hidden": "true",
|
|
59
|
+
className: cn(thumbSizeClass[size], thumbClass, thumbForcedClass)
|
|
60
|
+
})]
|
|
61
|
+
}), children != null && children !== false ? /* @__PURE__ */ jsx("span", { children }) : null]
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { Switch };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Size } from "./types.js";
|
|
2
|
+
import { ComponentProps, ReactElement } from "react";
|
|
3
|
+
/** Textarea의 size. Input과 같이 `sm`/`md`/`lg`만 받는다. */
|
|
4
|
+
type TextareaSize = Extract<Size, "sm" | "md" | "lg">;
|
|
5
|
+
export interface TextareaProps extends ComponentProps<"textarea"> {
|
|
6
|
+
/** 글자 크기와 padding. 글자 크기는 같은 size의 Input과 같다(12/14/16px). 기본값은 `md`다. */
|
|
7
|
+
size?: TextareaSize;
|
|
8
|
+
/**
|
|
9
|
+
* 오류 상태. `aria-invalid="true"`와 `data-invalid`가 붙고 경계가 danger 색이 된다.
|
|
10
|
+
* Field 안에서는 Field의 값을 따르고, 여기에 준 값이 이긴다.
|
|
11
|
+
*/
|
|
12
|
+
invalid?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 여러 줄 텍스트를 입력하는 네이티브 `<textarea>`. 기본 3줄 높이이고 세로로만 크기를 바꿀 수 있다.
|
|
16
|
+
* Field 안에 두면 label, 설명, 오류 메시지가 자동으로 연결된다.
|
|
17
|
+
*/
|
|
18
|
+
export declare function Textarea({ size, invalid, id, disabled, required, rows, className, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }: TextareaProps): ReactElement;
|
|
19
|
+
export {};
|
package/dist/Textarea.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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/Textarea.tsx
|
|
9
|
+
/** 좌우·세로 padding, 텍스트 스타일 */
|
|
10
|
+
const sizeClass = {
|
|
11
|
+
sm: "px-2.5 py-1.5 text-caption",
|
|
12
|
+
md: "px-3 py-2 text-body-sm",
|
|
13
|
+
lg: "px-4 py-3 text-body-md"
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* 여러 줄 텍스트를 입력하는 네이티브 `<textarea>`. 기본 3줄 높이이고 세로로만 크기를 바꿀 수 있다.
|
|
17
|
+
* Field 안에 두면 label, 설명, 오류 메시지가 자동으로 연결된다.
|
|
18
|
+
*/
|
|
19
|
+
function Textarea({ size = "md", invalid, id, disabled, required, rows = 3, 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("textarea", {
|
|
29
|
+
...props,
|
|
30
|
+
...control,
|
|
31
|
+
rows,
|
|
32
|
+
className: cn(sizeClass[size], inputBaseClass, "resize-y", className)
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { Textarea };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { FieldContext } from "./field-context.js";
|
|
4
|
+
import { createContext, use } from "react";
|
|
5
|
+
|
|
6
|
+
// src/choice-group.ts
|
|
7
|
+
/** CheckboxGroup 안에서만 값이 있다. */
|
|
8
|
+
const CheckboxGroupContext = createContext(null);
|
|
9
|
+
/** RadioGroup 안에서만 값이 있다. */
|
|
10
|
+
const RadioGroupContext = createContext(null);
|
|
11
|
+
/**
|
|
12
|
+
* Field 연결 정보와 묶음 자신의 props를 합친다. 묶음에 직접 준 값이 Field 값을 이긴다(useFieldControl과 같은 규칙).
|
|
13
|
+
* `aria-labelledby`를 직접 주면 Field label 대신 그것을 쓴다. `aria-describedby`는 Field 것 뒤에 이어 붙인다.
|
|
14
|
+
*/
|
|
15
|
+
function useChoiceGroup(props) {
|
|
16
|
+
const field = use(FieldContext);
|
|
17
|
+
const invalid = props.invalid ?? field?.invalid ?? false;
|
|
18
|
+
const disabled = props.disabled ?? field?.disabled ?? false;
|
|
19
|
+
const required = props.required ?? field?.required ?? false;
|
|
20
|
+
return {
|
|
21
|
+
rootProps: {
|
|
22
|
+
id: props.id ?? field?.controlId,
|
|
23
|
+
"aria-labelledby": props["aria-labelledby"] ?? field?.labelId,
|
|
24
|
+
"aria-describedby": [field?.describedBy, props["aria-describedby"]].filter(Boolean).join(" ") || void 0,
|
|
25
|
+
"data-invalid": invalid ? "" : void 0,
|
|
26
|
+
"data-disabled": disabled ? "" : void 0
|
|
27
|
+
},
|
|
28
|
+
invalid,
|
|
29
|
+
disabled,
|
|
30
|
+
required
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { CheckboxGroupContext, RadioGroupContext, useChoiceGroup };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/choice-style.ts
|
|
2
|
+
/** 루트 label. 글자 크기 class와 사용자 className은 뒤에 붙인다. */
|
|
3
|
+
const choiceRootClass = "group/choice inline-flex items-start gap-2 align-top text-default cursor-pointer has-disabled:cursor-not-allowed has-disabled:text-disabled";
|
|
4
|
+
/** 상자와 아이콘을 겹쳐 두는 24×24 조작 영역 */
|
|
5
|
+
const choiceAreaClass = "grid size-6 shrink-0 place-items-center";
|
|
6
|
+
/** 네이티브 input을 상자로 그린다. 모양(rounded)과 크기 class는 Component가 앞에 붙인다. */
|
|
7
|
+
const choiceInputClass = "peer col-start-1 row-start-1 m-0 appearance-none border border-strong bg-canvas cursor-pointer transition-colors checked:border-accent checked:bg-accent-solid enabled:not-checked:not-indeterminate:group-hover/choice:bg-accent-subtle enabled:checked:group-hover/choice:bg-accent-solid-hover indeterminate:border-accent indeterminate:bg-accent-solid enabled:indeterminate:group-hover/choice:bg-accent-solid-hover enabled:not-checked:not-indeterminate:group-active/choice:bg-accent-subtle-active enabled:checked:group-active/choice:bg-accent-solid-active enabled:indeterminate:group-active/choice:bg-accent-solid-active aria-invalid:border-danger focus-visible:focus-ring disabled:cursor-not-allowed disabled:border-default disabled:bg-disabled forced-colors:forced-color-adjust-none forced-colors:[--tide-bg-canvas:Canvas] forced-colors:[--tide-border-strong:CanvasText] forced-colors:[--tide-border-danger:CanvasText] forced-colors:[--tide-accent-border:Highlight] forced-colors:[--tide-accent-solid:Highlight] forced-colors:[--tide-accent-solid-hover:Highlight] forced-colors:[--tide-accent-solid-active:Highlight] forced-colors:[--tide-accent-subtle:Canvas] forced-colors:[--tide-accent-subtle-active:Canvas] forced-colors:[--tide-border-default:GrayText] forced-colors:[--tide-bg-disabled:Canvas] forced-colors:[--tide-outline-focus:Highlight]";
|
|
8
|
+
/** 상자 위에 겹치는 표시 아이콘. 누름은 input이 받는다. 보이는 조건 class는 Component가 붙인다. */
|
|
9
|
+
const choiceMarkClass = "pointer-events-none col-start-1 row-start-1 hidden text-accent-on-solid peer-disabled:text-disabled forced-colors:forced-color-adjust-none forced-colors:[--tide-accent-on-solid:HighlightText] forced-colors:[--tide-text-disabled:GrayText]";
|
|
10
|
+
/** 글자 스타일. md 행간 24px은 조작 영역과 같고, sm 행간 20px은 위아래 2px을 둬 첫 줄을 상자 가운데에 맞춘다. */
|
|
11
|
+
const choiceTextClass = {
|
|
12
|
+
sm: "py-0.5 text-body-sm",
|
|
13
|
+
md: "text-body-md"
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { choiceAreaClass, choiceInputClass, choiceMarkClass, choiceRootClass, choiceTextClass };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, use } from "react";
|
|
4
|
+
|
|
5
|
+
// src/field-context.ts
|
|
6
|
+
/** Field 밖에서는 `null`이다. */
|
|
7
|
+
const FieldContext = createContext(null);
|
|
8
|
+
/**
|
|
9
|
+
* Field 연결 정보와 입력 Component 자신의 props를 합친다.
|
|
10
|
+
* 입력 Component에 직접 준 값(id, disabled 등)이 Field 값보다 이긴다. `aria-describedby`는 Field 것 뒤에 이어 붙인다.
|
|
11
|
+
*/
|
|
12
|
+
function useFieldControl(props) {
|
|
13
|
+
const field = use(FieldContext);
|
|
14
|
+
const invalid = props.invalid ?? field?.invalid ?? false;
|
|
15
|
+
const describedBy = [field?.describedBy, props["aria-describedby"]].filter(Boolean).join(" ") || void 0;
|
|
16
|
+
return {
|
|
17
|
+
id: props.id ?? field?.controlId,
|
|
18
|
+
"aria-describedby": describedBy,
|
|
19
|
+
"aria-invalid": invalid ? true : props["aria-invalid"],
|
|
20
|
+
"data-invalid": invalid ? "" : void 0,
|
|
21
|
+
disabled: (props.disabled ?? field?.disabled) || void 0,
|
|
22
|
+
required: (props.required ?? field?.required) || void 0
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { FieldContext, useFieldControl };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,5 +2,16 @@ import { cn } from "./cn.js";
|
|
|
2
2
|
import { VisuallyHidden, VisuallyHiddenProps } from "./VisuallyHidden.js";
|
|
3
3
|
import { Typography, TypographyProps } from "./Typography.js";
|
|
4
4
|
import { Spinner, SpinnerProps } from "./Spinner.js";
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import { Button, ButtonProps } from "./Button.js";
|
|
6
|
+
import { IconButton, IconButtonProps } from "./IconButton.js";
|
|
7
|
+
import { Link, LinkProps } from "./Link.js";
|
|
8
|
+
import { Field, FieldProps } from "./Field.js";
|
|
9
|
+
import { Input, InputProps } from "./Input.js";
|
|
10
|
+
import { Textarea, TextareaProps } from "./Textarea.js";
|
|
11
|
+
import { Checkbox, CheckboxProps } from "./Checkbox.js";
|
|
12
|
+
import { CheckboxGroup, CheckboxGroupProps, ChoiceOrientation } from "./CheckboxGroup.js";
|
|
13
|
+
import { Radio, RadioProps } from "./Radio.js";
|
|
14
|
+
import { RadioGroup, RadioGroupProps } from "./RadioGroup.js";
|
|
15
|
+
import { Switch, SwitchProps } from "./Switch.js";
|
|
16
|
+
import { Color, ColorFamilyName, ColorFamilyRegistry, FillVariant, Role, RoleRegistry, Size } from "./types.js";
|
|
17
|
+
export { Button, type ButtonProps, 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, type Size, Spinner, type SpinnerProps, Switch, type SwitchProps, Textarea, type TextareaProps, Typography, type TypographyProps, VisuallyHidden, type VisuallyHiddenProps, cn };
|
package/dist/index.js
CHANGED
|
@@ -2,5 +2,16 @@ import { cn } from "./cn.js";
|
|
|
2
2
|
import { VisuallyHidden } from "./VisuallyHidden.js";
|
|
3
3
|
import { Typography } from "./Typography.js";
|
|
4
4
|
import { Spinner } from "./Spinner.js";
|
|
5
|
+
import { Button } from "./Button.js";
|
|
6
|
+
import { IconButton } from "./IconButton.js";
|
|
7
|
+
import { Link } from "./Link.js";
|
|
8
|
+
import { Field } from "./Field.js";
|
|
9
|
+
import { Input } from "./Input.js";
|
|
10
|
+
import { Textarea } from "./Textarea.js";
|
|
11
|
+
import { Checkbox } from "./Checkbox.js";
|
|
12
|
+
import { CheckboxGroup } from "./CheckboxGroup.js";
|
|
13
|
+
import { Radio } from "./Radio.js";
|
|
14
|
+
import { RadioGroup } from "./RadioGroup.js";
|
|
15
|
+
import { Switch } from "./Switch.js";
|
|
5
16
|
|
|
6
|
-
export { Spinner, Typography, VisuallyHidden, cn };
|
|
17
|
+
export { Button, Checkbox, CheckboxGroup, Field, IconButton, Input, Link, Radio, RadioGroup, Spinner, Switch, Textarea, Typography, VisuallyHidden, cn };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// src/input-style.ts
|
|
2
|
+
/** 입력창의 공통 모양. size class 뒤에 넣는다. */
|
|
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
|
+
|
|
5
|
+
export { inputBaseClass };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/pressable.ts
|
|
2
|
+
/** 기본 색(배경, 글자, 경계). 색 이름마다 Foundation이 같은 대비 규칙으로 검사한다. */
|
|
3
|
+
const colorClass = {
|
|
4
|
+
solid: "border-transparent bg-accent-solid text-accent-on-solid",
|
|
5
|
+
subtle: "border-transparent bg-accent-subtle text-accent",
|
|
6
|
+
outline: "border-accent text-accent",
|
|
7
|
+
ghost: "border-transparent text-accent"
|
|
8
|
+
};
|
|
9
|
+
/** hover·누름 색. 활성 상태일 때만 붙인다(비활성·로딩에는 붙이지 않아 CSS 순서에 기대지 않는다). */
|
|
10
|
+
const interactionClass = {
|
|
11
|
+
solid: "hover:bg-accent-solid-hover active:bg-accent-solid-active",
|
|
12
|
+
subtle: "hover:bg-accent-subtle-hover active:bg-accent-subtle-active",
|
|
13
|
+
outline: "hover:bg-accent-subtle-hover active:bg-accent-subtle-active",
|
|
14
|
+
ghost: "hover:bg-accent-subtle-hover active:bg-accent-subtle-active"
|
|
15
|
+
};
|
|
16
|
+
/** 비활성 색. 색 이름과 관계없이 같다. disabled Token은 대비 검사 대상이 아니다. */
|
|
17
|
+
const disabledClass = {
|
|
18
|
+
solid: "cursor-not-allowed border-transparent bg-disabled text-disabled",
|
|
19
|
+
subtle: "cursor-not-allowed border-transparent bg-disabled text-disabled",
|
|
20
|
+
outline: "cursor-not-allowed border-default text-disabled",
|
|
21
|
+
ghost: "cursor-not-allowed border-transparent text-disabled"
|
|
22
|
+
};
|
|
23
|
+
/** 모양의 공통 부분. radius는 모든 size에서 `md`다. 높이·폭·padding은 Component가 정한다. */
|
|
24
|
+
const pressableBaseClass = "inline-flex shrink-0 items-center justify-center rounded-md border whitespace-nowrap select-none transition-colors focus-visible:focus-ring";
|
|
25
|
+
const pressScaleClass = "motion-safe:transition-[color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,scale] motion-safe:active:scale-96";
|
|
26
|
+
/** `disabled`를 네이티브로 지원하는 요소인지. Button류가 렌더할 만한 요소 중에서는 `button`뿐이다. */
|
|
27
|
+
const supportsNativeDisabled = (render) => render === void 0 || render.type === "button";
|
|
28
|
+
/**
|
|
29
|
+
* 채움 방식·색·상태에서 class와 루트 props를 정한다.
|
|
30
|
+
* - disabled: 기본 요소와 `render={<button />}`는 네이티브 `disabled`, 그 밖의 요소는 `aria-disabled`·`data-disabled`와 클릭 차단
|
|
31
|
+
* - loading: 포커스를 남긴 채 `aria-disabled`·`data-loading`과 클릭 차단. disabled와 함께 오면 disabled가 이긴다
|
|
32
|
+
* - 커서: 활성 pointer, 로딩 wait, 비활성 not-allowed
|
|
33
|
+
* - hover·누름 색과 누름 축소는 활성 상태에서만 붙인다
|
|
34
|
+
*/
|
|
35
|
+
function pressableState({ variant, color, disabled, loading, noPressScale, render, type, onClick, ariaDisabled }) {
|
|
36
|
+
const native = supportsNativeDisabled(render);
|
|
37
|
+
const softDisabled = disabled && !native;
|
|
38
|
+
const blocked = softDisabled || loading && !disabled;
|
|
39
|
+
const interactive = !disabled && !loading;
|
|
40
|
+
const showSpinner = loading && !disabled;
|
|
41
|
+
return {
|
|
42
|
+
className: [
|
|
43
|
+
disabled ? disabledClass[variant] : colorClass[variant],
|
|
44
|
+
interactive ? interactionClass[variant] : void 0,
|
|
45
|
+
interactive && !noPressScale ? pressScaleClass : void 0,
|
|
46
|
+
interactive ? "cursor-pointer" : void 0,
|
|
47
|
+
showSpinner ? "cursor-wait" : void 0
|
|
48
|
+
].filter(Boolean).join(" "),
|
|
49
|
+
showSpinner,
|
|
50
|
+
rootProps: {
|
|
51
|
+
type: render === void 0 ? type ?? "button" : type,
|
|
52
|
+
disabled: native && disabled ? true : void 0,
|
|
53
|
+
"data-color": color,
|
|
54
|
+
"aria-disabled": blocked ? true : ariaDisabled,
|
|
55
|
+
"data-disabled": softDisabled ? "" : void 0,
|
|
56
|
+
"data-loading": showSpinner ? "" : void 0,
|
|
57
|
+
onClick: blocked ? (event) => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
event.stopPropagation();
|
|
60
|
+
} : onClick
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { pressableBaseClass, pressableState };
|
package/dist/types.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
|
+
import { Color, ColorFamilyName, ColorFamilyRegistry, Role, RoleRegistry } from "@tide-tds/foundation";
|
|
1
2
|
/** Component size. 높이는 `xs` 24px, `sm` 32px, `md` 40px, `lg` 48px다(spec size 표). */
|
|
2
|
-
export type Size = "xs" | "sm" | "md" | "lg";
|
|
3
|
+
export type Size = "xs" | "sm" | "md" | "lg";
|
|
4
|
+
/**
|
|
5
|
+
* Button류의 채움 방식. `solid`·`subtle`은 Token의 강도와 이름이 같고, `outline`·`ghost`는 Component에만 있다.
|
|
6
|
+
* 스타일 축 이름이 `variant`라도 값 집합은 채움 방식이라 `Variant`가 아닌 이 이름을 쓴다(GLOSSARY 「채움 방식」).
|
|
7
|
+
*/
|
|
8
|
+
export type FillVariant = "solid" | "subtle" | "outline" | "ghost";
|
|
9
|
+
export type { Color, ColorFamilyName, ColorFamilyRegistry, Role, RoleRegistry };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tide-tds/components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Tide 디자인 시스템의 React Component",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"./styles.css": "./dist/styles.css"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@tide-tds/foundation": "0.
|
|
27
|
-
"@tide-tds/icons": "0.
|
|
26
|
+
"@tide-tds/foundation": "0.2.0",
|
|
27
|
+
"@tide-tds/icons": "0.2.0",
|
|
28
28
|
"clsx": "^2.1.1",
|
|
29
29
|
"react-aria": "^3.53.1",
|
|
30
30
|
"react-stately": "^3.51.0",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"@types/react": "^19.3.0",
|
|
37
37
|
"@types/react-dom": "^19.3.0",
|
|
38
38
|
"@vitest/browser-playwright": "^5.1.0",
|
|
39
|
+
"next": "^16.4.5",
|
|
39
40
|
"playwright": "^1.65.0",
|
|
40
41
|
"react": "^19.3.1",
|
|
41
42
|
"react-dom": "^19.3.1",
|