@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/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 };
@@ -0,0 +1,20 @@
1
+ import { Size } from "./types.js";
2
+ import { ComponentProps, ReactElement } from "react";
3
+ /** Spinner의 size. 공통 size 4개를 모두 받는다. */
4
+ type SpinnerSize = Extract<Size, "xs" | "sm" | "md" | "lg">;
5
+ export interface SpinnerProps extends Omit<ComponentProps<"svg">, "children"> {
6
+ /**
7
+ * 같은 size의 Component(Button 등) 안 텍스트 옆 아이콘과 같은 크기다.
8
+ * `xs`·`sm` 14px, `md` 16px, `lg` 20px. 기본값은 `md`다.
9
+ */
10
+ size?: SpinnerSize;
11
+ /** 접근 가능한 이름. 기본값은 `"로딩 중"`이다. 무엇을 불러오는지 적으면 더 좋다. */
12
+ "aria-label"?: string;
13
+ }
14
+ /**
15
+ * 로딩 중임을 알리는 회전 표시. 역할은 `status`이고 접근 가능한 이름이 기본으로 붙는다.
16
+ * 회전은 keyframe(`animate-spin`)이고 움직임 줄이기에서도 멈추지 않는다(로딩 여부 자체가 정보다).
17
+ * 색은 `currentColor`라 주변 글자색을 따른다. 서버 컴포넌트로 쓸 수 있다.
18
+ */
19
+ export declare function Spinner({ size, className, "aria-label": ariaLabel, ...props }: SpinnerProps): ReactElement;
20
+ export {};
@@ -0,0 +1,35 @@
1
+ import { cn } from "./cn.js";
2
+ import { jsx } from "react/jsx-runtime";
3
+
4
+ // src/Spinner.tsx
5
+ const sizeClass = {
6
+ xs: "size-3.5",
7
+ sm: "size-3.5",
8
+ md: "size-4",
9
+ lg: "size-5"
10
+ };
11
+ /**
12
+ * 로딩 중임을 알리는 회전 표시. 역할은 `status`이고 접근 가능한 이름이 기본으로 붙는다.
13
+ * 회전은 keyframe(`animate-spin`)이고 움직임 줄이기에서도 멈추지 않는다(로딩 여부 자체가 정보다).
14
+ * 색은 `currentColor`라 주변 글자색을 따른다. 서버 컴포넌트로 쓸 수 있다.
15
+ */
16
+ function Spinner({ size = "md", className, "aria-label": ariaLabel = "로딩 중", ...props }) {
17
+ return /* @__PURE__ */ jsx("svg", {
18
+ role: "status",
19
+ "aria-label": ariaLabel,
20
+ viewBox: "0 0 24 24",
21
+ fill: "none",
22
+ focusable: "false",
23
+ ...props,
24
+ className: cn("shrink-0 animate-spin", sizeClass[size], className),
25
+ children: /* @__PURE__ */ jsx("path", {
26
+ d: "M12 3a9 9 0 1 1-9 9",
27
+ stroke: "currentColor",
28
+ strokeWidth: 2,
29
+ strokeLinecap: "round",
30
+ vectorEffect: "non-scaling-stroke"
31
+ })
32
+ });
33
+ }
34
+
35
+ export { Spinner };
@@ -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 {};
@@ -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,36 @@
1
+ import { HTMLAttributes, ReactElement, Ref } from "react";
2
+ import { TextStyleName } from "@tide-tds/foundation";
3
+ /** heading·display 텍스트 스타일. 항상 `<h*>`로 렌더되고 `level`을 받는다. */
4
+ type HeadingVariant = Extract<TextStyleName, `display-${string}` | `heading-${string}`>;
5
+ /** heading·display가 아닌 텍스트 스타일 */
6
+ type TextVariant = Exclude<TextStyleName, HeadingVariant>;
7
+ /** 제목 단계 */
8
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
9
+ interface TypographyBaseProps extends HTMLAttributes<HTMLElement> {
10
+ /** 루트 DOM 요소에 연결한다. */
11
+ ref?: Ref<HTMLElement>;
12
+ /**
13
+ * 다른 요소로 렌더한다. 예: `render={<label htmlFor="name" />}`.
14
+ * props, ref, className을 그 요소에 병합하고 `level`과 기본 요소는 무시한다.
15
+ */
16
+ render?: ReactElement;
17
+ }
18
+ interface TypographyHeadingProps extends TypographyBaseProps {
19
+ /** 텍스트 스타일 이름 */
20
+ variant: HeadingVariant;
21
+ /** 제목 단계. 주지 않으면 텍스트 스타일의 기본 단계를 쓴다. */
22
+ level?: HeadingLevel;
23
+ }
24
+ interface TypographyTextProps extends TypographyBaseProps {
25
+ /** 텍스트 스타일 이름. 기본값은 `body-md`다. */
26
+ variant?: TextVariant;
27
+ /** heading·display 텍스트 스타일에만 있다. */
28
+ level?: never;
29
+ }
30
+ export type TypographyProps = TypographyHeadingProps | TypographyTextProps;
31
+ /**
32
+ * 텍스트 스타일 하나로 크기, 행간, 굵기를 정하고 알맞은 시맨틱 요소로 렌더한다.
33
+ * heading·display는 항상 `<h*>`다. 다른 요소가 필요하면 `render`를 쓴다. 서버 컴포넌트로 쓸 수 있다.
34
+ */
35
+ export declare function Typography({ variant, level, render, ...props }: TypographyProps): ReactElement;
36
+ export {};
@@ -0,0 +1,50 @@
1
+ import { renderElement } from "./render.js";
2
+
3
+ // src/Typography.tsx
4
+ const variantClass = {
5
+ "display-lg": "text-display-lg",
6
+ "display-md": "text-display-md",
7
+ "heading-xl": "text-heading-xl",
8
+ "heading-lg": "text-heading-lg",
9
+ "heading-md": "text-heading-md",
10
+ "heading-sm": "text-heading-sm",
11
+ "heading-xs": "text-heading-xs",
12
+ "body-lg": "text-body-lg",
13
+ "body-md": "text-body-md",
14
+ "body-sm": "text-body-sm",
15
+ "label-lg": "text-label-lg",
16
+ "label-md": "text-label-md",
17
+ "label-sm": "text-label-sm",
18
+ caption: "text-caption"
19
+ };
20
+ const elementByVariant = {
21
+ "display-lg": 1,
22
+ "display-md": 1,
23
+ "heading-xl": 1,
24
+ "heading-lg": 2,
25
+ "heading-md": 3,
26
+ "heading-sm": 4,
27
+ "heading-xs": 5,
28
+ "body-lg": "p",
29
+ "body-md": "p",
30
+ "body-sm": "p",
31
+ "label-lg": "span",
32
+ "label-md": "span",
33
+ "label-sm": "span",
34
+ caption: "p"
35
+ };
36
+ /**
37
+ * 텍스트 스타일 하나로 크기, 행간, 굵기를 정하고 알맞은 시맨틱 요소로 렌더한다.
38
+ * heading·display는 항상 `<h*>`다. 다른 요소가 필요하면 `render`를 쓴다. 서버 컴포넌트로 쓸 수 있다.
39
+ */
40
+ function Typography({ variant = "body-md", level, render, ...props }) {
41
+ const element = elementByVariant[variant];
42
+ return renderElement({
43
+ defaultElement: typeof element === "number" ? `h${level ?? element}` : element,
44
+ render,
45
+ baseClassName: variantClass[variant],
46
+ props
47
+ });
48
+ }
49
+
50
+ export { Typography };
@@ -0,0 +1,4 @@
1
+ import { ComponentProps, ReactElement } from "react";
2
+ export type VisuallyHiddenProps = ComponentProps<"span">;
3
+ /** 화면에는 보이지 않고 보조 기술에는 읽히는 텍스트. 서버 컴포넌트로 쓸 수 있다. */
4
+ export declare function VisuallyHidden({ className, ...props }: VisuallyHiddenProps): ReactElement;
@@ -0,0 +1,13 @@
1
+ import { cn } from "./cn.js";
2
+ import { jsx } from "react/jsx-runtime";
3
+
4
+ // src/VisuallyHidden.tsx
5
+ /** 화면에는 보이지 않고 보조 기술에는 읽히는 텍스트. 서버 컴포넌트로 쓸 수 있다. */
6
+ function VisuallyHidden({ className, ...props }) {
7
+ return /* @__PURE__ */ jsx("span", {
8
+ ...props,
9
+ className: cn("sr-only", className)
10
+ });
11
+ }
12
+
13
+ export { VisuallyHidden };
@@ -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,2 @@
1
+ /** Checkbox·Radio의 size. 상자 크기와 글자 크기만 바뀌고 조작 영역은 24×24로 같다. */
2
+ export type ChoiceSize = "sm" | "md";
@@ -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 };
package/dist/cn.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { ClassValue } from "clsx";
2
+ /**
3
+ * class를 합치고 충돌을 푼다. 뒤에 온 class가 이긴다.
4
+ * Component는 사용자 `className`을 항상 마지막 인자로 넘긴다.
5
+ */
6
+ export declare function cn(...inputs: ClassValue[]): string;
package/dist/cn.js ADDED
@@ -0,0 +1,15 @@
1
+ import { tailwindMergeConfig } from "@tide-tds/foundation/tailwind-merge";
2
+ import { clsx } from "clsx";
3
+ import { extendTailwindMerge } from "tailwind-merge";
4
+
5
+ // src/cn.ts
6
+ const twMerge = extendTailwindMerge(tailwindMergeConfig);
7
+ /**
8
+ * class를 합치고 충돌을 푼다. 뒤에 온 class가 이긴다.
9
+ * Component는 사용자 `className`을 항상 마지막 인자로 넘긴다.
10
+ */
11
+ function cn(...inputs) {
12
+ return twMerge(clsx(inputs));
13
+ }
14
+
15
+ export { cn };
@@ -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 };
@@ -0,0 +1,17 @@
1
+ import { cn } from "./cn.js";
2
+ import { VisuallyHidden, VisuallyHiddenProps } from "./VisuallyHidden.js";
3
+ import { Typography, TypographyProps } from "./Typography.js";
4
+ import { Spinner, SpinnerProps } from "./Spinner.js";
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 ADDED
@@ -0,0 +1,17 @@
1
+ import { cn } from "./cn.js";
2
+ import { VisuallyHidden } from "./VisuallyHidden.js";
3
+ import { Typography } from "./Typography.js";
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";
16
+
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 };