npm package: @korsolutions/guidon
Description: A cross-platform walkthrough/onboarding component library for React Native with web support. Features spotlight effects, customizable tooltips, and flexible persistence options. 
Author: Christian Jimenez
Latest version: 1.1.2

# Guidon

A lightweight, cross-platform walkthrough/onboarding library for React Native with web support. Features spotlight effects, draggable tooltips, and flexible persistence options.

## Features

- **Zero state management dependencies** - Built with React's `useSyncExternalStore`
- **New Architecture ready** - Full support for React Native's Fabric renderer
- **Cross-platform** - iOS, Android, and Web (React Native Web)
- **Draggable tooltips** - Users can reposition tooltips by dragging
- **Multi-screen tours** - Guides pause when targets aren't mounted, resume when user navigates
- **Floating steps** - Create announcement/welcome screens without targeting elements
- **Flexible persistence** - Save progress to localStorage, AsyncStorage, or your API
- **Customizable** - Full theming, custom tooltip renderers, and per-step callbacks

## Installation

```bash
yarn add @korsolutions/guidon
# or
npm install @korsolutions/guidon
```

### Peer Dependencies

Make sure you have these dependencies installed:

```bash
yarn add react react-native react-native-reanimated react-native-safe-area-context react-native-svg
```

## Quick Start

### 1. Configure Tours (at app root)

```tsx
import { Guidon, GuidonProvider, GuidonToursConfig } from '@korsolutions/guidon';

// Configure all tours ONCE outside React (stable reference)
const config: GuidonToursConfig  = {
  // Global theme applies to all tours
  theme: {
    primaryColor: '#007AFF',
    backdropOpacity: 0.8,
  },
  animationDuration: 300,

  // Define your tours
  tours: {
    explore: {
      id: 'explore',
      steps: [
        { id: 'search', targetId: 'search-btn', title: 'Search', description: 'Find what you need' },
        { id: 'filter', targetId: 'filter-btn', title: 'Filter', description: 'Narrow results' },
      ],
      onComplete: () => console.log('Explore tour done'),
    },
    profile: {
      id: 'profile',
      steps: [
        { id: 'edit', targetId: 'edit-btn', title: 'Edit Profile', description: 'Update your info' },
      ],
      // Override theme just for this tour
      theme: { primaryColor: '#FF6B6B' },
    },
  },
} satisfies GuidonToursConfig;

Guidon.configureTours(config);

// In your app root
export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <GuidonProvider>
        <Navigation />
      </GuidonProvider>
    </QueryClientProvider>
  );
}
```

### 2. Start Tours from Screens

```tsx
import { useEffect } from 'react';
import { Guidon, useGuidon } from '@korsolutions/guidon';

function ExploreScreen() {
  const { register } = useGuidon();

  useEffect(() => {
    Guidon.start('explore');  // Start this screen's tour
    return () => Guidon.stop(); // Stop when leaving screen
  }, []);

  return (
    <View>
      <Button ref={register('search-btn')} title="Search" />
      <Button ref={register('filter-btn')} title="Filter" />
    </View>
  );
}
```

### Alternative: GuidonTarget Component

```tsx
import { GuidonTarget } from '@korsolutions/guidon';

function ExploreScreen() {
  return (
    <View>
      <GuidonTarget targetId="search-btn">
        <Button title="Search" />
      </GuidonTarget>
      <GuidonTarget targetId="filter-btn">
        <Button title="Filter" />
      </GuidonTarget>
    </View>
  );
}
```

## Floating Steps (No Target)

Create steps without a target element for welcome screens or announcements:

```tsx
const config = {
  tours: {
    welcome: {
      id: 'welcome',
      steps: [
        {
          id: 'intro',
          // No targetId - this is a floating step!
          title: 'Welcome to Our App!',
          description: 'Let us show you around.',
          floatingPosition: 'center', // 'center' | 'top' | 'bottom' | 'top-left' | etc.
        },
        {
          id: 'feature-1',
          targetId: 'some-button',
          title: 'First Feature',
          description: 'This highlights an element.',
        },
      ],
    },
  },
} satisfies GuidonToursConfig;
```

## Draggable Tooltips

Enable draggable tooltips so users can reposition them if they're blocking content:

```tsx
const config = {
  theme: {
    tooltipDraggable: true,
    dragHintText: 'Drag to reposition', // Optional custom hint
  },
  tours: {
    // ...
  },
} satisfies GuidonToursConfig;
```

When enabled:
- A hint ("Drag to move") appears on the first step
- Users can drag the tooltip to any position on screen
- Position resets when advancing to the next step
- Works on both mobile (touch) and web (mouse)

## Multi-Screen Tours

Tours can span multiple screens. When a step targets an element that isn't mounted yet, the guide shows a waiting state until the user navigates to the correct screen:

