Skip to content

Commit

Permalink
feat(login-page): add password action
Browse files Browse the repository at this point in the history
  • Loading branch information
kiaking committed Jul 18, 2024
1 parent 9a5f3d5 commit 891a434
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 72 deletions.
39 changes: 34 additions & 5 deletions docs/components/login-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,16 @@ interface CoverPhotographer {
link: string
}

interface Action {
type Action = ActionPassword | ActionSocial

interface ActionPassword {
type: 'password'
onSubmit(email: string, password: string): Promise<void>
}

interface ActionSocial {
type: 'google'
onClick: () => Promise<void>
onClick(): Promise<void>
}
```

Expand Down Expand Up @@ -97,15 +104,37 @@ interface CoverPhotographer {

### `:actions`

This prop is an array of login buttons, where `type` is auth provider, and `onClick` is a function to log in.
This prop is an array of login buttons, where `type` is auth provider.

```ts
interface Props {
actions: Action[]
}

interface Action {
type Action = ActionPassword | ActionSocial

interface ActionPassword {
type: 'password'
onSubmit(email: string, password: string): Promise<void>
}

interface ActionSocial {
type: 'google'
onClick: () => Promise<void>
onClick(): Promise<void>
}
```

When selecting `type: 'password'`, it will open a email/password form modal when a user clicks the action button, and `onSubmit` will be called when the user submits the form. You can await for a promise to show a loading spinner while the form is being submitted.

```ts
const actions = [
{
type: 'password',
async onSubmit(email, password) {
// The email/password dialog will show loading spinner
// while this promise is being awaited
await login(email, password)
}
}
]
```
92 changes: 71 additions & 21 deletions lib/components/SLoginPage.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<script setup lang="ts">
import LockKey from '@iconify-icons/ph/lock-key-fill'
import IconGoogle from '@iconify-icons/ri/google-fill'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { usePower } from '../composables/Power'
import SButton from './SButton.vue'
import SLink from './SLink.vue'
import SLoginPagePasswordDialog from './SLoginPagePasswordDialog.vue'
import SModal from './SModal.vue'
import SIconGbLogoWhite from './icon/SIconGbLogoWhite.vue'
export interface CoverTitle {
Expand All @@ -15,37 +19,68 @@ export interface CoverPhotographer {
link: string
}
export interface Action {
export type Action = ActionPassword | ActionSocial
export interface ActionPassword {
type: 'password'
onSubmit(email: string, password: string): Promise<void>
}
export interface ActionSocial {
type: 'google'
onClick: () => Promise<void>
onClick(): Promise<void>
}
export type ActionType = 'password' | 'google'
const props = defineProps<{
cover: string
coverTitle: CoverTitle
coverPhotographer: CoverPhotographer
actions: Action[]
}>()
const passwordDialog = usePower()
const selectedPasswordAction = ref<ActionPassword | null>(null)
const actionInProgress = ref(false)
const coverBgImageStyle = computed(() => `url(${props.cover})`)
function getActionLabel(type: Action['type']) {
function getActionLabel(type: ActionType) {
switch (type) {
case 'password':
return 'Sign in via Password'
case 'google':
return 'Sign in via Google'
default:
throw new Error('[sefirot] Invalid action type')
}
}
function getIconComponent(type: Action['type']) {
function getIconComponent(type: ActionType) {
switch (type) {
case 'password':
return LockKey
case 'google':
return IconGoogle
default:
throw new Error('[sefirot] Invalid action type')
}
}
function onAction(action: Action) {
switch (action.type) {
case 'password':
selectedPasswordAction.value = action
return passwordDialog.on()
case 'google':
return action.onClick()
}
}
async function onSubmit(email: string, password: string) {
actionInProgress.value = true
await selectedPasswordAction.value!.onSubmit(email, password)
actionInProgress.value = false
passwordDialog.off()
}
</script>

<template>
Expand Down Expand Up @@ -74,20 +109,29 @@ function getIconComponent(type: Action['type']) {
<p class="form-lead">This is a very closed login form meant for specific audiences only. If you can’t login, well, you know who to ask.</p>
</div>

<div class="form-actions">
<SButton
v-for="action in actions"
:key="action.type"
size="large"
mode="white"
rounded
:label="getActionLabel(action.type)"
:icon="getIconComponent(action.type)"
@click="action.onClick"
/>
<div class="form-actions" :class="{ multi: actions.length > 1 }">
<div v-for="action in actions" :key="action.type" class="form-action">
<SButton
size="large"
mode="white"
block
rounded
:label="getActionLabel(action.type)"
:icon="getIconComponent(action.type)"
@click="onAction(action)"
/>
</div>
</div>
</div>
</div>

<SModal :open="passwordDialog.state.value" @close="passwordDialog.off">
<SLoginPagePasswordDialog
:loading="actionInProgress"
@cancel="passwordDialog.off"
@submit="onSubmit"
/>
</SModal>
</div>
</template>

Expand Down Expand Up @@ -196,9 +240,15 @@ function getIconComponent(type: Action['type']) {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 16px;
padding-top: 24px;
text-align: center;
margin: 0 auto;
}
.form-actions.multi .form-action {
display: block;
width: 100%;
max-width: 256px;
}
</style>
90 changes: 90 additions & 0 deletions lib/components/SLoginPagePasswordDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { useD } from '../composables/D'
import { useV } from '../composables/V'
import { email, maxLength, required } from '../validation/rules'
import SCard from './SCard.vue'
import SCardBlock from './SCardBlock.vue'
import SContent from './SContent.vue'
import SControl from './SControl.vue'
import SControlButton from './SControlButton.vue'
import SControlRight from './SControlRight.vue'
import SDoc from './SDoc.vue'
import SInputText from './SInputText.vue'
defineProps<{
loading: boolean
}>()
const emit = defineEmits<{
cancel: []
submit: [email: string, password: string]
}>()
const { data } = useD({
email: null as string | null,
password: null as string | null
})
const { validation, validateAndNotify } = useV(data, {
email: {
required: required(),
maxLength: maxLength(255),
email: email()
},
password: {
required: required(),
maxLength: maxLength(255)
}
})
async function onSubmit() {
if (await validateAndNotify()) {
emit('submit', data.value.email!, data.value.password!)
}
}
</script>

<template>
<SCard size="small">
<SCardBlock class="s-p-24">
<SDoc>
<SContent>
<h2>Sign in to account</h2>
</SContent>
<SInputText
name="email"
type="email"
label="Email"
placeholder="[email protected]"
v-model="data.email"
:validation="validation.email"
/>
<SInputText
name="password"
type="password"
label="Password"
placeholder="Password"
v-model="data.password"
:validation="validation.password"
/>
</SDoc>
</SCardBlock>
<SCardBlock size="xlarge" class="s-px-24">
<SControl>
<SControlRight>
<SControlButton
label="Cancel"
:disabled="loading"
@click="$emit('cancel')"
/>
<SControlButton
mode="info"
label="Sign in"
:loading="loading"
@click="onSubmit"
/>
</SControlRight>
</SControl>
</SCardBlock>
</SCard>
</template>
70 changes: 24 additions & 46 deletions stories/components/SLoginPage.01_Playground.story.vue
Original file line number Diff line number Diff line change
@@ -1,59 +1,37 @@
<script setup lang="ts">
import SLoginPage from 'sefirot/components/SLoginPage.vue'
import SLoginPage, { type Action } from 'sefirot/components/SLoginPage.vue'
import { sleep } from 'sefirot/support/Time'
const title = 'Components / SLoginPage / 01. Playground'
const docs = '/components/login-page'
function state() {
const state: InstanceType<typeof SLoginPage>['$props'] = {
cover: 'https://images.unsplash.com/photo-1526783166374-1239abde1c20?q=80&w=3008&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
coverTitle: {
text: 'Golden Gate Bridge',
link: 'https://unsplash.com/photos/bottom-view-of-orange-building-LjE32XEW01g'
},
coverPhotographer: {
text: 'Keegan Houser',
link: 'https://unsplash.com/@khouser01'
},
actions: [
{ type: 'google', onClick: async () => {} }
]
}
const cover = 'https://images.unsplash.com/photo-1526783166374-1239abde1c20?q=80&w=3008&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'
return state
const coverTitle = {
text: 'Golden Gate Bridge',
link: 'https://unsplash.com/photos/bottom-view-of-orange-building-LjE32XEW01g'
}
const coverPhotographer = {
text: 'Keegan Houser',
link: 'https://unsplash.com/@khouser01'
}
const actions: Action[] = [
{ type: 'password', onSubmit: async () => { await sleep(1000) } },
{ type: 'google', onClick: async () => {} }
]
</script>

<template>
<Story :title="title" :init-state="state" source="Not available" auto-props-disabled>
<template #controls="{ state }">
<HstText
title="cover"
v-model="state.cover"
/>
<HstJson
title="coverTitle"
v-model="state.coverTitle"
<Story :title="title" source="Not available" auto-props-disabled>
<Board :title="title" :docs="docs">
<SLoginPage
:cover="cover"
:cover-title="coverTitle"
:cover-photographer="coverPhotographer"
:actions="actions"
/>
<HstJson
title="coverPhotographer"
v-model="state.coverPhotographer"
/>
<HstJson
title="actions"
v-model="state.actions"
/>
</template>

<template #default="{ state }">
<Board :title="title" :docs="docs">
<SLoginPage
:cover="state.cover"
:cover-title="state.coverTitle"
:cover-photographer="state.coverPhotographer"
:actions="state.actions"
/>
</Board>
</template>
</Board>
</Story>
</template>

0 comments on commit 891a434

Please sign in to comment.