Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(useStepper): new function #1679

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/contributors.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"lpj145",
"maxma241",
"xizher",
"nhedger",
"g-plane",
"coolhome",
"herrmannplatz",
Expand Down Expand Up @@ -114,6 +115,7 @@
"Maiquu",
"Danmer",
"emilsgulbis",
"innocenzi",
"iendeavor",
"web2033",
"sp1ker",
Expand All @@ -132,6 +134,7 @@
"ilkome",
"iagafonov",
"ivanq3w",
"jd-solanki",
"jairoblatt",
"Jamiewarb",
"itpropro",
Expand All @@ -147,6 +150,7 @@
"joaoeudes7",
"xvaara",
"DrJume",
"Julien-Martin",
"kidonng",
"kirklin",
"Spice-King",
Expand Down Expand Up @@ -192,6 +196,7 @@
"Atinux",
"thalesagapito",
"thierrymichel",
"LeSuisse",
"tmkx",
"tobyzerner",
"livthomas",
Expand All @@ -204,6 +209,7 @@
"vhhsyt",
"chaii3",
"r1ader",
"hsyq",
"hchlq",
"iGalaxyz",
"winter-ice",
Expand Down
1 change: 1 addition & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export * from './useWebWorkerFn'
export * from './useWindowFocus'
export * from './useWindowScroll'
export * from './useWindowSize'
export * from './useStepper'
export * from './types'
export * from '@vueuse/shared'
export * from './ssr-handlers'
103 changes: 103 additions & 0 deletions packages/core/useStepper/demo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { useStepper } from '@vueuse/core'
import { reactive } from 'vue'

const { index, current, goToNext, steps, backTo, currentStepIs, isFirst, isLast, currentStepIsBefore: todo } = useStepper([
'Billing address',
'Terms',
'Payment',
] as const)

const form = reactive({
billingAddress: '',
contract: false,
carbonOffsetting: false,
payment: 'credit-card' as 'paypal' | 'credit-card',
})

const wizard = reactive({
index,
current,
steps,
isFirst,
isLast,
})

function canGoNext() {
if (currentStepIs('Billing address') && form.billingAddress === '')
return false

if (currentStepIs('Terms') && !form.contract)
return false

return true
}

function submit() {
if (!canGoNext())
return

goToNext()
}
</script>

<template>
<div>
<div class="flex gap-2 justify-center">
<div v-for="step in steps" :key="step" class="">
<button :disabled="todo(step)" @click="backTo(step)" v-text="step" />
</div>
</div>

<form class="mt-10" @submit.prevent="submit">
<span class="text-lg font-bold" v-text="current" />
<div class="flex gap-4 items-center">
<div v-if="currentStepIs('Billing address')">
<input v-model="form.billingAddress" type="text">
</div>

<div v-if="currentStepIs('Terms')">
<div>
<input id="carbon-offsetting" v-model="form.carbonOffsetting" type="checkbox" class="mr-2">
<label for="carbon-offsetting">I accept to deposit a carbon offsetting fee</label>
</div>
<div>
<input id="contract" v-model="form.contract" type="checkbox" class="mr-2">
<label for="contract">I accept the terms of the contract</label>
</div>
</div>

<div v-if="currentStepIs('Payment')">
<div>
<input id="credit-card" v-model="form.payment" type="radio" class="mr-2" value="credit-card">
<label for="credit-card">Credit card</label>
</div>
<div>
<input id="paypal" v-model="form.payment" type="radio" class="mr-2" value="paypal">
<label for="paypal">PayPal</label>
</div>
</div>

<button v-if="!isLast" :disabled="!canGoNext()">
Next
</button>

<button v-if="isLast" :disabled="!canGoNext()">
Submit
</button>
</div>
</form>

<div class="flex gap-4 mt-12">
<div class="px-4 py-2 rounded border border-main space-y-2">
<span class="font-bold">Form</span>
<pre v-text="form" />
</div>

<div class="px-4 py-2 rounded border border-main space-y-2">
<span class="font-bold">Wizard</span>
<pre v-text="wizard" />
</div>
</div>
</div>
</template>
15 changes: 15 additions & 0 deletions packages/core/useStepper/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
category: Utilities
---

# useStepper

Provides helpers for building a multi-step wizard interface.

## Usage

```js
import { useStepper } from '@vueuse/core'

const { current, previous, next, steps, isFirst, isLast } = useStepper()
```
70 changes: 70 additions & 0 deletions packages/core/useStepper/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useStepper } from '.'

