Skip to content

Commit 112a8b4

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
Add shared invalid state to EMCN OTP slots
1 parent 5d7f002 commit 112a8b4

6 files changed

Lines changed: 118 additions & 43 deletions

File tree

‎apps/sim/app/(auth)/verify/verify-content.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { Suspense, useEffect, useState } from 'react'
4-
import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
4+
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
55
import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
66
import {
77
AuthFormMessage,
@@ -84,14 +84,16 @@ function VerificationForm({
8484
</p>
8585

8686
<div className='flex justify-center'>
87-
<InputOTP maxLength={6} value={otp} onChange={handleOtpChange} disabled={isLoading}>
87+
<InputOTP
88+
maxLength={6}
89+
value={otp}
90+
onChange={handleOtpChange}
91+
disabled={isLoading}
92+
aria-invalid={isInvalidOtp}
93+
>
8894
<InputOTPGroup>
8995
{OTP_SLOTS.map((index) => (
90-
<InputOTPSlot
91-
key={index}
92-
index={index}
93-
className={cn(isInvalidOtp && 'border-[var(--text-error)]')}
94-
/>
96+
<InputOTPSlot key={index} index={index} invalid={isInvalidOtp} />
9597
))}
9698
</InputOTPGroup>
9799
</InputOTP>

‎apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,11 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
182182
}}
183183
disabled={verifyOtp.isPending}
184184
className={cn('gap-2', authError && 'otp-error')}
185+
aria-invalid={Boolean(authError)}
185186
>
186187
<InputOTPGroup>
187188
{[0, 1, 2, 3, 4, 5].map((index) => (
188-
<InputOTPSlot
189-
key={index}
190-
index={index}
191-
className={cn(authError && 'border-[var(--text-error)]')}
192-
/>
189+
<InputOTPSlot key={index} index={index} invalid={Boolean(authError)} />
193190
))}
194191
</InputOTPGroup>
195192
</InputOTP>

‎apps/sim/app/f/[token]/public-file-email-auth.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,11 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
144144
}}
145145
disabled={verifyOtp.isPending}
146146
className={cn('gap-2', error && 'otp-error')}
147+
aria-invalid={Boolean(error)}
147148
>
148149
<InputOTPGroup>
149150
{[0, 1, 2, 3, 4, 5].map((i) => (
150-
<InputOTPSlot
151-
key={i}
152-
index={i}
153-
className={cn(error && 'border-[var(--text-error)]')}
154-
/>
151+
<InputOTPSlot key={i} index={i} invalid={Boolean(error)} />
155152
))}
156153
</InputOTPGroup>
157154
</InputOTP>

‎packages/emcn/src/components/index.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,13 @@ export {
161161
type InfoCardProps,
162162
} from './info-card/info-card'
163163
export { Input, type InputProps } from './input/input'
164-
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp'
164+
export {
165+
InputOTP,
166+
InputOTPGroup,
167+
InputOTPSeparator,
168+
InputOTPSlot,
169+
type InputOTPSlotProps,
170+
} from './input-otp/input-otp'
165171
export { Label } from './label/label'
166172
export { Lightbox, type LightboxProps } from './lightbox/lightbox'
167173
export { focusFirstTextInput, focusFirstTextInputIn } from './modal/auto-focus'
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { InputOTP, InputOTPGroup, InputOTPSlot } from './input-otp'
8+
9+
let root: Root
10+
let host: HTMLDivElement
11+
12+
beforeEach(() => {
13+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
14+
vi.stubGlobal(
15+
'ResizeObserver',
16+
class {
17+
observe() {}
18+
unobserve() {}
19+
disconnect() {}
20+
}
21+
)
22+
host = document.createElement('div')
23+
document.body.appendChild(host)
24+
root = createRoot(host)
25+
})
26+
27+
afterEach(() => {
28+
act(() => root.unmount())
29+
host.remove()
30+
vi.unstubAllGlobals()
31+
})
32+
33+
function renderOtp(invalid: boolean) {
34+
act(() => {
35+
root.render(
36+
<InputOTP maxLength={2} value='' onChange={vi.fn()} aria-invalid={invalid}>
37+
<InputOTPGroup>
38+
<InputOTPSlot index={0} invalid={invalid} data-testid='first-slot' />
39+
<InputOTPSlot index={1} invalid={invalid} />
40+
</InputOTPGroup>
41+
</InputOTP>
42+
)
43+
})
44+
}
45+
46+
describe('InputOTPSlot invalid state', () => {
47+
it('keeps the standard border when valid', () => {
48+
renderOtp(false)
49+
const slot = host.querySelector('[data-testid="first-slot"]')
50+
expect(slot?.classList.contains('border-[var(--border-1)]')).toBe(true)
51+
expect(slot?.classList.contains('border-[var(--text-error)]')).toBe(false)
52+
expect(host.querySelector('input')?.getAttribute('aria-invalid')).toBe('false')
53+
})
54+
55+
it('keeps the error border while the active slot has a focus ring', () => {
56+
renderOtp(true)
57+
const input = host.querySelector('input')
58+
expect(input?.getAttribute('aria-invalid')).toBe('true')
59+
60+
act(() => input?.focus())
61+
const slot = host.querySelector('[data-testid="first-slot"]')
62+
expect(slot?.classList.contains('border-[var(--text-error)]')).toBe(true)
63+
expect(slot?.classList.contains('ring-1')).toBe(true)
64+
expect(slot?.classList.contains('border-[var(--text-muted)]')).toBe(false)
65+
})
66+
})

