@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.
Files changed (67) hide show
  1. package/accordion/AGENTS.md +60 -0
  2. package/accordion/index.module.css +10 -0
  3. package/accordion/index.tsx +91 -0
  4. package/alert-dialog/AGENTS.md +78 -0
  5. package/alert-dialog/index.tsx +119 -0
  6. package/badge/AGENTS.md +35 -0
  7. package/badge/index.tsx +34 -0
  8. package/button/AGENTS.md +39 -0
  9. package/button/index.tsx +98 -0
  10. package/callout/AGENTS.md +42 -0
  11. package/callout/index.tsx +70 -0
  12. package/checkbox/AGENTS.md +54 -0
  13. package/checkbox/index.tsx +151 -0
  14. package/code-block/AGENTS.md +60 -0
  15. package/code-block/index.module.css +46 -0
  16. package/code-block/index.tsx +127 -0
  17. package/combobox/AGENTS.md +150 -0
  18. package/combobox/index.tsx +420 -0
  19. package/command-palette/AGENTS.md +44 -0
  20. package/command-palette/index.tsx +108 -0
  21. package/copy-button/AGENTS.md +43 -0
  22. package/copy-button/index.tsx +119 -0
  23. package/dialog/AGENTS.md +81 -0
  24. package/dialog/index.tsx +108 -0
  25. package/dist/index.js +189 -0
  26. package/drawer/AGENTS.md +66 -0
  27. package/drawer/index.module.css +17 -0
  28. package/drawer/index.tsx +198 -0
  29. package/dropdown-menu/AGENTS.md +133 -0
  30. package/dropdown-menu/index.tsx +223 -0
  31. package/field/AGENTS.md +96 -0
  32. package/field/index.tsx +149 -0
  33. package/input/AGENTS.md +35 -0
  34. package/input/index.tsx +36 -0
  35. package/kbd/AGENTS.md +17 -0
  36. package/kbd/index.tsx +13 -0
  37. package/label/AGENTS.md +21 -0
  38. package/label/index.tsx +13 -0
  39. package/number-field/AGENTS.md +53 -0
  40. package/number-field/index.tsx +227 -0
  41. package/package.json +73 -0
  42. package/popover/AGENTS.md +64 -0
  43. package/popover/index.tsx +66 -0
  44. package/press.css +37 -0
  45. package/radio-group/AGENTS.md +81 -0
  46. package/radio-group/index.tsx +139 -0
  47. package/registry.json +726 -0
  48. package/select/AGENTS.md +90 -0
  49. package/select/index.tsx +169 -0
  50. package/styles.css +347 -0
  51. package/switch/AGENTS.md +37 -0
  52. package/switch/index.tsx +42 -0
  53. package/table/AGENTS.md +82 -0
  54. package/table/index.tsx +120 -0
  55. package/tabs/AGENTS.md +60 -0
  56. package/tabs/index.module.css +14 -0
  57. package/tabs/index.tsx +105 -0
  58. package/test-utils.ts +10 -0
  59. package/textarea/AGENTS.md +35 -0
  60. package/textarea/index.tsx +37 -0
  61. package/toast/AGENTS.md +42 -0
  62. package/toast/index.tsx +71 -0
  63. package/toggle/AGENTS.md +37 -0
  64. package/toggle/index.tsx +36 -0
  65. package/tooltip/AGENTS.md +50 -0
  66. package/tooltip/index.tsx +57 -0
  67. package/tv.ts +23 -0
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
4
+
5
+ import { Button } from '../button'
6
+
7
+ export type CopyState = 'idle' | 'copied' | 'failed'
8
+
9
+ function writeLegacy(value: string): boolean {
10
+ const ta = document.createElement('textarea')
11
+ ta.value = value
12
+ ta.setAttribute('readonly', '')
13
+ ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'
14
+ document.body.appendChild(ta)
15
+ ta.select()
16
+
17
+ let ok: boolean
18
+ try { ok = document.execCommand('copy') }
19
+ catch { ok = false }
20
+
21
+ ta.remove()
22
+ return ok
23
+ }
24
+
25
+ async function write(value: string): Promise<boolean> {
26
+ try {
27
+ await navigator.clipboard.writeText(value)
28
+ return true
29
+ }
30
+ catch {
31
+ return writeLegacy(value)
32
+ }
33
+ }
34
+
35
+ export function useCopy(resetAfter = 2000) {
36
+ const [state, setState] = useState<CopyState>('idle')
37
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
38
+
39
+ useEffect(() => () => clearTimeout(timer.current), [])
40
+
41
+ const copy = async (value: string) => {
42
+ clearTimeout(timer.current)
43
+ const next: CopyState = await write(value) ? 'copied' : 'failed'
44
+ setState(next)
45
+ timer.current = setTimeout(() => setState('idle'), resetAfter)
46
+ return next
47
+ }
48
+
49
+ return { state, copy }
50
+ }
51
+
52
+ export interface CopyButtonProps {
53
+ value: string
54
+ color?: 'primary' | 'neutral' | 'danger'
55
+ emphasis?: 'solid' | 'weak' | 'outline' | 'ghost'
56
+ size?: 'sm' | 'md' | 'lg'
57
+ shape?: 'rect' | 'square' | 'pill' | 'circle'
58
+ onCopied?: (value: string) => void
59
+ onFailed?: () => void
60
+ disabled?: boolean
61
+ className?: string
62
+ 'aria-label'?: string
63
+ children?: ReactNode
64
+ }
65
+
66
+ export function CopyButton({
67
+ value,
68
+ color = 'neutral',
69
+ emphasis = 'outline',
70
+ size = 'md',
71
+ shape = 'rect',
72
+ onCopied,
73
+ onFailed,
74
+ children,
75
+ ...rest
76
+ }: CopyButtonProps) {
77
+ const { state, copy } = useCopy()
78
+
79
+ const run = async () => {
80
+ const result = await copy(value)
81
+ if (result === 'copied') onCopied?.(value)
82
+ else onFailed?.()
83
+ }
84
+
85
+ return (
86
+ <Button {...rest} color={color} emphasis={emphasis} size={size} shape={shape} onClick={run}>
87
+ <svg
88
+ viewBox="0 0 24 24"
89
+ fill="none"
90
+ stroke="currentColor"
91
+ strokeWidth="2"
92
+ strokeLinecap="round"
93
+ strokeLinejoin="round"
94
+ aria-hidden="true"
95
+ className="shrink-0"
96
+ >
97
+ {state === 'idle' && (
98
+ <g>
99
+ <rect x="8" y="8" width="14" height="14" rx="2" />
100
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
101
+ </g>
102
+ )}
103
+
104
+ {state === 'copied' && (
105
+ <path className="animate-draw motion-reduce:animate-none" d="M4 12l5 5L20 6" />
106
+ )}
107
+
108
+ {state === 'failed' && (
109
+ <g className="animate-enter motion-reduce:animate-none">
110
+ <path d="M18 6 6 18" />
111
+ <path d="m6 6 12 12" />
112
+ </g>
113
+ )}
114
+ </svg>
115
+
116
+ {children}
117
+ </Button>
118
+ )
119
+ }
@@ -0,0 +1,81 @@
1
+ # Dialog
2
+
3
+ ```
4
+ 받기 npx @pantoia/ui add dialog
5
+ 층 ui
6
+ 파일 dialog/index.tsx · dialog/AGENTS.md
7
+ ```
8
+
9
+ ## 부위
10
+
11
+ | 이름 | props |
12
+ |---|---|
13
+ | `Dialog` | `DialogProps` |
14
+ | `DialogTrigger` | — |
15
+ | `DialogClose` | — |
16
+ | `DialogContent` | `DialogContentProps` |
17
+ | `DialogTitle` | `ComponentProps<typeof DialogPrimitive.Title>` |
18
+ | `DialogDescription` | `ComponentProps<typeof DialogPrimitive.Description>` |
19
+ | `DialogHeader` | `DialogHeaderProps` |
20
+ | `DialogFooter` | — |
21
+
22
+ ### `DialogTrigger`
23
+
24
+ | 이름 | 타입 | 필수 |
25
+ |---|---|---|
26
+ | `children` | `ReactNode` | 예 |
27
+
28
+ ### `DialogClose`
29
+
30
+ | 이름 | 타입 | 필수 |
31
+ |---|---|---|
32
+ | `children` | `ReactNode` | 예 |
33
+
34
+ ### `DialogFooter`
35
+
36
+ | 이름 | 타입 | 필수 |
37
+ |---|---|---|
38
+ | `className` | `string` | |
39
+ | `children` | `ReactNode` | |
40
+
41
+ ## props
42
+
43
+ ### `DialogProps`
44
+
45
+ | 이름 | 타입 | 필수 |
46
+ |---|---|---|
47
+ | `open` | `boolean` | |
48
+ | `defaultOpen` | `boolean` | |
49
+ | `onOpenChange` | `(open: boolean) => void` | |
50
+ | `children` | `ReactNode` | |
51
+
52
+ ### `DialogContentProps`
53
+
54
+ `ComponentProps<typeof DialogPrimitive.Content>` 를 확장한다.
55
+
56
+ | 이름 | 타입 | 필수 |
57
+ |---|---|---|
58
+ | `size` | `'md' \| 'full' \| 'top'` | |
59
+
60
+ ### `DialogHeaderProps`
61
+
62
+ | 이름 | 타입 | 필수 |
63
+ |---|---|---|
64
+ | `lead` | `ReactNode` | |
65
+ | `trail` | `ReactNode` | |
66
+ | `className` | `string` | |
67
+ | `children` | `ReactNode` | |
68
+
69
+ ## 변형 — `dialog`
70
+
71
+ | 축 | 값 | 기본 |
72
+ |---|---|---|
73
+ | `size` | `md` · `full` · `top` | `md` |
74
+
75
+ ## 함께 받는 것
76
+
77
+ `tv`
78
+
79
+ ## npm 의존
80
+
81
+ `radix-ui`
@@ -0,0 +1,108 @@
1
+ 'use client'
2
+
3
+ import { Dialog as DialogPrimitive } from 'radix-ui'
4
+ import type { ComponentProps, ReactNode } from 'react'
5
+
6
+ import { tv } from '../tv'
7
+
8
+ export const dialog = tv({
9
+ base: 'bg-float text-fg z-overlay fixed flex flex-col gap-stack overflow-y-auto'
10
+ + ' open:animate-fade-in closed:animate-fade-out focus-visible:outline-none',
11
+
12
+ variants: {
13
+ size: {
14
+ md: 'left-1/2 top-1/2 w-full max-w-120 -translate-x-1/2 -translate-y-1/2'
15
+ + ' rounded-r20 p-6',
16
+ full: 'inset-0 p-6',
17
+ top: 'left-1/2 top-24 w-full max-w-144 -translate-x-1/2'
18
+ + ' rounded-r20 gap-0 overflow-hidden',
19
+ },
20
+ },
21
+
22
+ defaultVariants: { size: 'md' },
23
+ })
24
+
25
+ export interface DialogProps {
26
+ open?: boolean
27
+ defaultOpen?: boolean
28
+ onOpenChange?: (open: boolean) => void
29
+ children?: ReactNode
30
+ }
31
+
32
+ export function Dialog({ open, defaultOpen, onOpenChange, children }: DialogProps) {
33
+ return (
34
+ <DialogPrimitive.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
35
+ {children}
36
+ </DialogPrimitive.Root>
37
+ )
38
+ }
39
+
40
+ export function DialogTrigger({ children }: { children: ReactNode }) {
41
+ return <DialogPrimitive.Trigger asChild>{children}</DialogPrimitive.Trigger>
42
+ }
43
+
44
+ export function DialogClose({ children }: { children: ReactNode }) {
45
+ return <DialogPrimitive.Close asChild>{children}</DialogPrimitive.Close>
46
+ }
47
+
48
+ export interface DialogContentProps extends ComponentProps<typeof DialogPrimitive.Content> {
49
+ size?: 'md' | 'full' | 'top'
50
+ }
51
+
52
+ export function DialogContent({ size, className, children, ...rest }: DialogContentProps) {
53
+ return (
54
+ <DialogPrimitive.Portal>
55
+ <DialogPrimitive.Overlay className="bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
56
+
57
+ <DialogPrimitive.Content {...rest} className={dialog({ size, className })}>
58
+ {children}
59
+ </DialogPrimitive.Content>
60
+ </DialogPrimitive.Portal>
61
+ )
62
+ }
63
+
64
+ export function DialogTitle({ className, ...rest }: ComponentProps<typeof DialogPrimitive.Title>) {
65
+ return <DialogPrimitive.Title {...rest} className={tv({ base: 'text-section min-w-0' })({ className })} />
66
+ }
67
+
68
+ export function DialogDescription({ className, ...rest }: ComponentProps<typeof DialogPrimitive.Description>) {
69
+ return (
70
+ <DialogPrimitive.Description {...rest} className={tv({ base: 'text-prose text-fg-muted' })({ className })} />
71
+ )
72
+ }
73
+
74
+ export const dialogHeader = tv({
75
+ slots: {
76
+ root: 'flex items-start gap-stack',
77
+ lead: 'shrink-0',
78
+ body: 'flex min-w-0 grow flex-col gap-tight',
79
+ trail: 'shrink-0',
80
+ },
81
+ })
82
+
83
+ export interface DialogHeaderProps {
84
+ lead?: ReactNode
85
+ trail?: ReactNode
86
+ className?: string
87
+ children?: ReactNode
88
+ }
89
+
90
+ export function DialogHeader({ lead, trail, className, children }: DialogHeaderProps) {
91
+ const ui = dialogHeader()
92
+
93
+ return (
94
+ <div className={ui.root({ className })}>
95
+ {lead && <div className={ui.lead()}>{lead}</div>}
96
+ <div className={ui.body()}>{children}</div>
97
+ {trail && <div className={ui.trail()}>{trail}</div>}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ export function DialogFooter({ className, children }: { className?: string, children?: ReactNode }) {
103
+ return (
104
+ <div className={tv({ base: 'flex justify-end gap-2' })({ className })}>
105
+ {children}
106
+ </div>
107
+ )
108
+ }
package/dist/index.js ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { argv, cwd, exit, stdin, stdout } from 'node:process';
5
+ import { createInterface } from 'node:readline/promises';
6
+ const HERE = join(import.meta.dirname, '..');
7
+ const CONFIG = 'components.json';
8
+ const DEFAULT_PATH = 'components/ui';
9
+ const USAGE = `pantoia-ui <명령>
10
+
11
+ init [--path <디렉터리>] ${CONFIG} 을 만든다
12
+ add <이름...> [--force] 소스를 복사한다
13
+ list 받을 수 있는 것을 늘어놓는다
14
+ diff [이름] 복사본과 패키지의 지금 것을 견준다`;
15
+ function fail(message) {
16
+ console.error(message);
17
+ exit(1);
18
+ }
19
+ function version() {
20
+ const manifest = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'));
21
+ return manifest.version;
22
+ }
23
+ function registry() {
24
+ const doc = JSON.parse(readFileSync(join(HERE, 'registry.json'), 'utf8'));
25
+ return doc.items ?? [];
26
+ }
27
+ function find(name) {
28
+ const item = registry().find(candidate => candidate.name === name);
29
+ if (!item)
30
+ fail(`${name} 은 목록에 없다. pantoia-ui list 로 이름을 본다`);
31
+ return item;
32
+ }
33
+ function configPath() {
34
+ return join(cwd(), CONFIG);
35
+ }
36
+ function readConfig() {
37
+ if (!existsSync(configPath()))
38
+ return undefined;
39
+ return JSON.parse(readFileSync(configPath(), 'utf8'));
40
+ }
41
+ function writeConfig(config) {
42
+ const sorted = Object.fromEntries(Object.entries(config.components).sort());
43
+ writeFileSync(configPath(), `${JSON.stringify({ ...config, components: sorted }, null, 2)}\n`);
44
+ }
45
+ function option(args, flag) {
46
+ const at = args.indexOf(flag);
47
+ return at === -1 ? undefined : args[at + 1];
48
+ }
49
+ function resolve(names) {
50
+ const seen = new Map();
51
+ const queue = [...names];
52
+ while (queue.length > 0) {
53
+ const name = queue.shift();
54
+ if (seen.has(name))
55
+ continue;
56
+ const item = find(name);
57
+ seen.set(name, item);
58
+ queue.push(...(item.registryDependencies ?? []));
59
+ }
60
+ return [...seen.values()];
61
+ }
62
+ async function confirm(question) {
63
+ if (!stdin.isTTY)
64
+ return false;
65
+ const rl = createInterface({ input: stdin, output: stdout });
66
+ const answer = await rl.question(`${question} (y/N) `);
67
+ rl.close();
68
+ return answer.trim().toLowerCase() === 'y';
69
+ }
70
+ async function copy(item, root, force) {
71
+ for (const file of item.files ?? []) {
72
+ const source = readFileSync(join(HERE, file.path), 'utf8');
73
+ const target = join(root, file.target ?? file.path);
74
+ if (existsSync(target) && readFileSync(target, 'utf8') !== source) {
75
+ if (!force && !await confirm(`${file.path} 이 이미 있고 내용이 다르다. 덮어쓰나?`)) {
76
+ console.log(` 건너뛴다 ${file.path} — 덮어쓰려면 --force`);
77
+ continue;
78
+ }
79
+ }
80
+ mkdirSync(dirname(target), { recursive: true });
81
+ writeFileSync(target, source);
82
+ console.log(` 썼다 ${file.path}`);
83
+ }
84
+ }
85
+ async function commandInit(args) {
86
+ const existing = readConfig();
87
+ if (existing)
88
+ fail(`${CONFIG} 이 이미 있다`);
89
+ const path = option(args, '--path') ?? DEFAULT_PATH;
90
+ writeConfig({ path, components: {} });
91
+ console.log(`${CONFIG} 을 만들었다 — 소스는 ${path} 아래로 간다`);
92
+ console.log('스타일을 한 줄 넣는다 — @import "@pantoia/ui/styles.css";');
93
+ }
94
+ async function commandAdd(args) {
95
+ const names = args.filter(arg => !arg.startsWith('--'));
96
+ if (names.length === 0)
97
+ fail('받을 이름을 적는다 — pantoia-ui add button');
98
+ const force = args.includes('--force');
99
+ let config = readConfig();
100
+ if (!config) {
101
+ config = { path: option(args, '--path') ?? DEFAULT_PATH, components: {} };
102
+ console.log(`${CONFIG} 이 없어 만들었다 — 소스는 ${config.path} 아래로 간다`);
103
+ }
104
+ const items = resolve(names);
105
+ const root = join(cwd(), config.path);
106
+ const extra = items.filter(item => !names.includes(item.name));
107
+ if (extra.length > 0)
108
+ console.log(`함께 받는다 — ${extra.map(item => item.name).join(' · ')}`);
109
+ for (const item of items) {
110
+ console.log(item.name);
111
+ await copy(item, root, force);
112
+ config.components[item.name] = version();
113
+ }
114
+ writeConfig(config);
115
+ const dependencies = [...new Set(items.flatMap(item => item.dependencies ?? []))].sort();
116
+ if (dependencies.length > 0)
117
+ console.log(`\nnpm 의존을 적는다 — ${dependencies.join(' · ')}`);
118
+ }
119
+ function commandList() {
120
+ for (const item of registry()) {
121
+ const together = (item.registryDependencies ?? []).join(' · ');
122
+ console.log(`${item.name.padEnd(18)}${(item.meta?.layer ?? '').padEnd(9)}${together}`);
123
+ }
124
+ }
125
+ function lcs(a, b) {
126
+ const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
127
+ for (let i = a.length - 1; i >= 0; i--)
128
+ for (let j = b.length - 1; j >= 0; j--)
129
+ table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
130
+ return table;
131
+ }
132
+ function difference(mine, theirs) {
133
+ const table = lcs(mine, theirs);
134
+ const out = [];
135
+ let i = 0;
136
+ let j = 0;
137
+ while (i < mine.length && j < theirs.length) {
138
+ if (mine[i] === theirs[j]) {
139
+ i++;
140
+ j++;
141
+ continue;
142
+ }
143
+ if (table[i + 1][j] >= table[i][j + 1])
144
+ out.push(`- ${mine[i++]}`);
145
+ else
146
+ out.push(`+ ${theirs[j++]}`);
147
+ }
148
+ while (i < mine.length)
149
+ out.push(`- ${mine[i++]}`);
150
+ while (j < theirs.length)
151
+ out.push(`+ ${theirs[j++]}`);
152
+ return out;
153
+ }
154
+ async function commandDiff(args) {
155
+ const config = readConfig();
156
+ if (!config)
157
+ fail(`${CONFIG} 이 없다 — pantoia-ui init 을 먼저 돌린다`);
158
+ const names = args.filter(arg => !arg.startsWith('--'));
159
+ const wanted = names.length > 0 ? names : Object.keys(config.components);
160
+ let changed = 0;
161
+ for (const item of wanted.map(find)) {
162
+ for (const file of item.files ?? []) {
163
+ const target = join(cwd(), config.path, file.target ?? file.path);
164
+ if (!existsSync(target))
165
+ continue;
166
+ const mine = readFileSync(target, 'utf8');
167
+ const theirs = readFileSync(join(HERE, file.path), 'utf8');
168
+ if (mine === theirs)
169
+ continue;
170
+ changed++;
171
+ console.log(`\n${file.path}`);
172
+ for (const line of difference(mine.split('\n'), theirs.split('\n')))
173
+ console.log(line);
174
+ }
175
+ }
176
+ if (changed === 0)
177
+ console.log('갈린 것이 없다');
178
+ }
179
+ const [command, ...args] = argv.slice(2);
180
+ if (command === 'init')
181
+ await commandInit(args);
182
+ else if (command === 'add')
183
+ await commandAdd(args);
184
+ else if (command === 'list')
185
+ commandList();
186
+ else if (command === 'diff')
187
+ await commandDiff(args);
188
+ else
189
+ fail(USAGE);
@@ -0,0 +1,66 @@
1
+ # Drawer
2
+
3
+ ```
4
+ 받기 npx @pantoia/ui add drawer
5
+ 층 ui
6
+ 파일 drawer/index.tsx · drawer/index.module.css · drawer/AGENTS.md
7
+ ```
8
+
9
+ ## 부위
10
+
11
+ | 이름 | props |
12
+ |---|---|
13
+ | `Drawer` | `DrawerProps` |
14
+ | `DrawerTrigger` | — |
15
+ | `DrawerClose` | — |
16
+ | `DrawerContent` | `ComponentProps<typeof Dialog.Content>` |
17
+ | `DrawerHeader` | `DrawerHeaderProps` |
18
+ | `DrawerTitle` | `ComponentProps<typeof Dialog.Title>` |
19
+ | `DrawerDescription` | `ComponentProps<typeof Dialog.Description>` |
20
+
21
+ ### `DrawerTrigger`
22
+
23
+ | 이름 | 타입 | 필수 |
24
+ |---|---|---|
25
+ | `children` | `ReactNode` | 예 |
26
+
27
+ ### `DrawerClose`
28
+
29
+ | 이름 | 타입 | 필수 |
30
+ |---|---|---|
31
+ | `children` | `ReactNode` | 예 |
32
+
33
+ ## props
34
+
35
+ ### `DrawerProps`
36
+
37
+ | 이름 | 타입 | 필수 |
38
+ |---|---|---|
39
+ | `side` | `Side` | |
40
+ | `open` | `boolean` | |
41
+ | `defaultOpen` | `boolean` | |
42
+ | `onOpenChange` | `(open: boolean) => void` | |
43
+ | `children` | `ReactNode` | 예 |
44
+
45
+ ### `DrawerHeaderProps`
46
+
47
+ | 이름 | 타입 | 필수 |
48
+ |---|---|---|
49
+ | `lead` | `ReactNode` | |
50
+ | `trail` | `ReactNode` | |
51
+ | `className` | `string` | |
52
+ | `children` | `ReactNode` | |
53
+
54
+ ## 변형 — `drawerContent`
55
+
56
+ | 축 | 값 | 기본 |
57
+ |---|---|---|
58
+ | `side` | `left` · `right` | — |
59
+
60
+ ## 함께 받는 것
61
+
62
+ `tv`
63
+
64
+ ## npm 의존
65
+
66
+ `radix-ui`
@@ -0,0 +1,17 @@
1
+ @keyframes slide-in { from { translate: var(--drawer-slide) 0; opacity: 0; } }
2
+ @keyframes slide-out { to { translate: var(--drawer-slide) 0; opacity: 0; } }
3
+
4
+ .panel {
5
+ --drawer-slide: -100%;
6
+ transform: translateX(var(--drawer-swipe-movement-x, 0px));
7
+ transition: transform var(--dur-enter) var(--ease-enter);
8
+ }
9
+
10
+ .panel[data-state="open"] { animation: slide-in var(--dur-enter) var(--ease-enter); }
11
+ .panel[data-state="closed"]{ animation: slide-out var(--dur-exit) var(--ease-exit); }
12
+ .panel[data-swiping] { transition-duration: 0ms; }
13
+ .panel-right { --drawer-slide: 100%; }
14
+
15
+ @media (prefers-reduced-motion: reduce) {
16
+ .panel { --drawer-slide: 0%; }
17
+ }