From bf45847085072b414e53fd42fa12c24117797897 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 3 Apr 2026 10:01:22 -0500 Subject: [PATCH] feat(Tour): add Tour component and useTour composable Headless guided tour plugin composing createStep, createRegistry, and createForm. Includes 11 compound components (Root, Content, Activator, Highlight, Keyboard, Title, Description, Progress, Prev, Next, Skip), docs pages, examples, and tests. --- .../src/examples/components/tour/basic.vue | 133 +++++++ .../examples/composables/use-tour/basic.vue | 65 ++++ .../src/pages/components/disclosure/tour.md | 83 +++++ apps/docs/src/pages/components/index.md | 1 + apps/docs/src/pages/composables/index.md | 1 + .../src/pages/composables/plugins/use-tour.md | 106 ++++++ apps/docs/src/typed-router.d.ts | 26 ++ packages/0/README.md | 2 + .../0/src/components/Tour/TourActivator.vue | 108 ++++++ .../0/src/components/Tour/TourContent.vue | 155 ++++++++ .../0/src/components/Tour/TourDescription.vue | 47 +++ .../0/src/components/Tour/TourHighlight.vue | 218 ++++++++++++ .../0/src/components/Tour/TourKeyboard.vue | 50 +++ packages/0/src/components/Tour/TourNext.vue | 73 ++++ packages/0/src/components/Tour/TourPrev.vue | 70 ++++ .../0/src/components/Tour/TourProgress.vue | 69 ++++ packages/0/src/components/Tour/TourRoot.vue | 109 ++++++ packages/0/src/components/Tour/TourSkip.vue | 53 +++ packages/0/src/components/Tour/TourTitle.vue | 47 +++ packages/0/src/components/Tour/index.test.ts | 158 +++++++++ packages/0/src/components/Tour/index.ts | 76 ++++ packages/0/src/components/index.ts | 1 + packages/0/src/composables/index.ts | 1 + .../0/src/composables/useTour/index.test.ts | 332 ++++++++++++++++++ packages/0/src/composables/useTour/index.ts | 214 +++++++++++ packages/0/src/maturity.json | 8 +- 26 files changed, 2205 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/examples/components/tour/basic.vue create mode 100644 apps/docs/src/examples/composables/use-tour/basic.vue create mode 100644 apps/docs/src/pages/components/disclosure/tour.md create mode 100644 apps/docs/src/pages/composables/plugins/use-tour.md create mode 100644 packages/0/src/components/Tour/TourActivator.vue create mode 100644 packages/0/src/components/Tour/TourContent.vue create mode 100644 packages/0/src/components/Tour/TourDescription.vue create mode 100644 packages/0/src/components/Tour/TourHighlight.vue create mode 100644 packages/0/src/components/Tour/TourKeyboard.vue create mode 100644 packages/0/src/components/Tour/TourNext.vue create mode 100644 packages/0/src/components/Tour/TourPrev.vue create mode 100644 packages/0/src/components/Tour/TourProgress.vue create mode 100644 packages/0/src/components/Tour/TourRoot.vue create mode 100644 packages/0/src/components/Tour/TourSkip.vue create mode 100644 packages/0/src/components/Tour/TourTitle.vue create mode 100644 packages/0/src/components/Tour/index.test.ts create mode 100644 packages/0/src/components/Tour/index.ts create mode 100644 packages/0/src/composables/useTour/index.test.ts create mode 100644 packages/0/src/composables/useTour/index.ts diff --git a/apps/docs/src/examples/components/tour/basic.vue b/apps/docs/src/examples/components/tour/basic.vue new file mode 100644 index 000000000..c97e6cad3 --- /dev/null +++ b/apps/docs/src/examples/components/tour/basic.vue @@ -0,0 +1,133 @@ + + + diff --git a/apps/docs/src/examples/composables/use-tour/basic.vue b/apps/docs/src/examples/composables/use-tour/basic.vue new file mode 100644 index 000000000..e139c6d98 --- /dev/null +++ b/apps/docs/src/examples/composables/use-tour/basic.vue @@ -0,0 +1,65 @@ + + + diff --git a/apps/docs/src/pages/components/disclosure/tour.md b/apps/docs/src/pages/components/disclosure/tour.md new file mode 100644 index 000000000..f1e954ab0 --- /dev/null +++ b/apps/docs/src/pages/components/disclosure/tour.md @@ -0,0 +1,83 @@ +--- +title: Tour - Guided Tour Component for Vue 3 +meta: +- name: description + content: Headless guided tour component for Vue 3 with step navigation, validation gates, keyboard support, and activator highlighting. Fully customizable and accessible. +- name: keywords + content: tour, guided tour, onboarding, walkthrough, tooltip, Vue 3, headless, accessibility +features: + category: Component + label: 'C: Tour' + github: /components/Tour/ + renderless: false + level: 2 +related: + - /components/disclosure/dialog + - /components/disclosure/popover + - /composables/plugins/use-tour +--- + +# Tour + +A headless guided tour component for building onboarding flows, feature walkthroughs, and contextual help. + + + +## Usage + +The Tour component composes step navigation, activator tracking, and overlay management into a compound component pattern. Install the plugin, register steps, and wrap target elements with activators. + +::: example +/components/tour/basic +::: + +## Anatomy + +```vue Anatomy playground + + + +``` + +## Step Types + +| Type | Behavior | +| - | - | +| `tooltip` | Anchored to an activator element (default) | +| `dialog` | Centered overlay, no activator needed | +| `floating` | Positioned freely, no activator anchoring | +| `wait` | Blocks navigation until `tour.ready()` is called | + +## Accessibility + +- Tour content uses `role="dialog"` with `aria-modal="true"` +- Title and description linked via `aria-labelledby` and `aria-describedby` +- Navigation buttons have descriptive `aria-label` attributes +- Progress uses `role="status"` for screen reader announcements +- Keyboard navigation via `TourKeyboard` (arrow keys + escape) + + diff --git a/apps/docs/src/pages/components/index.md b/apps/docs/src/pages/components/index.md index 4f7239007..62167d954 100644 --- a/apps/docs/src/pages/components/index.md +++ b/apps/docs/src/pages/components/index.md @@ -97,5 +97,6 @@ Components for showing/hiding content. | [ExpansionPanel](/components/disclosure/expansion-panel) | Accordion-style collapsible panels | | [Popover](/components/disclosure/popover) | CSS anchor-positioned popup content | | [Tabs](/components/disclosure/tabs) | Tab panel navigation with keyboard support and lazy content rendering | +| [Tour](/components/disclosure/tour) | Guided tour with step navigation, validation gates, and keyboard support | | [Treeview](/components/disclosure/treeview) | Hierarchical tree with nested selection and expand/collapse | diff --git a/apps/docs/src/pages/composables/index.md b/apps/docs/src/pages/composables/index.md index 7820600f6..2f45a40dc 100644 --- a/apps/docs/src/pages/composables/index.md +++ b/apps/docs/src/pages/composables/index.md @@ -302,6 +302,7 @@ Application-level features installable via Vue plugins. | [useStack](/composables/plugins/use-stack) | Overlay z-index stacking with automatic calculation and scrim integration | | [useStorage](/composables/plugins/use-storage) | Reactive browser storage interface | | [useTheme](/composables/plugins/use-theme) | Theme management with CSS custom properties | +| [useTour](/composables/plugins/use-tour) | Guided tour orchestration with step navigation and validation gates | ## Data diff --git a/apps/docs/src/pages/composables/plugins/use-tour.md b/apps/docs/src/pages/composables/plugins/use-tour.md new file mode 100644 index 000000000..ec66afb2e --- /dev/null +++ b/apps/docs/src/pages/composables/plugins/use-tour.md @@ -0,0 +1,106 @@ +--- +title: useTour - Guided Tour Plugin for Vue 3 +meta: +- name: description + content: Vue 3 composable for building guided tours and onboarding flows. Step navigation, validation gates, activator tracking, and z-index coordination. +- name: keywords + content: useTour, tour, guided tour, onboarding, walkthrough, composable, Vue 3 +features: + category: Plugin + label: 'E: useTour' + github: /composables/useTour/ + level: 2 +related: +- /composables/selection/create-step +- /composables/registration/create-registry +- /composables/forms/create-form +- /components/disclosure/tour +--- + +# useTour + + + +Headless guided tour plugin composing createStep, createRegistry, and createForm for step orchestration, activator tracking, and validation gates. + +## Installation + +Install the Tour plugin in your app's entry point: + +```ts main.ts +import { createApp } from 'vue' +import { createTourPlugin } from '@vuetify/v0' +import App from './App.vue' + +const app = createApp(App) + +app.use(createTourPlugin()) + +app.mount('#app') +``` + +## Usage + +```ts collapse +import { useTour } from '@vuetify/v0' + +const tour = useTour() + +// Register steps +tour.steps.onboard([ + { id: 'welcome', type: 'dialog' }, + { id: 'search', type: 'tooltip' }, + { id: 'profile', type: 'tooltip' }, + { id: 'action', type: 'wait' }, +]) + +// Start tour +tour.start() + +// Navigate +await tour.next() +tour.prev() +await tour.step(3) + +// Lifecycle +tour.stop() // Dismiss without completing +tour.complete() // Mark as finished +tour.reset() // Clear all state +tour.ready() // Unblock a 'wait' step +``` + +## Architecture + +```mermaid +graph TD + A[createTour] --> B[createStep] + A --> C[createRegistry] + A --> D[createForm] + B --> E[Step navigation] + C --> F[Activator tracking] + D --> G[Validation gates] +``` + +## Reactivity + +| Property | Type | Description | +| - | - | - | +| `isActive` | `Readonly>` | Whether the tour is currently running | +| `isComplete` | `Readonly>` | Whether the tour finished via `complete()` | +| `isReady` | `Readonly>` | Whether the current step allows navigation | +| `isFirst` | `Readonly>` | Whether the current step is the first | +| `isLast` | `Readonly>` | Whether the current step is the last | +| `canGoBack` | `Readonly>` | Ready and not first | +| `canGoNext` | `Readonly>` | Ready and not last | +| `selectedId` | `Ref` | Current step ID | +| `total` | `number` | Total registered steps | + +## Examples + +### Basic + +::: example +/composables/use-tour/basic +::: + + diff --git a/apps/docs/src/typed-router.d.ts b/apps/docs/src/typed-router.d.ts index f97331bda..c2e6f4bc6 100644 --- a/apps/docs/src/typed-router.d.ts +++ b/apps/docs/src/typed-router.d.ts @@ -121,6 +121,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/components/disclosure/tour': RouteRecordInfo< + '/components/disclosure/tour', + '/components/disclosure/tour', + Record, + Record, + | never + >, '/components/disclosure/treeview': RouteRecordInfo< '/components/disclosure/treeview', '/components/disclosure/treeview', @@ -555,6 +562,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/composables/plugins/use-tour': RouteRecordInfo< + '/composables/plugins/use-tour', + '/composables/plugins/use-tour', + Record, + Record, + | never + >, '/composables/reactivity/use-proxy-model': RouteRecordInfo< '/composables/reactivity/use-proxy-model', '/composables/reactivity/use-proxy-model', @@ -1157,6 +1171,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/components/disclosure/tour.md': { + routes: + | '/components/disclosure/tour' + views: + | never + } 'src/pages/components/disclosure/treeview.md': { routes: | '/components/disclosure/treeview' @@ -1529,6 +1549,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/composables/plugins/use-tour.md': { + routes: + | '/composables/plugins/use-tour' + views: + | never + } 'src/pages/composables/reactivity/use-proxy-model.md': { routes: | '/composables/reactivity/use-proxy-model' diff --git a/packages/0/README.md b/packages/0/README.md index 7269e6e4b..838f56904 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -130,6 +130,7 @@ import { ... } from '@vuetify/v0/date' // Date adapter and utilities | [ExpansionPanel](https://0.vuetifyjs.com/components/disclosure/expansion-panel) | Accordion-style collapsible panels | | [Popover](https://0.vuetifyjs.com/components/disclosure/popover) | CSS anchor-positioned popup content | | [Tabs](https://0.vuetifyjs.com/components/disclosure/tabs) | Tab panel navigation with keyboard support and lazy content rendering | +| [Tour](https://0.vuetifyjs.com/components/disclosure/tour) | Guided tour with step navigation, validation gates, and keyboard support | | [Treeview](https://0.vuetifyjs.com/components/disclosure/treeview) | Hierarchical tree with nested selection and expand/collapse | #### Semantic @@ -248,6 +249,7 @@ Plugin-capable composables following the trinity pattern: - [`useStack`](https://0.vuetifyjs.com/composables/plugins/use-stack) - Overlay z-index stacking with automatic scrim coordination - [`useStorage`](https://0.vuetifyjs.com/composables/plugins/use-storage) - Storage adapter (localStorage/sessionStorage/memory) - [`useTheme`](https://0.vuetifyjs.com/composables/plugins/use-theme) - Theme management with CSS variable injection +- [`useTour`](https://0.vuetifyjs.com/composables/plugins/use-tour) - Guided tour orchestration with step navigation and validation gates ## Design Principles diff --git a/packages/0/src/components/Tour/TourActivator.vue b/packages/0/src/components/Tour/TourActivator.vue new file mode 100644 index 000000000..cfda9518c --- /dev/null +++ b/packages/0/src/components/Tour/TourActivator.vue @@ -0,0 +1,108 @@ +/** + * @module TourActivator + * + * @remarks + * Registers a DOM element as the anchor target for a tour step. + * Handles scroll-into-view and CSS anchor-name for positioning. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourContent.vue b/packages/0/src/components/Tour/TourContent.vue new file mode 100644 index 000000000..7e46e9881 --- /dev/null +++ b/packages/0/src/components/Tour/TourContent.vue @@ -0,0 +1,155 @@ +/** + * @module TourContent + * + * @remarks + * Tour content container. Teleported to body for overlay positioning. + * Self-gates via root context isActive. Handles CSS anchor positioning + * for tooltip steps and centering for dialog/floating steps. + * Safari fallback centers content at the viewport edge. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourDescription.vue b/packages/0/src/components/Tour/TourDescription.vue new file mode 100644 index 000000000..b3b9de023 --- /dev/null +++ b/packages/0/src/components/Tour/TourDescription.vue @@ -0,0 +1,47 @@ +/** + * @module TourDescription + * + * @remarks + * Semantic paragraph for tour step description. + * Sets id for aria-describedby reference from TourContent. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourHighlight.vue b/packages/0/src/components/Tour/TourHighlight.vue new file mode 100644 index 000000000..8f8655d3e --- /dev/null +++ b/packages/0/src/components/Tour/TourHighlight.vue @@ -0,0 +1,218 @@ +/** + * @module TourHighlight + * + * @remarks + * SVG overlay with cutout that highlights the active step's activator. + * Tracks activator bounding rect via rAF loop. Teleports to body. + * Renders full scrim for dialog/floating steps without activators. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourKeyboard.vue b/packages/0/src/components/Tour/TourKeyboard.vue new file mode 100644 index 000000000..245190cdc --- /dev/null +++ b/packages/0/src/components/Tour/TourKeyboard.vue @@ -0,0 +1,50 @@ +/** + * @module TourKeyboard + * + * @remarks + * Renderless keyboard navigation for tours. + * Wires useHotkey for prev/next/stop actions. + * Opt-in — not included means no keyboard handling. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourNext.vue b/packages/0/src/components/Tour/TourNext.vue new file mode 100644 index 000000000..84bdd582d --- /dev/null +++ b/packages/0/src/components/Tour/TourNext.vue @@ -0,0 +1,73 @@ +/** + * @module TourNext + * + * @remarks + * Next step / complete tour button with dynamic ARIA label. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourPrev.vue b/packages/0/src/components/Tour/TourPrev.vue new file mode 100644 index 000000000..332622d6f --- /dev/null +++ b/packages/0/src/components/Tour/TourPrev.vue @@ -0,0 +1,70 @@ +/** + * @module TourPrev + * + * @remarks + * Previous step navigation button with ARIA label and disabled state. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourProgress.vue b/packages/0/src/components/Tour/TourProgress.vue new file mode 100644 index 000000000..40517b576 --- /dev/null +++ b/packages/0/src/components/Tour/TourProgress.vue @@ -0,0 +1,69 @@ +/** + * @module TourProgress + * + * @remarks + * Step counter with ARIA status role. + * Exposes current, total, text, and percent via slot props. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourRoot.vue b/packages/0/src/components/Tour/TourRoot.vue new file mode 100644 index 000000000..bb6603265 --- /dev/null +++ b/packages/0/src/components/Tour/TourRoot.vue @@ -0,0 +1,109 @@ +/** + * @module TourRoot + * + * @remarks + * Per-step context provider for the Tour compound component. + * Gates children by active step and provides step-scoped context. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourSkip.vue b/packages/0/src/components/Tour/TourSkip.vue new file mode 100644 index 000000000..4000cd07d --- /dev/null +++ b/packages/0/src/components/Tour/TourSkip.vue @@ -0,0 +1,53 @@ +/** + * @module TourSkip + * + * @remarks + * Dismiss/skip tour button. Emits 'skip' event for consumer routing logic. + */ + + + + + + diff --git a/packages/0/src/components/Tour/TourTitle.vue b/packages/0/src/components/Tour/TourTitle.vue new file mode 100644 index 000000000..5710da2aa --- /dev/null +++ b/packages/0/src/components/Tour/TourTitle.vue @@ -0,0 +1,47 @@ +/** + * @module TourTitle + * + * @remarks + * Semantic heading for tour step content. + * Sets id for aria-labelledby reference from TourContent. + */ + + + + + + diff --git a/packages/0/src/components/Tour/index.test.ts b/packages/0/src/components/Tour/index.test.ts new file mode 100644 index 000000000..f86f4e15a --- /dev/null +++ b/packages/0/src/components/Tour/index.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' + +// Composables +import { createStackPlugin } from '#v0/composables/useStack' +import { createTourPlugin, useTour } from '#v0/composables/useTour' + +// Utilities +import { mount } from '@vue/test-utils' +import { defineComponent, nextTick } from 'vue' + +import { Tour } from '.' + +function createApp (template: string, setup?: () => Record) { + return defineComponent({ + components: { + TourRoot: Tour.Root, + TourTitle: Tour.Title, + TourDescription: Tour.Description, + TourProgress: Tour.Progress, + TourPrev: Tour.Prev, + TourNext: Tour.Next, + TourSkip: Tour.Skip, + }, + template, + setup, + }) +} + +function mountWithPlugin (component: ReturnType) { + return mount(component, { + global: { + plugins: [createTourPlugin(), createStackPlugin()], + }, + }) +} + +describe('tour components', () => { + describe('tour.Root', () => { + it('should expose isActive slot prop', () => { + const App = createApp( + ` + {{ isActive }} + `, + ) + + const wrapper = mountWithPlugin(App) + expect(wrapper.find('[data-testid="active"]').text()).toBe('false') + }) + }) + + describe('tour.Title', () => { + it('should render h2 with id', () => { + const App = createApp( + ` + My Title + `, + ) + + const wrapper = mountWithPlugin(App) + const h2 = wrapper.find('h2') + expect(h2.exists()).toBe(true) + expect(h2.text()).toBe('My Title') + expect(h2.attributes('id')).toBeTruthy() + expect(h2.attributes('data-scope')).toBe('tour') + expect(h2.attributes('data-part')).toBe('title') + }) + }) + + describe('tour.Description', () => { + it('should render p with id', () => { + const App = createApp( + ` + My Description + `, + ) + + const wrapper = mountWithPlugin(App) + const p = wrapper.find('p') + expect(p.exists()).toBe(true) + expect(p.text()).toBe('My Description') + expect(p.attributes('data-scope')).toBe('tour') + }) + }) + + describe('tour.Progress', () => { + it('should render step counter with role status', () => { + const App = createApp( + ` + + `, + ) + + const wrapper = mountWithPlugin(App) + const span = wrapper.find('[role="status"]') + expect(span.exists()).toBe(true) + expect(span.attributes('data-part')).toBe('progress') + }) + }) + + describe('navigation', () => { + it('should show second step content after next()', async () => { + const App = createApp( + `
+ + Step 1 + + + Step 2 + +
`, + () => { + const tour = useTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + ]) + tour.start() + return { tour } + }, + ) + + const wrapper = mountWithPlugin(App) + expect(wrapper.find('[data-testid="step-1"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="step-2"]').exists()).toBe(false) + + const tour = (wrapper.vm as any).tour + await tour.next() + await nextTick() + + expect(wrapper.find('[data-testid="step-1"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="step-2"]').exists()).toBe(true) + }) + }) + + describe('tour.Skip', () => { + it('should emit skip event on click', async () => { + const App = createApp( + ` + Skip + `, + () => { + const skipped = { value: false } + return { + onSkip: () => { + skipped.value = true + }, + skipped, + } + }, + ) + + const wrapper = mountWithPlugin(App) + const btn = wrapper.find('[data-part="skip"]') + expect(btn.exists()).toBe(true) + await btn.trigger('click') + }) + }) +}) diff --git a/packages/0/src/components/Tour/index.ts b/packages/0/src/components/Tour/index.ts new file mode 100644 index 000000000..7641a0637 --- /dev/null +++ b/packages/0/src/components/Tour/index.ts @@ -0,0 +1,76 @@ +export { default as TourRoot } from './TourRoot.vue' +export { provideTourRootContext, useTourRootContext } from './TourRoot.vue' +export type { TourRootContext, TourRootProps, TourRootSlotProps } from './TourRoot.vue' + +export { default as TourActivator } from './TourActivator.vue' +export type { TourActivatorProps, TourActivatorSlotProps } from './TourActivator.vue' + +export { default as TourContent } from './TourContent.vue' +export type { TourContentProps, TourContentSlotProps } from './TourContent.vue' + +export { default as TourHighlight } from './TourHighlight.vue' +export type { TourHighlightProps, TourHighlightSlotProps } from './TourHighlight.vue' + +export { default as TourKeyboard } from './TourKeyboard.vue' +export type { TourKeyboardProps } from './TourKeyboard.vue' + +export { default as TourTitle } from './TourTitle.vue' +export type { TourTitleProps, TourTitleSlotProps } from './TourTitle.vue' + +export { default as TourDescription } from './TourDescription.vue' +export type { TourDescriptionProps, TourDescriptionSlotProps } from './TourDescription.vue' + +export { default as TourProgress } from './TourProgress.vue' +export type { TourProgressProps, TourProgressSlotProps } from './TourProgress.vue' + +export { default as TourPrev } from './TourPrev.vue' +export type { TourPrevProps, TourPrevSlotProps } from './TourPrev.vue' + +export { default as TourNext } from './TourNext.vue' +export type { TourNextProps, TourNextSlotProps } from './TourNext.vue' + +export { default as TourSkip } from './TourSkip.vue' +export type { TourSkipEmits, TourSkipProps, TourSkipSlotProps } from './TourSkip.vue' + +// Components +import Activator from './TourActivator.vue' +import Content from './TourContent.vue' +import Description from './TourDescription.vue' +import Highlight from './TourHighlight.vue' +import Keyboard from './TourKeyboard.vue' +import Next from './TourNext.vue' +import Prev from './TourPrev.vue' +import Progress from './TourProgress.vue' +import Root from './TourRoot.vue' +import Skip from './TourSkip.vue' +import Title from './TourTitle.vue' + +/** + * Tour component with sub-components for building guided tours. + * + * @see https://0.vuetifyjs.com/components/disclosure/tour + */ +export const Tour = { + /** Per-step context provider. @see https://0.vuetifyjs.com/components/disclosure/tour */ + Root, + /** Element registration and CSS anchor positioning. */ + Activator, + /** Tour content container, teleported to body. */ + Content, + /** SVG overlay with cutout highlighting the active activator. */ + Highlight, + /** Renderless keyboard navigation (arrow keys + escape). */ + Keyboard, + /** Semantic heading with aria-labelledby integration. */ + Title, + /** Semantic paragraph with aria-describedby integration. */ + Description, + /** Step counter with role="status". */ + Progress, + /** Previous step button with disabled state. */ + Prev, + /** Next step / complete tour button. */ + Next, + /** Dismiss tour button emitting skip event. */ + Skip, +} diff --git a/packages/0/src/components/index.ts b/packages/0/src/components/index.ts index ad5f4a9ec..923c16223 100644 --- a/packages/0/src/components/index.ts +++ b/packages/0/src/components/index.ts @@ -36,4 +36,5 @@ export * from './Switch' export * from './Tabs' export * from './Theme' export * from './Toggle' +export * from './Tour' export * from './Treeview' diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts index 0f1363371..2987cb77a 100644 --- a/packages/0/src/composables/index.ts +++ b/packages/0/src/composables/index.ts @@ -64,4 +64,5 @@ export * from './useStorage' export * from './useTheme' export * from './useTimer' export * from './useToggleScope' +export * from './useTour' export * from './useVirtualFocus' diff --git a/packages/0/src/composables/useTour/index.test.ts b/packages/0/src/composables/useTour/index.test.ts new file mode 100644 index 000000000..1d054573d --- /dev/null +++ b/packages/0/src/composables/useTour/index.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createTour, createTourPlugin } from '.' + +describe('useTour', () => { + describe('createTour', () => { + it('should create tour with default state', () => { + const tour = createTour() + + expect(tour.isActive.value).toBe(false) + expect(tour.isComplete.value).toBe(false) + expect(tour.isReady.value).toBe(true) + expect(tour.isFirst.value).toBe(false) + expect(tour.isLast.value).toBe(true) + expect(tour.canGoBack.value).toBe(false) + expect(tour.canGoNext.value).toBe(false) + expect(tour.total).toBe(0) + expect(tour.selectedId.value).toBeUndefined() + }) + + it('should expose composed primitives', () => { + const tour = createTour() + + expect(tour.steps).toBeDefined() + expect(tour.steps.register).toBeDefined() + expect(tour.activators).toBeDefined() + expect(tour.activators.register).toBeDefined() + expect(tour.form).toBeDefined() + expect(tour.form.has).toBeDefined() + }) + + it('should expose navigation methods', () => { + const tour = createTour() + + expect(typeof tour.start).toBe('function') + expect(typeof tour.stop).toBe('function') + expect(typeof tour.complete).toBe('function') + expect(typeof tour.reset).toBe('function') + expect(typeof tour.next).toBe('function') + expect(typeof tour.prev).toBe('function') + expect(typeof tour.step).toBe('function') + expect(typeof tour.ready).toBe('function') + }) + }) + + describe('start', () => { + it('should activate tour and select first step', () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + { id: 'step-3' }, + ]) + + tour.start() + + expect(tour.isActive.value).toBe(true) + expect(tour.isComplete.value).toBe(false) + expect(tour.selectedId.value).toBe('step-1') + expect(tour.isFirst.value).toBe(true) + expect(tour.total).toBe(3) + }) + + it('should start at specific step', () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + ]) + + tour.start({ stepId: 'step-2' }) + + expect(tour.selectedId.value).toBe('step-2') + }) + }) + + describe('stop', () => { + it('should deactivate tour', () => { + const tour = createTour() + tour.steps.onboard([{ id: 'step-1' }]) + tour.start() + + tour.stop() + + expect(tour.isActive.value).toBe(false) + expect(tour.isComplete.value).toBe(false) + }) + }) + + describe('complete', () => { + it('should mark tour as complete', () => { + const tour = createTour() + tour.steps.onboard([{ id: 'step-1' }]) + tour.start() + + tour.complete() + + expect(tour.isActive.value).toBe(false) + expect(tour.isComplete.value).toBe(true) + }) + }) + + describe('reset', () => { + it('should clear all state', () => { + const tour = createTour() + tour.steps.onboard([{ id: 'step-1' }]) + tour.start() + + tour.reset() + + expect(tour.isActive.value).toBe(false) + expect(tour.isComplete.value).toBe(false) + expect(tour.total).toBe(0) + expect(tour.selectedId.value).toBeUndefined() + }) + }) + + describe('next', () => { + it('should advance to next step', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + { id: 'step-3' }, + ]) + tour.start() + + await tour.next() + + expect(tour.selectedId.value).toBe('step-2') + }) + + it('should not advance past last step', async () => { + const tour = createTour() + tour.steps.onboard([{ id: 'step-1' }, { id: 'step-2' }]) + tour.start() + await tour.next() + + await tour.next() + + expect(tour.selectedId.value).toBe('step-2') + }) + + it('should not advance when not ready', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1', type: 'wait' }, + { id: 'step-2' }, + ]) + tour.start() + + await tour.next() + + expect(tour.selectedId.value).toBe('step-1') + }) + + it('should advance after ready() on wait step', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1', type: 'wait' }, + { id: 'step-2' }, + ]) + tour.start() + + tour.ready() + await tour.next() + + expect(tour.selectedId.value).toBe('step-2') + }) + }) + + describe('prev', () => { + it('should go to previous step', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + ]) + tour.start() + await tour.next() + + tour.prev() + + expect(tour.selectedId.value).toBe('step-1') + }) + + it('should not go before first step', () => { + const tour = createTour() + tour.steps.onboard([{ id: 'step-1' }, { id: 'step-2' }]) + tour.start() + + tour.prev() + + expect(tour.selectedId.value).toBe('step-1') + }) + }) + + describe('step', () => { + it('should jump to step by index', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + { id: 'step-3' }, + ]) + tour.start() + + await tour.step(3) + + expect(tour.selectedId.value).toBe('step-3') + }) + + it('should do nothing for invalid index', async () => { + const tour = createTour() + tour.steps.onboard([{ id: 'step-1' }]) + tour.start() + + await tour.step(999) + + expect(tour.selectedId.value).toBe('step-1') + }) + }) + + describe('isReady', () => { + it('should be true for non-wait steps', () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1', type: 'tooltip' }, + ]) + tour.start() + + expect(tour.isReady.value).toBe(true) + }) + + it('should be false for wait steps', () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1', type: 'wait' }, + ]) + tour.start() + + expect(tour.isReady.value).toBe(false) + }) + + it('should reset on step change', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2', type: 'wait' }, + ]) + tour.start() + expect(tour.isReady.value).toBe(true) + + await tour.next() + + expect(tour.isReady.value).toBe(false) + }) + }) + + describe('validation', () => { + it('should block next when validation fails', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + ]) + tour.start() + + tour.form.register({ id: 'step-1', value: {} as any }) + const submitSpy = vi.spyOn(tour.form, 'submit').mockResolvedValue(false) + await tour.next() + + expect(tour.selectedId.value).toBe('step-1') + expect(submitSpy).toHaveBeenCalledWith('step-1') + }) + + it('should allow next when validation passes', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + ]) + tour.start() + + tour.form.register({ id: 'step-1', value: {} as any }) + vi.spyOn(tour.form, 'submit').mockResolvedValue(true) + + await tour.next() + + expect(tour.selectedId.value).toBe('step-2') + }) + + it('should skip validation when no form registered for step', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + ]) + tour.start() + + const submitSpy = vi.spyOn(tour.form, 'submit') + await tour.next() + + expect(submitSpy).not.toHaveBeenCalled() + expect(tour.selectedId.value).toBe('step-2') + }) + + it('should validate before step() jump', async () => { + const tour = createTour() + tour.steps.onboard([ + { id: 'step-1' }, + { id: 'step-2' }, + { id: 'step-3' }, + ]) + tour.start() + + tour.form.register({ id: 'step-1', value: {} as any }) + vi.spyOn(tour.form, 'submit').mockResolvedValue(false) + + await tour.step(3) + + expect(tour.selectedId.value).toBe('step-1') + }) + }) + + describe('createTourPlugin', () => { + it('should create a Vue plugin', () => { + const plugin = createTourPlugin() + expect(plugin.install).toBeDefined() + }) + }) +}) diff --git a/packages/0/src/composables/useTour/index.ts b/packages/0/src/composables/useTour/index.ts new file mode 100644 index 000000000..0884e1ecf --- /dev/null +++ b/packages/0/src/composables/useTour/index.ts @@ -0,0 +1,214 @@ +/** + * @module useTour + * + * @remarks + * Headless guided tour plugin. Composes createStep, createRegistry, + * and createForm for step orchestration, activator tracking, + * and validation gates. + * + * Key features: + * - Step types: tooltip, dialog, floating, wait + * - Form validation gates on navigation + * - Activator element registry + * - isReady gate for wait-type steps + */ + +// Composables +import { createForm } from '#v0/composables/createForm' +import { createPluginContext } from '#v0/composables/createPlugin' +import { createRegistry } from '#v0/composables/createRegistry' +import { createStep } from '#v0/composables/createStep' + +// Utilities +import { isUndefined } from '#v0/utilities' +import { readonly, shallowRef, toRef } from 'vue' + +// Types +import type { FormContext } from '#v0/composables/createForm' +import type { RegistryContext, RegistryTicket } from '#v0/composables/createRegistry' +import type { StepContext, StepTicket, StepTicketInput } from '#v0/composables/createStep' +import type { MaybeElementRef } from '#v0/composables/toElement' +import type { ID } from '#v0/types' +import type { Ref, ShallowRef } from 'vue' + +// ---- Types ---- + +export type TourStepType = 'tooltip' | 'dialog' | 'floating' | 'wait' + +export type TourStepInput = StepTicketInput & { + type?: TourStepType + placement?: string + placementMobile?: string +} + +export type TourStepTicket = StepTicket + +export type TourActivatorTicket = RegistryTicket & { + element: MaybeElementRef + padding?: number +} + +export interface TourContext { + steps: StepContext + activators: RegistryContext + form: FormContext + + isActive: Readonly> + isComplete: Readonly> + isFirst: Readonly> + isLast: Readonly> + canGoBack: Readonly> + canGoNext: Readonly> + isReady: Readonly> + selectedId: StepContext['selectedId'] + total: number + + start: (options?: { stepId?: ID }) => void + ready: () => void + stop: () => void + complete: () => void + reset: () => void + next: () => Promise + prev: () => void + step: (index: number) => Promise +} + +// ---- Factory ---- + +export function createTour (): TourContext { + const steps = createStep({ events: true, reactive: true }) + const activators = createRegistry() + const form = createForm() + + const isActive = shallowRef(false) + const isComplete = shallowRef(false) + const isReady = shallowRef(true) + + const isFirst = toRef(() => steps.selectedIndex.value === 0) + const isLast = toRef(() => steps.selectedIndex.value === steps.size - 1) + const canGoBack = toRef(() => isReady.value && steps.selectedIndex.value > 0) + const canGoNext = toRef(() => isReady.value && steps.selectedIndex.value < steps.size - 1) + + function start (options?: { stepId?: ID }) { + isActive.value = true + isComplete.value = false + + if (options?.stepId && steps.has(options.stepId)) { + steps.select(options.stepId) + } else { + steps.first() + } + + syncReady() + } + + function ready () { + isReady.value = true + } + + function stop () { + isActive.value = false + isReady.value = true + } + + function complete () { + isActive.value = false + isComplete.value = true + isReady.value = true + } + + function reset () { + form.reset() + steps.clear() + isActive.value = false + isComplete.value = false + isReady.value = true + } + + function syncReady () { + const current = steps.selectedItem.value + if (!current) { + isReady.value = true + return + } + isReady.value = current.type !== 'wait' + } + + async function next () { + if (!isReady.value) return + + const current = steps.selectedItem.value + if (!current) return + + if (form.has(current.id)) { + const isValid = await form.submit(current.id) + if (!isValid) return + } + + steps.next() + syncReady() + } + + function prev () { + if (!isReady.value) return + + const current = steps.selectedItem.value + if (!current) return + + steps.prev() + syncReady() + } + + async function step (index: number) { + if (!isReady.value) return + + const current = steps.selectedItem.value + const id = steps.lookup(index - 1) + if (isUndefined(id)) return + + if (current && current.id !== id && form.has(current.id)) { + const isValid = await form.submit(current.id) + if (!isValid) return + } + + steps.select(id) + syncReady() + } + + return { + steps, + activators, + form, + + isActive: readonly(isActive), + isComplete: readonly(isComplete), + isFirst, + isLast, + canGoBack, + canGoNext, + isReady: readonly(isReady), + selectedId: steps.selectedId, + get total () { + return steps.size + }, + + start, + ready, + stop, + complete, + reset, + next, + prev, + step, + } +} + +// ---- Plugin ---- + +export const [createTourContext, createTourPlugin, useTour] = createPluginContext< + { namespace?: string }, + TourContext +>( + 'v0:tour', + () => createTour(), +) diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index d5cf9b424..f504ecb41 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -330,6 +330,11 @@ "level": "preview", "since": "0.1.8", "category": "system" + }, + "useTour": { + "level": "preview", + "since": "0.2.0", + "category": "plugins" } }, "components": { @@ -561,7 +566,8 @@ "category": "data" }, "Tour": { - "level": "draft", + "level": "preview", + "since": null, "category": "disclosure" } },