From 6b0166eac77493ba6f628c6467c4c7e1eea22585 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 6 Jul 2022 20:38:07 +0200 Subject: [PATCH] feat(useStepper): new function (#1754) Co-authored-by: Anthony Fu --- packages/core/index.ts | 1 + packages/core/useStepper/demo.vue | 118 +++++++++++ packages/core/useStepper/index.md | 84 ++++++++ packages/core/useStepper/index.test.ts | 263 +++++++++++++++++++++++++ packages/core/useStepper/index.ts | 137 +++++++++++++ 5 files changed, 603 insertions(+) create mode 100644 packages/core/useStepper/demo.vue create mode 100644 packages/core/useStepper/index.md create mode 100644 packages/core/useStepper/index.test.ts create mode 100644 packages/core/useStepper/index.ts diff --git a/packages/core/index.ts b/packages/core/index.ts index c6be4d7f438..2ebba49a95b 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -95,6 +95,7 @@ export * from './useSessionStorage' export * from './useShare' export * from './useSpeechRecognition' export * from './useSpeechSynthesis' +export * from './useStepper' export * from './useStorage' export * from './useStorageAsync' export * from './useStyleTag' diff --git a/packages/core/useStepper/demo.vue b/packages/core/useStepper/demo.vue new file mode 100644 index 00000000000..8b1676fa0e0 --- /dev/null +++ b/packages/core/useStepper/demo.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/core/useStepper/index.md b/packages/core/useStepper/index.md new file mode 100644 index 00000000000..a813296b8d3 --- /dev/null +++ b/packages/core/useStepper/index.md @@ -0,0 +1,84 @@ +--- +category: Utilities +--- + +# useStepper + +Provides helpers for building a multi-step wizard interface. + +## Usage + +### Steps as array + +```js +import { useStepper } from '@vueuse/core' + +const { + steps, + stepNames, + index, + current, + next, + previous, + isFirst, + isLast, + goTo, + goNext, + goPrevious, + goBackTo, + isNext, + isPrevious, + isCurrent, + isBefore, + isAfter, +} = useStepper([ + 'billing-address', + 'terms', + 'payment', +]) + +// Access the step through `current` +console.log(current.value) // 'billing-address' +``` + +### Steps as object + +```js +import { useStepper } from '@vueuse/core' + +const { + steps, + stepNames, + index, + current, + next, + previous, + isFirst, + isLast, + goTo, + goNext, + goPrevious, + goBackTo, + isNext, + isPrevious, + isCurrent, + isBefore, + isAfter, +} = useStepper({ + 'user-information': { + title: 'User information', + }, + 'billing-address': { + title: 'Billing address', + }, + 'terms': { + title: 'Terms', + }, + 'payment': { + title: 'Payment', + }, +}) + +// Access the step object through `current` +console.log(current.value.title) // 'User information' +``` diff --git a/packages/core/useStepper/index.test.ts b/packages/core/useStepper/index.test.ts new file mode 100644 index 00000000000..c8ecf6262c3 --- /dev/null +++ b/packages/core/useStepper/index.test.ts @@ -0,0 +1,263 @@ +import { reactive, ref } from 'vue-demi' +import { useStepper } from '.' + +describe('useStepper', () => { + it('should be defined', () => { + expect(useStepper).toBeDefined() + }) + + describe('common', () => { + test('steps are reactive', () => { + const flag = ref(true) + const steps = reactive({ + first: { + title: 'First', + enabled: flag, + }, + second: { + title: 'Second', + enabled: flag, + }, + }) + + const stepper = useStepper(steps) + + expect(stepper.current.value.enabled).toBe(true) + flag.value = false + expect(stepper.current.value.enabled).toBe(false) + }) + + it('does not navigate to steps that do not exist', () => { + const stepper = useStepper({ + first: 'First', + second: 'Second', + last: 'Last', + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + stepper.goTo('unexisting step') + expect(stepper.current.value).toBe('First') + }) + + it('supports navigating through steps', () => { + const stepper = useStepper({ + first: 'First', + second: 'Second', + last: 'Last', + }) + + expect(stepper.current.value).toBe('First') + + // Checks that when this is the first step, we can't go back + stepper.goToPrevious() + expect(stepper.current.value).toBe('First') + + // Checks that we can simply go next + stepper.goToNext() + expect(stepper.current.value).toBe('Second') + stepper.goToNext() + expect(stepper.current.value).toBe('Last') + + // Checks that when this is the last step, we can't go next + stepper.goToNext() + expect(stepper.current.value).toBe('Last') + + // Checks that we can go back to a previous step + stepper.goBackTo('second') + expect(stepper.current.value).toBe('Second') + + // Checks that we CANNOT go back to a future step + stepper.goBackTo('last') + expect(stepper.current.value).toBe('Second') + + // Checks that we can go to a any step + stepper.goTo('first') + expect(stepper.current.value).toBe('First') + }) + + it('can tell the step position', () => { + const stepper = useStepper({ + first: 'First', + second: 'Second', + last: 'Last', + }) + + // First step + expect(stepper.isFirst.value).toBe(true) + expect(stepper.isLast.value).toBe(false) + + expect(stepper.isAfter('first')).toBe(false) + expect(stepper.isAfter('second')).toBe(false) + expect(stepper.isAfter('last')).toBe(false) + + expect(stepper.isBefore('first')).toBe(false) + expect(stepper.isBefore('second')).toBe(true) + expect(stepper.isBefore('last')).toBe(true) + + expect(stepper.isCurrent('first')).toBe(true) + expect(stepper.isCurrent('second')).toBe(false) + expect(stepper.isCurrent('last')).toBe(false) + + expect(stepper.isPrevious('first')).toBe(false) + expect(stepper.isPrevious('second')).toBe(false) + expect(stepper.isPrevious('last')).toBe(false) + + expect(stepper.isNext('first')).toBe(false) + expect(stepper.isNext('second')).toBe(true) + expect(stepper.isNext('last')).toBe(false) + + // Second step + stepper.goToNext() + + expect(stepper.isFirst.value).toBe(false) + expect(stepper.isLast.value).toBe(false) + + expect(stepper.isAfter('first')).toBe(true) + expect(stepper.isAfter('second')).toBe(false) + expect(stepper.isAfter('last')).toBe(false) + + expect(stepper.isBefore('first')).toBe(false) + expect(stepper.isBefore('second')).toBe(false) + expect(stepper.isBefore('last')).toBe(true) + + expect(stepper.isCurrent('first')).toBe(false) + expect(stepper.isCurrent('second')).toBe(true) + expect(stepper.isCurrent('last')).toBe(false) + + expect(stepper.isPrevious('first')).toBe(true) + expect(stepper.isPrevious('second')).toBe(false) + expect(stepper.isPrevious('last')).toBe(false) + + expect(stepper.isNext('first')).toBe(false) + expect(stepper.isNext('second')).toBe(false) + expect(stepper.isNext('last')).toBe(true) + + // Last step + stepper.goToNext() + + expect(stepper.isFirst.value).toBe(false) + expect(stepper.isLast.value).toBe(true) + + expect(stepper.isAfter('first')).toBe(true) + expect(stepper.isAfter('second')).toBe(true) + expect(stepper.isAfter('last')).toBe(false) + + expect(stepper.isBefore('first')).toBe(false) + expect(stepper.isBefore('second')).toBe(false) + expect(stepper.isBefore('last')).toBe(false) + + expect(stepper.isCurrent('first')).toBe(false) + expect(stepper.isCurrent('second')).toBe(false) + expect(stepper.isCurrent('last')).toBe(true) + + expect(stepper.isPrevious('first')).toBe(false) + expect(stepper.isPrevious('second')).toBe(true) + expect(stepper.isPrevious('last')).toBe(false) + + expect(stepper.isNext('first')).toBe(false) + expect(stepper.isNext('second')).toBe(false) + expect(stepper.isNext('last')).toBe(false) + }) + }) + + describe('as array', () => { + it('supports being initialized with a specific step', () => { + const stepper = useStepper([ + 'First step', + 'Second step', + 'Last step', + ], 'Last step') + + expect(stepper.current.value).toBe('Last step') + expect(stepper.isCurrent('Last step')).toBeTruthy() + }) + + it('support type-specific features', () => { + const stepper = useStepper([ + 'First step', + 'Second step', + 'Last step', + ]) + + expect(stepper.stepNames.value).toEqual(['First step', 'Second step', 'Last step']) + expect(stepper.steps.value).toEqual(['First step', 'Second step', 'Last step']) + }) + + it('can get a step at a specific index', () => { + const stepper = useStepper([ + 'First step', + 'Second step', + 'Last step', + ]) + + expect(stepper.at(0)).toBe('First step') + expect(stepper.at(1)).toBe('Second step') + expect(stepper.at(2)).toBe('Last step') + }) + + it('can get a step by its name', () => { + const stepper = useStepper([ + 'First step', + 'Second step', + 'Last step', + ]) + + expect(stepper.get('First step')).toBe('First step') + expect(stepper.get('Second step')).toBe('Second step') + expect(stepper.get('Last step')).toBe('Last step') + }) + }) + + describe('as object', () => { + it('supports being initialized with a specific step', () => { + const stepper = useStepper({ + first: 'First', + second: 'Second', + last: 'Last', + }, 'second') + + expect(stepper.current.value).toBe('Second') + expect(stepper.isCurrent('second')).toBeTruthy() + }) + + it('support type-specific features', () => { + const stepper = useStepper({ + first: 'First', + second: 'Second', + last: 'Last', + }) + + expect(stepper.stepNames.value).toEqual(['first', 'second', 'last']) + expect(stepper.steps.value).toEqual({ + first: 'First', + second: 'Second', + last: 'Last', + }) + }) + + it('can get a step at a specific index', () => { + const stepper = useStepper({ + first: 'First', + second: 'Second', + last: 'Last', + }) + + expect(stepper.at(0)).toBe('First') + expect(stepper.at(1)).toBe('Second') + expect(stepper.at(2)).toBe('Last') + }) + + it('can get a step by its name', () => { + const stepper = useStepper({ + first: 'First', + second: 'Second', + last: 'Last', + }) + + expect(stepper.get('first')).toBe('First') + expect(stepper.get('second')).toBe('Second') + expect(stepper.get('last')).toBe('Last') + }) + }) +}) diff --git a/packages/core/useStepper/index.ts b/packages/core/useStepper/index.ts new file mode 100644 index 00000000000..fc928544031 --- /dev/null +++ b/packages/core/useStepper/index.ts @@ -0,0 +1,137 @@ +import type { MaybeRef } from '@vueuse/shared' +import type { ComputedRef, Ref } from 'vue-demi' +import { computed, ref } from 'vue-demi' + +export interface UseStepperReturn { + /** List of steps. */ + steps: Readonly> + /** List of step names. */ + stepNames: Readonly> + /** Index of the current step. */ + index: Ref + /** Current step. */ + current: ComputedRef + /** Next step, or undefined if the current step is the last one. */ + next: ComputedRef + /** Previous step, or undefined if the current step is the first one. */ + previous: ComputedRef + /** Whether the current step is the first one. */ + isFirst: ComputedRef + /** Whether the current step is the last one. */ + isLast: ComputedRef + /** Get the step at the specified index. */ + at: (index: number) => Step | undefined + /** Get a step by the specified name. */ + get: (step: StepName) => Step | undefined + /** Go to the specified step. */ + goTo: (step: StepName) => void + /** Go to the next step. Does nothing if the current step is the last one. */ + goToNext: () => void + /** Go to the previous step. Does nothing if the current step is the previous one. */ + goToPrevious: () => void + /** Go back to the given step, only if the current step is after. */ + goBackTo: (step: StepName) => void + /** Checks whether the given step is the next step. */ + isNext: (step: StepName) => boolean + /** Checks whether the given step is the previous step. */ + isPrevious: (step: StepName) => boolean + /** Checks whether the given step is the current step. */ + isCurrent: (step: StepName) => boolean + /** Checks if the current step is before the given step. */ + isBefore: (step: StepName) => boolean + /** Checks if the current step is after the given step. */ + isAfter: (step: StepName) => boolean +} + +export function useStepper(steps: MaybeRef, initialStep?: T): UseStepperReturn +export function useStepper>(steps: MaybeRef, initialStep?: keyof T): UseStepperReturn, T, T[keyof T]> +export function useStepper(steps: any, initialStep?: any): UseStepperReturn { + const stepsRef = ref(steps) + const stepNames = computed(() => Array.isArray(stepsRef.value) ? stepsRef.value : Object.keys(stepsRef.value)) + const index = ref(stepNames.value.indexOf(initialStep ?? stepNames.value[0])) + const current = computed(() => at(index.value)) + const isFirst = computed(() => index.value === 0) + const isLast = computed(() => index.value === stepNames.value.length - 1) + const next = computed(() => stepNames.value[index.value + 1]) + const previous = computed(() => stepNames.value[index.value - 1]) + + function at(index: number) { + if (Array.isArray(stepsRef.value)) + return stepsRef.value[index] + + return stepsRef.value[stepNames.value[index]] + } + + function get(step: any) { + if (!stepNames.value.includes(step)) + return + + return at(stepNames.value.indexOf(step)) + } + + function goTo(step: any) { + if (stepNames.value.includes(step)) + index.value = stepNames.value.indexOf(step) + } + + function goToNext() { + if (isLast.value) + return + + index.value++ + } + + function goToPrevious() { + if (isFirst.value) + return + + index.value-- + } + + function goBackTo(step: any) { + if (isAfter(step)) + goTo(step) + } + + function isNext(step: any) { + return stepNames.value.indexOf(step) === index.value + 1 + } + + function isPrevious(step: any) { + return stepNames.value.indexOf(step) === index.value - 1 + } + + function isCurrent(step: any) { + return stepNames.value.indexOf(step) === index.value + } + + function isBefore(step: any) { + return index.value < stepNames.value.indexOf(step) + } + + function isAfter(step: any) { + return index.value > stepNames.value.indexOf(step) + } + + return { + steps: stepsRef, + stepNames, + index, + current, + next, + previous, + isFirst, + isLast, + at, + get, + goTo, + goToNext, + goToPrevious, + goBackTo, + isNext, + isPrevious, + isCurrent, + isBefore, + isAfter, + } +}