정수 단위의 수량을 늘리거나 줄일 때 사용하는 컴포넌트입니다.
@seed-design/lynx-react@0.8.0, @seed-design/lynx-css@0.12.0
import "./styles" ;
import { useSeedClassName } from "@seed-design/lynx-react" ;
import { QuantityPicker } from "@/components/ui/quantity-picker" ;
export default function Example () {
const seedClassName = useSeedClassName ({ colorMode: "system" });
return (
< view className = { `${ seedClassName } docs-lynx-quantity-picker-root` }>
< view className = "quantity-picker-preview" >
< QuantityPicker min = { 1 } max = { 99 } defaultValue = { 1 } accessibility-label = "상품 수량" />
</ view >
</ view >
);
}
npx @seed-design/cli@latest add ui:quantity-picker pnpm dlx @seed-design/cli@latest add ui:quantity-picker yarn dlx @seed-design/cli@latest add ui:quantity-picker bun x @seed-design/cli@latest add ui:quantity-picker
의존성 설치
npm install @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react yarn add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react pnpm add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react bun add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react 아래 코드를 복사 후 붙여넣고 사용하세요 /**
* @file ui:quantity-picker
* @requires @seed-design/lynx-react@>=0.8.0 <1.0.0
* @requires @seed-design/lynx-css@>=0.12.0 <1.0.0
* @requires @karrotmarket/lynx-monochrome-icon@>=1.20.0 <2.0.0
**/
import IconMinusLine from "@karrotmarket/lynx-monochrome-icon/IconMinusLine" ;
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine" ;
import IconTrashcanLine from "@karrotmarket/lynx-monochrome-icon/IconTrashcanLine" ;
import * as React from "@lynx-js/react" ;
import {
ProgressCircle as SeedProgressCircle,
QuantityPicker as SeedQuantityPicker,
} from "@seed-design/lynx-react" ;
function resolveProgressCircleProps (
size : SeedQuantityPicker . RootProps [ "size" ],
) : Pick < SeedProgressCircle . RootProps , "size" | "style" > {
switch (size) {
case "small" :
return { size: "16" };
case "large" :
return { size: "24" , style: { transform: "scale(0.9166667)" } };
default :
return { size: "18" };
}
}
type DistributiveOmit < T , K extends PropertyKey > = T extends unknown ? Omit < T , K > : never ;
export type QuantityPickerProps = DistributiveOmit <
SeedQuantityPicker . RootProps ,
"children" | "removeAccessibilityLabel"
> & {
/**
* Remove 버튼의 접근성 이름입니다.
* @default " 상품 삭제 "
*/
removeAccessibilityLabel ?: SeedQuantityPicker . RootProps [ "removeAccessibilityLabel" ];
/**
* Decrement 버튼의 접근성 이름입니다.
* @default " 수량 줄이기 "
*/
decrementAccessibilityLabel ?: string ;
/**
* Increment 버튼의 접근성 이름입니다.
* @default " 수량 늘리기 "
*/
incrementAccessibilityLabel ?: string ;
/**
* Decrement 버튼에 표시할 아이콘입니다.
* @default <IconMinusLine />
*/
decrementIcon ?: React . ReactNode ;
/**
* Increment 버튼에 표시할 아이콘입니다.
* @default <IconPlusLine />
*/
incrementIcon ?: React . ReactNode ;
/**
* Remove 버튼에 표시할 아이콘입니다.
* @default <IconTrashcanLine />
*/
removeIcon ?: React . ReactNode ;
/**
* loading 상태일 때 버튼에 표시할 요소입니다.
* @default <ProgressCircle.Root><ProgressCircle.Range /></ProgressCircle.Root>
*/
loadingIndicator ?: React . ReactNode ;
};
/**
* @see https://seed-design.io/lynx/components/quantity-picker
*/
export const QuantityPicker = React. forwardRef < unknown , QuantityPickerProps >(
(
{
decrementAccessibilityLabel = "수량 줄이기" ,
incrementAccessibilityLabel = "수량 늘리기" ,
removeAccessibilityLabel = "상품 삭제" ,
decrementIcon = < IconMinusLine />,
incrementIcon = < IconPlusLine />,
removeIcon = < IconTrashcanLine />,
loadingIndicator,
size,
... rootProps
},
ref,
) => {
const resolvedLoadingIndicator =
loadingIndicator === undefined ? (
< SeedProgressCircle.Root { ... resolveProgressCircleProps (size)} tone = "inherit" >
< SeedProgressCircle.Range />
</ SeedProgressCircle.Root >
) : (
loadingIndicator
);
const resolvedRootProps : SeedQuantityPicker . RootProps = rootProps.removable
? {
... rootProps,
removable: true ,
removeAccessibilityLabel,
}
: {
... rootProps,
removable: false ,
removeAccessibilityLabel,
};
return (
< SeedQuantityPicker.Root ref = {ref} size = {size} { ... resolvedRootProps}>
< SeedQuantityPicker.DecrementButton
accessibility-label = {decrementAccessibilityLabel}
icon = {decrementIcon}
loadingIndicator = {resolvedLoadingIndicator}
removeIcon = {removeIcon}
/>
< SeedQuantityPicker.ValueDisplay />
< SeedQuantityPicker.IncrementButton
accessibility-label = {incrementAccessibilityLabel}
icon = {incrementIcon}
loadingIndicator = {resolvedLoadingIndicator}
/>
</ SeedQuantityPicker.Root >
);
},
);
QuantityPicker.displayName = "QuantityPicker" ;
/**
* 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.
*/
Registry의 QuantityPicker는 수량 선택에 필요한 세 슬롯을 하나의 컴포넌트로 제공하는 편의 API입니다.
style?CSSProperties | undefined
className?string | undefined
minnumber
maxnumber
step?number | undefined
value?number | undefined
defaultValue?number | undefined
onValueChange?(( value : number ) => void ) | undefined
disabled?boolean | undefined
invalid?boolean | undefined
readOnly?boolean | undefined
loading?QuantityPickerLoading | undefined
onRemove?(() => void ) | ((() => void ) & (() => void )) | undefined
getValueText?QuantityPickerGetValueText | undefined
dir?"ltr" | "rtl" | undefined
layout?"fill" | "hug" | undefined
size?"small" | "medium" | "large" | undefined
removable?boolean | undefined
min과 max는 필수이며 안전한 정수여야 합니다. step의 기본값은 1이고, defaultValue를 생략하면 min에서 시작합니다. value와 onValueChange를 함께 사용하면 외부 상태로 값을 제어할 수 있습니다.
size: small, medium(기본값), large
layout: hug(기본값), fill. fill에서는 Value Display 영역이 남은 너비를 채웁니다.
loading: true이면 Decrement와 Increment를 모두 막고, 객체로 전달하면 { decrement?: boolean, increment?: boolean } 각 동작을 따로 막습니다.
removable: 최솟값에서 Decrement를 Remove 동작으로 바꿉니다. onRemove에서 상품 삭제 등을 처리합니다.
getValueText: 화면에 표시할 수량 텍스트를 바꿉니다. 실제 수량 값과 onValueChange의 인자는 숫자로 유지됩니다.
Registry를 사용하지 않고 저수준 API를 직접 조합할 때는 다음 네 가지 공개 컴포넌트를 사용합니다.
import { QuantityPicker } from "@seed-design/lynx-react" ;
< QuantityPicker.Root min = { 1 } max = { 99 } accessibility-label = "상품 수량" >
< QuantityPicker.DecrementButton accessibility-label = "수량 줄이기" />
< QuantityPicker.ValueDisplay />
< QuantityPicker.IncrementButton accessibility-label = "수량 늘리기" />
</ QuantityPicker.Root >;
Root에는 수량 범위와 상태(min, max, step, value, defaultValue, onValueChange, disabled, invalid, readOnly, loading, removable, onRemove, getValueText, dir, layout, size)를 전달합니다. removable={true}일 때는 removeAccessibilityLabel도 전달해야 합니다.
DecrementButton, ValueDisplay, IncrementButton은 Root의 Context를 사용해 값과 상태를 공유합니다. 아이콘, loading indicator, 네이티브 accessibility-* 속성, Lynx 이벤트를 각 슬롯에 직접 전달할 수 있습니다. Registry 편의 컴포넌트의 decrementAccessibilityLabel, incrementAccessibilityLabel, decrementIcon, incrementIcon, removeIcon, loadingIndicator는 이 compound 슬롯을 대신 구성해 주는 props입니다. Lynx compound API에는 웹 전용 HiddenInput 슬롯이 없습니다.
layout="hug"는 콘텐츠에 맞는 너비를 유지합니다. 부모가 Flex 레이아웃이고 남은 공간을 채워야 한다면 layout="fill"을 사용하세요. 양쪽 버튼 크기는 유지되고 Value Display 영역만 늘어납니다.
import "./styles" ;
import { useSeedClassName } from "@seed-design/lynx-react" ;
import { QuantityPicker } from "@/components/ui/quantity-picker" ;
export default function Example () {
const seedClassName = useSeedClassName ({ colorMode: "system" });
return (
< view className = { `${ seedClassName } docs-lynx-quantity-picker-root` }>
< view className = "quantity-picker-preview" >
< view className = "quantity-picker-example quantity-picker-layout-host" >
< text className = "quantity-picker-example-title" >Layout</ text >
< view className = "quantity-picker-layout-row" >
< text className = "quantity-picker-layout-label" >Hug (기본)</ text >
< view className = "quantity-picker-example-controls" >
< QuantityPicker
layout = "hug"
min = { 1 }
max = { 99 }
defaultValue = { 1 }
accessibility-label = "Hug 상품 수량"
/>
</ view >
</ view >
< view className = "quantity-picker-layout-row" >
< text className = "quantity-picker-layout-label" >Fill</ text >
< view className = "quantity-picker-example-controls" >
< QuantityPicker
layout = "fill"
min = { 1 }
max = { 99 }
defaultValue = { 1 }
accessibility-label = "Fill 상품 수량"
/>
</ view >
</ view >
</ view >
</ view >
</ view >
);
}
이 예제에서 사용하는 Quantity Picker recipe는 display: grid를 사용합니다. Android/iOS Lynx engine 2.1 이상과 HarmonyOS Lynx engine 3.4 이상에서 호환됩니다.
getValueText를 사용하면 표시되는 수량에 단위나 보조 설명을 덧붙일 수 있습니다. 반환한 값은 Value Display와 접근성 값에 사용할 표시 텍스트이며, 내부 수량 상태나 증감 단위에는 영향을 주지 않습니다.
import "./styles" ;
import { useSeedClassName } from "@seed-design/lynx-react" ;
import { QuantityPicker } from "@/components/ui/quantity-picker" ;
export default function Example () {
const seedClassName = useSeedClassName ({ colorMode: "system" });
return (
< view className = { `${ seedClassName } quantity-picker-example` }>
< text className = "quantity-picker-example-title" >Value text</ text >
< view className = "quantity-picker-example-controls" >
< QuantityPicker
min = { 1 }
max = { 99 }
defaultValue = { 1 }
accessibility-label = "상품 수량"
getValueText = {( valueText : string ) => `${ valueText }개` }
/>
</ view >
</ view >
);
}
value와 onValueChange를 사용해 수량 상태를 앱의 상태로 제어할 수 있습니다. 변경 콜백에서 새 숫자를 저장하고, 저장한 값을 다시 value로 전달하세요.
import "./styles" ;
import { useState } from "@lynx-js/react" ;
import { useSeedClassName } from "@seed-design/lynx-react" ;
import { QuantityPicker } from "@/components/ui/quantity-picker" ;
export default function Example () {
const seedClassName = useSeedClassName ({ colorMode: "system" });
const [ quantity , setQuantity ] = useState ( 2 );
function handleValueChange ( nextQuantity : number ) {
"background only" ;
setQuantity (nextQuantity);
}
return (
< view className = { `${ seedClassName } docs-lynx-quantity-picker-root` }>
< view className = "quantity-picker-preview" >
< view className = "quantity-picker-example" >
< text className = "quantity-picker-example-title" >Controlled</ text >
< QuantityPicker
min = { 1 }
max = { 99 }
value = {quantity}
onValueChange = {handleValueChange}
accessibility-label = "상품 수량"
/>
< text className = "quantity-picker-example-status" >현재 수량: {quantity}개</ text >
</ view >
</ view >
</ view >
);
}
removable을 사용하면 값이 min에 도달했을 때 Decrement 버튼이 Remove 버튼으로 전환됩니다. 이때 Decrement를 누르면 수량을 변경하지 않고 onRemove만 호출합니다. 삭제나 목록에서 제거하는 동작은 onRemove에서 처리하세요.
import "./styles" ;
import { useState } from "@lynx-js/react" ;
import { ActionButton, useSeedClassName, VStack } from "@seed-design/lynx-react" ;
import { QuantityPicker } from "@/components/ui/quantity-picker" ;
export default function Example () {
const seedClassName = useSeedClassName ({ colorMode: "system" });
const [ removed , setRemoved ] = useState ( false );
function handleRemove () {
"background only" ;
setRemoved ( true );
}
function handleRestore () {
"background only" ;
setRemoved ( false );
}
return (
< view className = { `${ seedClassName } quantity-picker-example` }>
< text className = "quantity-picker-example-title" >Removable</ text >
{removed ? (
< VStack gap = "x4" >
< text className = "quantity-picker-example-status" >상품을 삭제했습니다.</ text >
< view className = "quantity-picker-example-controls" >
< ActionButton
className = "quantity-picker-example-control"
variant = "neutralWeak"
bindtap = {handleRestore}
>
되돌리기
</ ActionButton >
</ view >
</ VStack >
) : (
<>
< view className = "quantity-picker-example-controls" >
< QuantityPicker
min = { 1 }
max = { 99 }
defaultValue = { 1 }
removable
removeAccessibilityLabel = "상품 삭제"
accessibility-label = "상품 수량"
onRemove = {handleRemove}
/>
</ view >
< text className = "quantity-picker-example-status" >
최솟값에서 감소 버튼을 누르면 수량 선택기가 제거됩니다.
</ text >
</>
)}
</ view >
);
}
loading으로 모든 action 또는 특정 action의 실행을 일시적으로 막고 loading indicator를 표시할 수 있습니다. loading={true}는 Decrement와 Increment 모두에 적용되며, loading={{ increment: true }}처럼 객체를 전달하면 해당 action만 막습니다.
import "./styles" ;
import { useState } from "@lynx-js/react" ;
import { ActionButton, useSeedClassName } from "@seed-design/lynx-react" ;
import { QuantityPicker } from "@/components/ui/quantity-picker" ;
export default function Example () {
const seedClassName = useSeedClassName ({ colorMode: "system" });
const [ decrementLoading , setDecrementLoading ] = useState ( false );
const [ incrementLoading , setIncrementLoading ] = useState ( false );
const allLoading = decrementLoading && incrementLoading;
function toggleAllLoading () {
"background only" ;
const nextLoading = ! allLoading;
setDecrementLoading (nextLoading);
setIncrementLoading (nextLoading);
}
function toggleDecrementLoading () {
"background only" ;
setDecrementLoading (( current ) => ! current);
}
function toggleIncrementLoading () {
"background only" ;
setIncrementLoading (( current ) => ! current);
}
return (
< view className = { `${ seedClassName } docs-lynx-quantity-picker-root` }>
< view className = "quantity-picker-preview" >
< view className = "quantity-picker-example" >
< text className = "quantity-picker-example-title" >Loading</ text >
< QuantityPicker
min = { 1 }
max = { 99 }
defaultValue = { 2 }
loading = {
allLoading
? true
: {
decrement: decrementLoading,
increment: incrementLoading,
}
}
accessibility-label = "상품 수량"
/>
< view className = "quantity-picker-example-controls" >
< ActionButton
className = "quantity-picker-example-control"
variant = "neutralWeak"
bindtap = {toggleAllLoading}
>
전체 {allLoading ? "끄기" : "켜기" }
</ ActionButton >
< ActionButton
className = "quantity-picker-example-control"
variant = "neutralWeak"
bindtap = {toggleDecrementLoading}
>
감소 {decrementLoading ? "끄기" : "켜기" }
</ ActionButton >
< ActionButton
className = "quantity-picker-example-control"
variant = "neutralWeak"
bindtap = {toggleIncrementLoading}
>
증가 {incrementLoading ? "끄기" : "켜기" }
</ ActionButton >
</ view >
< text className = "quantity-picker-example-status" >
감소: {decrementLoading ? "loading" : "준비" } · 증가:{ " " }
{incrementLoading ? "loading" : "준비" }
</ text >
</ view >
</ view >
</ view >
);
}
disabled, readOnly, loading은 모두 action을 실행하지 못하게 합니다. disabled는 두 action을 비활성화하고 접근성 상태에도 disabled를 전달합니다. readOnly는 수량 변경과 Remove 콜백 호출을 막지만 값을 다른 상태로 바꾸지는 않습니다. loading은 지정된 action만 일시적으로 막으며, 해당 action의 indicator를 표시합니다. 따라서 removable의 최솟값에서도 Decrement가 disabled, readOnly 또는 decrement loading이면 onRemove가 호출되지 않습니다.
Lynx에는 HTML <form>, hidden <input>, FormData 제출 모델이 없으므로 웹의 inputProps와 hidden input 기반 Form 예제를 제공하지 않습니다. 수량을 제출해야 할 때는 value와 onValueChange로 앱 상태를 관리한 뒤, 제출 또는 네트워크 요청을 만드는 코드에서 그 상태를 payload에 직접 넣으세요. 이 방식은 UI 표시용 getValueText와 서버에 보낼 raw 숫자 값을 분리할 수 있습니다.
Quantity Picker의 용도를 설명하는 accessibility-label을 Root에 제공하세요. Root는 기본적으로 accessibility-role-description="quantity picker"로 노출하며, 필요하면 accessibility-role-description, accessibility-value 같은 네이티브 접근성 속성을 직접 지정할 수 있습니다.
Registry 편의 컴포넌트에서는 decrementAccessibilityLabel과 incrementAccessibilityLabel로 각 버튼의 접근성 이름을 지정합니다. removable 상태에서 최솟값에 도달하면 Decrement 버튼이 Remove 버튼으로 바뀌므로, Remove 동작을 설명하는 removeAccessibilityLabel도 지정하세요. Registry의 기본값은 각각 수량 줄이기, 수량 늘리기, 상품 삭제입니다. compound API에서는 Decrement/Increment 버튼에 accessibility-label을 직접 지정하고, Root에 removeAccessibilityLabel을 전달합니다.
disabled 상태의 버튼은 접근성 traits에 disabled가 반영됩니다. readOnly와 loading 상태에서는 해당 action을 실행할 수 없으므로 상태와 제한을 주변 레이블이나 안내 텍스트로 함께 설명하세요. loading indicator는 시각적 표시 요소이며 접근성 요소에서 숨겨집니다.
Lynx QuantityPicker는 React 웹 버전과 다음과 같은 차이가 있습니다.
웹 (React) Lynx Registry API QuantityPicker 편의 컴포넌트와 aria-* propsQuantityPicker 편의 컴포넌트와 accessibility-* props저수준 API Root, DecrementButton, ValueDisplay, IncrementButton, HiddenInputRoot, DecrementButton, ValueDisplay, IncrementButton렌더링 HTML 요소와 버튼을 렌더링 Lynx native <view>와 접근성 요소를 렌더링하며 tap으로 동작 접근성 이름 aria-label, decrementAriaLabel, incrementAriaLabel, removeAriaLabelRoot와 슬롯의 accessibility-label, Registry의 decrementAccessibilityLabel, incrementAccessibilityLabel, removeAccessibilityLabel Form 제출 inputProps로 hidden input의 name/value를 HTML form에 제출HTML form, hidden input, FormData, inputProps 미지원. 앱 상태의 value를 요청 payload에 직접 포함 Compound API HiddenInput으로 브라우저 form 모델과 연결 가능 HiddenInput 없이 앱이 value와 onValueChange를 관리
Lynx에는 브라우저의 HTML form 검증과 키보드 focus 모델이 없으므로, form 제출·required 검증·DOM 이벤트 대신 앱 상태, 네이티브 tap, Lynx 접근성 탐색을 사용합니다.