11'use client'
22
33import Link from 'next/link'
4- import type { AnchorHTMLAttributes , ButtonHTMLAttributes , ReactNode } from 'react'
4+ import {
5+ type AnchorHTMLAttributes ,
6+ type ButtonHTMLAttributes ,
7+ type MouseEvent ,
8+ type ReactNode ,
9+ useRef ,
10+ useState
11+ } from 'react'
512import { cn } from '@/utils/cn'
613import { getMobileWidthClass , getPcWidthClass , isWideOnlyWidth } from './controlMeta'
714import type { Device , PcWidthVariant , WidthToken } from './controlMeta'
@@ -33,6 +40,14 @@ export type GdgButtonProps = {
3340 loading ?: boolean
3441} & Omit < ButtonLike , 'as' >
3542
43+ type Ripple = {
44+ id : number
45+ x : number
46+ y : number
47+ size : number
48+ active : boolean
49+ }
50+
3651const SIZE_CLASS : Record < Device , Record < ButtonSize , string > > = {
3752 pc : {
3853 large : 'h-13 px-12 text-base leading-6' ,
@@ -71,6 +86,8 @@ export function GdgButton({
7186} : GdgButtonProps ) {
7287 const isDisabled = disabled || variant === 'disabled' || loading
7388 const effectiveVariant = isDisabled ? 'disabled' : variant
89+ const [ ripples , setRipples ] = useState < Ripple [ ] > ( [ ] )
90+ const rippleIdRef = useRef ( 0 )
7491
7592 const defaultPcVariant : PcWidthVariant =
7693 widthToken && isWideOnlyWidth ( widthToken ) ? 'wide' : 'narrow'
@@ -85,29 +102,74 @@ export function GdgButton({
85102 : SIZE_CLASS [ device ] [ size ] . split ( ' ' ) . find ( ( c ) => c . startsWith ( 'w-' ) ) || ''
86103
87104 const classes = cn (
88- 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full border font-medium transition-all duration-200 ease-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/40 font-pretendard shrink-0' ,
105+ 'relative isolate inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full border font-medium overflow-hidden transition-all duration-200 ease-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/40 font-pretendard shrink-0' ,
89106 SIZE_CLASS [ device ] [ size ] . replace ( / w - \S + / g, '' ) ,
90107 widthClass ,
91108 VARIANT_CLASS [ effectiveVariant ] ,
92109 effectiveVariant === 'white' && ( device === 'pc' ? 'typo-pc-s3' : 'typo-m-s2' ) ,
110+ ! isDisabled && 'cursor-pointer active:scale-[0.98] active:translate-y-px' ,
93111 isDisabled && 'pointer-events-none cursor-not-allowed opacity-70' ,
94112 className
95113 )
114+ const rippleColorClass = effectiveVariant === 'white' ? 'bg-black/20' : 'bg-white/35'
115+
116+ const triggerRipple = ( event : MouseEvent < HTMLElement > ) => {
117+ if ( isDisabled ) return
118+
119+ const element = event . currentTarget as HTMLElement
120+ const rect = element . getBoundingClientRect ( )
121+ const size = Math . max ( rect . width , rect . height ) * 2
122+ const isKeyboardClick = event . detail === 0
123+ const x = isKeyboardClick ? rect . width / 2 : event . clientX - rect . left
124+ const y = isKeyboardClick ? rect . height / 2 : event . clientY - rect . top
125+ const id = rippleIdRef . current ++
126+
127+ setRipples ( ( prev ) => [ ...prev , { id, x, y, size, active : false } ] )
128+
129+ requestAnimationFrame ( ( ) => {
130+ setRipples ( ( prev ) =>
131+ prev . map ( ( ripple ) => ( ripple . id === id ? { ...ripple , active : true } : ripple ) )
132+ )
133+ } )
134+
135+ window . setTimeout ( ( ) => {
136+ setRipples ( ( prev ) => prev . filter ( ( ripple ) => ripple . id !== id ) )
137+ } , 550 )
138+ }
96139
97140 const inner = (
98141 < >
99- { loading && (
100- < span
101- className = "size-4 animate-spin rounded-full border-2 border-white/60 border-t-transparent"
102- aria-hidden
103- />
104- ) }
105- { icon && (
106- < span className = "shrink-0" aria-hidden >
107- { icon }
108- </ span >
109- ) }
110- { children }
142+ < span className = "pointer-events-none absolute inset-0" >
143+ { ripples . map ( ( ripple ) => (
144+ < span
145+ key = { ripple . id }
146+ className = { cn ( 'absolute rounded-full' , rippleColorClass ) }
147+ style = { {
148+ left : ripple . x ,
149+ top : ripple . y ,
150+ width : ripple . size ,
151+ height : ripple . size ,
152+ transform : `translate(-50%, -50%) scale(${ ripple . active ? 1 : 0 } )` ,
153+ opacity : ripple . active ? 0 : 0.35 ,
154+ transition : 'transform 550ms ease-out, opacity 550ms ease-out'
155+ } }
156+ />
157+ ) ) }
158+ </ span >
159+ < span className = "relative z-10 inline-flex items-center justify-center gap-2" >
160+ { loading && (
161+ < span
162+ className = "size-4 animate-spin rounded-full border-2 border-white/60 border-t-transparent"
163+ aria-hidden
164+ />
165+ ) }
166+ { icon && (
167+ < span className = "shrink-0" aria-hidden >
168+ { icon }
169+ </ span >
170+ ) }
171+ { children }
172+ </ span >
111173 </ >
112174 )
113175
@@ -118,22 +180,34 @@ export function GdgButton({
118180 }
119181
120182 if ( as === 'a' ) {
183+ const anchorProps = rest as AnchorHTMLAttributes < HTMLAnchorElement >
184+
121185 return (
122186 < Link
123- { ...( rest as AnchorHTMLAttributes < HTMLAnchorElement > ) }
187+ { ...anchorProps }
124188 href = { href ?? '#' }
189+ onClick = { ( event ) => {
190+ triggerRipple ( event )
191+ anchorProps . onClick ?.( event )
192+ } }
125193 { ...commonProps }
126194 >
127195 { inner }
128196 </ Link >
129197 )
130198 }
131199
200+ const buttonProps = rest as ButtonHTMLAttributes < HTMLButtonElement >
201+
132202 return (
133203 < button
134- { ...( rest as ButtonHTMLAttributes < HTMLButtonElement > ) }
135- type = { ( rest as ButtonHTMLAttributes < HTMLButtonElement > ) . type ?? 'button' }
204+ { ...buttonProps }
205+ type = { buttonProps . type ?? 'button' }
136206 disabled = { isDisabled }
207+ onClick = { ( event ) => {
208+ triggerRipple ( event )
209+ buttonProps . onClick ?.( event )
210+ } }
137211 { ...commonProps }
138212 >
139213 { inner }
0 commit comments