diff --git a/.claude/rules/docs.md b/.claude/rules/docs.md index fbd2fb45d..5058e296c 100644 --- a/.claude/rules/docs.md +++ b/.claude/rules/docs.md @@ -88,7 +88,7 @@ Adapters let you swap the underlying implementation without changing your applic 2. `` — renders badges from frontmatter 3. `` — optional, for native API features 4. **Usage** — brief intro + code fence (not a live example) -5. **Anatomy** — Vue template tree in `` ```vue playground collapse `` `` code fence +5. **Anatomy** — Vue template tree in `` ```vue Anatomy playground `` `` code fence. **Show only component hierarchy** — no props, no slot bindings, no text content, no directives (`v-if`, `v-slot`, `@click`). Use self-closing tags (`` not `Title`) 6. **Architecture** — optional Mermaid diagram 7. **Examples** — `::: example` blocks, each with 2+ files 8. **Recipes** — code fences or single-file `::: example` blocks 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 5503c83a5..df660768a 100644 --- a/apps/docs/src/pages/components/index.md +++ b/apps/docs/src/pages/components/index.md @@ -87,5 +87,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 0a7d4279c..1bf6ba99e 100644 --- a/apps/docs/src/pages/composables/index.md +++ b/apps/docs/src/pages/composables/index.md @@ -185,6 +185,7 @@ Application-level features installable via Vue plugins. | [useRules](/composables/plugins/use-rules) | Validation rule aliases with locale-aware messages | | [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 632046d0c..4646f20e9 100644 --- a/apps/docs/src/typed-router.d.ts +++ b/apps/docs/src/typed-router.d.ts @@ -104,6 +104,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', @@ -447,6 +454,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', @@ -995,6 +1009,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' @@ -1289,6 +1309,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 6189b5e11..3ed9e18fb 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -124,6 +124,7 @@ import { ... } from '@vuetify/v0/date' // Date adapter and utilities | **ExpansionPanel** | Accordion-style collapsible panels | | **Popover** | CSS anchor-positioned popup content | | **Tabs** | Tab panel navigation with keyboard support and lazy content rendering | +| **Tour** | Guided tour with step navigation, validation gates, and keyboard support | | **Treeview** | Hierarchical tree with nested selection and expand/collapse | #### Semantic @@ -230,6 +231,7 @@ Plugin-capable composables following the trinity pattern: - **`useStack`** - Overlay z-index stacking with automatic scrim coordination - **`useStorage`** - Storage adapter (localStorage/sessionStorage/memory) - **`useTheme`** - Theme management with CSS variable injection +- **`useTour`** - 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 431c9b8d9..83e37802a 100644 --- a/packages/0/src/components/index.ts +++ b/packages/0/src/components/index.ts @@ -27,4 +27,5 @@ export * from './Single' export * from './Step' export * from './Tabs' export * from './Theme' +export * from './Tour' export * from './Treeview' diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts index 5eef814b0..bcdd92bf7 100644 --- a/packages/0/src/composables/index.ts +++ b/packages/0/src/composables/index.ts @@ -52,6 +52,7 @@ export * from './useTimer' export * from './createTimeline' export * from './createValidation' export * from './useToggleScope' +export * from './useTour' export * from './createTokens' export * from './createVirtual' 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 6f2ce79d3..7f7ca0a30 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -292,6 +292,11 @@ "level": "preview", "since": "0.1.8", "category": "system" + }, + "useTour": { + "level": "preview", + "since": "0.2.0", + "category": "plugins" } }, "components": { @@ -346,7 +351,7 @@ }, "Toggle": { "level": "draft", - "category": "providers" + "category": "actions" }, "Button": { "level": "preview", @@ -447,10 +452,6 @@ "since": "0.1.9", "category": "semantic" }, - "Toast": { - "level": "draft", - "category": "disclosure" - }, "Tooltip": { "level": "draft", "category": "disclosure" @@ -509,8 +510,9 @@ "category": "data" }, "Tour": { - "level": "draft", - "category": "specialty" + "level": "preview", + "since": "0.2.0", + "category": "disclosure" } }, "utilities": {