diff --git a/docs/components/example/index.json b/docs/components/example/index.json
index 4444b47fb..3b7537243 100644
--- a/docs/components/example/index.json
+++ b/docs/components/example/index.json
@@ -108,6 +108,7 @@
"inline-banner-with-icon": "import { InlineBanner } from \"seed-design/ui/inline-banner\";\nimport { IconILowercaseSerifCircleFill } from \"@daangn/react-monochrome-icon\";\n\nexport default function InlineBannerWithIcon() {\n return (\n }>\n 다른 사람과 예약된 물품이 있어요.\n \n );\n}",
"inline-banner-with-link": "import { InlineBanner } from \"seed-design/ui/inline-banner\";\n\nexport default function InlineBannerWithLink() {\n return (\n {} }}>\n 다른 사람과 예약된 물품이 있어요.\n \n );\n}",
"inline-banner-with-title-text": "import { InlineBanner } from \"seed-design/ui/inline-banner\";\n\nexport default function InlineBannerWithTitleText() {\n return (\n \n 다른 사람과 예약된 물품이 있어요.\n \n );\n}",
+ "multiline-text-field-preview": "import { useState } from \"react\";\nimport { MultilineTextField } from \"seed-design/ui/multiline-text-field\";\n\nexport default function TextFieldPreview() {\n const [value, setValue] = useState(\"\");\n\n return (\n
\n );\n}",
"segmented-control-disabled": "import { SegmentedControl, Segment } from \"seed-design/ui/segmented-control\";\n\nexport default function SegmentedControlPreview() {\n return (\n \n Hot\n New\n \n );\n}",
"segmented-control-fixed-width": "import { SegmentedControl, Segment } from \"seed-design/ui/segmented-control\";\n\nexport default function SegmentedControlFixedWidth() {\n return (\n \n New\n Hot\n \n );\n}",
"segmented-control-long-label-fixed-width": "import { SegmentedControl, Segment } from \"seed-design/ui/segmented-control\";\n\nexport default function SegmentedControlLongLabelFixedWidth() {\n return (\n \n 가격 높은 순\n 할인율 높은 순\n 인기 많은 순\n \n );\n}",
diff --git a/docs/components/example/multiline-text-field-preview.tsx b/docs/components/example/multiline-text-field-preview.tsx
new file mode 100644
index 000000000..d3f13d655
--- /dev/null
+++ b/docs/components/example/multiline-text-field-preview.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { useState } from "react";
+import { MultilineTextField } from "seed-design/ui/multiline-text-field";
+
+export default function TextFieldPreview() {
+ const [value, setValue] = useState("");
+
+ return (
+
+ );
+}
diff --git a/docs/content/docs/react/components/text-fields/meta.json b/docs/content/docs/react/components/text-fields/meta.json
new file mode 100644
index 000000000..31698d2e2
--- /dev/null
+++ b/docs/content/docs/react/components/text-fields/meta.json
@@ -0,0 +1,5 @@
+{
+ "title": "Text Fields",
+ "pages": ["..."],
+ "defaultOpen": true
+}
diff --git a/docs/content/docs/react/components/text-fields/multiline-text-field.mdx b/docs/content/docs/react/components/text-fields/multiline-text-field.mdx
new file mode 100644
index 000000000..abb22e183
--- /dev/null
+++ b/docs/content/docs/react/components/text-fields/multiline-text-field.mdx
@@ -0,0 +1,61 @@
+---
+title: Multiline Text Field
+description: 사용자가 입력할 수 있는 텍스트를 받는 컴포넌트에요. 여러 줄의 텍스트를 입력할 수 있고 높이가 자동으로 조절돼요.
+---
+
+
+
+## 설치
+
+
+
+## Props
+
+
+
+## 예제
+
+### Status
+
+#### Enabled
+
+
+
+#### Disabled
+
+
+
+#### Read Only
+
+
+
+### Customizable Parts
+
+#### Required Indicator
+
+
+
+#### Optional Indicator
+
+
+
+#### Grapheme Count
+
+
+
+### Size
+
+#### XLarge
+
+
+
+#### Large
+
+
+
+#### Medium
+
+
diff --git a/docs/content/docs/react/components/text-field.mdx b/docs/content/docs/react/components/text-fields/text-field.mdx
similarity index 100%
rename from docs/content/docs/react/components/text-field.mdx
rename to docs/content/docs/react/components/text-fields/text-field.mdx
diff --git a/docs/public/__registry__/ui/index.json b/docs/public/__registry__/ui/index.json
index 6ca94c07f..5308d6e24 100644
--- a/docs/public/__registry__/ui/index.json
+++ b/docs/public/__registry__/ui/index.json
@@ -195,5 +195,15 @@
"files": [
"ui:text-field.tsx"
]
+ },
+ {
+ "name": "multiline-text-field",
+ "dependencies": [
+ "@seed-design/react-text-field",
+ "@daangn/react-monochrome-icon"
+ ],
+ "files": [
+ "ui:multiline-text-field.tsx"
+ ]
}
]
\ No newline at end of file
diff --git a/docs/public/__registry__/ui/multiline-text-field.json b/docs/public/__registry__/ui/multiline-text-field.json
new file mode 100644
index 000000000..6c9d8bc47
--- /dev/null
+++ b/docs/public/__registry__/ui/multiline-text-field.json
@@ -0,0 +1,14 @@
+{
+ "name": "multiline-text-field",
+ "dependencies": [
+ "@seed-design/react-text-field",
+ "@daangn/react-monochrome-icon"
+ ],
+ "registries": [
+ {
+ "name": "multiline-text-field.tsx",
+ "type": "ui",
+ "content": "\"use client\";\n\nimport \"@seed-design/stylesheet/textField.css\";\n\nimport * as React from \"react\";\nimport clsx from \"clsx\";\nimport {\n textField,\n type TextFieldVariantProps,\n} from \"@seed-design/recipe/textField\";\nimport { IconExclamationmarkCircleFill } from \"@daangn/react-monochrome-icon\";\nimport {\n useTextField,\n type UseTextFieldProps,\n} from \"@seed-design/react-text-field\";\nimport type { Assign } from \"../util/types\";\n\nexport interface MultilineTextFieldProps\n extends UseTextFieldProps,\n TextFieldVariantProps {\n label?: string;\n requiredIndicator?: string;\n optionalIndicator?: string;\n\n description?: string;\n errorMessage?: string;\n\n maxGraphemeCount?: number;\n hideGraphemeCount?: boolean;\n}\n\ntype ReactMultilineTextFieldProps = Assign<\n Omit<\n React.TextareaHTMLAttributes,\n \"children\" | \"maxLength\"\n >,\n MultilineTextFieldProps\n>;\n\nexport const MultilineTextField = React.forwardRef<\n HTMLTextAreaElement,\n ReactMultilineTextFieldProps\n>(\n (\n {\n size = \"medium\",\n label,\n requiredIndicator,\n optionalIndicator,\n hideGraphemeCount,\n ...restProps\n },\n ref,\n ) => {\n const {\n rootProps: { className: rootClassName, ...rootProps },\n inputProps: { className: inputClassName, ...inputProps },\n labelProps: { className: labelClassName, ...labelProps },\n descriptionProps,\n errorMessageProps,\n stateProps,\n restProps: restInternalProps,\n isInvalid,\n isRequired,\n graphemes,\n } = useTextField({ ...restProps });\n\n const { description, errorMessage, maxGraphemeCount } = restProps;\n\n const classNames = textField({ size });\n\n const indicator = isRequired ? requiredIndicator : optionalIndicator;\n\n const renderDescription = !isInvalid && description;\n const renderErrorMessage = isInvalid && !!errorMessage;\n const renderCharacterCount = !hideGraphemeCount && maxGraphemeCount;\n\n return (\n \n {label && (\n // XXX\n // biome-ignore lint/a11y/noLabelWithoutControl:
\n \n )}\n \n {(renderDescription || renderErrorMessage || renderCharacterCount) && (\n \n {renderDescription && (\n
\n {description}\n
\n )}\n {renderErrorMessage && (\n
\n )}\n {renderCharacterCount && (\n
\n \n {graphemes.length}\n \n \n /{maxGraphemeCount}\n \n
\n )}\n
\n )}\n \n );\n },\n);\nMultilineTextField.displayName = \"MultilineTextField\";\n"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/registry/registry-ui.ts b/docs/registry/registry-ui.ts
index 02077b886..aa488c729 100644
--- a/docs/registry/registry-ui.ts
+++ b/docs/registry/registry-ui.ts
@@ -127,4 +127,12 @@ export const registryUI: RegistryUI = [
],
files: ["ui:text-field.tsx"],
},
+ {
+ name: "multiline-text-field",
+ dependencies: [
+ "@seed-design/react-text-field",
+ "@daangn/react-monochrome-icon",
+ ],
+ files: ["ui:multiline-text-field.tsx"],
+ },
];
diff --git a/docs/registry/ui/multiline-text-field.tsx b/docs/registry/ui/multiline-text-field.tsx
new file mode 100644
index 000000000..865425373
--- /dev/null
+++ b/docs/registry/ui/multiline-text-field.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import "@seed-design/stylesheet/textField.css";
+
+import * as React from "react";
+import clsx from "clsx";
+import {
+ textField,
+ type TextFieldVariantProps,
+} from "@seed-design/recipe/textField";
+import { IconExclamationmarkCircleFill } from "@daangn/react-monochrome-icon";
+import {
+ useTextField,
+ type UseTextFieldProps,
+} from "@seed-design/react-text-field";
+import type { Assign } from "../util/types";
+
+export interface MultilineTextFieldProps
+ extends UseTextFieldProps,
+ TextFieldVariantProps {
+ label?: string;
+ requiredIndicator?: string;
+ optionalIndicator?: string;
+
+ description?: string;
+ errorMessage?: string;
+
+ maxGraphemeCount?: number;
+ hideGraphemeCount?: boolean;
+}
+
+type ReactMultilineTextFieldProps = Assign<
+ Omit<
+ React.TextareaHTMLAttributes,
+ "children" | "maxLength"
+ >,
+ MultilineTextFieldProps
+>;
+
+export const MultilineTextField = React.forwardRef<
+ HTMLTextAreaElement,
+ ReactMultilineTextFieldProps
+>(
+ (
+ {
+ size = "medium",
+ label,
+ requiredIndicator,
+ optionalIndicator,
+ hideGraphemeCount,
+ ...restProps
+ },
+ ref,
+ ) => {
+ const {
+ rootProps: { className: rootClassName, ...rootProps },
+ textareaProps: { className: textareaClassName, ...textareaProps },
+ labelProps: { className: labelClassName, ...labelProps },
+ descriptionProps,
+ errorMessageProps,
+ stateProps,
+ restProps: restInternalProps,
+ isInvalid,
+ isRequired,
+ graphemes,
+ } = useTextField({ elementType: "textarea", ...restProps });
+
+ const { description, errorMessage, maxGraphemeCount } = restProps;
+
+ const classNames = textField({ size });
+
+ const indicator = isRequired ? requiredIndicator : optionalIndicator;
+
+ const renderDescription = !isInvalid && description;
+ const renderErrorMessage = isInvalid && !!errorMessage;
+ const renderCharacterCount = !hideGraphemeCount && maxGraphemeCount;
+
+ return (
+
+ {label && (
+ // XXX
+ // biome-ignore lint/a11y/noLabelWithoutControl:
+
+ )}
+
+
+
+ {(renderDescription || renderErrorMessage || renderCharacterCount) && (
+
+ {renderDescription && (
+
+ {description}
+
+ )}
+ {renderErrorMessage && (
+
+ )}
+ {renderCharacterCount && (
+
+
+ {graphemes.length}
+
+
+ /{maxGraphemeCount}
+
+
+ )}
+
+ )}
+
+ );
+ },
+);
+MultilineTextField.displayName = "MultilineTextField";
diff --git a/packages/react-headless/text-field/src/index.ts b/packages/react-headless/text-field/src/index.ts
index 642dd155d..096b88a3d 100644
--- a/packages/react-headless/text-field/src/index.ts
+++ b/packages/react-headless/text-field/src/index.ts
@@ -2,7 +2,14 @@ import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { useId, useState } from "react";
import { graphemeSegments } from "unicode-segmenter/grapheme";
-import { dataAttr, ariaAttr, elementProps, inputProps, labelProps } from "@seed-design/dom-utils";
+import {
+ dataAttr,
+ ariaAttr,
+ elementProps,
+ inputProps,
+ labelProps,
+ textareaProps,
+} from "@seed-design/dom-utils";
import { getDescriptionId, getErrorMessageId, getInputId, getLabelId } from "./dom";
export interface UseTextFieldStateProps {
@@ -54,6 +61,11 @@ export interface UseTextFieldProps extends UseTextFieldStateProps {
*/
invalid?: boolean;
+ /**
+ * @default "input"
+ */
+ elementType?: "input" | "textarea";
+
name?: string;
description?: string;
@@ -76,6 +88,7 @@ const getSlicedGraphemes = ({
export function useTextField(props: UseTextFieldProps) {
const id = useId();
const {
+ elementType,
value,
description,
errorMessage,
@@ -173,40 +186,80 @@ export function useTextField(props: UseTextFieldProps) {
htmlFor: getInputId(id),
}),
- inputProps: inputProps({
- ...stateProps,
- disabled,
- readOnly,
- "aria-required": ariaAttr(required),
- "aria-invalid": ariaAttr(invalid),
- "aria-describedby": ariaDescribedBy,
- onChange: (e) => {
- const givenValue = e.target.value;
-
- const slicedGraphemes = getSlicedGraphemes({
- maxGraphemeCount,
- value: givenValue,
- });
-
- const value = slicedGraphemes.join("");
-
- setValue(value);
- setIsFocusVisible(e.target.matches(":focus-visible"));
- },
- onBlur(e) {
- setIsFocused(false);
- setIsFocusVisible(false);
- onBlur?.(e);
- },
- onFocus(e) {
- setIsFocused(true);
- setIsFocusVisible(e.target.matches(":focus-visible"));
- onFocus?.(e);
- },
- name: props.name || id,
- id: getInputId(id),
- value: slicedValue,
+ ...(elementType === "input" && {
+ inputProps: inputProps({
+ ...stateProps,
+ disabled,
+ readOnly,
+ "aria-required": ariaAttr(required),
+ "aria-invalid": ariaAttr(invalid),
+ "aria-describedby": ariaDescribedBy,
+ onChange: (e) => {
+ const givenValue = e.target.value;
+
+ const slicedGraphemes = getSlicedGraphemes({
+ maxGraphemeCount,
+ value: givenValue,
+ });
+
+ const value = slicedGraphemes.join("");
+
+ setValue(value);
+ setIsFocusVisible(e.target.matches(":focus-visible"));
+ },
+ onBlur(e) {
+ setIsFocused(false);
+ setIsFocusVisible(false);
+ onBlur?.(e);
+ },
+ onFocus(e) {
+ setIsFocused(true);
+ setIsFocusVisible(e.target.matches(":focus-visible"));
+ onFocus?.(e);
+ },
+ name: props.name || id,
+ id: getInputId(id),
+ value: slicedValue,
+ }),
+ }),
+
+ ...(elementType === "textarea" && {
+ textareaProps: textareaProps({
+ ...stateProps,
+ disabled,
+ readOnly,
+ "aria-required": ariaAttr(required),
+ "aria-invalid": ariaAttr(invalid),
+ "aria-describedby": ariaDescribedBy,
+ onChange: (e) => {
+ const givenValue = e.target.value;
+
+ const slicedGraphemes = getSlicedGraphemes({
+ maxGraphemeCount,
+ value: givenValue,
+ });
+
+ const value = slicedGraphemes.join("");
+
+ setValue(value);
+ setIsFocusVisible(e.target.matches(":focus-visible"));
+ },
+ onBlur(e) {
+ setIsFocused(false);
+ setIsFocusVisible(false);
+ onBlur?.(e);
+ },
+ onFocus(e) {
+ setIsFocused(true);
+ setIsFocusVisible(e.target.matches(":focus-visible"));
+ onFocus?.(e);
+ },
+ name: props.name || id,
+ id: getInputId(id),
+ value: slicedValue,
+ }),
}),
+
descriptionProps: elementProps({
id: getDescriptionId(id),
...stateProps,
diff --git a/packages/recipe-generator/preset/src/text-field.recipe.ts b/packages/recipe-generator/preset/src/text-field.recipe.ts
index 8ec4c502a..290ea53d0 100644
--- a/packages/recipe-generator/preset/src/text-field.recipe.ts
+++ b/packages/recipe-generator/preset/src/text-field.recipe.ts
@@ -111,11 +111,16 @@ const textField = defineRecipe({
},
inputText: {
// XXX: CSS reset 들어오면 제거될 수 있음
- all: "unset",
+ font: "inherit",
+ border: "none",
+ paddingInline: 0,
+ background: "none",
[pseudo(focus)]: {
outline: "none",
},
+ resize: "none",
+
flexGrow: 1,
color: vars.base.enabled.inputText.color,
@@ -161,7 +166,7 @@ const textField = defineRecipe({
graphemeCount: {
flex: "none",
- height: vars.base.enabled.graphemeCount.height,
+ height: vars.base.enabled.graphemeCount.size,
lineHeight: vars.base.enabled.graphemeCount.lineHeight,
},
currentGraphemeCount: {
@@ -196,7 +201,7 @@ const textField = defineRecipe({
marginInlineStart: vars.sizeXlarge.enabled.indicator.marginXStart,
},
input: {
- height: vars.sizeXlarge.enabled.input.size,
+ minHeight: vars.sizeXlarge.enabled.input.minHeight,
borderRadius: vars.sizeXlarge.enabled.input.cornerRadius,
gap: vars.sizeXlarge.enabled.input.gap,
@@ -245,7 +250,7 @@ const textField = defineRecipe({
marginInlineStart: vars.sizeLarge.enabled.indicator.marginXStart,
},
input: {
- height: vars.sizeLarge.enabled.input.size,
+ minHeight: vars.sizeLarge.enabled.input.minHeight,
borderRadius: vars.sizeLarge.enabled.input.cornerRadius,
gap: vars.sizeLarge.enabled.input.gap,
@@ -294,7 +299,7 @@ const textField = defineRecipe({
marginInlineStart: vars.sizeMedium.enabled.indicator.marginXStart,
},
input: {
- height: vars.sizeMedium.enabled.input.size,
+ minHeight: vars.sizeMedium.enabled.input.minHeight,
borderRadius: vars.sizeMedium.enabled.input.cornerRadius,
gap: vars.sizeMedium.enabled.input.gap,
diff --git a/packages/rootage/artifacts/components/text-field.yaml b/packages/rootage/artifacts/components/text-field.yaml
index 2a9670f6c..04ea2c383 100644
--- a/packages/rootage/artifacts/components/text-field.yaml
+++ b/packages/rootage/artifacts/components/text-field.yaml
@@ -44,7 +44,7 @@ data:
fontWeight: $font-weight.regular
graphemeCount:
# XXX: description의 한 줄과 높이가 같아야 함
- height: $line-height.t4
+ size: $line-height.t4
lineHeight: $line-height.t2
currentGraphemeCount:
color: $color.fg.neutral
@@ -90,7 +90,7 @@ data:
fontSize: $font-size.t5
marginXStart: $unit.x1_5
input:
- size: 56px
+ minHeight: 56px
cornerRadius: $radius.x2_5
gap: $unit.x2
paddingX: $unit.x4
@@ -124,7 +124,7 @@ data:
fontSize: $font-size.t5
marginXStart: $unit.x1_5
input:
- size: 52px
+ minHeight: 52px
cornerRadius: $radius.x2_5
gap: $unit.x2
paddingX: $unit.x4
@@ -158,7 +158,7 @@ data:
fontSize: $font-size.t4
marginXStart: $unit.x1
input:
- size: 40px
+ minHeight: 40px
cornerRadius: $radius.x2
gap: $unit.x1_5
paddingX: $unit.x3_5
diff --git a/packages/stylesheet/textField.css b/packages/stylesheet/textField.css
index 6c748d8fb..15409696d 100644
--- a/packages/stylesheet/textField.css
+++ b/packages/stylesheet/textField.css
@@ -69,12 +69,16 @@
color: var(--seed-v3-color-fg-disabled);
}
.textField__inputText {
- all: unset;
+ font: inherit;
+ border: none;
+ padding-inline: 0;
+ background: none;
}
.textField__inputText:is(:focus, [data-focus]) {
outline: none;
}
.textField__inputText {
+ resize: none;
flex-grow: 1;
color: var(--seed-v3-color-fg-neutral);
}
@@ -139,7 +143,7 @@
margin-inline-start: var(--seed-v3-unit-x1_5);
}
.textField__input--size_xlarge {
- height: 56px;
+ min-height: 56px;
border-radius: var(--seed-v3-radius-x2_5);
gap: var(--seed-v3-unit-x2);
padding-inline: var(--seed-v3-unit-x4);
@@ -184,7 +188,7 @@
margin-inline-start: var(--seed-v3-unit-x1_5);
}
.textField__input--size_large {
- height: 52px;
+ min-height: 52px;
border-radius: var(--seed-v3-radius-x2_5);
gap: var(--seed-v3-unit-x2);
padding-inline: var(--seed-v3-unit-x4);
@@ -229,7 +233,7 @@
margin-inline-start: var(--seed-v3-unit-x1);
}
.textField__input--size_medium {
- height: 40px;
+ min-height: 40px;
border-radius: var(--seed-v3-radius-x2);
gap: var(--seed-v3-unit-x1_5);
padding-inline: var(--seed-v3-unit-x3_5);
diff --git a/packages/utils/dom-utils/src/index.ts b/packages/utils/dom-utils/src/index.ts
index 6e3be6821..704e1b97b 100644
--- a/packages/utils/dom-utils/src/index.ts
+++ b/packages/utils/dom-utils/src/index.ts
@@ -20,3 +20,7 @@ export const buttonProps = (props: React.ButtonHTMLAttributes
props;
export const imgProps = (props: React.ImgHTMLAttributes & DataAttr) => props;
+
+export const textareaProps = (
+ props: React.TextareaHTMLAttributes & DataAttr,
+) => props;
diff --git a/packages/vars/src/component/text-field.vars.ts b/packages/vars/src/component/text-field.vars.ts
index d27d66218..b1abc8f33 100644
--- a/packages/vars/src/component/text-field.vars.ts
+++ b/packages/vars/src/component/text-field.vars.ts
@@ -51,7 +51,7 @@ export const vars = {
"fontWeight": "var(--seed-v3-font-weight-regular)"
},
"graphemeCount": {
- "height": "var(--seed-v3-line-height-t4)",
+ "size": "var(--seed-v3-line-height-t4)",
"lineHeight": "var(--seed-v3-line-height-t2)"
},
"currentGraphemeCount": {
@@ -118,7 +118,7 @@ export const vars = {
"marginXStart": "var(--seed-v3-unit-x1_5)"
},
"input": {
- "size": "56px",
+ "minHeight": "56px",
"cornerRadius": "var(--seed-v3-radius-x2_5)",
"gap": "var(--seed-v3-unit-x2)",
"paddingX": "var(--seed-v3-unit-x4)"
@@ -164,7 +164,7 @@ export const vars = {
"marginXStart": "var(--seed-v3-unit-x1_5)"
},
"input": {
- "size": "52px",
+ "minHeight": "52px",
"cornerRadius": "var(--seed-v3-radius-x2_5)",
"gap": "var(--seed-v3-unit-x2)",
"paddingX": "var(--seed-v3-unit-x4)"
@@ -210,7 +210,7 @@ export const vars = {
"marginXStart": "var(--seed-v3-unit-x1)"
},
"input": {
- "size": "40px",
+ "minHeight": "40px",
"cornerRadius": "var(--seed-v3-radius-x2)",
"gap": "var(--seed-v3-unit-x1_5)",
"paddingX": "var(--seed-v3-unit-x3_5)"