```tsx
const config = {
  // Auto-skip after 10 seconds if target doesn't appear
  defaultWaitTimeout: 10000,
  tours: {
    onboarding: {
      id: 'onboarding',
      steps: [
        { id: 'home', targetId: 'home-button', title: 'Welcome', description: 'Start here' },
        {
          id: 'settings',
          targetId: 'settings-toggle', // On Settings screen
          title: 'Settings',
          description: 'Configure your preferences',
          waitingMessage: 'Navigate to Settings to continue...', // Custom waiting text
        },
      ],
    },
  },
} satisfies GuidonToursConfig;
```

The guide will:
1. Show a loading indicator when the target element isn't found
2. Display the `waitingMessage` (or default "Navigate to continue...")
3. Automatically resume when the user navigates and the target mounts
4. Optionally auto-skip after `defaultWaitTimeout` milliseconds

## Persistence

The library supports flexible persistence through adapters. You can save guidon progress to local storage, AsyncStorage, or your backend API.

### Using Built-in Adapters

```tsx
import {
  GuidonProvider,
  createLocalStorageAdapter,
  createAsyncStorageAdapter,
} from '@korsolutions/guidon';
import AsyncStorage from '@react-native-async-storage/async-storage';

// For web (localStorage)
const webAdapter = createLocalStorageAdapter();

// For React Native (AsyncStorage)
const nativeAdapter = createAsyncStorageAdapter(AsyncStorage);

function App() {
  return (
    <GuidonProvider persistenceAdapter={nativeAdapter}>
      <YourApp />
    </GuidonProvider>
  );
}
```

### Creating a Custom API Adapter

```tsx
import { createApiAdapter } from '@korsolutions/guidon';

const apiAdapter = createApiAdapter({
  loadProgress: async (guidonId) => {
    const response = await fetch(`/api/guidon/${guidonId}`);
    if (!response.ok) return null;
    return response.json();
  },
  saveProgress: async (progress) => {
    await fetch(`/api/guidon/${progress.guidonId}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(progress),
    });
  },
  clearProgress: async (guidonId) => {
    await fetch(`/api/guidon/${guidonId}`, {
      method: 'DELETE',
    });
  },
});
```

### Combining Multiple Adapters

```tsx
import { createCompositeAdapter, createLocalStorageAdapter } from '@korsolutions/guidon';

// Save to both local storage and API
const compositeAdapter = createCompositeAdapter([
  createLocalStorageAdapter(),
  apiAdapter,
]);
```

## API Reference

### Guidon API (Imperative)

Use outside React components or for programmatic control:

```tsx
import { Guidon } from '@korsolutions/guidon';

// Configuration
Guidon.configureTours(config);   // Configure all tours

// Control
Guidon.start('explore');         // Start a specific tour by ID
Guidon.stop();                   // Stop current tour without completing
Guidon.next();                   // Go to next step
Guidon.previous();               // Go to previous step
Guidon.goToStep(2);              // Jump to specific step
Guidon.skip();                   // Skip the tour
Guidon.complete();               // Complete the tour

// State queries
Guidon.isActive();               // Check if a tour is running
Guidon.isCompleted();            // Check if tour completed
Guidon.getActiveTourId();        // Get current tour ID
Guidon.getTours();               // Get all configured tours
Guidon.getCurrentStepIndex();    // Get current step index
Guidon.getCurrentStep();         // Get current step object
Guidon.getSteps();               // Get all steps of active tour

// Reset
Guidon.reset();                  // Reset to initial state
```

### GuidonProvider Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `autoStart` | `string` | - | Tour ID to auto-start when mounted |
| `shouldStart` | `() => boolean \| Promise<boolean>` | - | Custom condition for starting |
| `persistenceAdapter` | `GuidonPersistenceAdapter` | - | Adapter for saving progress |
| `portalComponent` | `React.ComponentType` | - | Custom portal for overlay rendering |
| `renderTooltip` | `(props) => ReactNode` | - | Custom tooltip renderer |
| `tooltipLabels` | `object` | - | Customize button labels |
| `onBackdropPress` | `() => void` | - | Called when backdrop is pressed |

### GuidonToursConfig

```tsx
import { GuidonToursConfig } from '@korsolutions/guidon';

