@jongh/cli 1.0.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.
@@ -0,0 +1,274 @@
1
+ import * as React from "react"
2
+ import { createContext } from "../../hooks/createContext"
3
+ import { useControlledState } from "../../hooks/useControllableState"
4
+ import { useAccordionHeight } from "./useAccordionHeight"
5
+ import { useKeyboardEvent } from "../../hooks/useKeyboardEvent"
6
+ import { Slot } from "@radix-ui/react-slot"
7
+ import { composeRefs } from "src/hooks/useComposedRefs"
8
+
9
+ const ACCORDION_KEYS = [
10
+ "Home",
11
+ "End",
12
+ "ArrowDown",
13
+ "ArrowUp",
14
+ "ArrowLeft",
15
+ "ArrowRight",
16
+ ]
17
+ export type TAccordionContext = {
18
+ selected?: string[]
19
+ onItemOpen: (value: string) => void
20
+ onItemClose: (value: string) => void
21
+ refs?: HTMLElement[]
22
+ }
23
+
24
+ const [AccordionProvider, useAccordionContext] =
25
+ createContext<TAccordionContext>("accordion")
26
+
27
+ export interface AccordionProps {
28
+ value?: string[]
29
+ defaultValue?: string[]
30
+ onValueChange?: (value: string[]) => void
31
+ asChild?: boolean
32
+ type?: "single" | "multi"
33
+ }
34
+
35
+ export const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
36
+ (props, ref) => {
37
+ const { type = "single", ...accordionProps } = props
38
+ if (type === "single") {
39
+ return <SingleAccordion {...accordionProps} ref={ref} />
40
+ }
41
+ if (type === "multi") {
42
+ return <MultiAccordion {...accordionProps} />
43
+ }
44
+ },
45
+ )
46
+
47
+ Accordion.displayName = "Accordion"
48
+
49
+ interface AccordionImplSingleProps {
50
+ value?: string[] // 현재 선택되어있는 item들을 담아놓는 용도
51
+ defaultValue?: string[]
52
+ onValueChange?: (value: string[]) => void
53
+ asChild?: boolean
54
+ children?: React.ReactNode
55
+ }
56
+
57
+ const SingleAccordion = React.forwardRef<
58
+ HTMLDivElement,
59
+ AccordionImplSingleProps
60
+ >(
61
+ (
62
+ { value, defaultValue = [], onValueChange, children, asChild, ...props },
63
+ forwardedRef,
64
+ ) => {
65
+ const [selected = [], setSelected] = useControlledState({
66
+ prop: value,
67
+ defaultProp: defaultValue,
68
+ onChange: onValueChange,
69
+ })
70
+
71
+ const { refs: accordionRefs, handleKeyDown } = useKeyboardEvent({
72
+ keyList: ACCORDION_KEYS,
73
+ })
74
+
75
+ const Comp = asChild ? Slot : "div"
76
+
77
+ return (
78
+ <AccordionProvider
79
+ selected={selected}
80
+ onItemOpen={(value) => setSelected((_) => [value])}
81
+ onItemClose={() => setSelected((_) => [])}
82
+ refs={accordionRefs.current || []}
83
+ >
84
+ <Comp
85
+ ref={composeRefs((node) => {
86
+ accordionRefs.current = Array.from(
87
+ node?.children || [],
88
+ ) as HTMLElement[]
89
+ }, forwardedRef)}
90
+ onKeyDown={(e) => {
91
+ handleKeyDown(e)
92
+ }}
93
+ {...props}
94
+ >
95
+ {children}
96
+ </Comp>
97
+ </AccordionProvider>
98
+ )
99
+ },
100
+ )
101
+
102
+ interface AccordionImplMultiProps {
103
+ value?: string[] // 현재 선택되어있는 item들을 담아놓는 용도
104
+ defaultValue?: string[]
105
+ onValueChange?: (value: string[]) => void
106
+ asChild?: boolean
107
+ children?: React.ReactNode
108
+ }
109
+
110
+ const MultiAccordion = React.forwardRef<
111
+ HTMLDivElement,
112
+ AccordionImplMultiProps
113
+ >(
114
+ (
115
+ { value, defaultValue, onValueChange, children, asChild, ...props },
116
+ forwardedRef,
117
+ ) => {
118
+ const [selected, setSelected] = useControlledState({
119
+ prop: value,
120
+ defaultProp: defaultValue,
121
+ onChange: onValueChange,
122
+ })
123
+
124
+ const handleItemOpen = React.useCallback(
125
+ (item: string) => {
126
+ setSelected((prev = []) => [...prev, item])
127
+ },
128
+ [setSelected],
129
+ )
130
+
131
+ const handleItemClose = React.useCallback(
132
+ (item: string) =>
133
+ setSelected((prev = []) => prev.filter((value) => value !== item)),
134
+ [setSelected],
135
+ )
136
+
137
+ const { refs: accordionRefs, handleKeyDown } = useKeyboardEvent({
138
+ keyList: ACCORDION_KEYS,
139
+ })
140
+
141
+ const Comp = asChild ? Slot : "div"
142
+
143
+ return (
144
+ <AccordionProvider
145
+ selected={selected}
146
+ onItemOpen={handleItemOpen}
147
+ onItemClose={handleItemClose}
148
+ >
149
+ <Comp
150
+ ref={(node) => {
151
+ accordionRefs.current = Array.from(
152
+ node?.children || [],
153
+ ) as HTMLElement[]
154
+ if (typeof forwardedRef === "function") {
155
+ forwardedRef(node)
156
+ } else if (forwardedRef) {
157
+ ;(
158
+ forwardedRef as React.MutableRefObject<HTMLDivElement | null>
159
+ ).current = node
160
+ }
161
+ }}
162
+ onKeyDown={handleKeyDown}
163
+ {...props}
164
+ >
165
+ {children}
166
+ </Comp>
167
+ </AccordionProvider>
168
+ )
169
+ },
170
+ )
171
+ //AccordionItem
172
+ interface TAccordionItemContext {
173
+ isOpen: boolean
174
+ onToggle: () => void
175
+ value?: string
176
+ }
177
+
178
+ const [AccordionItemProvider, useAccordionItemProvider] =
179
+ createContext<TAccordionItemContext>("accordionItem")
180
+ export interface AccordionItemProps {
181
+ value: string //unique value
182
+ disabled?: boolean
183
+ children: React.ReactNode
184
+ asChild?: boolean
185
+ }
186
+
187
+ export const AccordionItem = React.forwardRef<
188
+ HTMLDivElement,
189
+ AccordionItemProps
190
+ >(({ value, disabled = false, children, asChild, ...props }, forwardedRef) => {
191
+ const { selected, onItemOpen, onItemClose } = useAccordionContext("accordion")
192
+
193
+ const isOpen = selected?.includes(value) && !disabled
194
+
195
+ const onToggle = () => {
196
+ if (disabled) {
197
+ return
198
+ }
199
+ isOpen ? onItemClose(value) : onItemOpen(value)
200
+ }
201
+
202
+ const Comp = asChild ? Slot : "div"
203
+
204
+ return (
205
+ <AccordionItemProvider isOpen={!!isOpen} onToggle={onToggle} value={value}>
206
+ <Comp
207
+ ref={forwardedRef}
208
+ data-state={isOpen ? "open" : "close"}
209
+ tabIndex={0}
210
+ onKeyDown={(e) => {
211
+ if (e.key === "Enter") {
212
+ onToggle()
213
+ }
214
+ }}
215
+ {...props}
216
+ >
217
+ {children}
218
+ </Comp>
219
+ </AccordionItemProvider>
220
+ )
221
+ })
222
+
223
+ export interface AccordionTriggerProps extends React.PropsWithChildren {}
224
+ export const AccordionTrigger = ({
225
+ children,
226
+ ...props
227
+ }: AccordionTriggerProps) => {
228
+ const { onToggle, isOpen, value } = useAccordionItemProvider("accordionItem")
229
+ return (
230
+ <h3 data-state={isOpen ? "open" : "close"} {...props}>
231
+ <span
232
+ onClick={onToggle}
233
+ aria-expanded={isOpen ? "true" : "false"}
234
+ aria-controls={`content-${value}`}
235
+ id={`trigger-${value}`}
236
+ >
237
+ <span>{children}</span>
238
+ </span>
239
+ </h3>
240
+ )
241
+ }
242
+ //AccordionContent
243
+ export interface AccordionContentProps extends React.PropsWithChildren {
244
+ duration?: number
245
+ asChild?: boolean
246
+ }
247
+ export const AccordionContent = ({
248
+ children,
249
+ duration = 150,
250
+ asChild,
251
+ ...props
252
+ }: AccordionContentProps) => {
253
+ const Comp = asChild ? Slot : "div"
254
+ const { isOpen, value } = useAccordionItemProvider("accordionItem")
255
+
256
+ const contentRef = useAccordionHeight<HTMLDivElement>(isOpen, duration) //duration 초 뒤에 accordion을 열거나 닫아줌
257
+
258
+ if (!isOpen) {
259
+ return null
260
+ }
261
+
262
+ return (
263
+ <Comp
264
+ data-state={isOpen ? "open" : "close"}
265
+ id={`content-${value}`}
266
+ aria-labelledby={`trigger-${value}`}
267
+ role="region"
268
+ ref={contentRef}
269
+ {...props}
270
+ >
271
+ {children}
272
+ </Comp>
273
+ )
274
+ }
@@ -0,0 +1,37 @@
1
+ import { accordion, type AccordionVariantProps } from "@styled-system/recipes"
2
+ import { createStyleContext } from "../../utils/createStyleContext"
3
+ import type { ComponentProps, HTMLStyledProps } from "@styled-system/types"
4
+ import type { Assign } from "../../types"
5
+ import {
6
+ Accordion as AccordionProvider,
7
+ AccordionContent,
8
+ AccordionItem,
9
+ AccordionTrigger,
10
+ type AccordionContentProps,
11
+ type AccordionItemProps,
12
+ type AccordionProps,
13
+ type AccordionTriggerProps,
14
+ } from "./Accordion"
15
+
16
+ const { withProvider, withContext } = createStyleContext(accordion)
17
+
18
+ export type RootProviderProps = ComponentProps<typeof AccordionProvider>
19
+ export const Accordion = withProvider<
20
+ HTMLDivElement,
21
+ Assign<Assign<HTMLStyledProps<"div">, AccordionProps>, AccordionVariantProps>
22
+ >(AccordionProvider, "root")
23
+
24
+ export const Item = withContext<
25
+ HTMLDivElement,
26
+ Assign<HTMLStyledProps<"div">, AccordionItemProps>
27
+ >(AccordionItem, "item")
28
+
29
+ export const Trigger = withContext<
30
+ HTMLButtonElement,
31
+ Assign<HTMLStyledProps<"button">, AccordionTriggerProps>
32
+ >(AccordionTrigger, "trigger")
33
+
34
+ export const Content = withContext<
35
+ HTMLDivElement,
36
+ Assign<HTMLStyledProps<"div">, AccordionContentProps>
37
+ >(AccordionContent, "content")
@@ -0,0 +1,28 @@
1
+ import { useEffect, useRef } from "react"
2
+ const ACCORDION_HEIGHT = "--accordion-height" //CSS 변수명- accordion의 높이를 제어하여 animation과 sync 맞춤
3
+ export const useAccordionHeight = <T extends HTMLElement>(
4
+ isOpen: boolean,
5
+ duration = 150,
6
+ ) => {
7
+ const ref = useRef<T>(null)
8
+
9
+ useEffect(() => {
10
+ const element = ref.current
11
+ if (element === null) {
12
+ return
13
+ }
14
+
15
+ if (isOpen) {
16
+ const height = element.style.getPropertyValue(ACCORDION_HEIGHT)
17
+ if (height === "0" || !height) {
18
+ element.style.setProperty(ACCORDION_HEIGHT, `${element.scrollHeight}px`)
19
+ }
20
+ } else {
21
+ setTimeout(() => {
22
+ element.style.setProperty(ACCORDION_HEIGHT, `${element.scrollHeight}px`)
23
+ }, duration)
24
+ }
25
+ }, [isOpen, duration])
26
+
27
+ return ref
28
+ }
@@ -0,0 +1,18 @@
1
+ import { Slot } from "@radix-ui/react-slot"
2
+ import { useAvatar, type UseAvatarStatusProps } from "./useAvatar"
3
+ import { forwardRef } from "react"
4
+
5
+ interface AvatarProps extends UseAvatarStatusProps {
6
+ asChild?: boolean
7
+ }
8
+
9
+ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>((props, ref) => {
10
+ const { asChild, src, onStatusChange } = props
11
+ const { getRootProps, getImgProps } = useAvatar({ src, onStatusChange })
12
+ const Comp = asChild ? Slot : "span"
13
+ return (
14
+ <Comp ref={ref} {...getRootProps()}>
15
+ <img {...getImgProps()}></img>
16
+ </Comp>
17
+ )
18
+ })
@@ -0,0 +1,73 @@
1
+ import {
2
+ useLayoutEffect,
3
+ useState,
4
+ type ComponentProps,
5
+ type HTMLAttributes,
6
+ } from "react"
7
+
8
+ type Status = "loading" | "loaded" | "error"
9
+
10
+ export interface UseAvatarStatusProps {
11
+ src?: string
12
+ onStatusChange?: (status: Status) => void
13
+ }
14
+
15
+ export const useAvatarStatus = ({
16
+ src,
17
+ onStatusChange,
18
+ }: UseAvatarStatusProps) => {
19
+ const [status, setStatus] = useState<Status>("loading")
20
+
21
+ useLayoutEffect(() => {
22
+ if (!src) {
23
+ setStatus("error")
24
+ return
25
+ }
26
+ let isMounted: boolean = true
27
+ const image = new window.Image()
28
+
29
+ const updateStatus = (status: Status) => () => {
30
+ if (!isMounted) return
31
+ setStatus(status)
32
+ onStatusChange?.(status)
33
+ }
34
+
35
+ setStatus("loading")
36
+
37
+ image.src = src
38
+ image.onload = updateStatus("loaded")
39
+ image.onerror = updateStatus("error")
40
+
41
+ return () => {
42
+ isMounted = false
43
+ }
44
+ }, [src])
45
+
46
+ return status
47
+ }
48
+
49
+ type DataAttr = Record<`data-${string}`, string | undefined>
50
+
51
+ const elementProps = (props: HTMLAttributes<HTMLElement> & DataAttr) => props
52
+
53
+ const imgProps = (props: ComponentProps<"img"> & DataAttr) => props
54
+
55
+ interface UseAvatarProps extends UseAvatarStatusProps {}
56
+ export const useAvatar = ({ src, onStatusChange }: UseAvatarProps) => {
57
+ const status = useAvatarStatus({ src, onStatusChange })
58
+ const isLoaded = status === "loaded"
59
+ return {
60
+ getRootProps() {
61
+ return elementProps({
62
+ "data-loading-state": status,
63
+ })
64
+ },
65
+ getImgProps() {
66
+ return imgProps({
67
+ hidden: !isLoaded,
68
+ "data-visible": `${isLoaded}`,
69
+ src,
70
+ })
71
+ },
72
+ }
73
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ forwardRef,
3
+ ComponentPropsWithoutRef,
4
+ isValidElement,
5
+ cloneElement,
6
+ type ReactElement,
7
+ type MouseEventHandler,
8
+ } from "react"
9
+ import { button, type ButtonVariant } from "@styled-system/recipes"
10
+ import { Slot } from "@radix-ui/react-slot"
11
+
12
+ type ButtonProps = Partial<ButtonVariant> &
13
+ ComponentPropsWithoutRef<"button"> & { as?: string } & {
14
+ /**왼쪽에 올 아이콘 */
15
+ leftIcon?: ReactElement
16
+ /**오른쪽에 위치할 아이콘 */
17
+ rightIcon?: ReactElement
18
+ }
19
+
20
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
21
+ (
22
+ {
23
+ as,
24
+ id,
25
+ className,
26
+ children,
27
+ disabled = false,
28
+ leftIcon,
29
+ rightIcon,
30
+ onClick,
31
+ ...rest
32
+ },
33
+ ref,
34
+ ) => {
35
+ const Comp = as ? Slot : "button"
36
+
37
+ const wrapIcon = (icon: ReactElement<{ onClick?: MouseEventHandler }>) => {
38
+ if (isValidElement(icon)) {
39
+ const { onClick: onClickIcon } = icon.props
40
+ return cloneElement(icon, {
41
+ onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
42
+ e.stopPropagation()
43
+ onClickIcon ? onClickIcon(e) : onClick?.(e)
44
+ },
45
+ })
46
+ }
47
+ }
48
+
49
+ return (
50
+ <Comp
51
+ role="button"
52
+ ref={ref}
53
+ disabled={disabled}
54
+ id={id}
55
+ data-testid={id}
56
+ onClick={onClick}
57
+ className={button({})}
58
+ {...rest}
59
+ >
60
+ {leftIcon && <span>{wrapIcon(leftIcon)}</span>}
61
+ {children}
62
+ {rightIcon && <span>{wrapIcon(rightIcon)}</span>}
63
+ </Comp>
64
+ )
65
+ },
66
+ )
@@ -0,0 +1,140 @@
1
+ import { useState } from "react"
2
+ import { useControlledState } from "../../hooks/useControllableState"
3
+
4
+ type Props = {
5
+ min: number
6
+ max: number
7
+ value: number
8
+ defaultValue?: number
9
+ onChange: (value: number) => void
10
+ }
11
+
12
+ const getPercentage = (value: number, min: number, max: number) =>
13
+ ((value - min) / (max - min)) * 100
14
+
15
+ const isTouchEvent = (e: TouchEvent | MouseEvent): e is TouchEvent => {
16
+ return e && "touches" in e
17
+ }
18
+
19
+ const isMouseEvent = (e: TouchEvent | MouseEvent): e is MouseEvent => {
20
+ return e && "screenX" in e
21
+ }
22
+
23
+ const getClientX = (
24
+ e: React.TouchEvent<HTMLElement> | React.MouseEvent<HTMLElement>,
25
+ ) => {
26
+ let clientX = 0
27
+ if (isMouseEvent(e.nativeEvent)) {
28
+ clientX = e.nativeEvent.clientX
29
+ } else if (isTouchEvent(e.nativeEvent)) {
30
+ clientX =
31
+ e.nativeEvent.touches.length > 0
32
+ ? e.nativeEvent.touches[0].clientX
33
+ : e.nativeEvent.changedTouches[0].clientX
34
+ }
35
+ return clientX
36
+ }
37
+
38
+ export const Slider = ({
39
+ min = 0,
40
+ max = 100,
41
+ value,
42
+ onChange,
43
+ defaultValue,
44
+ }: Props) => {
45
+ const [isDragging, setIsDragging] = useState(false)
46
+
47
+ const [innerValue = 0, setValue] = useControlledState({
48
+ prop: value,
49
+ defaultProp: defaultValue || min,
50
+ onChange,
51
+ })
52
+
53
+ const handlePointerDown = (e: React.PointerEvent) => {
54
+ e.preventDefault()
55
+ setIsDragging(true)
56
+ e.currentTarget.setPointerCapture(e.pointerId)
57
+ }
58
+
59
+ const handlePointerUp = (e: React.PointerEvent) => {
60
+ e.preventDefault()
61
+ setIsDragging(false)
62
+ e.currentTarget.releasePointerCapture(e.pointerId)
63
+ }
64
+
65
+ const percentage = getPercentage(innerValue, min, max)
66
+
67
+ const handleMouseMove = (e: React.PointerEvent<HTMLElement>) => {
68
+ e.preventDefault()
69
+ if (!isDragging) return
70
+ //width는 본인의 상위태그여야함..
71
+ const { left, width } = e.currentTarget.getBoundingClientRect()
72
+ let percentage = (getClientX(e) - left) / width
73
+ console.log(left, width)
74
+ percentage = Math.min(Math.max(percentage, 0), 1)
75
+ console.log(percentage)
76
+ const newValue = min + percentage * (max - min)
77
+ setValue(newValue)
78
+ }
79
+
80
+ return (
81
+ <>
82
+ <span
83
+ className="slider-wrapper"
84
+ style={{
85
+ position: "relative",
86
+ display: "flex",
87
+ alignItems: "center",
88
+ width: "200px",
89
+ height: "50px",
90
+ }}
91
+ >
92
+ <span
93
+ className="slider-track"
94
+ onPointerDownCapture={handlePointerDown}
95
+ onPointerUpCapture={handlePointerUp}
96
+ onPointerMoveCapture={handleMouseMove}
97
+ onPointerCancelCapture={handlePointerUp}
98
+ style={{
99
+ position: "relative",
100
+ backgroundColor: "black",
101
+ flexGrow: 1,
102
+ borderRadius: "9999px",
103
+ height: "10px",
104
+ }}
105
+ >
106
+ <span
107
+ className="slider-value"
108
+ style={{
109
+ backgroundColor: "white",
110
+ borderRadius: "9999px",
111
+ height: "100%",
112
+ position: "absolute",
113
+ width: `${percentage}%`,
114
+ }}
115
+ ></span>
116
+ <span
117
+ className="slider-thumb"
118
+ style={{
119
+ position: "absolute",
120
+ left: `${percentage}%`,
121
+ transform: "translateX(-50%)",
122
+ }}
123
+ >
124
+ <span
125
+ style={{
126
+ width: "10px",
127
+ height: "10px",
128
+ borderRadius: "100px",
129
+ backgroundColor: "pink",
130
+ opacity: "0.5",
131
+ display: "block",
132
+ }}
133
+ ></span>
134
+ </span>
135
+ </span>
136
+ </span>
137
+ <div style={{ color: "pink" }}>{innerValue}</div>
138
+ </>
139
+ )
140
+ }
@@ -0,0 +1,42 @@
1
+ import { define } from "../../../dev"
2
+
3
+ export const sliderRecipe = define.recipe({
4
+ className: "slider",
5
+ base: {
6
+ WebkitAppearance: "none",
7
+ height: "4px",
8
+ borderRadius: "lg",
9
+ backgroundColor: "grey_300",
10
+ backgroundImage: "linear-gradient(#7DD3FC, #7DD3FC)",
11
+ backgroundSize: "var(--range-size)",
12
+ backgroundRepeat: "no-repeat",
13
+ "&::-webkit-slider-thumb": {
14
+ WebkitAppearance: "none",
15
+ width: "20px",
16
+ height: "20px",
17
+ backgroundColor: "grey_300",
18
+ borderRadius: "rounded",
19
+ border: "none",
20
+ cursor: "pointer",
21
+ },
22
+ "&::-moz-range-thumb": {
23
+ WebkitAppearance: "none",
24
+ width: "20px",
25
+ height: "20px",
26
+ backgroundColor: "grey_300",
27
+ borderRadius: "rounded",
28
+ border: "none",
29
+ cursor: "pointer",
30
+ },
31
+ },
32
+ variants: {
33
+ orientation: {
34
+ horizontal: {
35
+ writingMode: "horizontal-tb",
36
+ },
37
+ vertical: {
38
+ writingMode: "vertical-rl",
39
+ },
40
+ },
41
+ },
42
+ })