describe('useStepper', () => {
it('should be defined', () => {
expect(useStepper).toBeDefined()
})

it('can navigate through steps', () => {
const { isFirst, isLast, goToNext, goToPrevious, nextStep, previousStep, current, index, goTo, backTo, isPrevious, isNext, currentStepIs, currentStepIsAfter, currentStepIsBefore } = useStepper([
'First step',
'Second step',
'Last step',
] as const)

expect(index.value).toBe(0)
expect(current.value).toBe('First step')
expect(isFirst.value).toBe(true)
expect(nextStep.value).toBe('Second step')
expect(previousStep.value).toBeUndefined()
expect(currentStepIs('First step')).toBe(true)
expect(currentStepIs('Second step')).toBe(false)
expect(currentStepIsAfter('Second step')).toBe(false)

goToNext()
expect(index.value).toBe(1)
expect(current.value).toBe('Second step')
expect(nextStep.value).toBe('Last step')
expect(previousStep.value).toBe('First step')
expect(isPrevious('First step')).toBe(true)
expect(isNext('Last step')).toBe(true)
expect(currentStepIsAfter('First step')).toBe(true)
expect(currentStepIsBefore('First step')).toBe(false)
expect(currentStepIsBefore('Last step')).toBe(true)

goToPrevious()
expect(index.value).toBe(0)
expect(current.value).toBe('First step')

backTo('Last step') // we can't go back to a future step
expect(index.value).toBe(0)
expect(current.value).toBe('First step')

goTo('Last step') // we can force a step
expect(nextStep.value).toBeUndefined()
expect(previousStep.value).toBe('Second step')
expect(index.value).toBe(2)
expect(current.value).toBe('Last step')
expect(isLast.value).toBe(true)
})

it('can use objects as steps', () => {
const { goToNext, index, current } = useStepper([
{ id: 1, label: 'First step' },
{ id: 2, label: 'Second step' },
{ id: 3, label: 'Third step' },
])

expect(index.value).toBe(0)
expect(current.value).toMatchObject({ id: 1, label: 'First step' })

goToNext()
expect(index.value).toBe(1)
expect(current.value).toMatchObject({ id: 2, label: 'Second step' })

goToNext()
goToNext()
expect(index.value).toBe(2)
expect(current.value).toMatchObject({ id: 3, label: 'Third step' })
})
})
72 changes: 72 additions & 0 deletions packages/core/useStepper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { computed, ref } from 'vue-demi'

export function useStepper<T>(steps: readonly T[], initial?: T) {
const index = ref(steps.indexOf(initial ?? steps[0]))
const current = computed(() => steps[index.value])
const isFirst = computed(() => index.value === 0)
const isLast = computed(() => index.value === steps.length - 1)
const nextStep = computed(() => isLast.value ? undefined : steps[index.value + 1])
const previousStep = computed(() => isFirst.value ? undefined : steps[index.value - 1])
innocenzi marked this conversation as resolved.
Show resolved Hide resolved

function backTo(step: T) {
if (currentStepIsAfter(step))
goTo(step)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to have this check on usage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, I don't understand. On usage of what? To clarify, goTo allows to jump to a step without any check, while backTo allows to go back to a previous step if it has been completed.

I needed this feature when trying to allow to click on the step names, but only when it has been done, as you can see on the demo:

<button :disabled="todo(step)" @click="backTo(step)" v-text="step" />

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would feel a bit weird to me that:

const s = useStepper([1,2,3,4])
s.goTo(2)
// ...
s.backTo(3)
// and s stays on 2

In your case, the disabled already serve as a guard for it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But backTo can be called from somewhere else than a button and in that case, disabled wouldn't guard it. Also a button's attribute is easier to tamper with than the code directly.

If you think it's weird maybe you just need to use goTo in such a situation? I designed backTo with the guard idea in mind, and if it's removed, the function becomes useless in favor of goTo. But in every wizard I made in my recent apps, this specific function was useful.

Maybe we can rename backTo with a name that would be less confusing?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, what I mean is the guard should be done on userland when calling goTo. Since there is only one line of addition in backTo, the trade-off of saving one line but introducing an additional abstraction layer that users/readers need to understand is a bit less worthy to me. I'd suggest we remove it, and let users define it when needed.

Copy link
Contributor Author

@innocenzi innocenzi Jun 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand - I removed it.

That being said, I still think this function is very useful, since I used it in all my wizards without exception - and I updated the demo code to show you how an userland backTo would look like, I think it's not the best DX-wise.

But I respect your decision if you still think it's not needed!


function goTo(step: T) {
index.value = steps.indexOf(step)
}

function goToNext() {
if (isLast.value)
return

index.value++
}

function goToPrevious() {
if (isFirst.value)
return

index.value--
}

function isNext(step: T) {
return steps.indexOf(step) === index.value + 1
}

function isPrevious(step: T) {
return steps.indexOf(step) === index.value - 1
}
antfu marked this conversation as resolved.
Show resolved Hide resolved

function currentStepIs(step: T) {
return steps.indexOf(step) === index.value
}

function currentStepIsAfter(step: T) {
return index.value > steps.indexOf(step)
}

function currentStepIsBefore(step: T) {
return index.value < steps.indexOf(step)
}
innocenzi marked this conversation as resolved.
Show resolved Hide resolved

return {
backTo,
goTo,
current,
index,
goToNext,
goToPrevious,
steps,
currentStepIs,
currentStepIsAfter,
currentStepIsBefore,
isFirst,
isLast,
isNext,
isPrevious,
nextStep,
previousStep,
}
}