‎packages/emcn/src/components/input-otp/input-otp.tsx‎

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,32 +66,39 @@ InputOTPGroup.displayName = 'InputOTPGroup'
6666
*
6767
* Uses emcn design tokens for consistent styling with the Input component.
6868
*/
69-
const InputOTPSlot = React.forwardRef<
70-
React.ElementRef<'div'>,
71-
React.ComponentPropsWithoutRef<'div'> & { index: number }
72-
>(({ index, className, ...props }, ref) => {
73-
const inputOTPContext = React.useContext(OTPInputContext)
74-
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
69+
export interface InputOTPSlotProps extends React.ComponentPropsWithoutRef<'div'> {
70+
/** Zero-based position of this slot in the OTP input. */
71+
index: number
72+
/** Keep the error border visible even while this slot is active. */
73+
invalid?: boolean
74+
}
75+
76+
const InputOTPSlot = React.forwardRef<React.ElementRef<'div'>, InputOTPSlotProps>(
77+
({ index, invalid = false, className, ...props }, ref) => {
78+
const inputOTPContext = React.useContext(OTPInputContext)
79+
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
7580

76-
return (
77-
<div
78-
ref={ref}
79-
className={cn(
80-
'relative flex h-12 w-12 items-center justify-center rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] text-[var(--text-primary)] text-lg transition-colors',
81-
isActive && 'z-10 border-[var(--text-muted)] ring-1 ring-[var(--text-muted)]',
82-
className
83-
)}
84-
{...props}
85-
>
86-
{char}
87-
{hasFakeCaret && (
88-
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
89-
<div className='h-6 w-px animate-caret-blink bg-[var(--text-primary)] duration-1000 motion-reduce:animate-none' />
90-
</div>
91-
)}
92-
</div>
93-
)
94-
})
81+
return (
82+
<div
83+
ref={ref}
84+
className={cn(
85+
'relative flex h-12 w-12 items-center justify-center rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] text-[var(--text-primary)] text-lg transition-colors',
86+
isActive && 'z-10 border-[var(--text-muted)] ring-1 ring-[var(--text-muted)]',
87+
invalid && 'border-[var(--text-error)]',
88+
className
89+
)}
90+
{...props}
91+
>
92+
{char}
93+
{hasFakeCaret && (
94+
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
95+
<div className='h-6 w-px animate-caret-blink bg-[var(--text-primary)] duration-1000 motion-reduce:animate-none' />
96+
</div>
97+
)}
98+
</div>
99+
)
100+
}
101+
)
95102
InputOTPSlot.displayName = 'InputOTPSlot'
96103

97104
/**

0 commit comments

Comments
 (0)