Field Button
입력 필드 형태의 버튼으로, 선택창이나 피커를 열 때 사용합니다. 선택이 완료되면 버튼 라벨에 선택된 값이 표시됩니다.
import "./styles";
import { useCallback, useState } from "@lynx-js/react";
import { VStack } from "@seed-design/lynx-react";
import {
FieldButton,
FieldButtonPlaceholder,
FieldButtonValue,
} from "@/components/ui/field-button";
export default function Example() {
const [value, setValue] = useState<string | null>(null);
const selectValue = useCallback(() => {
"background only";
setValue("판교동");
}, []);
const changeValues = useCallback(([nextValue]: string[]) => {
"background only";
setValue(nextValue ?? null);
}, []);
return (
<VStack className="field-button-preview__content">
<FieldButton
label="동네"
description="거래할 동네를 선택해 주세요."
values={value == null ? [] : [value]}
onValuesChange={changeValues}
showClearButton={value != null}
buttonProps={{
"accessibility-label": value ? `동네 변경. 현재: ${value}` : "동네 선택",
bindtap: selectValue,
}}
>
{value == null ? (
<FieldButtonPlaceholder>동네를 선택해 주세요</FieldButtonPlaceholder>
) : (
<FieldButtonValue>{value}</FieldButtonValue>
)}
</FieldButton>
</VStack>
);
}Installation
npx @seed-design/cli@latest add ui:field-buttonpnpm dlx @seed-design/cli@latest add ui:field-buttonyarn dlx @seed-design/cli@latest add ui:field-buttonbun x @seed-design/cli@latest add ui:field-button의존성 설치
npm install @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactyarn add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactpnpm add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactbun add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react아래 코드를 복사 후 붙여넣고 사용하세요
/**
* @file ui:field-button
* @requires @seed-design/lynx-react@^1.0.0
* @requires @seed-design/lynx-css@^1.0.0
* @requires @karrotmarket/lynx-monochrome-icon@>=1.20.0 <2.0.0
**/
import IconXmarkCircleFill from "@karrotmarket/lynx-monochrome-icon/IconXmarkCircleFill";
import * as React from "@lynx-js/react";
import { Field as SeedField, FieldButton as SeedFieldButton } from "@seed-design/lynx-react";
interface FieldButtonClearButtonProps extends Omit<SeedFieldButton.ClearButtonProps, "icon"> {}
export interface FieldButtonProps extends Omit<SeedFieldButton.RootProps, "children"> {
children?: React.ReactNode;
label?: React.ReactNode;
labelWeight?: SeedField.LabelProps["weight"];
indicator?: React.ReactNode;
prefixIcon?: SeedFieldButton.PrefixIconProps["icon"];
prefix?: React.ReactNode;
suffixIcon?: SeedFieldButton.SuffixIconProps["icon"];
suffix?: React.ReactNode;
description?: React.ReactNode;
errorMessage?: React.ReactNode;
required?: boolean;
showRequiredIndicator?: boolean;
showClearButton?: boolean;
buttonProps?: SeedFieldButton.ButtonProps;
clearButtonProps?: FieldButtonClearButtonProps;
fieldRef?: React.Ref<React.ComponentRef<typeof SeedField.Root>>;
controlRef?: React.Ref<React.ComponentRef<typeof SeedFieldButton.Root>>;
}
/**
* @see https://seed-design.io/lynx/components/field-button
*/
export const FieldButton = React.forwardRef<unknown, FieldButtonProps>((props, ref) => {
const {
children,
label,
labelWeight,
indicator,
prefixIcon,
prefix,
suffixIcon,
suffix,
description,
errorMessage,
required,
showRequiredIndicator,
showClearButton,
buttonProps,
clearButtonProps,
fieldRef,
controlRef,
disabled,
invalid,
readOnly,
...rootProps
} = props;
const renderHeader = label != null || indicator != null;
const renderDescription = description != null && !(invalid && errorMessage != null);
const renderErrorMessage = invalid && errorMessage != null;
const renderFooter = renderDescription || renderErrorMessage;
const renderClearButton = showClearButton && !disabled && !readOnly;
if (process.env.NODE_ENV !== "production" && !buttonProps?.["accessibility-label"]) {
console.warn("FieldButton: `buttonProps.accessibility-label` should be provided.");
}
return (
<SeedField.Root
ref={fieldRef}
required={required}
disabled={disabled}
invalid={invalid}
readOnly={readOnly}
>
{renderHeader ? (
<SeedField.Header>
<SeedField.Label weight={labelWeight}>
{label}
{showRequiredIndicator ? <SeedField.RequiredIndicator /> : null}
{indicator != null ? (
<SeedField.IndicatorText>{indicator}</SeedField.IndicatorText>
) : null}
</SeedField.Label>
</SeedField.Header>
) : null}
<SeedFieldButton.Root
ref={controlRef}
disabled={disabled}
invalid={invalid}
readOnly={readOnly}
{...rootProps}
>
<SeedFieldButton.Button ref={ref} {...buttonProps} />
{prefixIcon ? <SeedFieldButton.PrefixIcon icon={prefixIcon} /> : null}
{prefix != null ? <SeedFieldButton.PrefixText>{prefix}</SeedFieldButton.PrefixText> : null}
{children}
{renderClearButton ? (
<SeedFieldButton.ClearButton
// 소비처에서 서비스 언어에 맞는 레이블로 재정의할 수 있습니다.
accessibility-label="지우기"
icon={<IconXmarkCircleFill />}
{...clearButtonProps}
/>
) : null}
{suffix != null ? <SeedFieldButton.SuffixText>{suffix}</SeedFieldButton.SuffixText> : null}
{suffixIcon ? <SeedFieldButton.SuffixIcon icon={suffixIcon} /> : null}
</SeedFieldButton.Root>
{renderFooter ? (
<SeedField.Footer>
{renderDescription ? <SeedField.Description>{description}</SeedField.Description> : null}
{renderErrorMessage ? (
<SeedField.ErrorMessage>{errorMessage}</SeedField.ErrorMessage>
) : null}
</SeedField.Footer>
) : null}
</SeedField.Root>
);
});
FieldButton.displayName = "FieldButton";
export interface FieldButtonValueProps extends SeedFieldButton.ValueProps {}
/**
* @see https://seed-design.io/lynx/components/field-button
*/
export const FieldButtonValue = SeedFieldButton.Value;
export interface FieldButtonPlaceholderProps extends SeedFieldButton.PlaceholderProps {}
/**
* @see https://seed-design.io/lynx/components/field-button
*/
export const FieldButtonPlaceholder = SeedFieldButton.Placeholder;
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
Props
FieldButton
Prop
Type
children?React.ReactNodelabel?React.ReactNodelabelWeight?"medium" | "bold" | undefinedindicator?React.ReactNodeprefixIcon?React.ReactElement<LynxIconElementProps, string | React.JSXElementConstructor<any>> | undefinedprefix?React.ReactNodesuffixIcon?React.ReactElement<LynxIconElementProps, string | React.JSXElementConstructor<any>> | undefinedsuffix?React.ReactNodedescription?React.ReactNodeerrorMessage?React.ReactNoderequired?boolean | undefinedshowRequiredIndicator?boolean | undefinedshowClearButton?boolean | undefinedbuttonProps?SeedFieldButton.ButtonProps | undefinedclearButtonProps?FieldButtonClearButtonProps | undefinedfieldRef?React.Ref<NodesRef> | undefinedcontrolRef?React.Ref<NodesRef> | undefinedFieldButtonValue
고유 prop이 없습니다. className, style, children을 받습니다.
FieldButtonPlaceholder
고유 prop이 없습니다. className, style, children을 받습니다.
Examples
Basic Usage
FieldButton은 값을 직접 편집하지 않고 선택창이나 피커를 여는 버튼입니다. buttonProps.bindtap에 열기 동작을 전달하고, 선택 여부에 따라 FieldButtonValue 또는 FieldButtonPlaceholder를 자식으로 사용합니다.
import "./styles";
import { useState } from "@lynx-js/react";
import { VStack } from "@seed-design/lynx-react";
import {
FieldButton,
FieldButtonPlaceholder,
FieldButtonValue,
} from "@/components/ui/field-button";
export default function Example() {
const [selectedCity, setSelectedCity] = useState("");
function selectCity() {
"background only";
setSelectedCity("서울");
}
function changeCities([nextCity = ""]: string[]) {
"background only";
setSelectedCity(nextCity);
}
return (
<VStack className="field-button-preview__content">
<FieldButton
label="도시"
values={selectedCity ? [selectedCity] : []}
onValuesChange={changeCities}
showClearButton={selectedCity !== ""}
buttonProps={{
bindtap: selectCity,
"accessibility-label": selectedCity ? `도시 변경. 현재: ${selectedCity}` : "도시 선택",
}}
>
{selectedCity ? (
<FieldButtonValue>{selectedCity}</FieldButtonValue>
) : (
<FieldButtonPlaceholder>도시를 선택해주세요</FieldButtonPlaceholder>
)}
</FieldButton>
</VStack>
);
}Clear Button
showClearButton을 true로 설정하면 Clear Button이 표시됩니다. 선택 값은 소비처가 values로 소유하고, Clear Button을 누르면 clearButtonProps.bindtap 다음에 onValuesChange([])가 호출됩니다. onValuesChange에서 소비처 state를 비우세요. Field Button은 값을 직접 바꾸지 않습니다.
FieldButton이 disabled 또는 readOnly 상태이면 Clear Button은 표시되지 않습니다.
import "./styles";
import { useState } from "@lynx-js/react";
import { VStack } from "@seed-design/lynx-react";
import {
FieldButton,
FieldButtonPlaceholder,
FieldButtonValue,
} from "@/components/ui/field-button";
export default function Example() {
const [value, setValue] = useState("판교동");
function selectValue() {
"background only";
setValue("정자동");
}
function changeValues([nextValue = ""]: string[]) {
"background only";
setValue(nextValue);
}
return (
<VStack className="field-button-preview__content">
<FieldButton
label="동네"
values={value ? [value] : []}
onValuesChange={changeValues}
showClearButton={value !== ""}
buttonProps={{
bindtap: selectValue,
"accessibility-label": `동네 선택.${value ? ` 현재 동네는 ${value}입니다.` : ""}`,
}}
>
{value ? (
<FieldButtonValue>{value}</FieldButtonValue>
) : (
<FieldButtonPlaceholder>동네를 선택해주세요</FieldButtonPlaceholder>
)}
</FieldButton>
</VStack>
);
}FieldButtonValue & FieldButtonPlaceholder
FieldButtonValue는 선택된 값을, FieldButtonPlaceholder는 값이 없을 때의 안내를 표시합니다. 두 요소의 텍스트는 스크린 리더가 중복해 읽지 않도록 접근성 트리에서 숨겨집니다.
import "./styles";
import { useState } from "@lynx-js/react";
import { VStack } from "@seed-design/lynx-react";
import {
FieldButton,
FieldButtonPlaceholder,
FieldButtonValue,
} from "@/components/ui/field-button";
export default function Example() {
const [value, setValue] = useState("");
function toggleValue() {
"background only";
setValue((current) => (current ? "" : "값 설정됨"));
}
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton buttonProps={{ "accessibility-label": "현재 값: FieldButtonValue" }}>
<FieldButtonValue>FieldButtonValue</FieldButtonValue>
</FieldButton>
<FieldButton buttonProps={{ "accessibility-label": "현재 값 없음" }}>
<FieldButtonPlaceholder>FieldButtonPlaceholder</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
buttonProps={{
bindtap: toggleValue,
"accessibility-label": value ? `값 지우기. 현재: ${value}` : "값 설정",
}}
>
{value ? (
<FieldButtonValue>{value}</FieldButtonValue>
) : (
<FieldButtonPlaceholder>탭하여 값 설정</FieldButtonPlaceholder>
)}
</FieldButton>
</VStack>
);
}Accessibility
Field Button 내부 버튼의 accessibility-label에 열리는 선택 화면과 현재 선택된 값을 함께 설명하세요.
<FieldButton
label="사용자 이름"
description="본명을 사용하지 않아도 괜찮습니다."
buttonProps={{
"accessibility-label": `사용자 이름 선택 화면 열기. 현재 선택된 이름: ${username || "없음"}`,
bindtap: openUsernamePicker,
}}
>
{username ? (
<FieldButtonValue>{username}</FieldButtonValue>
) : (
<FieldButtonPlaceholder>김하늘</FieldButtonPlaceholder>
)}
</FieldButton>Use Cases
Controlled State
Lynx는 HTML Form을 지원하지 않습니다. 선택 값은 React state로 관리해 values로 전달하고, buttonProps.bindtap에서 피커를 연 뒤 값을 갱신합니다. Clear Button이 요청하는 빈 배열은 onValuesChange에서 같은 state에 반영합니다.
import "./styles";
import { useState } from "@lynx-js/react";
import { VStack } from "@seed-design/lynx-react";
import {
FieldButton,
FieldButtonPlaceholder,
FieldButtonValue,
} from "@/components/ui/field-button";
export default function Example() {
const [selectedCity, setSelectedCity] = useState("");
function selectCity() {
"background only";
setSelectedCity("서울");
}
function changeCities([nextCity = ""]: string[]) {
"background only";
setSelectedCity(nextCity);
}
return (
<VStack className="field-button-preview__content">
<FieldButton
label="도시"
values={selectedCity ? [selectedCity] : []}
onValuesChange={changeCities}
showClearButton={selectedCity !== ""}
buttonProps={{
bindtap: selectCity,
"accessibility-label": selectedCity ? `도시 변경. 현재: ${selectedCity}` : "도시 선택",
}}
>
{selectedCity ? (
<FieldButtonValue>{selectedCity}</FieldButtonValue>
) : (
<FieldButtonPlaceholder>도시를 선택해주세요</FieldButtonPlaceholder>
)}
</FieldButton>
</VStack>
);
}Bottom Sheet or Picker
buttonProps.bindtap에서 Bottom Sheet나 피커를 열고, 선택 결과를 FieldButtonValue로 렌더링하세요. Field Button은 어떤 선택 UI를 열지 정하지 않습니다.
import "./styles";
import { useState } from "@lynx-js/react";
import { ActionButton, VStack } from "@seed-design/lynx-react";
import {
BottomSheetContent,
BottomSheetFooter,
BottomSheetRoot,
} from "@/components/ui/bottom-sheet";
import {
FieldButton,
FieldButtonPlaceholder,
FieldButtonValue,
} from "@/components/ui/field-button";
export default function Example() {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
function openPicker() {
"background only";
setOpen(true);
}
function selectValue() {
"background only";
setValue("판교동");
setOpen(false);
}
function changeValues([nextValue = ""]: string[]) {
"background only";
setValue(nextValue);
}
return (
<VStack className="field-button-preview__content">
<FieldButton
label="동네"
values={value ? [value] : []}
onValuesChange={changeValues}
showClearButton={value !== ""}
buttonProps={{
bindtap: openPicker,
"accessibility-label": value ? `동네 변경. 현재: ${value}` : "동네 선택",
}}
>
{value ? (
<FieldButtonValue>{value}</FieldButtonValue>
) : (
<FieldButtonPlaceholder>동네를 선택해주세요</FieldButtonPlaceholder>
)}
</FieldButton>
<BottomSheetRoot open={open} onOpenChange={setOpen}>
<BottomSheetContent title="동네 선택" description="거래할 동네를 선택해주세요.">
<BottomSheetFooter>
<ActionButton variant="neutralSolid" bindtap={selectValue}>
판교동 선택
</ActionButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetRoot>
</VStack>
);
}State
Enabled
import "./styles";
import { VStack } from "@seed-design/lynx-react";
import { FieldButton, FieldButtonPlaceholder } from "@/components/ui/field-button";
export default function Example() {
function handleTap() {
"background only";
}
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton
label="라벨"
description="설명을 써주세요"
buttonProps={{ bindtap: handleTap, "accessibility-label": "값 선택" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
label="라벨"
invalid
errorMessage="오류가 발생한 이유를 써주세요"
buttonProps={{ bindtap: handleTap, "accessibility-label": "값 다시 선택" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
</VStack>
);
}Disabled
disabled 상태에서는 버튼 tap handler가 실행되지 않고 Clear Button도 렌더링되지 않습니다.
import "./styles";
import { VStack } from "@seed-design/lynx-react";
import { FieldButton, FieldButtonPlaceholder } from "@/components/ui/field-button";
export default function Example() {
function handleTap() {
"background only";
}
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton
label="라벨"
description="설명을 써주세요"
disabled
showClearButton
buttonProps={{ bindtap: handleTap, "accessibility-label": "값 선택" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
label="라벨"
disabled
invalid
errorMessage="오류가 발생한 이유를 써주세요"
buttonProps={{ bindtap: handleTap, "accessibility-label": "값 선택" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
</VStack>
);
}Read Only
readOnly 상태에서는 현재 값을 표시하지만 버튼 tap handler가 실행되지 않고 Clear Button도 렌더링되지 않습니다.
import "./styles";
import { VStack } from "@seed-design/lynx-react";
import { FieldButton, FieldButtonPlaceholder } from "@/components/ui/field-button";
export default function Example() {
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton
label="라벨"
description="설명을 써주세요"
readOnly
showClearButton
buttonProps={{ "accessibility-label": "현재 값: 플레이스홀더" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
label="라벨"
readOnly
invalid
errorMessage="오류가 발생한 이유를 써주세요"
buttonProps={{ "accessibility-label": "현재 값: 플레이스홀더" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
</VStack>
);
}Size
size로 Field Button의 크기를 정합니다. 기본값은 large입니다.
Lynx에서는 large와 medium을 지원합니다. CSS viewport breakpoint가 없어 responsive는 지원하지 않습니다.
import "./styles";
import { VStack } from "@seed-design/lynx-react";
import { FieldButton, FieldButtonPlaceholder } from "@/components/ui/field-button";
export default function Example() {
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton
label="라벨"
description="size=large (default)"
size="large"
buttonProps={{ "accessibility-label": "큰 크기 선택 화면 열기" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
label="라벨"
description="size=medium"
size="medium"
buttonProps={{ "accessibility-label": "중간 크기 선택 화면 열기" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
</VStack>
);
}Customizable Parts
Prefix
import "./styles";
import IconMagnifyingglassLine from "@karrotmarket/lynx-monochrome-icon/IconMagnifyingglassLine";
import { VStack } from "@seed-design/lynx-react";
import { FieldButton, FieldButtonPlaceholder } from "@/components/ui/field-button";
export default function Example() {
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton
label="주소"
description="사이트 주소를 선택해주세요."
prefix="https://"
buttonProps={{ "accessibility-label": "사이트 주소 선택" }}
>
<FieldButtonPlaceholder>example.com</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
label="검색"
description="검색 조건을 선택해주세요."
prefixIcon={<IconMagnifyingglassLine />}
buttonProps={{ "accessibility-label": "검색 조건 선택" }}
>
<FieldButtonPlaceholder>검색 조건</FieldButtonPlaceholder>
</FieldButton>
</VStack>
);
}Suffix
import "./styles";
import IconWonLine from "@karrotmarket/lynx-monochrome-icon/IconWonLine";
import { VStack } from "@seed-design/lynx-react";
import { FieldButton, FieldButtonPlaceholder } from "@/components/ui/field-button";
export default function Example() {
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton
label="키"
description="측정한 키를 선택해주세요."
suffix="cm"
buttonProps={{ "accessibility-label": "키 선택" }}
>
<FieldButtonPlaceholder>170</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
label="금액"
description="거래 금액을 선택해주세요."
suffixIcon={<IconWonLine />}
buttonProps={{ "accessibility-label": "거래 금액 선택" }}
>
<FieldButtonPlaceholder>50,000</FieldButtonPlaceholder>
</FieldButton>
</VStack>
);
}Both Affixes
import "./styles";
import IconPlusCircleLine from "@karrotmarket/lynx-monochrome-icon/IconPlusCircleLine";
import IconWonLine from "@karrotmarket/lynx-monochrome-icon/IconWonLine";
import { VStack } from "@seed-design/lynx-react";
import { FieldButton, FieldButtonPlaceholder } from "@/components/ui/field-button";
export default function Example() {
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton
label="나이"
description="나이를 선택해주세요."
prefix="만"
suffix="세"
buttonProps={{ "accessibility-label": "나이 선택" }}
>
<FieldButtonPlaceholder>25</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
label="추가 금액"
description="추가할 금액을 선택해주세요."
prefixIcon={<IconPlusCircleLine />}
suffixIcon={<IconWonLine />}
buttonProps={{ "accessibility-label": "추가 금액 선택" }}
>
<FieldButtonPlaceholder>50,000</FieldButtonPlaceholder>
</FieldButton>
</VStack>
);
}Indicator
indicator 또는 showRequiredIndicator를 사용할 수 있습니다. 필수 항목에는 required도 함께 지정합니다.
import "./styles";
import { VStack } from "@seed-design/lynx-react";
import { FieldButton, FieldButtonPlaceholder } from "@/components/ui/field-button";
export default function Example() {
return (
<VStack className="field-button-preview__content" gap="spacingY.componentDefault">
<FieldButton
label="선택 필드"
labelWeight="bold"
indicator="선택"
description="이 필드는 선택사항입니다."
buttonProps={{ "accessibility-label": "선택 값 입력" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
<FieldButton
label="필수 필드"
required
showRequiredIndicator
description="이 필드는 필수사항입니다."
buttonProps={{ "accessibility-label": "필수 값 입력" }}
>
<FieldButtonPlaceholder>플레이스홀더</FieldButtonPlaceholder>
</FieldButton>
</VStack>
);
}Web Version Differences
- HTML
<button>대신 Lynx<view>에 tap handler와accessibility-*속성을 적용합니다. onClick대신buttonProps.bindtap을 사용합니다. main thread handler가 필요하면buttonProps["main-thread:bindtap"]을 사용할 수 있습니다.aria-label대신buttonProps["accessibility-label"]을 사용합니다.
Unsupported Lynx Features
- HTML form submit과 hidden input(
HiddenInput·name), browser validation, React Hook Form 연동은 지원하지 않습니다. Lynx에는 native form 제출 모델이 없습니다. - DOM id 기반
aria-describedby연결과aria-haspopup은 지원하지 않습니다.
Last updated on