@pantoia/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/accordion/AGENTS.md +60 -0
- package/accordion/index.module.css +10 -0
- package/accordion/index.tsx +91 -0
- package/alert-dialog/AGENTS.md +78 -0
- package/alert-dialog/index.tsx +119 -0
- package/badge/AGENTS.md +35 -0
- package/badge/index.tsx +34 -0
- package/button/AGENTS.md +39 -0
- package/button/index.tsx +98 -0
- package/callout/AGENTS.md +42 -0
- package/callout/index.tsx +70 -0
- package/checkbox/AGENTS.md +54 -0
- package/checkbox/index.tsx +151 -0
- package/code-block/AGENTS.md +60 -0
- package/code-block/index.module.css +46 -0
- package/code-block/index.tsx +127 -0
- package/combobox/AGENTS.md +150 -0
- package/combobox/index.tsx +420 -0
- package/command-palette/AGENTS.md +44 -0
- package/command-palette/index.tsx +108 -0
- package/copy-button/AGENTS.md +43 -0
- package/copy-button/index.tsx +119 -0
- package/dialog/AGENTS.md +81 -0
- package/dialog/index.tsx +108 -0
- package/dist/index.js +189 -0
- package/drawer/AGENTS.md +66 -0
- package/drawer/index.module.css +17 -0
- package/drawer/index.tsx +198 -0
- package/dropdown-menu/AGENTS.md +133 -0
- package/dropdown-menu/index.tsx +223 -0
- package/field/AGENTS.md +96 -0
- package/field/index.tsx +149 -0
- package/input/AGENTS.md +35 -0
- package/input/index.tsx +36 -0
- package/kbd/AGENTS.md +17 -0
- package/kbd/index.tsx +13 -0
- package/label/AGENTS.md +21 -0
- package/label/index.tsx +13 -0
- package/number-field/AGENTS.md +53 -0
- package/number-field/index.tsx +227 -0
- package/package.json +73 -0
- package/popover/AGENTS.md +64 -0
- package/popover/index.tsx +66 -0
- package/press.css +37 -0
- package/radio-group/AGENTS.md +81 -0
- package/radio-group/index.tsx +139 -0
- package/registry.json +726 -0
- package/select/AGENTS.md +90 -0
- package/select/index.tsx +169 -0
- package/styles.css +347 -0
- package/switch/AGENTS.md +37 -0
- package/switch/index.tsx +42 -0
- package/table/AGENTS.md +82 -0
- package/table/index.tsx +120 -0
- package/tabs/AGENTS.md +60 -0
- package/tabs/index.module.css +14 -0
- package/tabs/index.tsx +105 -0
- package/test-utils.ts +10 -0
- package/textarea/AGENTS.md +35 -0
- package/textarea/index.tsx +37 -0
- package/toast/AGENTS.md +42 -0
- package/toast/index.tsx +71 -0
- package/toggle/AGENTS.md +37 -0
- package/toggle/index.tsx +36 -0
- package/tooltip/AGENTS.md +50 -0
- package/tooltip/index.tsx +57 -0
- package/tv.ts +23 -0
package/field/index.tsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { CircleAlert, CircleCheck, LoaderCircle } from 'lucide-react'
|
|
4
|
+
import { createContext, use, useId, type ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
import { Label } from '../label'
|
|
7
|
+
import { tv } from '../tv'
|
|
8
|
+
|
|
9
|
+
export const field = tv({
|
|
10
|
+
base: 'group flex flex-wrap gap-x-2 gap-y-tight',
|
|
11
|
+
|
|
12
|
+
variants: {
|
|
13
|
+
layout: {
|
|
14
|
+
stack: 'flex-col',
|
|
15
|
+
inline: 'flex-row items-center',
|
|
16
|
+
split: 'flex-row items-center justify-between',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
defaultVariants: { layout: 'stack' },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export interface FieldContext {
|
|
24
|
+
id: string
|
|
25
|
+
labelId: string
|
|
26
|
+
messageId: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FieldControl {
|
|
30
|
+
id: string
|
|
31
|
+
'aria-labelledby': string
|
|
32
|
+
'aria-invalid': 'true' | undefined
|
|
33
|
+
'aria-describedby': string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const Context = createContext<FieldContext | null>(null)
|
|
37
|
+
|
|
38
|
+
export interface FieldProps {
|
|
39
|
+
invalid?: boolean
|
|
40
|
+
layout?: 'stack' | 'inline' | 'split'
|
|
41
|
+
className?: string
|
|
42
|
+
children?: ReactNode | ((args: { control: FieldControl }) => ReactNode)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Field({ invalid = false, layout, className, children }: FieldProps) {
|
|
46
|
+
const id = useId()
|
|
47
|
+
const ctx: FieldContext = {
|
|
48
|
+
id,
|
|
49
|
+
labelId: `${id}-label`,
|
|
50
|
+
messageId: `${id}-message`,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const control: FieldControl = {
|
|
54
|
+
'id': id,
|
|
55
|
+
'aria-labelledby': ctx.labelId,
|
|
56
|
+
'aria-invalid': invalid ? 'true' : undefined,
|
|
57
|
+
'aria-describedby': ctx.messageId,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Context value={ctx}>
|
|
62
|
+
<div className={field({ layout, className })}>
|
|
63
|
+
{typeof children === 'function' ? children({ control }) : children}
|
|
64
|
+
</div>
|
|
65
|
+
</Context>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function FieldLabel({ className, children }: { className?: string, children?: ReactNode }) {
|
|
70
|
+
const ctx = use(Context)
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Label
|
|
74
|
+
id={ctx?.labelId}
|
|
75
|
+
htmlFor={ctx?.id}
|
|
76
|
+
className={tv({ base: 'group-has-disabled:text-fg-disabled' })({ className })}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</Label>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function FieldHelp({ className, children }: { className?: string, children?: ReactNode }) {
|
|
84
|
+
const ctx = use(Context)
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<p
|
|
88
|
+
id={ctx?.messageId}
|
|
89
|
+
className={tv({ base: 'basis-full text-caption text-fg-subtle'
|
|
90
|
+
+ ' group-has-disabled:text-fg-disabled' })({ className })}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</p>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface FieldErrorProps {
|
|
98
|
+
items?: string | string[]
|
|
99
|
+
className?: string
|
|
100
|
+
children?: ReactNode
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function FieldError({ items, className, children }: FieldErrorProps) {
|
|
104
|
+
const ctx = use(Context)
|
|
105
|
+
const list = items == null ? [] : Array.isArray(items) ? items : [items]
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<p
|
|
109
|
+
id={ctx?.messageId}
|
|
110
|
+
className={tv({ base: 'basis-full text-caption text-danger'
|
|
111
|
+
+ ' flex items-start gap-tight' })({ className })}
|
|
112
|
+
>
|
|
113
|
+
<CircleAlert className="mt-0.5 size-4 shrink-0" />
|
|
114
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
115
|
+
{children ?? list.map(t => <span key={t}>{t}</span>)}
|
|
116
|
+
</span>
|
|
117
|
+
</p>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function FieldPending({ className, children }: { className?: string, children?: ReactNode }) {
|
|
122
|
+
const ctx = use(Context)
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<p
|
|
126
|
+
id={ctx?.messageId}
|
|
127
|
+
className={tv({ base: 'basis-full text-caption text-fg-subtle'
|
|
128
|
+
+ ' flex items-start gap-tight' })({ className })}
|
|
129
|
+
>
|
|
130
|
+
<LoaderCircle className="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none" />
|
|
131
|
+
<span className="min-w-0">{children}</span>
|
|
132
|
+
</p>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function FieldSuccess({ className, children }: { className?: string, children?: ReactNode }) {
|
|
137
|
+
const ctx = use(Context)
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<p
|
|
141
|
+
id={ctx?.messageId}
|
|
142
|
+
className={tv({ base: 'basis-full text-caption text-success'
|
|
143
|
+
+ ' flex items-start gap-tight' })({ className })}
|
|
144
|
+
>
|
|
145
|
+
<CircleCheck className="mt-0.5 size-4 shrink-0" />
|
|
146
|
+
<span className="min-w-0">{children}</span>
|
|
147
|
+
</p>
|
|
148
|
+
)
|
|
149
|
+
}
|
package/input/AGENTS.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
받기 npx @pantoia/ui add input
|
|
5
|
+
층 ui
|
|
6
|
+
파일 input/index.tsx · input/AGENTS.md
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 부위
|
|
10
|
+
|
|
11
|
+
| 이름 | props |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `Input` | `InputProps` |
|
|
14
|
+
|
|
15
|
+
## props
|
|
16
|
+
|
|
17
|
+
### `InputProps`
|
|
18
|
+
|
|
19
|
+
`Omit<InputHTMLAttributes<HTMLInputElement>, 'size'>` 를 확장한다.
|
|
20
|
+
|
|
21
|
+
| 이름 | 타입 | 필수 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `surface` | `'bare' \| 'well'` | |
|
|
24
|
+
| `size` | `'sm' \| 'md' \| 'lg'` | |
|
|
25
|
+
|
|
26
|
+
## 변형 — `input`
|
|
27
|
+
|
|
28
|
+
| 축 | 값 | 기본 |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `surface` | `bare` · `well` | `bare` |
|
|
31
|
+
| `size` | `sm` · `md` · `lg` | `md` |
|
|
32
|
+
|
|
33
|
+
## 함께 받는 것
|
|
34
|
+
|
|
35
|
+
`tv`
|
package/input/index.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
import { tv } from '../tv'
|
|
4
|
+
|
|
5
|
+
export const input = tv({
|
|
6
|
+
base: 'w-full rounded-r8 text-value text-fg'
|
|
7
|
+
+ ' placeholder:text-fg-placeholder'
|
|
8
|
+
+ ' invalid:ring-2 invalid:ring-danger'
|
|
9
|
+
+ ' read-only:text-fg-muted'
|
|
10
|
+
+ ' transition-state'
|
|
11
|
+
+ ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
12
|
+
+ ' disabled:bg-disabled disabled:text-fg-disabled disabled:cursor-not-allowed',
|
|
13
|
+
|
|
14
|
+
variants: {
|
|
15
|
+
surface: {
|
|
16
|
+
bare: 'bg-transparent ring-1 ring-inset ring-line-strong read-only:bg-well',
|
|
17
|
+
well: 'bg-well',
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
sm: 'h-8 px-3',
|
|
21
|
+
md: 'h-9 px-3',
|
|
22
|
+
lg: 'h-10 px-3 text-t16',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
defaultVariants: { surface: 'bare', size: 'md' },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
|
30
|
+
surface?: 'bare' | 'well'
|
|
31
|
+
size?: 'sm' | 'md' | 'lg'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Input({ surface, size, className, ...rest }: InputProps) {
|
|
35
|
+
return <input {...rest} className={input({ surface, size, className })} />
|
|
36
|
+
}
|
package/kbd/AGENTS.md
ADDED
package/kbd/index.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
import { tv } from '../tv'
|
|
4
|
+
|
|
5
|
+
export function Kbd({ className, ...rest }: HTMLAttributes<HTMLElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<kbd
|
|
8
|
+
{...rest}
|
|
9
|
+
className={tv({ base: 'bg-well text-fg-muted text-micro rounded-mark inline-flex items-center'
|
|
10
|
+
+ ' border border-line px-1.5 py-0.5 font-mono' })({ className })}
|
|
11
|
+
/>
|
|
12
|
+
)
|
|
13
|
+
}
|
package/label/AGENTS.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Label
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
받기 npx @pantoia/ui add label
|
|
5
|
+
층 ui
|
|
6
|
+
파일 label/index.tsx · label/AGENTS.md
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 부위
|
|
10
|
+
|
|
11
|
+
| 이름 | props |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `Label` | `ComponentProps<typeof LabelPrimitive.Root>` |
|
|
14
|
+
|
|
15
|
+
## 함께 받는 것
|
|
16
|
+
|
|
17
|
+
`tv`
|
|
18
|
+
|
|
19
|
+
## npm 의존
|
|
20
|
+
|
|
21
|
+
`radix-ui`
|
package/label/index.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Label as LabelPrimitive } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
3
|
+
|
|
4
|
+
import { tv } from '../tv'
|
|
5
|
+
|
|
6
|
+
export function Label({ className, ...rest }: ComponentProps<typeof LabelPrimitive.Root>) {
|
|
7
|
+
return (
|
|
8
|
+
<LabelPrimitive.Root
|
|
9
|
+
{...rest}
|
|
10
|
+
className={tv({ base: 'text-label text-fg' })({ className })}
|
|
11
|
+
/>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# NumberField
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
받기 npx @pantoia/ui add number-field
|
|
5
|
+
층 ui
|
|
6
|
+
파일 number-field/index.tsx · number-field/AGENTS.md
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 부위
|
|
10
|
+
|
|
11
|
+
| 이름 | props |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `NumberField` | `NumberFieldProps` |
|
|
14
|
+
|
|
15
|
+
## props
|
|
16
|
+
|
|
17
|
+
### `NumberFieldProps`
|
|
18
|
+
|
|
19
|
+
`Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange' | 'size' | 'min' | 'max' | 'step'>` 를 확장한다.
|
|
20
|
+
|
|
21
|
+
| 이름 | 타입 | 필수 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `value` | `number \| undefined` | |
|
|
24
|
+
| `defaultValue` | `number` | |
|
|
25
|
+
| `onValueChange` | `(value: number \| undefined) => void` | |
|
|
26
|
+
| `decrementLabel` | `string` | 예 |
|
|
27
|
+
| `incrementLabel` | `string` | 예 |
|
|
28
|
+
| `min` | `number` | |
|
|
29
|
+
| `max` | `number` | |
|
|
30
|
+
| `step` | `number` | |
|
|
31
|
+
| `surface` | `'bare' \| 'well'` | |
|
|
32
|
+
| `size` | `'sm' \| 'md' \| 'lg'` | |
|
|
33
|
+
| `disabled` | `boolean` | |
|
|
34
|
+
| `readOnly` | `boolean` | |
|
|
35
|
+
| `className` | `string` | |
|
|
36
|
+
|
|
37
|
+
## 변형 — `numberField`
|
|
38
|
+
|
|
39
|
+
슬롯 `root` · `step` · `input`
|
|
40
|
+
|
|
41
|
+
| 축 | 값 | 기본 |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `surface` | `bare` · `well` | `bare` |
|
|
44
|
+
| `size` | `sm` · `md` · `lg` | `md` |
|
|
45
|
+
| `readonly` | `true` | — |
|
|
46
|
+
|
|
47
|
+
## 함께 받는 것
|
|
48
|
+
|
|
49
|
+
`tv`
|
|
50
|
+
|
|
51
|
+
## npm 의존
|
|
52
|
+
|
|
53
|
+
`lucide-react`
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Minus, Plus } from 'lucide-react'
|
|
4
|
+
import { useEffect, useRef, useState, type InputHTMLAttributes, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
|
5
|
+
|
|
6
|
+
import { tv } from '../tv'
|
|
7
|
+
|
|
8
|
+
export const numberField = tv({
|
|
9
|
+
slots: {
|
|
10
|
+
root: 'flex items-center rounded-r8'
|
|
11
|
+
+ ' transition-state'
|
|
12
|
+
+ ' invalid-within:ring-2 invalid-within:ring-danger'
|
|
13
|
+
+ ' has-disabled:bg-disabled has-disabled:cursor-not-allowed'
|
|
14
|
+
+ ' has-focus-visible:outline-2 has-focus-visible:outline-offset-2'
|
|
15
|
+
+ ' has-focus-visible:outline-ring',
|
|
16
|
+
step: 'text-fg-muted grid shrink-0 place-items-center rounded-r8'
|
|
17
|
+
+ ' transition-state press press-shrink enabled:hover:bg-hover'
|
|
18
|
+
+ ' disabled:text-fg-disabled disabled:cursor-not-allowed',
|
|
19
|
+
input: 'text-value text-fg w-full min-w-0 bg-transparent text-center outline-none'
|
|
20
|
+
+ ' disabled:text-fg-disabled disabled:cursor-not-allowed',
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
variants: {
|
|
24
|
+
surface: {
|
|
25
|
+
bare: { root: 'bg-transparent ring-1 ring-inset ring-line-strong' },
|
|
26
|
+
well: { root: 'bg-well' },
|
|
27
|
+
},
|
|
28
|
+
size: {
|
|
29
|
+
sm: { root: 'h-8', step: 'size-8' },
|
|
30
|
+
md: { root: 'h-9', step: 'size-9' },
|
|
31
|
+
lg: { root: 'h-10', step: 'size-10', input: 'text-t16' },
|
|
32
|
+
},
|
|
33
|
+
readonly: {
|
|
34
|
+
true: { root: 'bg-well', input: 'text-fg-muted' },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
defaultVariants: { surface: 'bare', size: 'md' },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const HOLD_DELAY = 400
|
|
42
|
+
const HOLD_EVERY = 60
|
|
43
|
+
|
|
44
|
+
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
|
|
45
|
+
|
|
46
|
+
const GROUPED = new Intl.NumberFormat('en-US')
|
|
47
|
+
|
|
48
|
+
const TYPING = /[^0-9.-]/g
|
|
49
|
+
|
|
50
|
+
function snap(n: number, min: number, step: number) {
|
|
51
|
+
const from = Number.isFinite(min) ? min : 0
|
|
52
|
+
return from + Math.round((n - from) / step) * step
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function useHold(step: () => void, blocked: boolean) {
|
|
56
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
57
|
+
const beat = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
|
|
58
|
+
const [pressed, setPressed] = useState(false)
|
|
59
|
+
|
|
60
|
+
const stop = () => {
|
|
61
|
+
clearTimeout(timer.current)
|
|
62
|
+
clearInterval(beat.current)
|
|
63
|
+
setPressed(false)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
useEffect(() => stop, [])
|
|
67
|
+
|
|
68
|
+
const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
69
|
+
if (e.button !== 0 || blocked) return
|
|
70
|
+
|
|
71
|
+
step()
|
|
72
|
+
setPressed(true)
|
|
73
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
74
|
+
|
|
75
|
+
timer.current = setTimeout(() => {
|
|
76
|
+
beat.current = setInterval(step, HOLD_EVERY)
|
|
77
|
+
}, HOLD_DELAY)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { pressed, onPointerDown, onPointerUp: stop, onPointerCancel: stop, onLostPointerCapture: stop }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface NumberFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange' | 'size' | 'min' | 'max' | 'step'> {
|
|
84
|
+
value?: number | undefined
|
|
85
|
+
defaultValue?: number
|
|
86
|
+
onValueChange?: (value: number | undefined) => void
|
|
87
|
+
decrementLabel: string
|
|
88
|
+
incrementLabel: string
|
|
89
|
+
min?: number
|
|
90
|
+
max?: number
|
|
91
|
+
step?: number
|
|
92
|
+
surface?: 'bare' | 'well'
|
|
93
|
+
size?: 'sm' | 'md' | 'lg'
|
|
94
|
+
disabled?: boolean
|
|
95
|
+
readOnly?: boolean
|
|
96
|
+
className?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function NumberField(props: NumberFieldProps) {
|
|
100
|
+
const {
|
|
101
|
+
value,
|
|
102
|
+
defaultValue,
|
|
103
|
+
onValueChange,
|
|
104
|
+
decrementLabel,
|
|
105
|
+
incrementLabel,
|
|
106
|
+
min = Number.NEGATIVE_INFINITY,
|
|
107
|
+
max = Number.POSITIVE_INFINITY,
|
|
108
|
+
step = 1,
|
|
109
|
+
surface,
|
|
110
|
+
size,
|
|
111
|
+
disabled = false,
|
|
112
|
+
readOnly = false,
|
|
113
|
+
className,
|
|
114
|
+
...rest
|
|
115
|
+
} = props
|
|
116
|
+
|
|
117
|
+
const controlled = 'value' in props
|
|
118
|
+
const [own, setOwn] = useState<number | undefined>(defaultValue)
|
|
119
|
+
const [typing, setTyping] = useState<string | null>(null)
|
|
120
|
+
const current = controlled ? value : own
|
|
121
|
+
|
|
122
|
+
const ui = numberField({ surface, size, readonly: readOnly })
|
|
123
|
+
|
|
124
|
+
const set = (next: number | undefined) => {
|
|
125
|
+
const after = next === undefined ? undefined : clamp(next, min, max)
|
|
126
|
+
if (!controlled) setOwn(after)
|
|
127
|
+
if (after !== current) onValueChange?.(after)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const blocked = disabled || readOnly
|
|
131
|
+
const from = current ?? (Number.isFinite(min) ? min : 0)
|
|
132
|
+
const atMin = current !== undefined && current <= min
|
|
133
|
+
const atMax = current !== undefined && current >= max
|
|
134
|
+
|
|
135
|
+
const down = useHold(() => { setTyping(null); set(from - step) }, blocked)
|
|
136
|
+
const up = useHold(() => { setTyping(null); set(from + step) }, blocked)
|
|
137
|
+
|
|
138
|
+
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
139
|
+
if (blocked) return
|
|
140
|
+
|
|
141
|
+
const moves: Record<string, number> = {
|
|
142
|
+
ArrowUp: from + step,
|
|
143
|
+
ArrowDown: from - step,
|
|
144
|
+
PageUp: from + step * 10,
|
|
145
|
+
PageDown: from - step * 10,
|
|
146
|
+
Home: min,
|
|
147
|
+
End: max,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const next = moves[e.key]
|
|
151
|
+
if (next === undefined || !Number.isFinite(next)) return
|
|
152
|
+
|
|
153
|
+
e.preventDefault()
|
|
154
|
+
setTyping(null)
|
|
155
|
+
set(next)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const onWheel = (e: React.WheelEvent<HTMLInputElement>) => {
|
|
159
|
+
e.currentTarget.blur()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
163
|
+
const raw = e.target.value.replace(TYPING, '')
|
|
164
|
+
setTyping(min >= 0 ? raw.replace(/-/g, '') : raw)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const onBlur = () => {
|
|
168
|
+
if (typing === null) return
|
|
169
|
+
|
|
170
|
+
const parsed = Number(typing)
|
|
171
|
+
const empty = typing.trim() === ''
|
|
172
|
+
setTyping(null)
|
|
173
|
+
|
|
174
|
+
if (empty) set(undefined)
|
|
175
|
+
else if (Number.isFinite(parsed)) set(snap(parsed, min, step))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className={ui.root({ className })}>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
aria-label={decrementLabel}
|
|
183
|
+
disabled={blocked || atMin}
|
|
184
|
+
data-pressed={down.pressed ? '' : undefined}
|
|
185
|
+
className={ui.step()}
|
|
186
|
+
onPointerDown={down.onPointerDown}
|
|
187
|
+
onPointerUp={down.onPointerUp}
|
|
188
|
+
onPointerCancel={down.onPointerCancel}
|
|
189
|
+
onLostPointerCapture={down.onLostPointerCapture}
|
|
190
|
+
>
|
|
191
|
+
<Minus className="size-4" />
|
|
192
|
+
</button>
|
|
193
|
+
|
|
194
|
+
<input
|
|
195
|
+
{...rest}
|
|
196
|
+
type="text"
|
|
197
|
+
inputMode="decimal"
|
|
198
|
+
role="spinbutton"
|
|
199
|
+
aria-valuenow={current}
|
|
200
|
+
aria-valuemin={Number.isFinite(min) ? min : undefined}
|
|
201
|
+
aria-valuemax={Number.isFinite(max) ? max : undefined}
|
|
202
|
+
value={typing ?? (current === undefined ? '' : GROUPED.format(current))}
|
|
203
|
+
disabled={disabled}
|
|
204
|
+
readOnly={readOnly}
|
|
205
|
+
onChange={onInput}
|
|
206
|
+
onBlur={onBlur}
|
|
207
|
+
onKeyDown={onKeyDown}
|
|
208
|
+
onWheel={onWheel}
|
|
209
|
+
className={ui.input()}
|
|
210
|
+
/>
|
|
211
|
+
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
aria-label={incrementLabel}
|
|
215
|
+
disabled={blocked || atMax}
|
|
216
|
+
data-pressed={up.pressed ? '' : undefined}
|
|
217
|
+
className={ui.step()}
|
|
218
|
+
onPointerDown={up.onPointerDown}
|
|
219
|
+
onPointerUp={up.onPointerUp}
|
|
220
|
+
onPointerCancel={up.onPointerCancel}
|
|
221
|
+
onLostPointerCapture={up.onLostPointerCapture}
|
|
222
|
+
>
|
|
223
|
+
<Plus className="size-4" />
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pantoia/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pantoia 디자인 시스템 — 소스를 복사해 가는 컴포넌트 스물여덟",
|
|
5
|
+
"homepage": "https://design.pantoia.com",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/pantoia/pantoia-design.git",
|
|
9
|
+
"directory": "packages/registry"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"pantoia-ui": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"*/index.tsx",
|
|
18
|
+
"*/index.module.css",
|
|
19
|
+
"*/AGENTS.md",
|
|
20
|
+
"dist",
|
|
21
|
+
"tv.ts",
|
|
22
|
+
"test-utils.ts",
|
|
23
|
+
"styles.css",
|
|
24
|
+
"press.css",
|
|
25
|
+
"registry.json"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
"./tv": "./tv.ts",
|
|
29
|
+
"./styles.css": "./styles.css",
|
|
30
|
+
"./press.css": "./press.css",
|
|
31
|
+
"./registry.json": "./registry.json",
|
|
32
|
+
"./test-utils": "./test-utils.ts",
|
|
33
|
+
"./*": "./*/index.tsx"
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": [
|
|
36
|
+
"*.css"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=24"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"prepack": "tsc cli/index.ts --ignoreConfig --outDir dist --target es2022 --module esnext --moduleResolution bundler --skipLibCheck --types node",
|
|
46
|
+
"lint": "eslint . --max-warnings 0",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@fontsource/jetbrains-mono": "5.3.0",
|
|
51
|
+
"lucide-react": "^1.39.0",
|
|
52
|
+
"pretendard": "1.3.9",
|
|
53
|
+
"radix-ui": "^1.6.7",
|
|
54
|
+
"sonner": "^2.0.8",
|
|
55
|
+
"tailwind-variants": "^3.3.1"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"react": "^19",
|
|
59
|
+
"react-dom": "^19",
|
|
60
|
+
"tailwindcss": "^4.3.3"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@pantoia/eslint-config": "*",
|
|
64
|
+
"@pantoia/typescript-config": "*",
|
|
65
|
+
"@types/node": "26.4.0",
|
|
66
|
+
"@types/react": "19.2.18",
|
|
67
|
+
"@types/react-dom": "19.2.5",
|
|
68
|
+
"eslint": "10.9.1",
|
|
69
|
+
"react": "19.2.8",
|
|
70
|
+
"react-dom": "19.2.8",
|
|
71
|
+
"typescript": "6.0.3"
|
|
72
|
+
}
|
|
73
|
+
}
|