const config = {
  // Global settings apply to all tours
  theme: {
    primaryColor: '#007AFF',
    backdropOpacity: 0.8,
  },
  animationDuration: 300,

  // Define tours
  tours: {
    explore: {
      id: 'explore',
      steps: [{ id: 's1', title: 'Welcome', description: '...' }],
      onComplete: () => console.log('Done'),
    },
    profile: {
      id: 'profile',
      steps: [...],
      theme: { primaryColor: '#FF0000' }, // Override for this tour only
    },
  },
} satisfies GuidonToursConfig;
```

### GuidonStep

```tsx
interface GuidonStep {
  id: string;                    // Unique step identifier
  targetId?: string;             // ID of target element (optional for floating steps)
  title: string;                 // Tooltip title
  description: string;           // Tooltip description
  tooltipPosition?: 'top' | 'bottom' | 'left' | 'right' | 'auto';
  floatingPosition?: 'center' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
  customContent?: ReactNode;     // Additional content in tooltip
  onStepEnter?: () => void;      // Called when step becomes active
  onStepExit?: () => void;       // Called when leaving step
  waitingMessage?: string;       // Message while waiting for target to mount
  waitTimeout?: number;          // Auto-skip timeout (ms) if target doesn't mount
}
```

### GuidonTheme

```tsx
interface GuidonTheme {
  backdropColor?: string;         // Overlay color (default: '#000000')
  backdropOpacity?: number;       // Overlay opacity (default: 0.5)
  tooltipBackgroundColor?: string;
  tooltipBorderColor?: string;
  tooltipBorderRadius?: number;
  titleColor?: string;
  descriptionColor?: string;
  primaryColor?: string;          // Button color
  mutedColor?: string;            // Secondary text color
  spotlightBorderRadius?: number; // Spotlight cutout radius
  spotlightPadding?: number;      // Padding around spotlight
  tooltipDraggable?: boolean;     // Enable draggable tooltips (default: false)
  dragHintText?: string;          // Hint text shown on first step (default: 'Drag to move')
}
```

## Controlling the Guidon

### Using the Context Hook

```tsx
import { useGuidonContext } from '@korsolutions/guidon';

function ReplayButton() {
  const { replay, isCompleted, start } = useGuidonContext();

  return (
    <>
      {isCompleted && (
        <Button onPress={() => replay('explore')}>Replay Tutorial</Button>
      )}
      <Button onPress={() => start('explore')}>Start Explore Tour</Button>
    </>
  );
}
```

### Using Hook Selectors

```tsx
import {
  useGuidonActive,
  useGuidonStep,
  useGuidonProgress,
} from '@korsolutions/guidon';

function GuidonStatus() {
  const isActive = useGuidonActive();
  const currentStep = useGuidonStep();
  const { currentStep: stepNum, totalSteps, percentage } = useGuidonProgress();

  if (!isActive) return null;

  return (
    <Text>
      Step {stepNum} of {totalSteps} ({percentage}%)
    </Text>
  );
}
```

## Custom Tooltip Rendering

```tsx
<GuidonProvider
  renderTooltip={({ step, currentIndex, totalSteps, onNext, onPrevious, onSkip }) => (
    <View style={styles.customTooltip}>
      <Text style={styles.title}>{step.title}</Text>
      <Text>{step.description}</Text>
      <View style={styles.buttons}>
        <Button onPress={onSkip}>Skip</Button>
        {currentIndex > 0 && <Button onPress={onPrevious}>Back</Button>}
        <Button onPress={onNext}>
          {currentIndex === totalSteps - 1 ? 'Done' : 'Next'}
        </Button>
      </View>
    </View>
  )}
>
```

## Conditional Starting

```tsx
<GuidonProvider
  autoStart="welcome"
  shouldStart={async () => {
    // Check if user is new
    const user = await getUser();
    return user.isFirstLogin;
  }}
>
```

## Auto-Scroll Behavior

### Web
On web, targets automatically scroll into view using the native `scrollIntoView()` API. You can customize this via theme:

```tsx
const config = {
  theme: {
    scrollOptions: {
      behavior: 'smooth',  // 'smooth' | 'instant'
      block: 'center',     // 'start' | 'center' | 'end' | 'nearest'
      inline: 'nearest',   // 'start' | 'center' | 'end' | 'nearest'
    },
  },
  tours: { ... },
} satisfies GuidonToursConfig;
```

### Mobile (React Native)
For mobile, use the `useGuidonScrollContainer` hook to enable auto-scrolling:

```tsx
import { useRef } from 'react';
import { ScrollView } from 'react-native';
import { useGuidon, useGuidonScrollContainer } from '@korsolutions/guidon';

function MyScreen() {
  const scrollRef = useRef<ScrollView>(null);
  const { register } = useGuidon();

  // Enable auto-scroll when steps change
  useGuidonScrollContainer(scrollRef);

  return (
    <ScrollView ref={scrollRef}>
      <View ref={register('step-1')}>First target</View>
      <View ref={register('step-2')}>Second target</View>
    </ScrollView>
  );
}
```

Options:
```tsx
useGuidonScrollContainer(scrollRef, {
  padding: 150,    // Distance from top of viewport (default: 100)
  enabled: false,  // Temporarily disable (default: true)
});
```

### Disabling Scroll Per Step
Disable auto-scroll for individual steps:

```tsx
{
  id: 'modal-step',
  targetId: 'modal-button',
  title: 'Modal Content',
  description: 'This element is already visible',
  scrollIntoView: false,  // Don't scroll for this step
}
```

## Platform Support

- iOS (New Architecture / Fabric supported)
- Android (New Architecture / Fabric supported)
- Web (React Native Web)

## Requirements

- React Native 0.71+ (for New Architecture support)
- React 18+ (for `useSyncExternalStore`)

## License

MIT