kku-ui 0.9.9981 → 0.9.9983

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/README.md CHANGED
@@ -1,84 +1,210 @@
1
- # ✨ KKU-UI ✨
2
- ### React Component Library for Modern UI Development<br>
3
-
4
- ### 🎨 스토리북
5
-
6
- 🔗 [Link](https://macjjuni.github.io/kku-ui)
7
-
8
- ### 소개
9
-
10
- kku-ui는 React 기반의 컴포넌트 라이브러리로, 컴포넌트 간 유연한 조합을 위해 컴파운드 패턴과 동적인 UI 구성을 위한 Render Props 패턴이 적용되어 있습니다.
11
- 이를 통해 재사용성과 확장성을 높이며, 가볍게 사용할 수 있도록 만들어졌습니다.
12
-
13
- ### 컴포넌트 구조
14
-
15
- 프로젝트 내 컴포넌트는 크게 **Main 컴포넌트**와 **Core 컴포넌트**로 분리되어 있습니다.
16
-
17
- #### **Main 컴포넌트**
18
- 다지안이 적용된 최종 UI 컴포넌트입니다.
19
-
20
- | 컴포넌트 | 기능 | 스토리북 | a11y |
21
- |:-------------------|:----:|:--------:|:----:|
22
- | `<KSwitch/>` | ✅ | ✅ | ✅ |
23
- | `<KButtonGroup/>` | ✅ | ✅ | ✅ |
24
- | `<KToggleButton/>` | ✅ | ✅ | ✅ |
25
- | `<KSwitch/>` | ✅ | ✅ | ✅ |
26
- | `<KSelect/>` | ✅ | ✅ | ✅ |
27
- | `<KSelect/>` | ✅ | ✅ | ✅ |
28
- | `<KAccordion/>` | ✅ | ✅ | ✅ |
29
- | `<KMenu/>` | ✅ | ✅ | ✅ |
30
- | `<KDropdown/>` | ✅ | ✅ | ✅ |
31
- | `<KBackdrop/>` | ✅ | ✅ | ✅ |
32
- | `<KModal/>` | ✅ | ✅ | ✅ |
33
- | `<KSpinner/>` | ✅ | ✅ | ✅ |
34
- | `<KIcon/>` | ✅ | ✅ | ✅ |
35
- | 🏃🏻‍♂
36
-
37
- #### **Core 컴포넌트**
38
- 디자인 없이 기본 로직과 접근성(A11y) 처리만 포함된 단일 컴포넌트입니다.
39
- Main 컴포넌트 내부에서 재사용되며, 재사용성과 별도의 디자인을 적용하기 위해 만들어 졌습니다.
40
-
41
- | 컴포넌트 | 기능 | 스토리북 | a11y |
42
- |:------------------------|:----:|:--------:|:----:|
43
- | `<KSwitch/>` | ✅ | ✅ | ✅ |
44
- | `<TextField/>` | ✅ | ✅ | ✅ |
45
- | `<Switch/>` | ✅ | ✅ | ✅ |
46
- | `<Menu/>` | ✅ | ✅ | ✅ |
47
- | `<MenuItem/>` | ✅ | ✅ | ✅ |
48
- | `<KAccordion/>` | ✅ | ✅ | ✅ |
49
- | `<KAccordion.Summary/>` | ✅ | ✅ | ✅ |
50
- | `<KAccordion.Content/>` | ✅ | ✅ | ✅ |
51
-
52
-
53
- ## 📦 Install
1
+ # KKU-UI
2
+
3
+ [![npm version](https://img.shields.io/npm/v/kku-ui.svg)](https://www.npmjs.com/package/kku-ui)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ 개인적으로 사용하려고 만든 Radix UI 기반 커스텀 컴포넌트 라이브러리입니다.
7
+
8
+ ### 🎨 Storybook
9
+
10
+ **[Link](https://macjjuni.github.io/kku-ui)**
11
+
12
+ ---
13
+
14
+ ## 설치
15
+
16
+ ```bash
17
+ npm install kku-ui react react-dom lucide-react
18
+ # or
19
+ pnpm add kku-ui react react-dom lucide-react
54
20
  ```
55
- npm install kku-ui
21
+
22
+ `react`, `react-dom`(19 이상)과 `lucide-react`는 peerDependency이므로 앱에 직접 설치해야 합니다.
23
+
24
+ ## 사용법
25
+
26
+ ### 1. 스타일 불러오기
27
+
28
+ ```tsx
29
+ // Tailwind를 쓰지 않는 앱: 전역 리셋(preflight) + 컴포넌트 스타일
30
+ import 'kku-ui/base.css'
31
+ import 'kku-ui/index.css'
32
+
33
+ // Tailwind를 쓰는 앱: 앱의 preflight를 그대로 쓰고 컴포넌트 스타일만
34
+ import 'kku-ui/index.css'
56
35
  ```
36
+
37
+ | 파일 | 내용 |
38
+ |---|---|
39
+ | `kku-ui/index.css` | 디자인 토큰(CSS 변수), 폰트, 컴포넌트 스타일, 기본 border 색상, `body` 배경·글자색(라이트/다크) |
40
+ | `kku-ui/base.css` | Tailwind preflight (선택, Tailwind를 쓰지 않는 앱용) |
41
+
42
+ ### 2. 컴포넌트 사용
43
+
44
+ ```tsx
45
+ import { KButton, KSwitch, KAccordion } from 'kku-ui'
46
+
47
+ function App() {
48
+ return (
49
+ <div>
50
+ <KButton>Click me</KButton>
51
+ <KSwitch />
52
+ </div>
53
+ )
54
+ }
57
55
  ```
58
- yarn add kku-ui
56
+
57
+ ### 3. 커스텀 훅 사용
58
+
59
+ ```tsx
60
+ import { useClickOutside, useStableId } from 'kku-ui/hooks'
61
+
62
+ function MyComponent() {
63
+ const id = useStableId('my-component')
64
+ // ...
65
+ }
66
+ ```
67
+
68
+ ---
69
+
70
+ ## 컴포넌트
71
+
72
+ ### Actions
73
+
74
+ - `KButton` - 버튼
75
+ - `KButtonGroup` - 버튼 그룹
76
+ - `KSwitch` - 스위치 토글
77
+
78
+ ### Data Display
79
+
80
+ - `KAccordion` - 아코디언
81
+ - `KDropdownMenu` - 드롭다운 메뉴
82
+ - `KBottomSheet` - 바텀시트
83
+
84
+ ### Feedback
85
+
86
+ - `KDialog` - 다이얼로그
87
+ - `KPopover` - 팝오버
88
+ - `KSpinner` - 로딩 스피너
89
+ - `KSkeleton` - 스켈레톤 UI
90
+ - `KToast` - 토스트 알림
91
+
92
+ ### Input
93
+
94
+ - `KInput` - 기본 입력
95
+ - `KTextField` - 텍스트 필드
96
+ - `KTextarea` - 텍스트 영역
97
+ - `KSelect` - 셀렉트
98
+ - `KInputGroup` - 입력 그룹
99
+ - `KNumberStepper` - 숫자 증감
100
+
101
+ ### Layout
102
+
103
+ - `KCard` - 카드
104
+ - `KListRow` - 리스트 행
105
+ - `KAspectRatio` - 종횡비 컨테이너
106
+
107
+ ### Foundation
108
+
109
+ - `KIcon` - 아이콘 (Lucide React)
110
+
111
+ ---
112
+
113
+ ## 컴파운드 패턴
114
+
115
+ ```tsx
116
+ import { KAccordion, KAccordionItem, KAccordionTrigger, KAccordionContent } from 'kku-ui'
117
+
118
+ function Example() {
119
+ return (
120
+ <KAccordion type="single" collapsible>
121
+ <KAccordionItem value="item-1">
122
+ <KAccordionTrigger>제목</KAccordionTrigger>
123
+ <KAccordionContent>컨텐츠 내용</KAccordionContent>
124
+ </KAccordionItem>
125
+ </KAccordion>
126
+ )
127
+ }
59
128
  ```
129
+
130
+ ## 스타일 커스터마이징
131
+
132
+ CSS 변수로 테마를 덮어쓸 수 있습니다:
133
+
134
+ ```css
135
+ :root {
136
+ --primary: 220 90% 56%;
137
+ --primary-foreground: 0 0% 100%;
138
+ --radius: 0.5rem;
139
+ }
60
140
  ```
61
- pnpm add kku-ui
141
+
142
+ 모든 컴포넌트는 `className` prop으로 Tailwind 클래스를 추가할 수 있습니다.
143
+
144
+ ### 폰트
145
+
146
+ `kku-ui/index.css`를 import하면 기본 폰트로 **Pretendard**(Regular/Medium/Bold)가 `body`에 자동 적용됩니다. 별도 폰트 설정이 필요 없습니다.
147
+
148
+ 다른 폰트를 쓰려면 `--font-sans` 변수를 덮어쓰면 됩니다. Portal로 렌더링되는 Dialog, Dropdown, Toast 등에도 함께 적용됩니다 CSS import 순서나 다크모드와 관계없이 앱의 `:root` 선언이 우선 적용됩니다.
149
+
150
+ ```css
151
+ :root {
152
+ --font-sans: 'Noto Sans KR', sans-serif;
153
+ }
62
154
  ```
63
155
 
64
- ## ⚙️ Initial Setting
65
- ```typescript
66
- // Entry Point 🏃🏻‍♂️
156
+ Next.js의 `next/font`를 쓰는 경우 `body`에 폰트 클래스를 지정하거나 변수로 연결합니다.
67
157
 
68
- import 'kku-ui/lib/index.css'
158
+ ```tsx
159
+ const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
160
+ // <html className={inter.variable}>
69
161
  ```
70
162
 
71
- ## 🧑🏻‍💻 Use Examples
163
+ ```css
164
+ :root {
165
+ --font-sans: var(--font-inter), sans-serif;
166
+ }
167
+ ```
72
168
 
73
- ```typescript
74
- import { KSwitch } from 'kku-ui'
169
+ 다른 폰트로 덮어쓰면 Pretendard 파일은 다운로드되지 않습니다.
75
170
 
76
- export default function Component() {
77
- return (
78
- <KSwitch label="Hello World!"/>
79
- );
80
- }
171
+ ---
172
+
173
+ ## 개발
174
+
175
+ ```bash
176
+ pnpm install
177
+
178
+ pnpm sb # Storybook
179
+ pnpm test # 유닛 테스트
180
+ pnpm e2e # E2E 테스트
181
+ pnpm build # 빌드
81
182
  ```
82
183
 
184
+ ### 프로젝트 구조
185
+
186
+ ```
187
+ src/
188
+ ├── components/ui/
189
+ │ ├── actions/
190
+ │ ├── data-display/
191
+ │ ├── feedback/
192
+ │ ├── input/
193
+ │ ├── layout/
194
+ │ └── foundation/
195
+ ├── common/hooks/
196
+ └── styles/
197
+
198
+ __tests__/ # 유닛 테스트
199
+ e2e/ # E2E 테스트 (Playwright)
200
+ ```
201
+
202
+ ## 기술 스택
203
+
204
+ - React 19 + TypeScript
205
+ - Radix UI + Tailwind CSS
206
+ - Vite / Vitest / Storybook / Playwright
83
207
 
208
+ ## 라이선스
84
209
 
210
+ MIT © [macjjuni](https://github.com/macjjuni/kku-ui)
package/lib/base.css ADDED
@@ -0,0 +1,3 @@
1
+ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
2
+
3
+ /*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}
@@ -1,7 +1,7 @@
1
1
  import { ButtonHTMLAttributes } from 'react';
2
2
  import { VariantProps } from 'class-variance-authority';
3
3
  declare const buttonVariants: (props?: ({
4
- variant?: "default" | "outline" | "primary" | "danger" | "ghost" | "link" | null | undefined;
4
+ variant?: "default" | "outline" | "primary" | "destructive" | "ghost" | "link" | null | undefined;
5
5
  size?: "sm" | "md" | "lg" | "icon" | null | undefined;
6
6
  width?: "default" | "full" | null | undefined;
7
7
  } & import('class-variance-authority/dist/types').ClassProp) | undefined) => string;
@@ -1,5 +1,5 @@
1
1
  import { HTMLAttributes } from 'react';
2
- import * as DialogPrimitive from "@radix-ui/react-dialog";
2
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
3
3
  export interface KDialogContextType {
4
4
  size?: 'sm' | 'md';
5
5
  opacity?: number;
@@ -1,4 +1,4 @@
1
- import * as PopoverPrimitive from "@radix-ui/react-popover";
1
+ import * as PopoverPrimitive from '@radix-ui/react-popover';
2
2
  export type KPopoverProps = React.ComponentPropsWithRef<typeof PopoverPrimitive.Root>;
3
3
  export type KPopoverTriggerProps = React.ComponentPropsWithRef<typeof PopoverPrimitive.Trigger>;
4
4
  export type KPopoverAnchorProps = React.ComponentPropsWithRef<typeof PopoverPrimitive.Anchor>;
@@ -4,7 +4,7 @@ export interface KToasterProps extends ComponentProps<typeof Sonner> {
4
4
  size?: 'sm' | 'md' | 'lg' | 'full';
5
5
  }
6
6
  declare const KToast: {
7
- ({ size, position, ...props }: KToasterProps): import("react/jsx-runtime").JSX.Element;
7
+ (props: KToasterProps): import("react/jsx-runtime").JSX.Element;
8
8
  displayName: string;
9
9
  };
10
10
  export { KToast, toast as kToast };
@@ -4,7 +4,7 @@ export type KIconSize = (typeof ICON_SIZES)[number];
4
4
  export interface KIconRefs {
5
5
  click: () => void;
6
6
  }
7
- export type KIconProps = ComponentPropsWithRef<'span'> & {
7
+ export type KIconProps = Omit<ComponentPropsWithRef<'span'>, 'ref'> & {
8
8
  icon: string;
9
9
  size?: KIconSize | number;
10
10
  disabled?: boolean;
@@ -4,7 +4,7 @@ export type KInputSizeType = 'sm' | 'md' | 'lg';
4
4
  declare const inputVariants: (props?: ({
5
5
  size?: "sm" | "md" | "lg" | null | undefined;
6
6
  } & import('class-variance-authority/dist/types').ClassProp) | undefined) => string;
7
- export interface KInputProps extends Omit<ComponentProps<"input">, 'size'>, VariantProps<typeof inputVariants> {
7
+ export interface KInputProps extends Omit<ComponentProps<'input'>, 'size'>, VariantProps<typeof inputVariants> {
8
8
  }
9
9
  declare const KInput: import('react').ForwardRefExoticComponent<Omit<KInputProps, "ref"> & import('react').RefAttributes<HTMLInputElement>>;
10
10
  export { KInput, inputVariants };
@@ -32,4 +32,4 @@ declare function KInputGroupTextarea({ className, ...props }: ComponentProps<'te
32
32
  declare namespace KInputGroupTextarea {
33
33
  var displayName: string;
34
34
  }
35
- export { KInputGroup, KInputGroupAddon, KInputGroupText, KInputGroupButton, KInputGroupInput, KInputGroupTextarea, };
35
+ export { KInputGroup, KInputGroupAddon, KInputGroupText, KInputGroupButton, KInputGroupInput, KInputGroupTextarea };
@@ -1,5 +1,5 @@
1
1
  import { ComponentProps, Ref } from 'react';
2
- export interface KTextareaProps extends Omit<ComponentProps<"textarea">, "ref"> {
2
+ export interface KTextareaProps extends Omit<ComponentProps<'textarea'>, 'ref'> {
3
3
  ref?: Ref<HTMLTextAreaElement>;
4
4
  }
5
5
  declare const KTextarea: (props: KTextareaProps) => import("react/jsx-runtime").JSX.Element;
@@ -12,6 +12,7 @@ export interface KTextFieldRefs {
12
12
  }
13
13
  export interface KTextFieldProps extends Omit<ComponentProps<'input'>, 'size' | 'width' | 'ref'> {
14
14
  label?: string;
15
+ inputClassName?: string;
15
16
  helperText?: string;
16
17
  rules?: KValidationRule[];
17
18
  width?: VariantProps<typeof textFieldVariants>['width'] | number;
package/lib/es/hooks.js CHANGED
@@ -1,36 +1,39 @@
1
- import { useEffect as i, useRef as c, useCallback as o } from "react";
2
- import { u as l } from "./chunks/useStableId-CvSXOC4i.js";
3
- function f(e, n, t) {
4
- i(() => {
5
- const r = (u) => {
6
- if (e.current && !e.current.contains(u.target)) {
1
+ import { useRef as c, useEffect as o, useCallback as a } from "react";
2
+ import { u as p } from "./chunks/useStableId-CvSXOC4i.js";
3
+ function f(e, r, t) {
4
+ const n = c(r);
5
+ o(() => {
6
+ n.current = r;
7
+ }), o(() => {
8
+ const u = (i) => {
9
+ if (e.current && !e.current.contains(i.target)) {
7
10
  if (!t) {
8
- n();
11
+ n.current();
9
12
  return;
10
13
  }
11
- t.current && !t.current.contains(u.target) && n();
14
+ t.current && !t.current.contains(i.target) && n.current();
12
15
  }
16
+ }, s = setTimeout(() => {
17
+ window.addEventListener("mousedown", u);
18
+ }, 0);
19
+ return () => {
20
+ clearTimeout(s), window.removeEventListener("mousedown", u);
13
21
  };
14
- return setTimeout(() => {
15
- window.addEventListener("mousedown", r);
16
- }, 0), () => {
17
- window.removeEventListener("mousedown", r);
18
- };
19
- }, [e]);
22
+ }, [e, t]);
20
23
  }
21
- function a() {
22
- const e = c([]), n = c([]), t = o((r) => (u) => {
23
- const s = n.current.indexOf(r);
24
+ function m() {
25
+ const e = c([]), r = c([]), t = a((n) => (u) => {
26
+ const s = r.current.indexOf(n);
24
27
  if (s !== -1) {
25
28
  e.current[s] = u;
26
29
  return;
27
30
  }
28
- n.current.push(r), e.current.push(u);
31
+ r.current.push(n), e.current.push(u);
29
32
  }, []);
30
33
  return { refs: e.current, handleRef: t };
31
34
  }
32
35
  export {
33
36
  f as useClickOutside,
34
- a as useDynamicRefs,
35
- l as useStableId
37
+ m as useDynamicRefs,
38
+ p as useStableId
36
39
  };