App.vue components/BrandPanel.vue components/ForgotPasswordForm.vue components/PasswordField.vue components/ProviderButtons.vue components/SignInForm.vue components/SignUpForm.vue components/SubmitButton.vue components/TextField.vue validation.ts Copy
< script setup lang = "ts" >
import { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import BrandPanel from './components/BrandPanel.vue'
import SignInForm from './components/SignInForm.vue'
import SignUpForm from './components/SignUpForm.vue'
import ForgotPasswordForm from './components/ForgotPasswordForm.vue'
import type { AuthView } from './validation'
const views : { id : AuthView ; label : string }[] = [
{ id: 'signin' , label: 'Sign in' },
{ id: 'signup' , label: 'Sign up' },
{ id: 'forgot' , label: 'Forgot password' },
]
// The view is mirrored to the URL hash (#signin, #signup, #forgot) so each screen can be linked to.
function fromHash () : AuthView {
const h = window.location.hash. slice ( 1 )
return views. some (( v ) => v.id === h) ? (h as AuthView ) : 'signin'
}
const view = ref < AuthView >( fromHash ())
const notice = ref ( '' )
const panel = useTemplateRef < HTMLElement >( 'panel' )
function navigate ( next : AuthView ) {
notice.value = ''
view.value = next
}
watch (view, async ( v ) => {
if (window.location.hash. slice ( 1 ) !== v) history. replaceState ( null , '' , `#${ v }` )
await nextTick ()
// Move focus to the new screen's heading for screen reader and keyboard users.
const heading = panel.value?. querySelector < HTMLElement >( 'h1' )
if (heading) {
heading.tabIndex = - 1
heading. focus ()
}
})
const onHashChange = () => { view.value = fromHash () }
onMounted (() => window. addEventListener ( 'hashchange' , onHashChange))
onBeforeUnmount (() => window. removeEventListener ( 'hashchange' , onHashChange))
</ script >
< template >
< div class = "grid min-h-screen bg-white lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] dark:bg-gray-950" >
< BrandPanel />
< main class = "flex flex-col px-5 py-8 sm:px-10 lg:px-16" >
<!-- Mobile brand + screen switcher (handy while previewing the template) -->
< div class = "flex items-center justify-between gap-4" >
< span class = "flex items-center gap-2 font-semibold text-gray-900 lg:invisible dark:text-white" >
< span class = "grid size-8 place-items-center rounded-lg bg-emerald-600 text-white" aria-hidden = "true" >
< svg viewBox = "0 0 24 24" class = "size-4" fill = "currentColor" >< path d = "M12 2l5 7h-3l4 6h-3.5l3.5 5H6l3.5-5H6l4-6H7l5-7z" /></ svg >
</ span >
Northpine
</ span >
< nav aria-label = "Auth screens" class = "flex rounded-lg bg-gray-100 p-0.5 text-xs font-medium dark:bg-white/5" >
< button
v-for = "v in views"
:key = "v.id"
type = "button"
class = "rounded-md px-2.5 py-1.5 transition focus-visible:outline-2 focus-visible:outline-emerald-500"
:class = "view === v.id ? 'bg-white text-gray-900 shadow-sm dark:bg-white/10 dark:text-white' : 'text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white'"
:aria-current = "view === v.id ? 'page' : undefined"
@click = "navigate(v.id)"
>{{ v.id === 'forgot' ? 'Reset' : v.label }}</ button >
</ nav >
</ div >
< div ref = "panel" class = "mx-auto flex w-full max-w-sm flex-1 flex-col justify-center py-12 [&_h1]:outline-none" >
< div v-if = "notice" role = "status" class = "mb-6 flex gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3.5 text-sm text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200" >
< svg viewBox = "0 0 20 20" class = "size-5 shrink-0" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" >< path d = "M5 10.5l3 3 7-7" /></ svg >
{{ notice }}
</ div >
< Transition mode = "out-in" enter-active-class = "transition duration-200" enter-from-class = "translate-y-1 opacity-0" leave-active-class = "transition duration-100" leave-to-class = "opacity-0" >
< SignInForm v-if = " view === 'signin' " @ navigate = " navigate " @ success = " notice = $event " />
< SignUpForm v-else-if = " view === 'signup' " @ navigate = " navigate " @ success = " notice = $event " />
< ForgotPasswordForm v-else @ navigate = " navigate " />
</ Transition >
</ div >
< p class = "text-center text-xs text-gray-500 dark:text-gray-400" >
Protected by rate limiting and optional two-factor authentication. Northpine is a fictional product.
</ p >
</ main >
</ div >
</ template >
< script setup lang = "ts" >
// Decorative left-hand panel shown from the lg breakpoint up.
</ script >
< template >
< aside class = "relative hidden overflow-hidden bg-emerald-950 p-12 text-white lg:flex lg:flex-col" >
<!-- drawn topography -->
< svg aria-hidden = "true" class = "absolute inset-0 size-full text-emerald-400/15" viewBox = "0 0 600 800" preserveAspectRatio = "xMidYMid slice" fill = "none" stroke = "currentColor" stroke-width = "1.5" >
< path v-for = "n in 12" :key = "n" :d = "`M-50 ${120 + n * 55} C 120 ${60 + n * 55}, 260 ${200 + n * 50}, 420 ${110 + n * 55} S 640 ${150 + n * 52}, 700 ${100 + n * 55}`" />
</ svg >
< div aria-hidden = "true" class = "absolute -right-24 -top-24 size-96 rounded-full bg-emerald-500/30 blur-3xl" / >
< div aria-hidden = "true" class = "absolute -bottom-32 left-10 size-80 rounded-full bg-teal-400/20 blur-3xl" / >
< div class = "relative flex items-center gap-2.5 text-lg font-semibold" >
< span class = "grid size-9 place-items-center rounded-xl bg-emerald-500 text-emerald-950" aria-hidden = "true" >
< svg viewBox = "0 0 24 24" class = "size-5" fill = "currentColor" >< path d = "M12 2l5 7h-3l4 6h-3.5l3.5 5H6l3.5-5H6l4-6H7l5-7z" /></ svg >
</ span >
Northpine
</ div >
< div class = "relative mt-auto" >
< figure class = "max-w-md" >
< blockquote class = "text-2xl font-medium leading-snug text-balance" >
“We moved our whole field team onto Northpine in a week. Nobody has asked me for a password reset since.”
</ blockquote >
< figcaption class = "mt-6 flex items-center gap-3" >
< span class = "grid size-11 place-items-center rounded-full bg-emerald-400/20 text-sm font-semibold text-emerald-200 ring-1 ring-emerald-300/30" aria-hidden = "true" >RO</ span >
< span >
< span class = "block font-semibold" >Rosa Okafor</ span >
< span class = "block text-sm text-emerald-200/80" >Operations Lead, Tidewater Surveys</ span >
</ span >
</ figcaption >
</ figure >
< dl class = "mt-12 grid max-w-md grid-cols-3 gap-6 border-t border-white/10 pt-8" >
< div >< dt class = "text-xs text-emerald-200/70" >Teams</ dt >< dd class = "mt-1 text-xl font-semibold" >12k+</ dd ></ div >
< div >< dt class = "text-xs text-emerald-200/70" >Uptime</ dt >< dd class = "mt-1 text-xl font-semibold" >99.98%</ dd ></ div >
< div >< dt class = "text-xs text-emerald-200/70" >Countries</ dt >< dd class = "mt-1 text-xl font-semibold" >64</ dd ></ div >
</ dl >
</ div >
</ aside >
</ template >
< script setup lang = "ts" >
import { computed, ref } from 'vue'
import TextField from './TextField.vue'
import SubmitButton from './SubmitButton.vue'
import { emailError, fakeRequest, type AuthView } from '../validation'
const emit = defineEmits <{ navigate : [ view : AuthView ] }>()
const email = ref ( '' )
const touched = ref ( false )
const loading = ref ( false )
const sentTo = ref ( '' )
const error = computed (() => (touched.value ? emailError (email.value) : '' ))
async function submit () {
touched.value = true
if (error.value) return
loading.value = true
await fakeRequest ()
loading.value = false
sentTo.value = email.value. trim ()
}
</ script >
< template >
< div >
< button type = "button" class = "inline-flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-emerald-500 dark:text-gray-400 dark:hover:text-white" @click = "emit('navigate', 'signin')" >
< svg viewBox = "0 0 20 20" class = "size-4" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" >< path d = "M16 10H4M9 5l-5 5 5 5" /></ svg >
Back to sign in
</ button >
< template v-if = " ! sentTo " >
< span class = "mt-8 grid size-12 place-items-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300" aria-hidden = "true" >
< svg viewBox = "0 0 24 24" class = "size-6" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "8" cy = "15" r = "4" />< path d = "M11 12l9-9M17 6l3 3M15 8l2 2" /></ svg >
</ span >
< h1 class = "mt-5 text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl dark:text-white" >Reset your password</ h1 >
< p class = "mt-2 text-sm text-gray-600 dark:text-gray-400" >Enter the email you signed up with and we'll send a link to choose a new password. The link expires in 30 minutes.</ p >
< form class = "mt-8 space-y-5" novalidate @submit.prevent = "submit" >
< TextField v-model = " email " label = "Email" type = "email" autocomplete = "email" placeholder = "you@example.com" : error = " error " @ blur = " touched = true " />
< SubmitButton : loading = " loading " >{{ loading ? 'Sending link…' : 'Send reset link' }}</ SubmitButton >
</ form >
</ template >
< div v-else class = "mt-8" role = "status" >
< span class = "grid size-12 place-items-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300" aria-hidden = "true" >
< svg viewBox = "0 0 24 24" class = "size-6" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M4 6h16v12H4zM4 7l8 6 8-6" /></ svg >
</ span >
< h1 class = "mt-5 text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl dark:text-white" >Check your inbox</ h1 >
< p class = "mt-2 text-sm text-gray-600 dark:text-gray-400" >
If an account exists for < strong class = "font-semibold text-gray-900 dark:text-white" >{{ sentTo }}</ strong >, a reset link is on its way. Don't see it? Check your spam folder.
</ p >
< button type = "button" class = "mt-6 text-sm font-semibold text-emerald-700 hover:text-emerald-600 focus-visible:outline-2 focus-visible:outline-emerald-500 dark:text-emerald-400" @click = "sentTo = ''" >Use a different email</ button >
</ div >
</ div >
</ template >
< script setup lang = "ts" >
import { computed, ref } from 'vue'
import TextField from './TextField.vue'
const props = withDefaults ( defineProps <{
label ?: string
autocomplete ?: 'current-password' | 'new-password'
error ?: string
hint ?: string
/** Show a strength meter under the field (use on sign up). */
showStrength ?: boolean
}>(), { label: 'Password' , autocomplete: 'current-password' })
const model = defineModel < string >({ required: true })
defineEmits <{ blur : [] }>()
const visible = ref ( false )
// A simple, transparent heuristic. Swap in a proper estimator such as zxcvbn if you need one.
const strength = computed (() => {
const v = model.value
let score = 0
if (v. length >= 8 ) score ++
if (v. length >= 12 ) score ++
if ( / [A-Z] / . test (v) && / [a-z] / . test (v)) score ++
if ( / \d / . test (v) && / [ ^ A-Za-z0-9] / . test (v)) score ++
return score
})
const strengthLabel = computed (() => [ 'Too short' , 'Weak' , 'Fair' , 'Good' , 'Strong' ][strength.value])
const strengthColor = computed (() => [ 'bg-rose-500' , 'bg-rose-500' , 'bg-amber-500' , 'bg-emerald-500' , 'bg-emerald-600' ][strength.value])
</ script >
< template >
< TextField
v-model = " model "
: label = " props.label "
: type = " visible ? 'text' : 'password' "
: autocomplete = " props.autocomplete "
: error = " props.error "
: hint = " props.hint "
placeholder = "••••••••"
@ blur = " $emit ( 'blur' ) "
>
< template # label-extra >< slot name = "label-extra" / ></ template >
< template # trailing >
< button
type = "button"
class = "absolute inset-y-0 right-0 grid w-11 place-items-center rounded-r-lg text-gray-500 hover:text-gray-800 focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-emerald-500 dark:text-gray-400 dark:hover:text-white"
:aria-pressed = "visible"
:aria-label = "visible ? 'Hide password' : 'Show password'"
@click = "visible = !visible"
>
< svg viewBox = "0 0 24 24" class = "size-5" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" >
< template v-if = "!visible" >
< path d = "M2.5 12S6 5 12 5s9.5 7 9.5 7-3.5 7-9.5 7-9.5-7-9.5-7z" />
< circle cx = "12" cy = "12" r = "3" />
</ template >
< template v-else >
< path d = "M10.6 5.1A9.9 9.9 0 0 1 12 5c6 0 9.5 7 9.5 7a17 17 0 0 1-2.4 3.3M6.6 6.6C3.9 8.4 2.5 12 2.5 12S6 19 12 19a9.6 9.6 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2M3 3l18 18" />
</ template >
</ svg >
</ button >
</ template >
< template v-if = " showStrength && model " # below >
< div class = "mt-2 flex items-center gap-3" aria-live = "polite" >
< div class = "flex flex-1 gap-1" aria-hidden = "true" >
< span v-for = "n in 4" :key = "n" class = "h-1 flex-1 rounded-full transition" :class = "n <= strength ? strengthColor : 'bg-gray-200 dark:bg-white/10'" / >
</ div >
< span class = "w-16 text-right text-xs font-medium text-gray-600 dark:text-gray-400" >{{ strengthLabel }}</ span >
</ div >
</ template >
</ TextField >
</ template >
< script setup lang = "ts" >
// Generic single sign-on buttons. Replace the labels and icons with the providers you
// support, following each provider's own brand guidelines.
export interface Provider {
id : string
label : string
/** 24x24 stroke icon path. */
icon : string
}
withDefaults ( defineProps <{ providers ?: Provider []; verb ?: string }>(), {
verb: 'Continue with' ,
providers : () => [
{ id: 'sso' , label: 'SSO' , icon: 'M7 11V8a5 5 0 0 1 10 0v3M5 11h14v10H5V11zM12 15v2' },
{ id: 'passkey' , label: 'passkey' , icon: 'M9 13a4 4 0 1 1 0-8 4 4 0 0 1 0 8zM3 21v-1a5 5 0 0 1 5-5h2M16 11a3 3 0 1 1 3 3v6l-1.5 1.5L16 20v-1l1-1-1-1v-3' },
],
})
defineEmits <{ select : [ provider : Provider ] }>()
</ script >
< template >
< div class = "grid gap-3" >
< button
v-for = "p in providers"
:key = "p.id"
type = "button"
class = "inline-flex items-center justify-center gap-2.5 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-semibold text-gray-800 shadow-sm transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500 dark:border-white/15 dark:bg-white/5 dark:text-gray-100 dark:hover:bg-white/10"
@click = "$emit('select', p)"
>
< svg viewBox = "0 0 24 24" class = "size-5 text-gray-500 dark:text-gray-400" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" >< path :d = "p.icon" /></ svg >
{{ verb }} {{ p.label }}
</ button >
</ div >
< div class = "my-6 flex items-center gap-4 text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500" >
< span class = "h-px flex-1 bg-gray-200 dark:bg-white/10" / > or with email < span class = "h-px flex-1 bg-gray-200 dark:bg-white/10" / >
</ div >
</ template >
< script setup lang = "ts" >
import { computed, reactive, ref } from 'vue'
import TextField from './TextField.vue'
import PasswordField from './PasswordField.vue'
import ProviderButtons from './ProviderButtons.vue'
import SubmitButton from './SubmitButton.vue'
import { emailError, fakeRequest, passwordError, type AuthView } from '../validation'
const emit = defineEmits <{ navigate : [ view : AuthView ]; success : [ message : string ] }>()
const form = reactive ({ email: '' , password: '' , remember: true })
const touched = reactive ({ email: false , password: false })
const loading = ref ( false )
const errors = computed (() => ({
email: touched.email ? emailError (form.email) : '' ,
password: touched.password ? passwordError (form.password) : '' ,
}))
async function submit () {
touched.email = touched.password = true
if (errors.value.email || errors.value.password) return
loading.value = true
await fakeRequest ()
loading.value = false
emit ( 'success' , `Signed in as ${ form . email . trim () }.` )
}
</ script >
< template >
< div >
< h1 class = "text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl dark:text-white" >Welcome back</ h1 >
< p class = "mt-2 text-sm text-gray-600 dark:text-gray-400" >
New to Northpine?
< button type = "button" class = "font-semibold text-emerald-700 hover:text-emerald-600 focus-visible:outline-2 focus-visible:outline-emerald-500 dark:text-emerald-400" @click = "emit('navigate', 'signup')" >Create an account</ button >
</ p >
< div class = "mt-8" >
< ProviderButtons />
< form class = "space-y-5" novalidate @submit.prevent = "submit" >
< TextField v-model = " form.email " label = "Email" type = "email" autocomplete = "email" placeholder = "you@example.com" : error = " errors.email " @ blur = " touched.email = true " />
< PasswordField v-model = " form.password " : error = " errors.password " @ blur = " touched.password = true " >
< template # label-extra >
< button type = "button" class = "text-sm font-medium text-emerald-700 hover:text-emerald-600 focus-visible:outline-2 focus-visible:outline-emerald-500 dark:text-emerald-400" @click = "emit('navigate', 'forgot')" >Forgot password?</ button >
</ template >
</ PasswordField >
< label class = "flex items-center gap-2.5 text-sm text-gray-700 dark:text-gray-300" >
< input v-model = "form.remember" type = "checkbox" class = "size-4 rounded border-gray-300 accent-emerald-600" >
Keep me signed in on this device
</ label >
< SubmitButton : loading = " loading " >{{ loading ? 'Signing in…' : 'Sign in' }}</ SubmitButton >
</ form >
</ div >
</ div >
</ template >
< script setup lang = "ts" >
import { computed, reactive, ref } from 'vue'
import TextField from './TextField.vue'
import PasswordField from './PasswordField.vue'
import ProviderButtons from './ProviderButtons.vue'
import SubmitButton from './SubmitButton.vue'
import { emailError, fakeRequest, passwordError, type AuthView } from '../validation'
const emit = defineEmits <{ navigate : [ view : AuthView ]; success : [ message : string ] }>()
const form = reactive ({ name: '' , email: '' , password: '' , terms: false })
const touched = reactive ({ name: false , email: false , password: false , terms: false })
const loading = ref ( false )
const errors = computed (() => ({
name: touched.name && ! form.name. trim () ? 'Tell us what to call you.' : '' ,
email: touched.email ? emailError (form.email) : '' ,
password: touched.password ? passwordError (form.password) : '' ,
terms: touched.terms && ! form.terms ? 'Please accept the terms to continue.' : '' ,
}))
async function submit () {
touched.name = touched.email = touched.password = touched.terms = true
if (Object. values (errors.value). some (Boolean)) return
loading.value = true
await fakeRequest ()
loading.value = false
emit ( 'success' , `Account created. We sent a confirmation link to ${ form . email . trim () }.` )
}
</ script >
< template >
< div >
< h1 class = "text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl dark:text-white" >Create your account</ h1 >
< p class = "mt-2 text-sm text-gray-600 dark:text-gray-400" >
Already have one?
< button type = "button" class = "font-semibold text-emerald-700 hover:text-emerald-600 focus-visible:outline-2 focus-visible:outline-emerald-500 dark:text-emerald-400" @click = "emit('navigate', 'signin')" >Sign in</ button >
</ p >
< div class = "mt-8" >
< ProviderButtons verb = "Sign up with" />
< form class = "space-y-5" novalidate @submit.prevent = "submit" >
< TextField v-model = " form.name " label = "Full name" autocomplete = "name" placeholder = "Alex Rivera" : error = " errors.name " @ blur = " touched.name = true " />
< TextField v-model = " form.email " label = "Work email" type = "email" autocomplete = "email" placeholder = "you@company.com" : error = " errors.email " @ blur = " touched.email = true " />
< PasswordField v-model = " form.password " autocomplete = "new-password" hint = "At least 8 characters. Mix letters, numbers and symbols." show-strength : error = " errors.password " @ blur = " touched.password = true " />
< div >
< label class = "flex items-start gap-2.5 text-sm text-gray-700 dark:text-gray-300" >
< input v-model = "form.terms" type = "checkbox" class = "mt-0.5 size-4 rounded border-gray-300 accent-emerald-600" :aria-invalid = "!!errors.terms" aria-describedby = "terms-error" @change = "touched.terms = true" >
< span >I agree to the < a href = "#" class = "font-medium text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-400" >Terms</ a > and < a href = "#" class = "font-medium text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-400" >Privacy Policy</ a >.</ span >
</ label >
< p v-if = "errors.terms" id = "terms-error" class = "mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400" >{{ errors.terms }}</ p >
</ div >
< SubmitButton : loading = " loading " >{{ loading ? 'Creating account…' : 'Create account' }}</ SubmitButton >
</ form >
</ div >
</ div >
</ template >
< script setup lang = "ts" >
defineProps <{ loading ?: boolean }>()
</ script >
< template >
< button
type = "submit"
:disabled = "loading"
:aria-busy = "loading"
class = "inline-flex w-full items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm shadow-emerald-600/20 transition hover:bg-emerald-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 disabled:cursor-wait disabled:opacity-75"
>
< svg v-if = "loading" viewBox = "0 0 24 24" class = "size-4 animate-spin" fill = "none" aria-hidden = "true" >
< circle cx = "12" cy = "12" r = "9" stroke = "currentColor" stroke-opacity = ".3" stroke-width = "3" />
< path d = "M21 12a9 9 0 0 0-9-9" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" />
</ svg >
< slot / >
</ button >
</ template >
< script setup lang = "ts" >
import { useId } from 'vue'
withDefaults ( defineProps <{
label : string
type ?: 'text' | 'email' | 'password'
autocomplete ?: string
placeholder ?: string
error ?: string
hint ?: string
}>(), { type: 'text' })
const model = defineModel < string >({ required: true })
defineEmits <{ blur : [] }>()
const id = useId ()
</ script >
< template >
< div >
< div class = "flex items-center justify-between" >
< label :for = "id" class = "block text-sm font-medium text-gray-800 dark:text-gray-200" >{{ label }}</ label >
< slot name = "label-extra" / >
</ div >
< div class = "relative mt-1.5" >
< input
:id = "id"
v-model = "model"
:type = "type"
:autocomplete = "autocomplete"
:placeholder = "placeholder"
:aria-invalid = "!!error"
:aria-describedby = "error ? `${id}-error` : hint ? `${id}-hint` : undefined"
class = "block w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm text-gray-900 shadow-sm transition placeholder:text-gray-400 focus:outline-none focus:ring-4 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500"
:class = "[
error
? 'border-rose-400 focus:border-rose-500 focus:ring-rose-500/15 dark:border-rose-500/70'
: 'border-gray-300 focus:border-emerald-500 focus:ring-emerald-500/15 dark:border-white/15 dark:focus:border-emerald-400',
$slots.trailing ? 'pr-11' : '',
]"
@blur = "$emit('blur')"
>
< slot name = "trailing" / >
</ div >
< p v-if = "error" :id = "`${id}-error`" class = "mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400" >{{ error }}</ p >
< p v-else-if = "hint" :id = "`${id}-hint`" class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >{{ hint }}</ p >
< slot name = "below" / >
</ div >
</ template >
export type AuthView = 'signin' | 'signup' | 'forgot'
const EMAIL = / ^ [ ^ \s@] + @ [ ^ \s@] + \. [ ^ \s@] +$ /
export function emailError ( value : string ) : string {
if ( ! value. trim ()) return 'Enter your email address.'
if ( ! EMAIL . test (value. trim ())) return 'That email address doesn’t look right.'
return ''
}
export function passwordError ( value : string , minLength = 8 ) : string {
if ( ! value) return 'Enter a password.'
if (value. length < minLength) return `Use at least ${ minLength } characters.`
return ''
}
/** Fake network latency so the loading states are visible in the demo. Replace with your API call. */
export function fakeRequest ( ms = 900 ) : Promise < void > {
return new Promise (( resolve ) => setTimeout (resolve, ms))
}