@nurimedia/react-ui 0.1.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/README.md +274 -0
- package/dist/AlertDialog/index.cjs +185 -0
- package/dist/AlertDialog/index.cjs.map +1 -0
- package/dist/AlertDialog/index.d.cts +49 -0
- package/dist/AlertDialog/index.d.ts +49 -0
- package/dist/AlertDialog/index.js +148 -0
- package/dist/AlertDialog/index.js.map +1 -0
- package/dist/AlertDialog/styles.css +187 -0
- package/dist/Button/index.cjs +136 -0
- package/dist/Button/index.cjs.map +1 -0
- package/dist/Button/index.d.cts +65 -0
- package/dist/Button/index.d.ts +65 -0
- package/dist/Button/index.js +109 -0
- package/dist/Button/index.js.map +1 -0
- package/dist/Button/styles.css +181 -0
- package/dist/Checkbox/index.cjs +83 -0
- package/dist/Checkbox/index.cjs.map +1 -0
- package/dist/Checkbox/index.d.cts +40 -0
- package/dist/Checkbox/index.d.ts +40 -0
- package/dist/Checkbox/index.js +46 -0
- package/dist/Checkbox/index.js.map +1 -0
- package/dist/Checkbox/styles.css +78 -0
- package/dist/Input/index.cjs +103 -0
- package/dist/Input/index.cjs.map +1 -0
- package/dist/Input/index.d.cts +53 -0
- package/dist/Input/index.d.ts +53 -0
- package/dist/Input/index.js +76 -0
- package/dist/Input/index.js.map +1 -0
- package/dist/Input/styles.css +135 -0
- package/dist/Modal/index.cjs +120 -0
- package/dist/Modal/index.cjs.map +1 -0
- package/dist/Modal/index.d.cts +62 -0
- package/dist/Modal/index.d.ts +62 -0
- package/dist/Modal/index.js +83 -0
- package/dist/Modal/index.js.map +1 -0
- package/dist/Modal/styles.css +229 -0
- package/dist/Radio/index.cjs +86 -0
- package/dist/Radio/index.cjs.map +1 -0
- package/dist/Radio/index.d.cts +46 -0
- package/dist/Radio/index.d.ts +46 -0
- package/dist/Radio/index.js +48 -0
- package/dist/Radio/index.js.map +1 -0
- package/dist/Radio/styles.css +76 -0
- package/dist/Select/index.cjs +116 -0
- package/dist/Select/index.cjs.map +1 -0
- package/dist/Select/index.d.cts +61 -0
- package/dist/Select/index.d.ts +61 -0
- package/dist/Select/index.js +79 -0
- package/dist/Select/index.js.map +1 -0
- package/dist/Select/styles.css +157 -0
- package/dist/Switch/index.cjs +71 -0
- package/dist/Switch/index.cjs.map +1 -0
- package/dist/Switch/index.d.cts +34 -0
- package/dist/Switch/index.d.ts +34 -0
- package/dist/Switch/index.js +34 -0
- package/dist/Switch/index.js.map +1 -0
- package/dist/Switch/styles.css +71 -0
- package/dist/Textarea/index.cjs +127 -0
- package/dist/Textarea/index.cjs.map +1 -0
- package/dist/Textarea/index.d.cts +48 -0
- package/dist/Textarea/index.d.ts +48 -0
- package/dist/Textarea/index.js +100 -0
- package/dist/Textarea/index.js.map +1 -0
- package/dist/Textarea/styles.css +95 -0
- package/dist/Toast/index.cjs +184 -0
- package/dist/Toast/index.cjs.map +1 -0
- package/dist/Toast/index.d.cts +67 -0
- package/dist/Toast/index.d.ts +67 -0
- package/dist/Toast/index.js +146 -0
- package/dist/Toast/index.js.map +1 -0
- package/dist/Toast/styles.css +122 -0
- package/dist/Tooltip/index.cjs +105 -0
- package/dist/Tooltip/index.cjs.map +1 -0
- package/dist/Tooltip/index.d.cts +65 -0
- package/dist/Tooltip/index.d.ts +65 -0
- package/dist/Tooltip/index.js +67 -0
- package/dist/Tooltip/index.js.map +1 -0
- package/dist/Tooltip/styles.css +42 -0
- package/dist/index.cjs +900 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +850 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +11 -0
- package/dist/tokens.css +232 -0
- package/dist/typography.css +104 -0
- package/llms.txt +369 -0
- package/package.json +183 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface SwitchProps {
|
|
5
|
+
/**
|
|
6
|
+
* 라벨 (컨트롤 우측). 디자인 시스템의 Toggle은 컨트롤 단독 정의지만,
|
|
7
|
+
* 범용 사용을 위해 라벨을 지원한다(Checkbox 라벨 스타일 준용). 없으면 `aria-label` 필수.
|
|
8
|
+
*/
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
/** 켜짐 상태 (제어형). */
|
|
11
|
+
checked?: boolean;
|
|
12
|
+
/** 초기 상태 (비제어형). */
|
|
13
|
+
defaultChecked?: boolean;
|
|
14
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** 폼 전송용 name. */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** 폼 전송 시 값. @default 'on' */
|
|
19
|
+
value?: string;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
id?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
'aria-label'?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 스위치(토글). Radix Switch 기반 — `role="switch"`·Space/Enter 토글·폼 참여 자동.
|
|
27
|
+
* 즉시 적용되는 켬/끔 설정에 사용한다 (제출이 필요한 선택은 Checkbox).
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* <Switch label="알림 받기" checked={on} onCheckedChange={setOn} />
|
|
31
|
+
*/
|
|
32
|
+
declare const Switch: react.ForwardRefExoticComponent<SwitchProps & react.RefAttributes<HTMLButtonElement>>;
|
|
33
|
+
|
|
34
|
+
export { Switch, type SwitchProps };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface SwitchProps {
|
|
5
|
+
/**
|
|
6
|
+
* 라벨 (컨트롤 우측). 디자인 시스템의 Toggle은 컨트롤 단독 정의지만,
|
|
7
|
+
* 범용 사용을 위해 라벨을 지원한다(Checkbox 라벨 스타일 준용). 없으면 `aria-label` 필수.
|
|
8
|
+
*/
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
/** 켜짐 상태 (제어형). */
|
|
11
|
+
checked?: boolean;
|
|
12
|
+
/** 초기 상태 (비제어형). */
|
|
13
|
+
defaultChecked?: boolean;
|
|
14
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** 폼 전송용 name. */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** 폼 전송 시 값. @default 'on' */
|
|
19
|
+
value?: string;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
id?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
'aria-label'?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 스위치(토글). Radix Switch 기반 — `role="switch"`·Space/Enter 토글·폼 참여 자동.
|
|
27
|
+
* 즉시 적용되는 켬/끔 설정에 사용한다 (제출이 필요한 선택은 Checkbox).
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* <Switch label="알림 받기" checked={on} onCheckedChange={setOn} />
|
|
31
|
+
*/
|
|
32
|
+
declare const Switch: react.ForwardRefExoticComponent<SwitchProps & react.RefAttributes<HTMLButtonElement>>;
|
|
33
|
+
|
|
34
|
+
export { Switch, type SwitchProps };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/Switch/Switch.tsx
|
|
2
|
+
import "./styles.css";
|
|
3
|
+
import * as RadixSwitch from "@radix-ui/react-switch";
|
|
4
|
+
import { forwardRef, useId } from "react";
|
|
5
|
+
|
|
6
|
+
// src/utils/cx.ts
|
|
7
|
+
function cx(...classes) {
|
|
8
|
+
return classes.filter(Boolean).join(" ");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/Switch/Switch.tsx
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
var Switch = forwardRef(function Switch2({ label, disabled = false, id: idProp, className, ...rest }, ref) {
|
|
14
|
+
const reactId = useId();
|
|
15
|
+
const id = idProp ?? `nuri-switch-${reactId}`;
|
|
16
|
+
return /* @__PURE__ */ jsxs("div", { className: cx("nuri-switch", className), children: [
|
|
17
|
+
/* @__PURE__ */ jsx(
|
|
18
|
+
RadixSwitch.Root,
|
|
19
|
+
{
|
|
20
|
+
ref,
|
|
21
|
+
id,
|
|
22
|
+
className: "nuri-switch__control",
|
|
23
|
+
disabled,
|
|
24
|
+
...rest,
|
|
25
|
+
children: /* @__PURE__ */ jsx(RadixSwitch.Thumb, { className: "nuri-switch__thumb" })
|
|
26
|
+
}
|
|
27
|
+
),
|
|
28
|
+
label != null && /* @__PURE__ */ jsx("label", { htmlFor: id, className: "nuri-switch__label", children: label })
|
|
29
|
+
] });
|
|
30
|
+
});
|
|
31
|
+
export {
|
|
32
|
+
Switch
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/Switch/Switch.tsx","../../src/utils/cx.ts"],"sourcesContent":["'use client';\n\nimport './styles.css';\nimport * as RadixSwitch from '@radix-ui/react-switch';\nimport { forwardRef, useId } from 'react';\nimport type { ReactNode } from 'react';\nimport { cx } from '../utils/cx.js';\n\nexport interface SwitchProps {\n /**\n * 라벨 (컨트롤 우측). 디자인 시스템의 Toggle은 컨트롤 단독 정의지만,\n * 범용 사용을 위해 라벨을 지원한다(Checkbox 라벨 스타일 준용). 없으면 `aria-label` 필수.\n */\n label?: ReactNode;\n /** 켜짐 상태 (제어형). */\n checked?: boolean;\n /** 초기 상태 (비제어형). */\n defaultChecked?: boolean;\n onCheckedChange?: (checked: boolean) => void;\n disabled?: boolean;\n /** 폼 전송용 name. */\n name?: string;\n /** 폼 전송 시 값. @default 'on' */\n value?: string;\n required?: boolean;\n id?: string;\n className?: string;\n 'aria-label'?: string;\n}\n\n/**\n * 스위치(토글). Radix Switch 기반 — `role=\"switch\"`·Space/Enter 토글·폼 참여 자동.\n * 즉시 적용되는 켬/끔 설정에 사용한다 (제출이 필요한 선택은 Checkbox).\n *\n * @example\n * <Switch label=\"알림 받기\" checked={on} onCheckedChange={setOn} />\n */\nexport const Switch = forwardRef<HTMLButtonElement, SwitchProps>(function Switch(\n { label, disabled = false, id: idProp, className, ...rest },\n ref,\n) {\n const reactId = useId();\n const id = idProp ?? `nuri-switch-${reactId}`;\n\n return (\n <div className={cx('nuri-switch', className)}>\n <RadixSwitch.Root\n ref={ref}\n id={id}\n className=\"nuri-switch__control\"\n disabled={disabled}\n {...rest}\n >\n <RadixSwitch.Thumb className=\"nuri-switch__thumb\" />\n </RadixSwitch.Root>\n {label != null && (\n <label htmlFor={id} className=\"nuri-switch__label\">\n {label}\n </label>\n )}\n </div>\n );\n});\n","/**\n * 조건부 className 합성 유틸. falsy 값(undefined/false/null/'')은 제거.\n * @example cx('btn', isActive && 'btn--active', className)\n */\nexport function cx(...classes: Array<string | false | null | undefined>): string {\n return classes.filter(Boolean).join(' ');\n}\n"],"mappings":";AAEA,OAAO;AACP,YAAY,iBAAiB;AAC7B,SAAS,YAAY,aAAa;;;ACA3B,SAAS,MAAM,SAA2D;AAC/E,SAAO,QAAQ,OAAO,OAAO,EAAE,KAAK,GAAG;AACzC;;;ADuCI,SAQI,KARJ;AARG,IAAM,SAAS,WAA2C,SAASA,QACxE,EAAE,OAAO,WAAW,OAAO,IAAI,QAAQ,WAAW,GAAG,KAAK,GAC1D,KACA;AACA,QAAM,UAAU,MAAM;AACtB,QAAM,KAAK,UAAU,eAAe,OAAO;AAE3C,SACE,qBAAC,SAAI,WAAW,GAAG,eAAe,SAAS,GACzC;AAAA;AAAA,MAAa;AAAA,MAAZ;AAAA,QACC;AAAA,QACA;AAAA,QACA,WAAU;AAAA,QACV;AAAA,QACC,GAAG;AAAA,QAEJ,8BAAa,mBAAZ,EAAkB,WAAU,sBAAqB;AAAA;AAAA,IACpD;AAAA,IACC,SAAS,QACR,oBAAC,WAAM,SAAS,IAAI,WAAU,sBAC3B,iBACH;AAAA,KAEJ;AAEJ,CAAC;","names":["Switch"]}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* 32×20 트랙 + 16px 썸. off 트랙 색 잠정 — TODO(Figma 실측 후 확정) */
|
|
2
|
+
.nuri-switch {
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: var(--nuri-control-gap);
|
|
6
|
+
font-family: inherit;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.nuri-switch__control {
|
|
10
|
+
box-sizing: border-box;
|
|
11
|
+
position: relative;
|
|
12
|
+
flex-shrink: 0;
|
|
13
|
+
width: var(--nuri-control-switch-width);
|
|
14
|
+
height: var(--nuri-control-switch-height);
|
|
15
|
+
padding: 0;
|
|
16
|
+
border: none;
|
|
17
|
+
border-radius: calc(var(--nuri-control-switch-height) / 2);
|
|
18
|
+
background-color: var(--nuri-control-switch-track-off);
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
transition: background-color 120ms ease;
|
|
21
|
+
}
|
|
22
|
+
.nuri-switch__control[data-state='checked'] {
|
|
23
|
+
background-color: var(--nuri-control-bg-checked, var(--nuri-color-brand-default));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.nuri-switch__thumb {
|
|
27
|
+
display: block;
|
|
28
|
+
width: var(--nuri-control-switch-thumb-size);
|
|
29
|
+
height: var(--nuri-control-switch-thumb-size);
|
|
30
|
+
border-radius: 50%;
|
|
31
|
+
background-color: var(--nuri-color-white, #fff);
|
|
32
|
+
/* 트랙 안 2px 여백에서 좌우 이동 */
|
|
33
|
+
transform: translateX(2px);
|
|
34
|
+
transition: transform 120ms ease;
|
|
35
|
+
will-change: transform;
|
|
36
|
+
}
|
|
37
|
+
.nuri-switch__control[data-state='checked'] .nuri-switch__thumb {
|
|
38
|
+
transform: translateX(
|
|
39
|
+
calc(var(--nuri-control-switch-width) - var(--nuri-control-switch-thumb-size) - 2px)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.nuri-switch__control:focus-visible {
|
|
44
|
+
outline: var(--nuri-button-focus-ring-width) solid
|
|
45
|
+
var(--nuri-button-focus-ring-color, var(--nuri-color-brand-default));
|
|
46
|
+
outline-offset: var(--nuri-button-focus-ring-offset);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.nuri-switch__control:disabled {
|
|
50
|
+
background-color: var(--nuri-control-border-disabled);
|
|
51
|
+
cursor: not-allowed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.nuri-switch__label {
|
|
55
|
+
font-size: var(--nuri-control-label-size);
|
|
56
|
+
line-height: var(--nuri-control-line-height);
|
|
57
|
+
color: var(--nuri-control-label-color);
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
user-select: none;
|
|
60
|
+
}
|
|
61
|
+
.nuri-switch:has(.nuri-switch__control:disabled) .nuri-switch__label {
|
|
62
|
+
color: var(--nuri-control-label-disabled);
|
|
63
|
+
cursor: not-allowed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
.nuri-switch__control,
|
|
68
|
+
.nuri-switch__thumb {
|
|
69
|
+
transition: none;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/Textarea/index.ts
|
|
21
|
+
var Textarea_exports = {};
|
|
22
|
+
__export(Textarea_exports, {
|
|
23
|
+
Textarea: () => Textarea
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(Textarea_exports);
|
|
26
|
+
|
|
27
|
+
// src/Textarea/Textarea.tsx
|
|
28
|
+
var import_styles = require("./styles.css");
|
|
29
|
+
var import_react = require("react");
|
|
30
|
+
|
|
31
|
+
// src/utils/cx.ts
|
|
32
|
+
function cx(...classes) {
|
|
33
|
+
return classes.filter(Boolean).join(" ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/Textarea/Textarea.tsx
|
|
37
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
38
|
+
var Textarea = (0, import_react.forwardRef)(function Textarea2({
|
|
39
|
+
label,
|
|
40
|
+
hideLabel = false,
|
|
41
|
+
description,
|
|
42
|
+
invalid = false,
|
|
43
|
+
errorMessage,
|
|
44
|
+
block = false,
|
|
45
|
+
resize = "vertical",
|
|
46
|
+
autosize = false,
|
|
47
|
+
maxRows,
|
|
48
|
+
rows = 3,
|
|
49
|
+
disabled = false,
|
|
50
|
+
className,
|
|
51
|
+
id: idProp,
|
|
52
|
+
value,
|
|
53
|
+
onChange,
|
|
54
|
+
"aria-describedby": ariaDescribedby,
|
|
55
|
+
...rest
|
|
56
|
+
}, ref) {
|
|
57
|
+
const reactId = (0, import_react.useId)();
|
|
58
|
+
const id = idProp ?? `nuri-textarea-${reactId}`;
|
|
59
|
+
const messageId = `${id}-message`;
|
|
60
|
+
const innerRef = (0, import_react.useRef)(null);
|
|
61
|
+
const setRefs = (0, import_react.useCallback)(
|
|
62
|
+
(node) => {
|
|
63
|
+
innerRef.current = node;
|
|
64
|
+
if (typeof ref === "function") ref(node);
|
|
65
|
+
else if (ref) ref.current = node;
|
|
66
|
+
},
|
|
67
|
+
[ref]
|
|
68
|
+
);
|
|
69
|
+
const fitHeight = (0, import_react.useCallback)(() => {
|
|
70
|
+
const el = innerRef.current;
|
|
71
|
+
if (!el) return;
|
|
72
|
+
const cs = getComputedStyle(el);
|
|
73
|
+
const borders = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
|
74
|
+
const lineHeight = parseFloat(cs.lineHeight);
|
|
75
|
+
const maxHeight = maxRows != null && !Number.isNaN(lineHeight) ? maxRows * lineHeight + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + borders : Infinity;
|
|
76
|
+
el.style.height = "auto";
|
|
77
|
+
const contentHeight = el.scrollHeight + borders;
|
|
78
|
+
const next = Math.min(contentHeight, maxHeight);
|
|
79
|
+
if (!Number.isNaN(next)) el.style.height = `${next}px`;
|
|
80
|
+
el.style.overflowY = contentHeight > maxHeight ? "auto" : "hidden";
|
|
81
|
+
}, [maxRows]);
|
|
82
|
+
(0, import_react.useLayoutEffect)(() => {
|
|
83
|
+
if (autosize) fitHeight();
|
|
84
|
+
}, [autosize, fitHeight, value]);
|
|
85
|
+
const handleChange = (e) => {
|
|
86
|
+
onChange?.(e);
|
|
87
|
+
if (autosize) fitHeight();
|
|
88
|
+
};
|
|
89
|
+
const message = invalid && errorMessage != null ? errorMessage : description;
|
|
90
|
+
const hasMessage = message != null;
|
|
91
|
+
const describedBy = cx(hasMessage ? messageId : void 0, ariaDescribedby) || void 0;
|
|
92
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: cx("nuri-textarea", block && "nuri-textarea--block", className), children: [
|
|
93
|
+
label != null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { htmlFor: id, className: cx("nuri-textarea__label", hideLabel && "nuri-sr-only"), children: label }),
|
|
94
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
95
|
+
"textarea",
|
|
96
|
+
{
|
|
97
|
+
ref: setRefs,
|
|
98
|
+
id,
|
|
99
|
+
rows,
|
|
100
|
+
value,
|
|
101
|
+
onChange: handleChange,
|
|
102
|
+
className: cx(
|
|
103
|
+
"nuri-textarea__control",
|
|
104
|
+
invalid && "nuri-textarea__control--invalid",
|
|
105
|
+
(resize === "none" || autosize) && "nuri-textarea__control--resize-none"
|
|
106
|
+
),
|
|
107
|
+
disabled,
|
|
108
|
+
"aria-invalid": invalid || void 0,
|
|
109
|
+
"aria-describedby": describedBy,
|
|
110
|
+
...rest
|
|
111
|
+
}
|
|
112
|
+
),
|
|
113
|
+
hasMessage && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
114
|
+
"p",
|
|
115
|
+
{
|
|
116
|
+
id: messageId,
|
|
117
|
+
className: cx("nuri-textarea__message", invalid && "nuri-textarea__message--error"),
|
|
118
|
+
children: message
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
] });
|
|
122
|
+
});
|
|
123
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
124
|
+
0 && (module.exports = {
|
|
125
|
+
Textarea
|
|
126
|
+
});
|
|
127
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/Textarea/index.ts","../../src/Textarea/Textarea.tsx","../../src/utils/cx.ts"],"sourcesContent":["export { Textarea } from './Textarea.js';\nexport type { TextareaProps } from './Textarea.js';\n","'use client';\n\nimport './styles.css';\nimport { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';\nimport type { ChangeEvent, TextareaHTMLAttributes, ReactNode } from 'react';\nimport { cx } from '../utils/cx.js';\n\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n /**\n * 라벨 (필드 위). 지정 시 `<label>`로 렌더되어 자동 연결됨.\n * 라벨을 두지 않으면 `aria-label`로 접근 가능한 이름을 주세요.\n */\n label?: ReactNode;\n /** 라벨을 시각적으로만 숨김 (스크린리더에는 노출). @default false */\n hideLabel?: boolean;\n /** 평상시 힌트 (회색, 필드 아래). `aria-describedby`로 연결됨. */\n description?: ReactNode;\n /** 에러 상태 — 보더·텍스트·배경이 에러색으로 바뀌고 `aria-invalid`가 설정됨. */\n invalid?: boolean;\n /** `invalid`일 때 표시할 에러 메시지 (빨강). 있으면 description을 대체. */\n errorMessage?: ReactNode;\n /** 컨테이너 전체 너비로 확장. @default false */\n block?: boolean;\n /**\n * 크기 조절 핸들. 세로 조절 허용이 기본 — 긴 입력을 사용자가 늘려 볼 수 있게.\n * `autosize`가 켜지면 무시된다(자동 크기와 수동 핸들은 충돌).\n * @default 'vertical'\n */\n resize?: 'vertical' | 'none';\n /**\n * 내용에 따라 높이 자동 조절. `rows`(최소)에서 시작해 내용만큼 늘어나고,\n * `maxRows`에 도달하면 멈추고 스크롤이 생긴다.\n * @default false\n */\n autosize?: boolean;\n /** `autosize`일 때 최대 줄 수 — 넘으면 스크롤. 미지정 시 무제한 확장. */\n maxRows?: number;\n}\n\n/**\n * 여러 줄 텍스트 입력. Input과 동일한 디자인 언어(보더·상태·타이포)와\n * 라벨/힌트/에러 자동 연결 패턴을 따르는 자체 완결형.\n * 높이는 native `rows`(기본 3)로 제어하고, `autosize`로 자동 확장도 지원한다.\n *\n * @example\n * <Textarea label=\"문의 내용\" rows={5} block />\n *\n * @example 자동 높이 — 3줄에서 시작해 8줄까지 늘어난 뒤 스크롤\n * <Textarea label=\"댓글\" autosize rows={3} maxRows={8} />\n */\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(\n {\n label,\n hideLabel = false,\n description,\n invalid = false,\n errorMessage,\n block = false,\n resize = 'vertical',\n autosize = false,\n maxRows,\n rows = 3,\n disabled = false,\n className,\n id: idProp,\n value,\n onChange,\n 'aria-describedby': ariaDescribedby,\n ...rest\n },\n ref,\n) {\n const reactId = useId();\n const id = idProp ?? `nuri-textarea-${reactId}`;\n const messageId = `${id}-message`;\n\n const innerRef = useRef<HTMLTextAreaElement | null>(null);\n const setRefs = useCallback(\n (node: HTMLTextAreaElement | null) => {\n innerRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) ref.current = node;\n },\n [ref],\n );\n\n // autosize: height를 내용(scrollHeight)에 맞추되 maxRows 상한에서 클램프.\n const fitHeight = useCallback(() => {\n const el = innerRef.current;\n if (!el) return;\n const cs = getComputedStyle(el);\n const borders = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);\n const lineHeight = parseFloat(cs.lineHeight);\n // maxRows → px 상한 (line-height 계산 불가 환경에서는 무제한)\n const maxHeight =\n maxRows != null && !Number.isNaN(lineHeight)\n ? maxRows * lineHeight + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + borders\n : Infinity;\n\n el.style.height = 'auto'; // 줄어드는 경우도 반영되도록 초기화 후 측정\n const contentHeight = el.scrollHeight + borders; // scrollHeight는 border 미포함(border-box 보정)\n const next = Math.min(contentHeight, maxHeight);\n if (!Number.isNaN(next)) el.style.height = `${next}px`;\n el.style.overflowY = contentHeight > maxHeight ? 'auto' : 'hidden';\n }, [maxRows]);\n\n // 마운트·제어형 value 변경·autosize 토글 시 재계산\n useLayoutEffect(() => {\n if (autosize) fitHeight();\n }, [autosize, fitHeight, value]);\n\n const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {\n onChange?.(e);\n if (autosize) fitHeight(); // 비제어형 입력에도 반응\n };\n\n // invalid + errorMessage → 에러 메시지 / 아니면 힌트. 시각 슬롯은 항상 한 줄. (Input과 동일)\n const message = invalid && errorMessage != null ? errorMessage : description;\n const hasMessage = message != null;\n const describedBy =\n cx(hasMessage ? messageId : undefined, ariaDescribedby as string | undefined) || undefined;\n\n return (\n <div className={cx('nuri-textarea', block && 'nuri-textarea--block', className)}>\n {label != null && (\n <label htmlFor={id} className={cx('nuri-textarea__label', hideLabel && 'nuri-sr-only')}>\n {label}\n </label>\n )}\n\n <textarea\n ref={setRefs}\n id={id}\n rows={rows}\n value={value}\n onChange={handleChange}\n className={cx(\n 'nuri-textarea__control',\n invalid && 'nuri-textarea__control--invalid',\n (resize === 'none' || autosize) && 'nuri-textarea__control--resize-none',\n )}\n disabled={disabled}\n aria-invalid={invalid || undefined}\n aria-describedby={describedBy}\n {...rest}\n />\n\n {hasMessage && (\n <p\n id={messageId}\n className={cx('nuri-textarea__message', invalid && 'nuri-textarea__message--error')}\n >\n {message}\n </p>\n )}\n </div>\n );\n});\n","/**\n * 조건부 className 합성 유틸. falsy 값(undefined/false/null/'')은 제거.\n * @example cx('btn', isActive && 'btn--active', className)\n */\nexport function cx(...classes: Array<string | false | null | undefined>): string {\n return classes.filter(Boolean).join(' ');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACEA,oBAAO;AACP,mBAAwE;;;ACCjE,SAAS,MAAM,SAA2D;AAC/E,SAAO,QAAQ,OAAO,OAAO,EAAE,KAAK,GAAG;AACzC;;;ADqHI;AAzEG,IAAM,eAAW,yBAA+C,SAASA,UAC9E;AAAA,EACE;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX;AAAA,EACA,OAAO;AAAA,EACP,WAAW;AAAA,EACX;AAAA,EACA,IAAI;AAAA,EACJ;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB,GAAG;AACL,GACA,KACA;AACA,QAAM,cAAU,oBAAM;AACtB,QAAM,KAAK,UAAU,iBAAiB,OAAO;AAC7C,QAAM,YAAY,GAAG,EAAE;AAEvB,QAAM,eAAW,qBAAmC,IAAI;AACxD,QAAM,cAAU;AAAA,IACd,CAAC,SAAqC;AACpC,eAAS,UAAU;AACnB,UAAI,OAAO,QAAQ,WAAY,KAAI,IAAI;AAAA,eAC9B,IAAK,KAAI,UAAU;AAAA,IAC9B;AAAA,IACA,CAAC,GAAG;AAAA,EACN;AAGA,QAAM,gBAAY,0BAAY,MAAM;AAClC,UAAM,KAAK,SAAS;AACpB,QAAI,CAAC,GAAI;AACT,UAAM,KAAK,iBAAiB,EAAE;AAC9B,UAAM,UAAU,WAAW,GAAG,cAAc,IAAI,WAAW,GAAG,iBAAiB;AAC/E,UAAM,aAAa,WAAW,GAAG,UAAU;AAE3C,UAAM,YACJ,WAAW,QAAQ,CAAC,OAAO,MAAM,UAAU,IACvC,UAAU,aAAa,WAAW,GAAG,UAAU,IAAI,WAAW,GAAG,aAAa,IAAI,UAClF;AAEN,OAAG,MAAM,SAAS;AAClB,UAAM,gBAAgB,GAAG,eAAe;AACxC,UAAM,OAAO,KAAK,IAAI,eAAe,SAAS;AAC9C,QAAI,CAAC,OAAO,MAAM,IAAI,EAAG,IAAG,MAAM,SAAS,GAAG,IAAI;AAClD,OAAG,MAAM,YAAY,gBAAgB,YAAY,SAAS;AAAA,EAC5D,GAAG,CAAC,OAAO,CAAC;AAGZ,oCAAgB,MAAM;AACpB,QAAI,SAAU,WAAU;AAAA,EAC1B,GAAG,CAAC,UAAU,WAAW,KAAK,CAAC;AAE/B,QAAM,eAAe,CAAC,MAAwC;AAC5D,eAAW,CAAC;AACZ,QAAI,SAAU,WAAU;AAAA,EAC1B;AAGA,QAAM,UAAU,WAAW,gBAAgB,OAAO,eAAe;AACjE,QAAM,aAAa,WAAW;AAC9B,QAAM,cACJ,GAAG,aAAa,YAAY,QAAW,eAAqC,KAAK;AAEnF,SACE,6CAAC,SAAI,WAAW,GAAG,iBAAiB,SAAS,wBAAwB,SAAS,GAC3E;AAAA,aAAS,QACR,4CAAC,WAAM,SAAS,IAAI,WAAW,GAAG,wBAAwB,aAAa,cAAc,GAClF,iBACH;AAAA,IAGF;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA,UAAU;AAAA,QACV,WAAW;AAAA,UACT;AAAA,UACA,WAAW;AAAA,WACV,WAAW,UAAU,aAAa;AAAA,QACrC;AAAA,QACA;AAAA,QACA,gBAAc,WAAW;AAAA,QACzB,oBAAkB;AAAA,QACjB,GAAG;AAAA;AAAA,IACN;AAAA,IAEC,cACC;AAAA,MAAC;AAAA;AAAA,QACC,IAAI;AAAA,QACJ,WAAW,GAAG,0BAA0B,WAAW,+BAA+B;AAAA,QAEjF;AAAA;AAAA,IACH;AAAA,KAEJ;AAEJ,CAAC;","names":["Textarea"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { TextareaHTMLAttributes, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
5
|
+
/**
|
|
6
|
+
* 라벨 (필드 위). 지정 시 `<label>`로 렌더되어 자동 연결됨.
|
|
7
|
+
* 라벨을 두지 않으면 `aria-label`로 접근 가능한 이름을 주세요.
|
|
8
|
+
*/
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
/** 라벨을 시각적으로만 숨김 (스크린리더에는 노출). @default false */
|
|
11
|
+
hideLabel?: boolean;
|
|
12
|
+
/** 평상시 힌트 (회색, 필드 아래). `aria-describedby`로 연결됨. */
|
|
13
|
+
description?: ReactNode;
|
|
14
|
+
/** 에러 상태 — 보더·텍스트·배경이 에러색으로 바뀌고 `aria-invalid`가 설정됨. */
|
|
15
|
+
invalid?: boolean;
|
|
16
|
+
/** `invalid`일 때 표시할 에러 메시지 (빨강). 있으면 description을 대체. */
|
|
17
|
+
errorMessage?: ReactNode;
|
|
18
|
+
/** 컨테이너 전체 너비로 확장. @default false */
|
|
19
|
+
block?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* 크기 조절 핸들. 세로 조절 허용이 기본 — 긴 입력을 사용자가 늘려 볼 수 있게.
|
|
22
|
+
* `autosize`가 켜지면 무시된다(자동 크기와 수동 핸들은 충돌).
|
|
23
|
+
* @default 'vertical'
|
|
24
|
+
*/
|
|
25
|
+
resize?: 'vertical' | 'none';
|
|
26
|
+
/**
|
|
27
|
+
* 내용에 따라 높이 자동 조절. `rows`(최소)에서 시작해 내용만큼 늘어나고,
|
|
28
|
+
* `maxRows`에 도달하면 멈추고 스크롤이 생긴다.
|
|
29
|
+
* @default false
|
|
30
|
+
*/
|
|
31
|
+
autosize?: boolean;
|
|
32
|
+
/** `autosize`일 때 최대 줄 수 — 넘으면 스크롤. 미지정 시 무제한 확장. */
|
|
33
|
+
maxRows?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 여러 줄 텍스트 입력. Input과 동일한 디자인 언어(보더·상태·타이포)와
|
|
37
|
+
* 라벨/힌트/에러 자동 연결 패턴을 따르는 자체 완결형.
|
|
38
|
+
* 높이는 native `rows`(기본 3)로 제어하고, `autosize`로 자동 확장도 지원한다.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* <Textarea label="문의 내용" rows={5} block />
|
|
42
|
+
*
|
|
43
|
+
* @example 자동 높이 — 3줄에서 시작해 8줄까지 늘어난 뒤 스크롤
|
|
44
|
+
* <Textarea label="댓글" autosize rows={3} maxRows={8} />
|
|
45
|
+
*/
|
|
46
|
+
declare const Textarea: react.ForwardRefExoticComponent<TextareaProps & react.RefAttributes<HTMLTextAreaElement>>;
|
|
47
|
+
|
|
48
|
+
export { Textarea, type TextareaProps };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { TextareaHTMLAttributes, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
5
|
+
/**
|
|
6
|
+
* 라벨 (필드 위). 지정 시 `<label>`로 렌더되어 자동 연결됨.
|
|
7
|
+
* 라벨을 두지 않으면 `aria-label`로 접근 가능한 이름을 주세요.
|
|
8
|
+
*/
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
/** 라벨을 시각적으로만 숨김 (스크린리더에는 노출). @default false */
|
|
11
|
+
hideLabel?: boolean;
|
|
12
|
+
/** 평상시 힌트 (회색, 필드 아래). `aria-describedby`로 연결됨. */
|
|
13
|
+
description?: ReactNode;
|
|
14
|
+
/** 에러 상태 — 보더·텍스트·배경이 에러색으로 바뀌고 `aria-invalid`가 설정됨. */
|
|
15
|
+
invalid?: boolean;
|
|
16
|
+
/** `invalid`일 때 표시할 에러 메시지 (빨강). 있으면 description을 대체. */
|
|
17
|
+
errorMessage?: ReactNode;
|
|
18
|
+
/** 컨테이너 전체 너비로 확장. @default false */
|
|
19
|
+
block?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* 크기 조절 핸들. 세로 조절 허용이 기본 — 긴 입력을 사용자가 늘려 볼 수 있게.
|
|
22
|
+
* `autosize`가 켜지면 무시된다(자동 크기와 수동 핸들은 충돌).
|
|
23
|
+
* @default 'vertical'
|
|
24
|
+
*/
|
|
25
|
+
resize?: 'vertical' | 'none';
|
|
26
|
+
/**
|
|
27
|
+
* 내용에 따라 높이 자동 조절. `rows`(최소)에서 시작해 내용만큼 늘어나고,
|
|
28
|
+
* `maxRows`에 도달하면 멈추고 스크롤이 생긴다.
|
|
29
|
+
* @default false
|
|
30
|
+
*/
|
|
31
|
+
autosize?: boolean;
|
|
32
|
+
/** `autosize`일 때 최대 줄 수 — 넘으면 스크롤. 미지정 시 무제한 확장. */
|
|
33
|
+
maxRows?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 여러 줄 텍스트 입력. Input과 동일한 디자인 언어(보더·상태·타이포)와
|
|
37
|
+
* 라벨/힌트/에러 자동 연결 패턴을 따르는 자체 완결형.
|
|
38
|
+
* 높이는 native `rows`(기본 3)로 제어하고, `autosize`로 자동 확장도 지원한다.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* <Textarea label="문의 내용" rows={5} block />
|
|
42
|
+
*
|
|
43
|
+
* @example 자동 높이 — 3줄에서 시작해 8줄까지 늘어난 뒤 스크롤
|
|
44
|
+
* <Textarea label="댓글" autosize rows={3} maxRows={8} />
|
|
45
|
+
*/
|
|
46
|
+
declare const Textarea: react.ForwardRefExoticComponent<TextareaProps & react.RefAttributes<HTMLTextAreaElement>>;
|
|
47
|
+
|
|
48
|
+
export { Textarea, type TextareaProps };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/Textarea/Textarea.tsx
|
|
2
|
+
import "./styles.css";
|
|
3
|
+
import { forwardRef, useCallback, useId, useLayoutEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
// src/utils/cx.ts
|
|
6
|
+
function cx(...classes) {
|
|
7
|
+
return classes.filter(Boolean).join(" ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/Textarea/Textarea.tsx
|
|
11
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
var Textarea = forwardRef(function Textarea2({
|
|
13
|
+
label,
|
|
14
|
+
hideLabel = false,
|
|
15
|
+
description,
|
|
16
|
+
invalid = false,
|
|
17
|
+
errorMessage,
|
|
18
|
+
block = false,
|
|
19
|
+
resize = "vertical",
|
|
20
|
+
autosize = false,
|
|
21
|
+
maxRows,
|
|
22
|
+
rows = 3,
|
|
23
|
+
disabled = false,
|
|
24
|
+
className,
|
|
25
|
+
id: idProp,
|
|
26
|
+
value,
|
|
27
|
+
onChange,
|
|
28
|
+
"aria-describedby": ariaDescribedby,
|
|
29
|
+
...rest
|
|
30
|
+
}, ref) {
|
|
31
|
+
const reactId = useId();
|
|
32
|
+
const id = idProp ?? `nuri-textarea-${reactId}`;
|
|
33
|
+
const messageId = `${id}-message`;
|
|
34
|
+
const innerRef = useRef(null);
|
|
35
|
+
const setRefs = useCallback(
|
|
36
|
+
(node) => {
|
|
37
|
+
innerRef.current = node;
|
|
38
|
+
if (typeof ref === "function") ref(node);
|
|
39
|
+
else if (ref) ref.current = node;
|
|
40
|
+
},
|
|
41
|
+
[ref]
|
|
42
|
+
);
|
|
43
|
+
const fitHeight = useCallback(() => {
|
|
44
|
+
const el = innerRef.current;
|
|
45
|
+
if (!el) return;
|
|
46
|
+
const cs = getComputedStyle(el);
|
|
47
|
+
const borders = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
|
48
|
+
const lineHeight = parseFloat(cs.lineHeight);
|
|
49
|
+
const maxHeight = maxRows != null && !Number.isNaN(lineHeight) ? maxRows * lineHeight + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + borders : Infinity;
|
|
50
|
+
el.style.height = "auto";
|
|
51
|
+
const contentHeight = el.scrollHeight + borders;
|
|
52
|
+
const next = Math.min(contentHeight, maxHeight);
|
|
53
|
+
if (!Number.isNaN(next)) el.style.height = `${next}px`;
|
|
54
|
+
el.style.overflowY = contentHeight > maxHeight ? "auto" : "hidden";
|
|
55
|
+
}, [maxRows]);
|
|
56
|
+
useLayoutEffect(() => {
|
|
57
|
+
if (autosize) fitHeight();
|
|
58
|
+
}, [autosize, fitHeight, value]);
|
|
59
|
+
const handleChange = (e) => {
|
|
60
|
+
onChange?.(e);
|
|
61
|
+
if (autosize) fitHeight();
|
|
62
|
+
};
|
|
63
|
+
const message = invalid && errorMessage != null ? errorMessage : description;
|
|
64
|
+
const hasMessage = message != null;
|
|
65
|
+
const describedBy = cx(hasMessage ? messageId : void 0, ariaDescribedby) || void 0;
|
|
66
|
+
return /* @__PURE__ */ jsxs("div", { className: cx("nuri-textarea", block && "nuri-textarea--block", className), children: [
|
|
67
|
+
label != null && /* @__PURE__ */ jsx("label", { htmlFor: id, className: cx("nuri-textarea__label", hideLabel && "nuri-sr-only"), children: label }),
|
|
68
|
+
/* @__PURE__ */ jsx(
|
|
69
|
+
"textarea",
|
|
70
|
+
{
|
|
71
|
+
ref: setRefs,
|
|
72
|
+
id,
|
|
73
|
+
rows,
|
|
74
|
+
value,
|
|
75
|
+
onChange: handleChange,
|
|
76
|
+
className: cx(
|
|
77
|
+
"nuri-textarea__control",
|
|
78
|
+
invalid && "nuri-textarea__control--invalid",
|
|
79
|
+
(resize === "none" || autosize) && "nuri-textarea__control--resize-none"
|
|
80
|
+
),
|
|
81
|
+
disabled,
|
|
82
|
+
"aria-invalid": invalid || void 0,
|
|
83
|
+
"aria-describedby": describedBy,
|
|
84
|
+
...rest
|
|
85
|
+
}
|
|
86
|
+
),
|
|
87
|
+
hasMessage && /* @__PURE__ */ jsx(
|
|
88
|
+
"p",
|
|
89
|
+
{
|
|
90
|
+
id: messageId,
|
|
91
|
+
className: cx("nuri-textarea__message", invalid && "nuri-textarea__message--error"),
|
|
92
|
+
children: message
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
] });
|
|
96
|
+
});
|
|
97
|
+
export {
|
|
98
|
+
Textarea
|
|
99
|
+
};
|
|
100
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/Textarea/Textarea.tsx","../../src/utils/cx.ts"],"sourcesContent":["'use client';\n\nimport './styles.css';\nimport { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';\nimport type { ChangeEvent, TextareaHTMLAttributes, ReactNode } from 'react';\nimport { cx } from '../utils/cx.js';\n\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n /**\n * 라벨 (필드 위). 지정 시 `<label>`로 렌더되어 자동 연결됨.\n * 라벨을 두지 않으면 `aria-label`로 접근 가능한 이름을 주세요.\n */\n label?: ReactNode;\n /** 라벨을 시각적으로만 숨김 (스크린리더에는 노출). @default false */\n hideLabel?: boolean;\n /** 평상시 힌트 (회색, 필드 아래). `aria-describedby`로 연결됨. */\n description?: ReactNode;\n /** 에러 상태 — 보더·텍스트·배경이 에러색으로 바뀌고 `aria-invalid`가 설정됨. */\n invalid?: boolean;\n /** `invalid`일 때 표시할 에러 메시지 (빨강). 있으면 description을 대체. */\n errorMessage?: ReactNode;\n /** 컨테이너 전체 너비로 확장. @default false */\n block?: boolean;\n /**\n * 크기 조절 핸들. 세로 조절 허용이 기본 — 긴 입력을 사용자가 늘려 볼 수 있게.\n * `autosize`가 켜지면 무시된다(자동 크기와 수동 핸들은 충돌).\n * @default 'vertical'\n */\n resize?: 'vertical' | 'none';\n /**\n * 내용에 따라 높이 자동 조절. `rows`(최소)에서 시작해 내용만큼 늘어나고,\n * `maxRows`에 도달하면 멈추고 스크롤이 생긴다.\n * @default false\n */\n autosize?: boolean;\n /** `autosize`일 때 최대 줄 수 — 넘으면 스크롤. 미지정 시 무제한 확장. */\n maxRows?: number;\n}\n\n/**\n * 여러 줄 텍스트 입력. Input과 동일한 디자인 언어(보더·상태·타이포)와\n * 라벨/힌트/에러 자동 연결 패턴을 따르는 자체 완결형.\n * 높이는 native `rows`(기본 3)로 제어하고, `autosize`로 자동 확장도 지원한다.\n *\n * @example\n * <Textarea label=\"문의 내용\" rows={5} block />\n *\n * @example 자동 높이 — 3줄에서 시작해 8줄까지 늘어난 뒤 스크롤\n * <Textarea label=\"댓글\" autosize rows={3} maxRows={8} />\n */\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(\n {\n label,\n hideLabel = false,\n description,\n invalid = false,\n errorMessage,\n block = false,\n resize = 'vertical',\n autosize = false,\n maxRows,\n rows = 3,\n disabled = false,\n className,\n id: idProp,\n value,\n onChange,\n 'aria-describedby': ariaDescribedby,\n ...rest\n },\n ref,\n) {\n const reactId = useId();\n const id = idProp ?? `nuri-textarea-${reactId}`;\n const messageId = `${id}-message`;\n\n const innerRef = useRef<HTMLTextAreaElement | null>(null);\n const setRefs = useCallback(\n (node: HTMLTextAreaElement | null) => {\n innerRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) ref.current = node;\n },\n [ref],\n );\n\n // autosize: height를 내용(scrollHeight)에 맞추되 maxRows 상한에서 클램프.\n const fitHeight = useCallback(() => {\n const el = innerRef.current;\n if (!el) return;\n const cs = getComputedStyle(el);\n const borders = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);\n const lineHeight = parseFloat(cs.lineHeight);\n // maxRows → px 상한 (line-height 계산 불가 환경에서는 무제한)\n const maxHeight =\n maxRows != null && !Number.isNaN(lineHeight)\n ? maxRows * lineHeight + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + borders\n : Infinity;\n\n el.style.height = 'auto'; // 줄어드는 경우도 반영되도록 초기화 후 측정\n const contentHeight = el.scrollHeight + borders; // scrollHeight는 border 미포함(border-box 보정)\n const next = Math.min(contentHeight, maxHeight);\n if (!Number.isNaN(next)) el.style.height = `${next}px`;\n el.style.overflowY = contentHeight > maxHeight ? 'auto' : 'hidden';\n }, [maxRows]);\n\n // 마운트·제어형 value 변경·autosize 토글 시 재계산\n useLayoutEffect(() => {\n if (autosize) fitHeight();\n }, [autosize, fitHeight, value]);\n\n const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {\n onChange?.(e);\n if (autosize) fitHeight(); // 비제어형 입력에도 반응\n };\n\n // invalid + errorMessage → 에러 메시지 / 아니면 힌트. 시각 슬롯은 항상 한 줄. (Input과 동일)\n const message = invalid && errorMessage != null ? errorMessage : description;\n const hasMessage = message != null;\n const describedBy =\n cx(hasMessage ? messageId : undefined, ariaDescribedby as string | undefined) || undefined;\n\n return (\n <div className={cx('nuri-textarea', block && 'nuri-textarea--block', className)}>\n {label != null && (\n <label htmlFor={id} className={cx('nuri-textarea__label', hideLabel && 'nuri-sr-only')}>\n {label}\n </label>\n )}\n\n <textarea\n ref={setRefs}\n id={id}\n rows={rows}\n value={value}\n onChange={handleChange}\n className={cx(\n 'nuri-textarea__control',\n invalid && 'nuri-textarea__control--invalid',\n (resize === 'none' || autosize) && 'nuri-textarea__control--resize-none',\n )}\n disabled={disabled}\n aria-invalid={invalid || undefined}\n aria-describedby={describedBy}\n {...rest}\n />\n\n {hasMessage && (\n <p\n id={messageId}\n className={cx('nuri-textarea__message', invalid && 'nuri-textarea__message--error')}\n >\n {message}\n </p>\n )}\n </div>\n );\n});\n","/**\n * 조건부 className 합성 유틸. falsy 값(undefined/false/null/'')은 제거.\n * @example cx('btn', isActive && 'btn--active', className)\n */\nexport function cx(...classes: Array<string | false | null | undefined>): string {\n return classes.filter(Boolean).join(' ');\n}\n"],"mappings":";AAEA,OAAO;AACP,SAAS,YAAY,aAAa,OAAO,iBAAiB,cAAc;;;ACCjE,SAAS,MAAM,SAA2D;AAC/E,SAAO,QAAQ,OAAO,OAAO,EAAE,KAAK,GAAG;AACzC;;;ADqHI,SAEI,KAFJ;AAzEG,IAAM,WAAW,WAA+C,SAASA,UAC9E;AAAA,EACE;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX;AAAA,EACA,OAAO;AAAA,EACP,WAAW;AAAA,EACX;AAAA,EACA,IAAI;AAAA,EACJ;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB,GAAG;AACL,GACA,KACA;AACA,QAAM,UAAU,MAAM;AACtB,QAAM,KAAK,UAAU,iBAAiB,OAAO;AAC7C,QAAM,YAAY,GAAG,EAAE;AAEvB,QAAM,WAAW,OAAmC,IAAI;AACxD,QAAM,UAAU;AAAA,IACd,CAAC,SAAqC;AACpC,eAAS,UAAU;AACnB,UAAI,OAAO,QAAQ,WAAY,KAAI,IAAI;AAAA,eAC9B,IAAK,KAAI,UAAU;AAAA,IAC9B;AAAA,IACA,CAAC,GAAG;AAAA,EACN;AAGA,QAAM,YAAY,YAAY,MAAM;AAClC,UAAM,KAAK,SAAS;AACpB,QAAI,CAAC,GAAI;AACT,UAAM,KAAK,iBAAiB,EAAE;AAC9B,UAAM,UAAU,WAAW,GAAG,cAAc,IAAI,WAAW,GAAG,iBAAiB;AAC/E,UAAM,aAAa,WAAW,GAAG,UAAU;AAE3C,UAAM,YACJ,WAAW,QAAQ,CAAC,OAAO,MAAM,UAAU,IACvC,UAAU,aAAa,WAAW,GAAG,UAAU,IAAI,WAAW,GAAG,aAAa,IAAI,UAClF;AAEN,OAAG,MAAM,SAAS;AAClB,UAAM,gBAAgB,GAAG,eAAe;AACxC,UAAM,OAAO,KAAK,IAAI,eAAe,SAAS;AAC9C,QAAI,CAAC,OAAO,MAAM,IAAI,EAAG,IAAG,MAAM,SAAS,GAAG,IAAI;AAClD,OAAG,MAAM,YAAY,gBAAgB,YAAY,SAAS;AAAA,EAC5D,GAAG,CAAC,OAAO,CAAC;AAGZ,kBAAgB,MAAM;AACpB,QAAI,SAAU,WAAU;AAAA,EAC1B,GAAG,CAAC,UAAU,WAAW,KAAK,CAAC;AAE/B,QAAM,eAAe,CAAC,MAAwC;AAC5D,eAAW,CAAC;AACZ,QAAI,SAAU,WAAU;AAAA,EAC1B;AAGA,QAAM,UAAU,WAAW,gBAAgB,OAAO,eAAe;AACjE,QAAM,aAAa,WAAW;AAC9B,QAAM,cACJ,GAAG,aAAa,YAAY,QAAW,eAAqC,KAAK;AAEnF,SACE,qBAAC,SAAI,WAAW,GAAG,iBAAiB,SAAS,wBAAwB,SAAS,GAC3E;AAAA,aAAS,QACR,oBAAC,WAAM,SAAS,IAAI,WAAW,GAAG,wBAAwB,aAAa,cAAc,GAClF,iBACH;AAAA,IAGF;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA,UAAU;AAAA,QACV,WAAW;AAAA,UACT;AAAA,UACA,WAAW;AAAA,WACV,WAAW,UAAU,aAAa;AAAA,QACrC;AAAA,QACA;AAAA,QACA,gBAAc,WAAW;AAAA,QACzB,oBAAkB;AAAA,QACjB,GAAG;AAAA;AAAA,IACN;AAAA,IAEC,cACC;AAAA,MAAC;AAAA;AAAA,QACC,IAAI;AAAA,QACJ,WAAW,GAAG,0BAA0B,WAAW,+BAA+B;AAAA,QAEjF;AAAA;AAAA,IACH;AAAA,KAEJ;AAEJ,CAAC;","names":["Textarea"]}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/* Input과 동일한 토큰(--nuri-input-*)을 공유 — 폼 계열 시각 언어 통일 */
|
|
2
|
+
.nuri-textarea {
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
gap: var(--nuri-input-field-gap);
|
|
6
|
+
font-family: inherit; /* 앱 폰트 상속 */
|
|
7
|
+
}
|
|
8
|
+
.nuri-textarea--block {
|
|
9
|
+
display: flex;
|
|
10
|
+
width: 100%;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.nuri-textarea__label {
|
|
14
|
+
font-size: var(--nuri-input-label-size);
|
|
15
|
+
font-weight: var(--nuri-input-label-weight);
|
|
16
|
+
line-height: var(--nuri-input-line-height);
|
|
17
|
+
color: var(--nuri-input-label-color);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* 시각적으로만 숨김 (스크린리더에는 노출) — 이 CSS 단독 사용 대비 재선언 */
|
|
21
|
+
.nuri-sr-only {
|
|
22
|
+
position: absolute;
|
|
23
|
+
width: 1px;
|
|
24
|
+
height: 1px;
|
|
25
|
+
padding: 0;
|
|
26
|
+
margin: -1px;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
clip: rect(0, 0, 0, 0);
|
|
29
|
+
white-space: nowrap;
|
|
30
|
+
border: 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.nuri-textarea__control {
|
|
34
|
+
box-sizing: border-box;
|
|
35
|
+
width: 100%;
|
|
36
|
+
min-width: 0;
|
|
37
|
+
padding: var(--nuri-input-padding-y) var(--nuri-input-padding-x);
|
|
38
|
+
background-color: var(--nuri-input-bg);
|
|
39
|
+
border: 1px solid var(--nuri-input-border);
|
|
40
|
+
border-radius: var(--nuri-input-radius);
|
|
41
|
+
font-family: inherit;
|
|
42
|
+
font-size: var(--nuri-input-font-size);
|
|
43
|
+
font-weight: var(--nuri-input-font-weight);
|
|
44
|
+
line-height: var(--nuri-input-line-height);
|
|
45
|
+
color: var(--nuri-input-text);
|
|
46
|
+
outline: none;
|
|
47
|
+
resize: vertical;
|
|
48
|
+
transition:
|
|
49
|
+
border-color 120ms ease,
|
|
50
|
+
background-color 120ms ease;
|
|
51
|
+
}
|
|
52
|
+
.nuri-textarea__control--resize-none {
|
|
53
|
+
resize: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.nuri-textarea__control::placeholder {
|
|
57
|
+
color: var(--nuri-input-placeholder);
|
|
58
|
+
opacity: 1; /* Firefox 기본 opacity 제거 */
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* focus — 브랜드색 보더 (Input의 focus 문법과 동일, use-site 폴백) */
|
|
62
|
+
.nuri-textarea__control:focus {
|
|
63
|
+
border-color: var(--nuri-input-border-focus, var(--nuri-color-brand-default));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* error — focus보다 뒤에 선언해 에러 상태가 우선 */
|
|
67
|
+
.nuri-textarea__control--invalid,
|
|
68
|
+
.nuri-textarea__control--invalid:focus {
|
|
69
|
+
background-color: var(--nuri-input-bg-error);
|
|
70
|
+
border-color: var(--nuri-input-border-error);
|
|
71
|
+
color: var(--nuri-input-text-error);
|
|
72
|
+
}
|
|
73
|
+
.nuri-textarea__control--invalid::placeholder {
|
|
74
|
+
color: var(--nuri-input-text-error);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* disabled */
|
|
78
|
+
.nuri-textarea__control:disabled {
|
|
79
|
+
background-color: var(--nuri-input-bg-disabled);
|
|
80
|
+
border-color: var(--nuri-input-border-disabled);
|
|
81
|
+
color: var(--nuri-input-text-disabled);
|
|
82
|
+
-webkit-text-fill-color: var(--nuri-input-text-disabled); /* Safari disabled 색 강제 */
|
|
83
|
+
cursor: not-allowed;
|
|
84
|
+
resize: none;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.nuri-textarea__message {
|
|
88
|
+
margin: 0;
|
|
89
|
+
font-size: var(--nuri-input-desc-size);
|
|
90
|
+
line-height: var(--nuri-input-line-height);
|
|
91
|
+
color: var(--nuri-input-desc-color);
|
|
92
|
+
}
|
|
93
|
+
.nuri-textarea__message--error {
|
|
94
|
+
color: var(--nuri-input-text-error);
|
|
95
|
+
}
|