diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d3bd82b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: galangel +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: galangel +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 596ed98..0441788 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -21,15 +21,7 @@ const preview: Preview = { options: { // Sort stories with Welcome first storySort: { - order: [ - 'Welcome', - 'Tooltip', - 'Flows', - 'Keyboard Shortcuts', - 'Transitions', - 'Examples', - '*', - ], + order: ['Welcome', 'The Tooltip', 'The Tourtip', 'The TipAdvisor', 'Tooltip', '*'], }, }, }, diff --git a/README.md b/README.md index a44d55c..8a59a30 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,23 @@ # React Tip Magic ✨ -A sophisticated, elegant, and performant tooltip library for React with an intelligent floating helper system. +A thoughtfully crafted tooltip library for Reactβ€”simple to use, delightful to experience. ![npm version](https://img.shields.io/npm/v/@galangel/react-tip-magic) ![bundle size](https://img.shields.io/bundlephobia/minzip/@galangel/react-tip-magic) ![license](https://img.shields.io/npm/l/@galangel/react-tip-magic) -## Features +## Why React Tip Magic? -- 🎯 **Zero-config tooltips** - Just add `data-tip="Hello"` to any element -- πŸš€ **High performance** - Single global instance, minimal re-renders -- 🎨 **Smooth transitions** - Tooltips gracefully move between elements -- πŸ€– **Intelligent Helper** - Floating assistant with multiple states and actions -- πŸ“± **Accessible** - Full keyboard navigation and screen reader support -- 🎭 **Customizable** - Extensive theming and configuration options -- πŸ“¦ **Lightweight** - Tree-shakeable, minimal dependencies +Tooltips seem simple, but getting them right takes care. They should appear when needed, stay out of the way when not, and feel natural as users navigate your interface. -## Quick Start +React Tip Magic handles the details so you don't have toβ€”positioning, transitions, accessibility, keyboard supportβ€”all with a clean, declarative API. -### Installation +## Quick Start ```bash npm install @galangel/react-tip-magic ``` -### Basic Setup - ```tsx import { TipMagicProvider } from '@galangel/react-tip-magic'; import '@galangel/react-tip-magic/styles.css'; @@ -39,185 +31,200 @@ function App() { } ``` -### Simple Tooltip +That's it. Now add `data-tip` to any element: ```tsx - + ``` -### Tooltip with Keyboard Shortcut +--- -```tsx - -``` +## Tooltips + +The core of React Tip Magic. One tooltip instance that gracefully moves between elements, providing a smooth, cohesive experience. -### Advanced Options +### Basic Usage ```tsx -

- Hover me -

+ + ``` -### Transition Behavior +### With Keyboard Shortcuts -Control how tooltips transition when moving between elements: +Display shortcuts alongside your tooltips using the `data-tip-shortcut` attribute: ```tsx -{/* Smooth move transition (default) */} - - - -{/* Jump transition (fade out/in) */} - + + + ``` -### Tooltip Groups - -Use `data-tip-group` to control move transitions between grouped elements. Tooltips will only smoothly move between elements in the **same group**: +### Positioning & Behavior ```tsx -{/* Group A - tooltips move smoothly within this group */} - - - -{/* Group B - tooltips move smoothly within this group */} - - +{ + /* Position control */ +} +; -{/* Moving from Group A to Group B will jump, not move */} +{ + /* Smooth transitions between grouped elements */ +} +; + +{ + /* Interactive tooltips that stay visible on hover */ +} + + More info +; ``` -**Group transition rules:** +--- -- **Same group** β†’ Smooth move transition -- **Different groups** β†’ Jump transition (tooltip appears at new position) -- **Grouped to ungrouped** (or vice versa) β†’ Smooth move transition +## Guided Tours -## Helper System - -The Helper is an optional floating element that provides contextual information and actions. - -### Onboarding Flow Example +Walk users through your interface with step-by-step tours. Helpful for onboarding, feature introductions, or contextual guidance. ```tsx -import { useTipMagic } from '@galangel/react-tip-magic'; +import { useTour } from '@galangel/react-tip-magic'; -function OnboardingFlow() { - const { helper } = useTipMagic(); - - useEffect(() => { - helper.startFlow([ - { - targetId: 'welcome-1', - message: 'Welcome! This is your dashboard.', - actions: [{ label: 'Next', action: 'next' }], - }, - { - targetId: 'welcome-2', - message: 'Click here to create your first project.', - actions: [{ label: 'Got it!', action: 'complete' }], - }, - ]); - }, []); +function App() { + const tour = useTour({ + steps: [ + { target: 'dashboard', title: 'Welcome', message: 'This is your dashboard.' }, + { target: 'create-btn', title: 'Create', message: 'Click here to get started.' }, + ], + }); return (
- - + +
); } ``` -### Helper States +Tours include navigation controls, progress indicators, keyboard support, and backdrop highlightingβ€”all configurable to fit your needs. + +--- + +## Tip Advisor + +A keyboard shortcut discovery menu. Press a key (F1 by default) to reveal all available shortcuts in your interface, with fuzzy search to quickly find what you need. ```tsx -// Show thinking state -helper.setState('thinking'); - -// Show working state with message -helper.show({ - state: 'working', - message: 'Processing your request...', -}); - -// Show call to action -helper.show({ - state: 'cta', - message: 'Ready to continue?', - actions: [ - { label: 'Yes', onClick: () => {} }, - { label: 'No', onClick: () => {} }, - ], -}); +import { TipAdvisor } from '@galangel/react-tip-magic'; + +function App() { + return ( + +
+ + + +
+ + {/* Press F1 to open the advisor */} + +
+ ); +} ``` +Features: + +- Fuzzy search with highlighted matches +- Keyboard navigation (arrow keys + Enter) +- Hover to preview tooltip locations +- Click to trigger the associated element + +--- + +## Data Attributes + +| Attribute | Description | Example | +| ---------------------- | ----------------------------------- | ----------------------------- | +| `data-tip` | Tooltip content | `data-tip="Hello"` | +| `data-tip-shortcut` | Keyboard shortcut badge | `data-tip-shortcut="⌘S"` | +| `data-tip-id` | Element identifier for tours | `data-tip-id="welcome"` | +| `data-tip-placement` | Position (top, bottom, left, right) | `data-tip-placement="bottom"` | +| `data-tip-delay` | Show delay in ms | `data-tip-delay="500"` | +| `data-tip-hide-delay` | Hide delay in ms | `data-tip-hide-delay="100"` | +| `data-tip-disabled` | Disable tooltip | `data-tip-disabled` | +| `data-tip-html` | Parse content as HTML | `data-tip-html` | +| `data-tip-interactive` | Keep tooltip on hover | `data-tip-interactive` | +| `data-tip-move` | Smooth move transition | `data-tip-move` | +| `data-tip-group` | Group for transitions | `data-tip-group="nav"` | +| `data-tip-no-arrow` | Hide tooltip arrow | `data-tip-no-arrow` | + +--- + ## Programmatic API -Use the `useTipMagic` hook for full programmatic control: +For more control, use the `useTipMagic` hook: ```tsx import { useTipMagic } from '@galangel/react-tip-magic'; function MyComponent() { - const { tooltip, helper, config } = useTipMagic(); - - // Show tooltip programmatically - tooltip.show('#my-element', 'Custom content'); + const { tooltip } = useTipMagic(); - // Hide tooltip - tooltip.hide(); + const handleClick = () => { + tooltip.show('#my-element', 'Dynamic content'); + }; - // Update content dynamically - tooltip.updateContent('New content'); - - return
Hover me
; + return ( + + ); } ``` -## Data Attributes +--- + +## Built With + +- **React 18+** with modern hooks +- **TypeScript** for type safety +- **Floating UI** for positioning +- **Storybook** for documentation -| Attribute | Description | Example | -| ------------------------- | ----------------------------------------- | ----------------------------- | -| `data-tip` | Tooltip content | `data-tip="Hello"` | -| `data-tip-id` | Element identifier for flows | `data-tip-id="welcome"` | -| `data-tip-placement` | Position (top, bottom, left, right, etc.) | `data-tip-placement="bottom"` | -| `data-tip-delay` | Show delay in ms | `data-tip-delay="500"` | -| `data-tip-hide-delay` | Hide delay in ms | `data-tip-hide-delay="100"` | -| `data-tip-disabled` | Disable tooltip | `data-tip-disabled` | -| `data-tip-ellipsis` | Enable text truncation | `data-tip-ellipsis` | -| `data-tip-max-lines` | Max lines before truncation | `data-tip-max-lines="2"` | -| `data-tip-word-wrap` | Enable word wrapping | `data-tip-word-wrap` | -| `data-tip-max-width` | Maximum width in pixels | `data-tip-max-width="300"` | -| `data-tip-html` | Parse content as HTML | `data-tip-html` | -| `data-tip-interactive` | Keep tooltip on hover | `data-tip-interactive` | -| `data-tip-move` | Smooth move transition | `data-tip-move` | -| `data-tip-jump` | Jump transition | `data-tip-jump` | -| `data-tip-group` | Group identifier for transitions | `data-tip-group="nav"` | -| `data-tip-no-arrow` | Hide tooltip arrow | `data-tip-no-arrow` | -| `data-tip-always-visible` | Keep element visible during tour focus | `data-tip-always-visible` | - -## Documentation - -- [Architecture](./docs/ARCHITECTURE.md) - Technical design and decisions -- [API Reference](./docs/API.md) - Complete API documentation -- [Roadmap](./docs/ROADMAP.md) - Planned features and milestones - -## Tech Stack - -- **React 18+** - Modern React with hooks -- **TypeScript** - Full type safety -- **Floating UI** - Robust positioning engine -- **Vite** - Fast development and building -- **Vitest** - Unit and integration testing -- **Storybook** - Component documentation and showcase +--- ## License Apache-2.0 + +--- + +

+ Made with care for the React community +

+ +

+ Buy Me A Coffee +

diff --git a/package-lock.json b/package-lock.json index 419c849..32757f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@galangel/react-tip-magic", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@galangel/react-tip-magic", - "version": "1.0.2", + "version": "1.0.3", "license": "Apache-2.0", "dependencies": { - "@floating-ui/react": "^0.27.16" + "@floating-ui/react": "^0.27.16", + "fuzzysort": "^3.1.0" }, "devDependencies": { "@eslint/js": "^9.28.0", @@ -4476,6 +4477,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", diff --git a/package.json b/package.json index d56567a..655d5de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@galangel/react-tip-magic", - "version": "1.0.3", + "version": "1.1.0", "description": "A sophisticated, elegant, and performant tooltip library for React with an intelligent floating helper system.", "type": "module", "main": "./dist/index.cjs", @@ -60,7 +60,8 @@ "react-dom": ">=18.0.0" }, "dependencies": { - "@floating-ui/react": "^0.27.16" + "@floating-ui/react": "^0.27.16", + "fuzzysort": "^3.1.0" }, "devDependencies": { "@eslint/js": "^9.28.0", diff --git a/src/KeyboardShortcuts.mdx b/src/KeyboardShortcuts.mdx index 3cdef5c..0a57644 100644 --- a/src/KeyboardShortcuts.mdx +++ b/src/KeyboardShortcuts.mdx @@ -8,125 +8,102 @@ Display keyboard shortcuts alongside tooltip content for better discoverability ## Overview -React Tip Magic automatically parses tooltip content to extract and style keyboard shortcuts. When a separator character is found in the tooltip content, everything after it is displayed as a styled keyboard shortcut badge. +React Tip Magic displays keyboard shortcuts as styled badges next to tooltip text. Use the `data-tip-shortcut` attribute to specify the shortcut. **Key features:** -- Automatic parsing β€” no extra markup needed -- Customizable separator (default: `;`) +- Simple attribute-based API - Styled `` element for the shortcut - CSS custom properties for theming -- Can be disabled globally or enabled per-element +- Can be disabled globally +- Works with TipAdvisor for shortcut discovery --- ## Quick Start -Use a semicolon (`;`) to separate the main text from the keyboard shortcut: +Use `data-tip-shortcut` to add a keyboard shortcut badge: ```tsx - - - + + + ``` -The library automatically: +The library displays: -1. Splits the content at the separator -2. Renders the main text normally -3. Displays the shortcut in a styled `` badge +1. The main text from `data-tip` +2. A styled `` badge with the shortcut --- ## API Reference -### Provider Options +### Data Attributes - - - + - - - + - - - +
OptionTypeDefaultAttribute Description
- contentSeparator - string - ';' + data-tip Character(s) used to separate main text from keyboard shortcutTooltip content (main text)
- enableShortcutStyle - boolean - true - - Whether to render shortcuts with styled <kbd> badges + data-tip-shortcut Keyboard shortcut to display as a styled badge
```tsx - - - + + ``` -### Data Attributes +### Provider Options - + + + + - - -
AttributeOptionTypeDefault Description
- data-tip + enableShortcutStyle boolean - Tooltip content. Format: "Main text; Shortcut" + true
- data-tip-separator + Whether to render shortcuts with styled <kbd> badges Override the separator for this element only
```tsx -{ - /* Uses custom separator for this element only */ -} -; - -{ - /* Still uses default semicolon separator */ -} -; + + + ``` ### CSS Classes @@ -314,19 +291,17 @@ Common keyboard symbols for use in shortcuts: ## Examples & Patterns -The following are implementation patterns showing how to use keyboard shortcut tooltips effectively. - ### Pattern: Text Editor Toolbar ```tsx
- - -
@@ -336,13 +311,13 @@ The following are implementation patterns showing how to use keyboard shortcut t ```tsx @@ -356,33 +331,29 @@ Detect the platform and show appropriate modifier keys: const isMac = navigator.platform.includes('Mac'); const mod = isMac ? '⌘' : 'Ctrl+'; - - - + + + ``` -### Pattern: Ensuring Shortcuts Work +### Pattern: With TipAdvisor -Only display shortcuts that are actually implemented: +Combine shortcuts with TipAdvisor for discoverability: ```tsx -// Implement the shortcut handler -useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 's') { - e.preventDefault(); - handleSave(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); -}, []); - -// Now it's safe to advertise the shortcut -; + +
+ + +
+ + {/* Press F1 to see all shortcuts */} + +
``` --- @@ -394,11 +365,11 @@ useEffect(() => { Place shortcuts on interactive elements users will naturally hover over: ```tsx -// βœ… Good: Toolbar buttons users interact with - +// Good: Toolbar buttons users interact with + -// ❌ Avoid: Shortcut on non-interactive element -Some text +// Avoid: Shortcut on non-interactive element +Some text ``` ### Be Consistent @@ -406,15 +377,15 @@ Place shortcuts on interactive elements users will naturally hover over: Use the same format throughout your application: ```tsx -// βœ… Good: Consistent format - - - - -// ❌ Avoid: Inconsistent format - - - +// Good: Consistent format + + + + +// Avoid: Inconsistent format + + + ``` ### Don't Overload @@ -422,9 +393,9 @@ Use the same format throughout your application: Not every action needs a keyboard shortcut displayed: ```tsx -// βœ… Good: Common, frequently used shortcuts - - +// Good: Common, frequently used shortcuts + + // Consider: Less common actions might not need shortcut display @@ -441,7 +412,7 @@ Keyboard shortcuts improve accessibility when implemented correctly: Announce shortcuts to screen readers: ```tsx - ``` @@ -452,11 +423,12 @@ Announce shortcuts to screen readers: - Document all shortcuts in a help section or keyboard shortcuts modal - Avoid conflicts with browser or OS shortcuts - Test with assistive technologies +- Use TipAdvisor to make shortcuts discoverable --- ## Next Steps - See the **[Keyboard Shortcuts](?path=/story/tooltip-keyboard-shortcuts--with-shortcuts)** stories for live demos -- Explore **[Basic Tooltips](?path=/story/tooltip-basic--simple-tooltips)** for general tooltip usage +- Explore **[TipAdvisor](?path=/docs/tooltip-tipadvisor-documentation--docs)** for shortcut discoverability - Check the **[Getting Started](/docs/getting-started--docs)** guide for setup instructions diff --git a/src/TipAdvisor.mdx b/src/TipAdvisor.mdx new file mode 100644 index 0000000..c0edb45 --- /dev/null +++ b/src/TipAdvisor.mdx @@ -0,0 +1,1087 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# TipAdvisor + +A discoverable menu component with fuzzy search that displays all tooltips with keyboard shortcuts, allowing users to explore and trigger actions via keyboard or mouse. + +## Overview + +The TipAdvisor is an **optional, standalone component** that you add alongside `TipMagicProvider` when you want shortcut discoverability. It: + +- Collects all elements with `data-tip` and `data-tip-shortcut` attributes +- Supports **preset items** for command palette patterns (no DOM elements needed) +- Provides fuzzy search to filter shortcuts +- Highlights matching text in search results +- Shows tooltips on target elements when hovering menu items +- Triggers clicks on target elements when selecting menu items + +**Key features:** + +- Keyboard activated (F1 by default) +- Fuzzy search with highlighting (powered by fuzzysort) +- Hover preview of tooltips on target elements +- Click to trigger the target element +- Preset items for command palette patterns +- Arrow key navigation +- Optional backdrop overlay +- Fully themeable via CSS custom properties +- Zero overhead when not used + +--- + +## Quick Start + +```tsx +import { TipMagicProvider, TipAdvisor } from 'react-tip-magic'; + +function App() { + return ( + + {/* Your app with tooltip elements */} + + + + {/* Add TipAdvisor - optional! */} + + + ); +} +``` + +Press **F1** to open the TipAdvisor menu, then start typing to search. + +--- + +## API Reference + +### TipAdvisor Props + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeDefaultDescription
+ triggerKey + string + 'F1' + Key to toggle the TipAdvisor menu
+ position + TipAdvisorPosition + 'center' + + Menu position: 'center', 'top', 'bottom', 'top-left', 'top-right', 'bottom-left', + 'bottom-right' +
+ showCloseButton + boolean + true + Show close button in header
+ showBackdrop + boolean + true + Show backdrop overlay behind the menu
+ closeOnBackdropClick + boolean + true + Close menu when clicking the backdrop (only applies if showBackdrop is true)
+ searchPlaceholder + string + 'Search shortcuts...' + Placeholder text for the search input
+ selector + string | null + '[data-tip][data-tip-shortcut]' + + CSS selector for elements to include. Set to null to disable DOM scanning + (useful for preset-only menus) +
+ items + TipAdvisorPresetItem[]- + Preset items for command palette patterns. These are added alongside DOM-scanned items +
+ className + string-Custom CSS class for the menu container
+ itemClassName + string-Custom CSS class for menu items
+ onOpen + function-Callback when menu opens
+ onClose + function-Callback when menu closes
+ +### useTipAdvisor Hook + +For programmatic control of the TipAdvisor: + +```tsx +import { useTipAdvisor, TipAdvisor } from 'react-tip-magic'; + +function MyComponent() { + const advisor = useTipAdvisor(); + + return ( + <> + + + + + + + ); +} +``` + +**Returns:** + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyTypeDescription
+ ref + React.RefObjectRef to pass to TipAdvisor component
+ open + functionOpen the TipAdvisor menu
+ close + functionClose the TipAdvisor menu
+ toggle + functionToggle the TipAdvisor menu
+ +--- + +## Preset Items + +For command palette patterns or actions not tied to DOM elements, use the `items` prop with `TipAdvisorPresetItem` objects: + +### TipAdvisorPresetItem + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyTypeRequiredDescription
+ id + stringYesUnique identifier for the item
+ label + stringYesText displayed in the menu
+ shortcut + stringNoKeyboard shortcut to display (optional)
+ onSelect + functionYesCallback executed when item is selected
+ +### Command Palette Example + +```tsx +import { TipAdvisor, useTipAdvisor, TipAdvisorPresetItem } from 'react-tip-magic'; + +function CommandPalette() { + const advisor = useTipAdvisor(); + + const commands: TipAdvisorPresetItem[] = [ + { + id: 'new-file', + label: 'New File', + shortcut: '⌘N', + onSelect: () => console.log('Creating new file...'), + }, + { + id: 'open-file', + label: 'Open File', + shortcut: '⌘O', + onSelect: () => console.log('Opening file picker...'), + }, + { + id: 'save', + label: 'Save', + shortcut: '⌘S', + onSelect: () => console.log('Saving...'), + }, + { + id: 'settings', + label: 'Open Settings', + shortcut: '⌘,', + onSelect: () => console.log('Opening settings...'), + }, + ]; + + return ( + <> + + + + + + + ); +} +``` + +### Combining DOM Elements and Preset Items + +You can use both DOM-scanned elements and preset items together: + +```tsx + +``` + +--- + +## Fuzzy Search + +The TipAdvisor includes built-in fuzzy search powered by [fuzzysort](https://github.com/farzher/fuzzysort). When you start typing: + +- Results are filtered in real-time +- Matching characters are highlighted +- Both tooltip content and shortcuts are searchable +- Typos and partial matches work + +### Search Behavior + +- The search input is autofocused when the menu opens +- Type to filter the list instantly +- Matching parts are highlighted with `` elements +- Arrow keys navigate filtered results +- Enter selects the focused item + +--- + +## Keyboard Navigation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyAction
+ F1 + + Toggle the TipAdvisor menu (configurable via triggerKey) +
+ Escape + Close the menu
+ Arrow Down + Move to next item
+ Arrow Up + Move to previous item
+ Enter + Select focused item (triggers click on target element)
Any characterStart searching (input is autofocused)
+ +--- + +## CSS Custom Properties + +Override these CSS variables to customize the appearance: + +### Background Colors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
+ --tip-advisor-bg + + rgba(24, 24, 27, 0.95) + Menu background color
+ --tip-advisor-backdrop-bg + + rgba(0, 0, 0, 0.5) + Backdrop overlay color
+ --tip-advisor-item-bg + + transparent + Item background (default state)
+ --tip-advisor-item-hover-bg + + rgba(255, 255, 255, 0.1) + Item background on hover
+ --tip-advisor-item-focus-bg + + rgba(255, 255, 255, 0.15) + Item background on keyboard focus
+ +### Text Colors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
+ --tip-advisor-text + + #fafafa + Primary text color
+ --tip-advisor-text-secondary + + #a1a1aa + Secondary/muted text color
+ --tip-advisor-shortcut-text + + #e5e7eb + Shortcut badge text color
+ --tip-advisor-shortcut-bg + + rgba(255, 255, 255, 0.15) + Shortcut badge background
+ +### Search Input + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
+ --tip-advisor-search-bg + + rgba(255, 255, 255, 0.1) + Search input background
+ --tip-advisor-search-border + + rgba(255, 255, 255, 0.15) + Search input border color
+ --tip-advisor-search-focus-border + + rgba(255, 255, 255, 0.3) + Search input border when focused
+ --tip-advisor-search-text + + #fafafa + Search input text color
+ --tip-advisor-search-placeholder + + #71717a + Search input placeholder color
+ +### Search Highlight + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
+ --tip-advisor-highlight-bg + + rgba(250, 204, 21, 0.4) + Highlighted match background
+ --tip-advisor-highlight-text + + inherit + Highlighted match text color
+ +### Border & Shape + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
+ --tip-advisor-border + + 1px solid rgba(255, 255, 255, 0.1) + Menu border
+ --tip-advisor-border-radius + + 12px + Menu border radius
+ --tip-advisor-item-border-radius + + 8px + Item border radius
+ --tip-advisor-shadow + + 0 25px 50px -12px rgba(0, 0, 0, 0.5) + Menu shadow
+ +### Sizing + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
+ --tip-advisor-width + + 320px + Menu width
+ --tip-advisor-max-height + + 400px + Maximum menu height (scrollable)
+ --tip-advisor-padding + + 8px + Menu padding
+ --tip-advisor-item-padding + + 10px 12px + Item padding
+ --tip-advisor-gap + + 4px + Gap between items
+ --tip-advisor-animation-duration + + 150ms + Animation duration
+ +--- + +## CSS Classes Reference + +Override these classes for custom styling: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassDescription
+ .tip-advisor + Main container (fixed, full screen)
+ .tip-advisor--no-backdrop + Applied when showBackdrop is false
+ .tip-advisor-backdrop + Backdrop overlay
+ .tip-advisor-menu + Menu panel
+ .tip-advisor-header + Header section (contains search and close button)
+ .tip-advisor-search + Search input field
+ .tip-advisor-close + Close button
+ .tip-advisor-list + Items list container
+ .tip-advisor-item + Individual menu item
+ .tip-advisor-item--hover + Hovered item state
+ .tip-advisor-item--focus + Keyboard-focused item state
+ .tip-advisor-item-content + Item text content
+ .tip-advisor-item-shortcut + Shortcut badge
+ .tip-advisor-item-content mark + Highlighted search match in content
+ .tip-advisor-item-shortcut mark + Highlighted search match in shortcut
+ +### Position Variants + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassDescription
+ .tip-advisor-menu--center + Centered (default)
+ .tip-advisor-menu--top + Top center
+ .tip-advisor-menu--bottom + Bottom center
+ .tip-advisor-menu--top-left + Top left corner
+ .tip-advisor-menu--top-right + Top right corner
+ .tip-advisor-menu--bottom-left + Bottom left corner
+ .tip-advisor-menu--bottom-right + Bottom right corner
+ +--- + +## Examples & Patterns + +### Toolbar Shortcuts with Search + +```tsx +
+ + + +
+ + +``` + +### Without Backdrop + +```tsx + +``` + +### Custom Search Placeholder + +```tsx + +``` + +### Custom Highlight Styling + +```css +:root { + /* Make highlights more prominent with a blue color */ + --tip-advisor-highlight-bg: rgba(59, 130, 246, 0.5); + --tip-advisor-highlight-text: #ffffff; +} +``` + +### Command Palette Pattern + +Use preset items for a command palette that doesn't rely on DOM elements: + +```tsx +const advisor = useTipAdvisor(); + +const commands: TipAdvisorPresetItem[] = [ + { id: 'new', label: 'New File', shortcut: '⌘N', onSelect: () => createFile() }, + { id: 'open', label: 'Open File', shortcut: '⌘O', onSelect: () => openFile() }, + { id: 'save', label: 'Save', shortcut: '⌘S', onSelect: () => saveFile() }, +]; + + + + + + +``` + +### Custom Toggle Button + +```tsx +const advisor = useTipAdvisor(); + + + + + +``` + +--- + +## Accessibility + +The TipAdvisor is built with accessibility in mind: + +- **Role attributes**: Uses `role="dialog"`, `role="menu"`, `role="menuitem"` +- **ARIA labels**: `aria-modal="true"`, `aria-label` on dialog and search input +- **Keyboard navigation**: Full keyboard support +- **Focus management**: Search input is autofocused when menu opens +- **Reduced motion**: Respects `prefers-reduced-motion` + +--- + +## Best Practices + +### When to Use + +- Applications with many keyboard shortcuts +- Power user features +- Toolbar-heavy interfaces +- Documentation/help systems +- Command palette functionality + +### DOM Scanning vs Preset Items + +**Use DOM scanning (default)** when: + +- Shortcuts are tied to visible UI elements +- You want tooltips to appear on the target elements +- Clicking should trigger the actual element + +**Use preset items** when: + +- Building a command palette with actions not tied to UI +- Actions are contextual or dynamically generated +- You want full control over what happens when an item is selected + +**Combine both** for the best of both worlds: + +```tsx + +``` + +### What Elements to Include + +- Buttons with actions +- Navigation links +- Toolbar buttons +- Form controls with keyboard shortcuts + +### Don't Overload + +Not every action needs a keyboard shortcut. Focus on: + +- Frequently used actions +- Actions that benefit from quick access +- Standard shortcuts (copy, paste, save, etc.) + +--- + +## Next Steps + +- See the **[TipAdvisor Playground](?path=/story/the-tipadvisor--the-tip-advisor)** for interactive demo +- Explore **[Use Case Stories](?path=/story/tooltip-tipadvisor--toolbar-shortcuts)** for real-world examples +- Check the **[Keyboard Shortcuts Guide](?path=/docs/tooltip-keyboard-shortcuts-documentation--docs)** for tooltip shortcuts diff --git a/src/Welcome.mdx b/src/Welcome.mdx index 5bee3d2..5a84b61 100644 --- a/src/Welcome.mdx +++ b/src/Welcome.mdx @@ -6,62 +6,82 @@ import { Meta } from '@storybook/addon-docs/blocks';

✨ React Tip Magic

-

Sophisticated, elegant, and performant tooltips for React

+

Thoughtfully crafted tooltips for React

## Welcome -**React Tip Magic** is a modern tooltip library designed with both user experience and developer experience in mind. It provides a simple way to add beautiful, accessible tooltips to your React applications with minimal configuration. +React Tip Magic is a tooltip library built with care. It handles the small details that make interfaces feel polishedβ€”smooth transitions, proper positioning, keyboard accessibilityβ€”so you can focus on building your application. -### Key Features +Whether you need simple tooltips, guided tours, or a way for users to discover keyboard shortcuts, React Tip Magic provides clean, declarative APIs that stay out of your way. + +--- + +## Features
-

🎯 Zero Config

-

Just add data-tip="Hello" to any element. No wrapper components needed.

+

🎯 Tooltips

+

Add data-tip="Hello" to any element. One tooltip instance moves gracefully between targets.

-

πŸš€ High Performance

-

Single global instance with event delegation. Minimal re-renders and memory footprint.

+

⌨️ Keyboard Shortcuts

+

+ Display shortcuts with data-tip-shortcut="⌘S". Styled badges that feel native to + your UI. +

-

🎨 Smooth Transitions

-

Tooltips gracefully animate between elements when hovering from one to another.

+

πŸ—ΊοΈ Guided Tours

+

+ Walk users through your interface step by step. Great for onboarding or introducing new + features. +

-

πŸ€– Intelligent Helper

-

- Optional floating assistant with multiple states for onboarding, actions, and contextual help. -

+

πŸ” Tip Advisor

+

A searchable menu of all shortcuts in your app. Press F1 to discover what's available.

+
+ +
+

🎨 Smooth Transitions

+

Tooltips animate between elements. Group related items for cohesive movement.

-

πŸ“± Fully Accessible

+

πŸ“± Accessible

Keyboard navigation, screen reader support, and respects reduced motion preferences.

+
+

πŸš€ Performant

+

Single global instance with event delegation. Minimal re-renders and memory footprint.

+
+
-

🎭 Customizable

-

Extensive theming via CSS custom properties. Light, dark, and auto themes included.

+

🎭 Themeable

+

CSS custom properties for easy customization. Adapts to light, dark, or custom themes.

+--- + ## Quick Start ### Installation ```bash -npm install react-tip-magic +npm install @galangel/react-tip-magic ``` -### Basic Setup +### Setup ```tsx -import { TipMagicProvider } from 'react-tip-magic'; -import 'react-tip-magic/styles.css'; +import { TipMagicProvider } from '@galangel/react-tip-magic'; +import '@galangel/react-tip-magic/styles.css'; function App() { return ( @@ -72,47 +92,52 @@ function App() { } ``` -### Simple Tooltip +### Add Tooltips ```tsx - -``` +; -### With Keyboard Shortcuts - -```tsx - +{ + /* With keyboard shortcut */ +} +; ``` +--- + ## Architecture -React Tip Magic uses a **global singleton pattern** where: +React Tip Magic uses a global singleton pattern: + +1. Mount `` at your app's root +2. Add `data-tip` attributes to elements +3. Event delegation handles interactions +4. A single tooltip moves between targets -1. A single `` is mounted at your app's root -2. Elements opt-in via `data-tip` attributes -3. Event delegation handles all hover/focus interactions -4. A single tooltip instance moves between targets +This means: -This approach ensures: +- Minimal DOM nodes +- Smooth transitions between tooltips +- Works with dynamically added elements +- No wrapper components in your JSX -- βœ… Minimal DOM nodes -- βœ… Smooth transitions between tooltips -- βœ… Works with dynamically added elements -- βœ… No wrapper components polluting your JSX +--- -## What's Next? +## Explore -Explore the sidebar to learn more: +Use the sidebar to learn more: -- **Getting Started** - Detailed setup and configuration -- **Components** - Tooltip and Helper components -- **Hooks** - The `useTipMagic` hook API -- **Examples** - Real-world usage patterns +- **[The Tooltip](?path=/story/the-tooltip--the-tooltip)** β€” Interactive playground +- **[Keyboard Shortcuts](?path=/docs/tooltip-keyboard-shortcuts-documentation--docs)** β€” Displaying shortcuts +- **[The Tour](?path=/story/the-tour--the-tour)** β€” Guided walkthroughs +- **[Tip Advisor](?path=/docs/tooltip-tipadvisor-documentation--docs)** β€” Shortcut discovery ---

- Made with ❀️ for the React community + Made with care for the React community

diff --git a/src/components/TipAdvisor/TipAdvisor.tsx b/src/components/TipAdvisor/TipAdvisor.tsx new file mode 100644 index 0000000..79f7e17 --- /dev/null +++ b/src/components/TipAdvisor/TipAdvisor.tsx @@ -0,0 +1,197 @@ +import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTipMagicContext } from '../../context/TipMagicContext'; +import type { TipAdvisorAPI, TipAdvisorProps } from '../../types/tipAdvisor'; +import { useTipAdvisorState } from './useTipAdvisorState'; +import { highlightFuzzyMatch } from './utils'; + +const DEFAULT_TRIGGER_KEY = 'F1'; +const DEFAULT_SELECTOR = '[data-tip][data-tip-shortcut]'; +const DEFAULT_POSITION = 'center'; +const DEFAULT_SEARCH_PLACEHOLDER = 'Search shortcuts...'; + +/** + * TipAdvisor - An optional menu component that displays all tooltips with shortcuts. + * + * Features: + * - Fuzzy search with highlighting + * - Keyboard navigation (arrow keys, enter) + * - Hover preview of tooltips + * - Click to trigger target element + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export const TipAdvisor = forwardRef(function TipAdvisor( + { + triggerKey = DEFAULT_TRIGGER_KEY, + position = DEFAULT_POSITION, + showCloseButton = true, + showBackdrop = true, + closeOnBackdropClick = true, + className, + itemClassName, + selector = DEFAULT_SELECTOR, + onOpen, + onClose, + searchPlaceholder = DEFAULT_SEARCH_PLACEHOLDER, + items: presetItems, + }, + ref +) { + const { state, dispatch } = useTipMagicContext(); + const menuRef = useRef(null); + const backdropRef = useRef(null); + + const { + isOpen, + items, + searchQuery, + focusedIndex, + filteredResults, + searchInputRef, + itemRefs, + open, + close, + toggle, + setSearchQuery, + handleItemClick, + handleItemHover, + handleItemLeave, + handleKeyDown, + } = useTipAdvisorState({ + selector, + triggerKey, + presetItems, + onOpen, + onClose, + dispatch, + }); + + // Expose API via ref + useImperativeHandle( + ref, + () => ({ + open, + close, + toggle, + isOpen, + items, + }), + [open, close, toggle, isOpen, items] + ); + + // Handle backdrop click + const handleBackdropClick = useCallback( + (event: React.MouseEvent) => { + if (!closeOnBackdropClick) return; + + // Close if clicking directly on the container or the backdrop element + const target = event.target; + if (target === event.currentTarget || target === backdropRef.current) { + close(); + } + }, + [closeOnBackdropClick, close] + ); + + // Render highlighted text + const renderHighlightedText = ( + text: string, + result: Fuzzysort.Result | null + ): React.ReactNode => { + const highlighted = highlightFuzzyMatch(text, result); + + if (highlighted === text) { + return text; + } + + return ; + }; + + // Don't render if not open + if (!isOpen) { + return null; + } + + const positionClass = `tip-advisor-menu--${position}`; + + return createPortal( +
+ {showBackdrop &&
} +
+
+ setSearchQuery(e.target.value)} + aria-label="Search shortcuts" + /> + {showCloseButton && ( + + )} +
+
+ {filteredResults.length === 0 ? ( +
+ {searchQuery ? 'No matching shortcuts' : 'No shortcuts available'} +
+ ) : ( + filteredResults.map(({ item, contentResult, shortcutResult }, index) => ( +
{ + if (el) { + itemRefs.current.set(index, el); + } else { + itemRefs.current.delete(index); + } + }} + className={`tip-advisor-item ${itemClassName || ''} ${ + focusedIndex === index ? 'tip-advisor-item--focus' : '' + }`} + role="menuitem" + tabIndex={-1} + onMouseEnter={() => handleItemHover(item, index)} + onMouseLeave={handleItemLeave} + onClick={() => handleItemClick(item)} + > + + {renderHighlightedText(item.content, contentResult)} + + {item.shortcut && ( + + {renderHighlightedText(item.shortcut, shortcutResult)} + + )} +
+ )) + )} +
+
+
, + document.body + ); +}); diff --git a/src/components/TipAdvisor/index.ts b/src/components/TipAdvisor/index.ts new file mode 100644 index 0000000..57c45ed --- /dev/null +++ b/src/components/TipAdvisor/index.ts @@ -0,0 +1 @@ +export { TipAdvisor } from './TipAdvisor'; diff --git a/src/components/TipAdvisor/useTipAdvisorState.ts b/src/components/TipAdvisor/useTipAdvisorState.ts new file mode 100644 index 0000000..6a18144 --- /dev/null +++ b/src/components/TipAdvisor/useTipAdvisorState.ts @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { TipMagicAction } from '../../context/TipMagicContext'; +import type { TipAdvisorItem, TipAdvisorPresetItem } from '../../types/tipAdvisor'; +import { + buildTooltipPayload, + collectTipAdvisorItems, + filterItemsWithFuzzy, + getNextFocusedIndex, + type FuzzySearchResult, +} from './utils'; + +export interface UseTipAdvisorStateOptions { + /** CSS selector for finding tip elements, or null to skip DOM scanning */ + selector: string | null | undefined; + /** Key to toggle the advisor */ + triggerKey: string; + /** Preset items to include (not tied to DOM elements) */ + presetItems?: TipAdvisorPresetItem[]; + /** Callback when advisor opens */ + onOpen?: () => void; + /** Callback when advisor closes */ + onClose?: () => void; + /** Context dispatch function */ + dispatch: React.Dispatch; +} + +export interface UseTipAdvisorStateReturn { + // State + isOpen: boolean; + items: TipAdvisorItem[]; + searchQuery: string; + focusedIndex: number; + filteredResults: FuzzySearchResult[]; + + // Refs + searchInputRef: React.RefObject; + itemRefs: React.MutableRefObject>; + + // Actions + open: () => void; + close: () => void; + toggle: () => void; + setSearchQuery: (query: string) => void; + setFocusedIndex: (index: number) => void; + + // Handlers + handleItemClick: (item: TipAdvisorItem) => void; + handleItemHover: (item: TipAdvisorItem, index: number) => void; + handleItemLeave: () => void; + handleKeyDown: (event: React.KeyboardEvent) => void; +} + +/** + * Hook that manages TipAdvisor state, keyboard navigation, and tooltip display. + */ +export function useTipAdvisorState(options: UseTipAdvisorStateOptions): UseTipAdvisorStateReturn { + const { selector, triggerKey, presetItems, onOpen, onClose, dispatch } = options; + + // Core state + const [isOpen, setIsOpen] = useState(false); + const [items, setItems] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + + // Refs + const searchInputRef = useRef(null); + const itemRefs = useRef>(new Map()); + + // Filter items using fuzzy search + const filteredResults = useMemo( + () => filterItemsWithFuzzy(items, searchQuery), + [items, searchQuery] + ); + + // Show tooltip for an item (only for element-based items) + const showTooltipForItem = useCallback( + (item: TipAdvisorItem) => { + // Build payload - returns null for preset items (no element) + const payload = buildTooltipPayload(item); + + if (!payload) { + dispatch({ type: 'HIDE_TOOLTIP' }); + return; + } + + // Scroll element into view if needed + item.element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + // Show tooltip on the target element + dispatch({ type: 'SHOW_TOOLTIP', payload }); + }, + [dispatch] + ); + + // Open the advisor + const open = useCallback(() => { + const collectedItems = collectTipAdvisorItems(selector, presetItems); + setItems(collectedItems); + setSearchQuery(''); + setIsOpen(true); + setFocusedIndex(0); + onOpen?.(); + + // Focus search input after render + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + }, [selector, presetItems, onOpen]); + + // Close the advisor + const close = useCallback(() => { + setIsOpen(false); + setFocusedIndex(0); + setSearchQuery(''); + dispatch({ type: 'HIDE_TOOLTIP' }); + onClose?.(); + }, [dispatch, onClose]); + + // Toggle the advisor + const toggle = useCallback(() => { + if (isOpen) { + close(); + } else { + open(); + } + }, [isOpen, open, close]); + + // Handle item click + const handleItemClick = useCallback( + (item: TipAdvisorItem) => { + close(); + + // For preset items, call the onSelect callback + if (item.onSelect) { + item.onSelect(); + return; + } + + // For element-based items, trigger click on the element + if (item.element instanceof HTMLElement) { + item.element.click(); + } + }, + [close] + ); + + // Handle item hover + const handleItemHover = useCallback( + (item: TipAdvisorItem, index: number) => { + setFocusedIndex(index); + showTooltipForItem(item); + }, + [showTooltipForItem] + ); + + // Handle item leave + const handleItemLeave = useCallback(() => { + dispatch({ type: 'HIDE_TOOLTIP' }); + }, [dispatch]); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const itemCount = filteredResults.length; + + switch (event.key) { + case 'Escape': + event.preventDefault(); + close(); + break; + + case 'ArrowDown': + event.preventDefault(); + setFocusedIndex((prev) => getNextFocusedIndex(prev, itemCount, 'down')); + break; + + case 'ArrowUp': + event.preventDefault(); + setFocusedIndex((prev) => getNextFocusedIndex(prev, itemCount, 'up')); + break; + + case 'Enter': + event.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < itemCount) { + handleItemClick(filteredResults[focusedIndex].item); + } + break; + } + }, + [close, filteredResults, focusedIndex, handleItemClick] + ); + + // Global trigger key listener + useEffect(() => { + const handleGlobalKeyDown = (event: KeyboardEvent) => { + if (event.key === triggerKey) { + event.preventDefault(); + toggle(); + } + }; + + document.addEventListener('keydown', handleGlobalKeyDown); + return () => document.removeEventListener('keydown', handleGlobalKeyDown); + }, [triggerKey, toggle]); + + // Reset focused index when search changes + useEffect(() => { + setFocusedIndex(0); + }, [searchQuery]); + + // Update tooltip and scroll menu item when focused index changes + useEffect(() => { + if (focusedIndex >= 0 && focusedIndex < filteredResults.length) { + showTooltipForItem(filteredResults[focusedIndex].item); + + // Scroll the focused menu item into view within the list + const itemElement = itemRefs.current.get(focusedIndex); + if (itemElement) { + itemElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } else { + dispatch({ type: 'HIDE_TOOLTIP' }); + } + }, [focusedIndex, filteredResults, showTooltipForItem, dispatch]); + + return { + // State + isOpen, + items, + searchQuery, + focusedIndex, + filteredResults, + + // Refs + searchInputRef, + itemRefs, + + // Actions + open, + close, + toggle, + setSearchQuery, + setFocusedIndex, + + // Handlers + handleItemClick, + handleItemHover, + handleItemLeave, + handleKeyDown, + }; +} diff --git a/src/components/TipAdvisor/utils/__tests__/collectItems.test.ts b/src/components/TipAdvisor/utils/__tests__/collectItems.test.ts new file mode 100644 index 0000000..c307e8c --- /dev/null +++ b/src/components/TipAdvisor/utils/__tests__/collectItems.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { collectElementItems, collectTipAdvisorItems, convertPresetItems } from '../collectItems'; + +describe('collectTipAdvisorItems', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + it('should return empty array when no elements match selector', () => { + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]'); + expect(result).toEqual([]); + }); + + it('should collect elements with both data-tip and data-tip-shortcut', () => { + container.innerHTML = ` + + + `; + + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]'); + + expect(result).toHaveLength(2); + expect(result[0].content).toBe('Copy'); + expect(result[0].shortcut).toBe('⌘C'); + expect(result[1].content).toBe('Paste'); + expect(result[1].shortcut).toBe('⌘V'); + }); + + it('should skip elements without data-tip-shortcut', () => { + container.innerHTML = ` + + + `; + + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]'); + + expect(result).toHaveLength(1); + expect(result[0].content).toBe('Copy'); + }); + + it('should use data-tip-id as id when provided', () => { + container.innerHTML = ` + + `; + + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('copy-btn'); + }); + + it('should generate fallback id when data-tip-id not provided', () => { + container.innerHTML = ` + + `; + + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]'); + + expect(result).toHaveLength(1); + expect(result[0].id).toMatch(/^tip-advisor-element-\d+$/); + }); + + it('should store reference to the element', () => { + container.innerHTML = ` + + `; + + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]'); + + expect(result[0].element).toBeInstanceOf(Element); + expect(result[0].element?.textContent).toBe('Copy'); + }); + + it('should work with custom selector', () => { + container.innerHTML = ` + + + `; + + const result = collectTipAdvisorItems('.toolbar-btn[data-tip][data-tip-shortcut]'); + + expect(result).toHaveLength(1); + expect(result[0].content).toBe('Copy'); + }); + + it('should handle elements with empty content', () => { + container.innerHTML = ` + + `; + + // Empty content should be skipped + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]'); + expect(result).toHaveLength(0); + }); + + it('should handle multiple elements in order', () => { + container.innerHTML = ` + + + + `; + + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]'); + + expect(result).toHaveLength(3); + expect(result[0].content).toBe('Cut'); + expect(result[1].content).toBe('Copy'); + expect(result[2].content).toBe('Paste'); + }); + + it('should return empty array when selector is null', () => { + const result = collectTipAdvisorItems(null); + expect(result).toEqual([]); + }); + + it('should return empty array when selector is empty string', () => { + const result = collectTipAdvisorItems(''); + expect(result).toEqual([]); + }); + + it('should merge element items with preset items', () => { + container.innerHTML = ` + + `; + + const presetItems = [ + { id: 'preset-1', label: 'Preset Action', shortcut: '⌘P', onSelect: vi.fn() }, + ]; + + const result = collectTipAdvisorItems('[data-tip][data-tip-shortcut]', presetItems); + + expect(result).toHaveLength(2); + expect(result[0].content).toBe('Copy'); + expect(result[1].content).toBe('Preset Action'); + }); +}); + +describe('collectElementItems', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + it('should return empty array when selector is null', () => { + expect(collectElementItems(null)).toEqual([]); + }); + + it('should return empty array when selector is undefined', () => { + expect(collectElementItems(undefined)).toEqual([]); + }); + + it('should return empty array when selector is empty string', () => { + expect(collectElementItems('')).toEqual([]); + }); +}); + +describe('convertPresetItems', () => { + it('should return empty array when presetItems is undefined', () => { + expect(convertPresetItems(undefined)).toEqual([]); + }); + + it('should return empty array when presetItems is empty', () => { + expect(convertPresetItems([])).toEqual([]); + }); + + it('should convert preset items to TipAdvisorItem format', () => { + const onSelect = vi.fn(); + const presetItems = [ + { id: 'action-1', label: 'Action One', shortcut: '⌘1', onSelect }, + { id: 'action-2', label: 'Action Two', onSelect }, + ]; + + const result = convertPresetItems(presetItems); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 'action-1', + content: 'Action One', + shortcut: '⌘1', + onSelect, + }); + expect(result[1]).toEqual({ + id: 'action-2', + content: 'Action Two', + shortcut: undefined, + onSelect, + }); + }); + + it('should preserve onSelect callback', () => { + const onSelect = vi.fn(); + const presetItems = [{ id: 'test', label: 'Test', onSelect }]; + + const result = convertPresetItems(presetItems); + + result[0].onSelect?.(); + expect(onSelect).toHaveBeenCalled(); + }); +}); diff --git a/src/components/TipAdvisor/utils/__tests__/fuzzySearch.test.ts b/src/components/TipAdvisor/utils/__tests__/fuzzySearch.test.ts new file mode 100644 index 0000000..6a0b8f0 --- /dev/null +++ b/src/components/TipAdvisor/utils/__tests__/fuzzySearch.test.ts @@ -0,0 +1,131 @@ +import fuzzysort from 'fuzzysort'; +import { describe, expect, it } from 'vitest'; +import type { TipAdvisorItem } from '../../../../types/tipAdvisor'; +import { filterItemsWithFuzzy, highlightFuzzyMatch } from '../fuzzySearch'; + +// Helper to create mock items +function createMockItem(content: string, shortcut: string): TipAdvisorItem { + return { + id: `item-${content.toLowerCase().replace(/\s+/g, '-')}`, + element: document.createElement('button'), + content, + shortcut, + }; +} + +describe('filterItemsWithFuzzy', () => { + const mockItems: TipAdvisorItem[] = [ + createMockItem('Copy', '⌘C'), + createMockItem('Paste', '⌘V'), + createMockItem('Cut', '⌘X'), + createMockItem('Save', '⌘S'), + createMockItem('Undo', '⌘Z'), + ]; + + describe('with empty query', () => { + it('should return all items when query is empty', () => { + const result = filterItemsWithFuzzy(mockItems, ''); + expect(result).toHaveLength(5); + }); + + it('should return all items when query is whitespace', () => { + const result = filterItemsWithFuzzy(mockItems, ' '); + expect(result).toHaveLength(5); + }); + + it('should have null results for all items (no highlighting)', () => { + const result = filterItemsWithFuzzy(mockItems, ''); + + result.forEach((r) => { + expect(r.contentResult).toBeNull(); + expect(r.shortcutResult).toBeNull(); + }); + }); + + it('should preserve original item order', () => { + const result = filterItemsWithFuzzy(mockItems, ''); + + expect(result[0].item.content).toBe('Copy'); + expect(result[1].item.content).toBe('Paste'); + expect(result[2].item.content).toBe('Cut'); + }); + }); + + describe('with search query', () => { + it('should filter items by content', () => { + const result = filterItemsWithFuzzy(mockItems, 'copy'); + + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].item.content).toBe('Copy'); + }); + + it('should filter items by shortcut', () => { + const result = filterItemsWithFuzzy(mockItems, '⌘C'); + + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].item.shortcut).toBe('⌘C'); + }); + + it('should support fuzzy matching', () => { + const result = filterItemsWithFuzzy(mockItems, 'cpy'); + + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].item.content).toBe('Copy'); + }); + + it('should return empty array when no matches', () => { + const result = filterItemsWithFuzzy(mockItems, 'zzzzzzz'); + expect(result).toHaveLength(0); + }); + + it('should include fuzzy results for highlighting', () => { + const result = filterItemsWithFuzzy(mockItems, 'copy'); + + expect(result.length).toBeGreaterThanOrEqual(1); + // At least one of the results should not be null + const hasResult = result[0].contentResult !== null || result[0].shortcutResult !== null; + expect(hasResult).toBe(true); + }); + }); + + describe('with empty items array', () => { + it('should return empty array', () => { + const result = filterItemsWithFuzzy([], 'copy'); + expect(result).toHaveLength(0); + }); + + it('should return empty array even with empty query', () => { + const result = filterItemsWithFuzzy([], ''); + expect(result).toHaveLength(0); + }); + }); +}); + +describe('highlightFuzzyMatch', () => { + it('should return original text when result is null', () => { + const result = highlightFuzzyMatch('Copy', null); + expect(result).toBe('Copy'); + }); + + it('should return highlighted text when result has matches', () => { + // Create a real fuzzy search result + const items = [{ content: 'Copy' }]; + const searchResult = fuzzysort.go('cop', items, { key: 'content' }); + + if (searchResult.length > 0) { + const highlighted = highlightFuzzyMatch('Copy', searchResult[0]); + expect(highlighted).toContain(''); + expect(highlighted).toContain(''); + } + }); + + it('should return original text when highlight returns null', () => { + // Mock a result where highlight returns null + const mockResult = { + highlight: () => null, + } as unknown as Fuzzysort.Result; + + const result = highlightFuzzyMatch('Copy', mockResult); + expect(result).toBe('Copy'); + }); +}); diff --git a/src/components/TipAdvisor/utils/__tests__/keyboardNavigation.test.ts b/src/components/TipAdvisor/utils/__tests__/keyboardNavigation.test.ts new file mode 100644 index 0000000..f3840ee --- /dev/null +++ b/src/components/TipAdvisor/utils/__tests__/keyboardNavigation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { getNextFocusedIndex, isNavigationKey } from '../keyboardNavigation'; + +describe('getNextFocusedIndex', () => { + describe('navigating down', () => { + it('should increment index when not at end', () => { + expect(getNextFocusedIndex(0, 5, 'down')).toBe(1); + expect(getNextFocusedIndex(1, 5, 'down')).toBe(2); + expect(getNextFocusedIndex(2, 5, 'down')).toBe(3); + }); + + it('should wrap to start when at end', () => { + expect(getNextFocusedIndex(4, 5, 'down')).toBe(0); + }); + + it('should handle single item', () => { + expect(getNextFocusedIndex(0, 1, 'down')).toBe(0); + }); + + it('should return 0 when item count is 0', () => { + expect(getNextFocusedIndex(0, 0, 'down')).toBe(0); + }); + }); + + describe('navigating up', () => { + it('should decrement index when not at start', () => { + expect(getNextFocusedIndex(4, 5, 'up')).toBe(3); + expect(getNextFocusedIndex(3, 5, 'up')).toBe(2); + expect(getNextFocusedIndex(1, 5, 'up')).toBe(0); + }); + + it('should wrap to end when at start', () => { + expect(getNextFocusedIndex(0, 5, 'up')).toBe(4); + }); + + it('should handle single item', () => { + expect(getNextFocusedIndex(0, 1, 'up')).toBe(0); + }); + + it('should return 0 when item count is 0', () => { + expect(getNextFocusedIndex(0, 0, 'up')).toBe(0); + }); + }); + + describe('edge cases', () => { + it('should handle two items', () => { + expect(getNextFocusedIndex(0, 2, 'down')).toBe(1); + expect(getNextFocusedIndex(1, 2, 'down')).toBe(0); + expect(getNextFocusedIndex(0, 2, 'up')).toBe(1); + expect(getNextFocusedIndex(1, 2, 'up')).toBe(0); + }); + }); +}); + +describe('isNavigationKey', () => { + it('should return true for Escape', () => { + expect(isNavigationKey('Escape')).toBe(true); + }); + + it('should return true for ArrowDown', () => { + expect(isNavigationKey('ArrowDown')).toBe(true); + }); + + it('should return true for ArrowUp', () => { + expect(isNavigationKey('ArrowUp')).toBe(true); + }); + + it('should return true for Enter', () => { + expect(isNavigationKey('Enter')).toBe(true); + }); + + it('should return false for other keys', () => { + expect(isNavigationKey('a')).toBe(false); + expect(isNavigationKey('Tab')).toBe(false); + expect(isNavigationKey('Space')).toBe(false); + expect(isNavigationKey('ArrowLeft')).toBe(false); + expect(isNavigationKey('ArrowRight')).toBe(false); + }); +}); diff --git a/src/components/TipAdvisor/utils/__tests__/tooltipPayload.test.ts b/src/components/TipAdvisor/utils/__tests__/tooltipPayload.test.ts new file mode 100644 index 0000000..9fface1 --- /dev/null +++ b/src/components/TipAdvisor/utils/__tests__/tooltipPayload.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { TipAdvisorItem } from '../../../../types/tipAdvisor'; +import { buildTooltipPayload } from '../tooltipPayload'; + +describe('buildTooltipPayload', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function createItem(element: Element, content: string, shortcut: string): TipAdvisorItem { + return { + id: 'test-item', + element, + content, + shortcut, + }; + } + + it('should return payload with target element', () => { + container.innerHTML = ''; + const element = container.querySelector('button')!; + const item = createItem(element, 'Copy', '⌘C'); + + const payload = buildTooltipPayload(item); + + expect(payload).not.toBeNull(); + expect(payload!.target).toBe(element); + }); + + it('should use shortcut as content', () => { + container.innerHTML = ''; + const element = container.querySelector('button')!; + const item = createItem(element, 'Copy', '⌘C'); + + const payload = buildTooltipPayload(item); + + expect(payload).not.toBeNull(); + expect(payload!.content).toBe('⌘C'); + }); + + it('should set parsedData content to shortcut', () => { + container.innerHTML = ''; + const element = container.querySelector('button')!; + const item = createItem(element, 'Copy', '⌘C'); + + const payload = buildTooltipPayload(item); + + expect(payload).not.toBeNull(); + expect(payload!.parsedData.content).toBe('⌘C'); + }); + + it('should set parsedData shortcut to undefined', () => { + container.innerHTML = ''; + const element = container.querySelector('button')!; + const item = createItem(element, 'Copy', '⌘C'); + + const payload = buildTooltipPayload(item); + + expect(payload).not.toBeNull(); + expect(payload!.parsedData.shortcut).toBeUndefined(); + }); + + it('should preserve other parsedData properties', () => { + container.innerHTML = + ''; + const element = container.querySelector('button')!; + const item = createItem(element, 'Copy', '⌘C'); + + const payload = buildTooltipPayload(item); + + expect(payload).not.toBeNull(); + expect(payload!.parsedData.placement).toBe('bottom'); + }); + + it('should handle empty shortcut', () => { + container.innerHTML = ''; + const element = container.querySelector('button')!; + const item = createItem(element, 'Copy', ''); + + const payload = buildTooltipPayload(item); + + expect(payload).not.toBeNull(); + expect(payload!.content).toBe(''); + expect(payload!.parsedData.content).toBe(''); + }); + + it('should return null for items without element', () => { + const item: TipAdvisorItem = { + id: 'preset-item', + content: 'Preset Action', + shortcut: '⌘A', + onSelect: () => {}, + }; + + const payload = buildTooltipPayload(item); + + expect(payload).toBeNull(); + }); +}); diff --git a/src/components/TipAdvisor/utils/collectItems.ts b/src/components/TipAdvisor/utils/collectItems.ts new file mode 100644 index 0000000..85d423c --- /dev/null +++ b/src/components/TipAdvisor/utils/collectItems.ts @@ -0,0 +1,70 @@ +import type { TipAdvisorItem, TipAdvisorPresetItem } from '../../../types/tipAdvisor'; +import { parseDataAttributes } from '../../../utils/parseDataAttributes'; + +/** + * Collects all elements matching the selector that have both tooltip content and a shortcut. + * + * @param selector - CSS selector to find elements, or null/empty to skip DOM scanning + * @returns Array of TipAdvisorItem objects from DOM + */ +export function collectElementItems(selector: string | null | undefined): TipAdvisorItem[] { + if (!selector) { + return []; + } + + const elements = document.querySelectorAll(selector); + const items: TipAdvisorItem[] = []; + + elements.forEach((element, index) => { + const parsedData = parseDataAttributes(element); + + if (parsedData.content && parsedData.shortcut) { + items.push({ + id: parsedData.id || `tip-advisor-element-${index}`, + element, + content: parsedData.content, + shortcut: parsedData.shortcut, + }); + } + }); + + return items; +} + +/** + * Converts preset items to TipAdvisorItem format. + * + * @param presetItems - Array of preset items from props + * @returns Array of TipAdvisorItem objects + */ +export function convertPresetItems( + presetItems: TipAdvisorPresetItem[] | undefined +): TipAdvisorItem[] { + if (!presetItems || presetItems.length === 0) { + return []; + } + + return presetItems.map((preset) => ({ + id: preset.id, + content: preset.label, + shortcut: preset.shortcut, + onSelect: preset.onSelect, + })); +} + +/** + * Collects all TipAdvisor items from both DOM elements and preset items. + * + * @param selector - CSS selector to find elements, or null/empty to skip DOM scanning + * @param presetItems - Optional preset items to include + * @returns Combined array of TipAdvisorItem objects + */ +export function collectTipAdvisorItems( + selector: string | null | undefined, + presetItems?: TipAdvisorPresetItem[] +): TipAdvisorItem[] { + const elementItems = collectElementItems(selector); + const convertedPresets = convertPresetItems(presetItems); + + return [...elementItems, ...convertedPresets]; +} diff --git a/src/components/TipAdvisor/utils/fuzzySearch.ts b/src/components/TipAdvisor/utils/fuzzySearch.ts new file mode 100644 index 0000000..22e44b2 --- /dev/null +++ b/src/components/TipAdvisor/utils/fuzzySearch.ts @@ -0,0 +1,62 @@ +import fuzzysort from 'fuzzysort'; +import type { TipAdvisorItem } from '../../../types/tipAdvisor'; + +/** + * Result of a fuzzy search on TipAdvisor items + */ +export interface FuzzySearchResult { + item: TipAdvisorItem; + contentResult: Fuzzysort.Result | null; + shortcutResult: Fuzzysort.Result | null; +} + +/** + * Filters TipAdvisor items using fuzzy search. + * + * When query is empty, returns all items with null results (no highlighting). + * When query is provided, searches in both 'content' and 'shortcut' fields. + * + * @param items - Array of TipAdvisorItem to search + * @param query - Search query string + * @returns Array of FuzzySearchResult with matched items and highlight data + */ +export function filterItemsWithFuzzy(items: TipAdvisorItem[], query: string): FuzzySearchResult[] { + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + // Return all items with null results (no highlighting needed) + return items.map((item) => ({ + item, + contentResult: null, + shortcutResult: null, + })); + } + + // Search in both content and shortcut + const results = fuzzysort.go(trimmedQuery, items, { + keys: ['content', 'shortcut'], + threshold: -10000, // Include more results + }); + + return results.map((result) => ({ + item: result.obj, + contentResult: result[0], + shortcutResult: result[1], + })); +} + +/** + * Highlights matching parts of text using fuzzysort result. + * + * @param text - Original text to display + * @param result - Fuzzysort result containing match information + * @returns HTML string with tags around matched characters, or original text if no result + */ +export function highlightFuzzyMatch(text: string, result: Fuzzysort.Result | null): string { + if (!result) { + return text; + } + + const highlighted = result.highlight('', ''); + return highlighted || text; +} diff --git a/src/components/TipAdvisor/utils/index.ts b/src/components/TipAdvisor/utils/index.ts new file mode 100644 index 0000000..8590166 --- /dev/null +++ b/src/components/TipAdvisor/utils/index.ts @@ -0,0 +1,8 @@ +export { collectElementItems, collectTipAdvisorItems, convertPresetItems } from './collectItems'; +export { filterItemsWithFuzzy, highlightFuzzyMatch, type FuzzySearchResult } from './fuzzySearch'; +export { + getNextFocusedIndex, + isNavigationKey, + type NavigationDirection, +} from './keyboardNavigation'; +export { buildTooltipPayload, type TooltipPayload } from './tooltipPayload'; diff --git a/src/components/TipAdvisor/utils/keyboardNavigation.ts b/src/components/TipAdvisor/utils/keyboardNavigation.ts new file mode 100644 index 0000000..5e1a222 --- /dev/null +++ b/src/components/TipAdvisor/utils/keyboardNavigation.ts @@ -0,0 +1,39 @@ +/** + * Navigation direction for keyboard control + */ +export type NavigationDirection = 'up' | 'down'; + +/** + * Calculates the next focused index for circular navigation. + * + * @param currentIndex - Current focused index + * @param itemCount - Total number of items + * @param direction - Navigation direction ('up' or 'down') + * @returns The new focused index with circular wrapping + */ +export function getNextFocusedIndex( + currentIndex: number, + itemCount: number, + direction: NavigationDirection +): number { + if (itemCount === 0) { + return 0; + } + + if (direction === 'down') { + return currentIndex < itemCount - 1 ? currentIndex + 1 : 0; + } + + // direction === 'up' + return currentIndex > 0 ? currentIndex - 1 : itemCount - 1; +} + +/** + * Determines if a key is a navigation key that should be handled. + * + * @param key - The keyboard event key + * @returns True if the key is a navigation key + */ +export function isNavigationKey(key: string): key is 'Escape' | 'ArrowDown' | 'ArrowUp' | 'Enter' { + return key === 'Escape' || key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter'; +} diff --git a/src/components/TipAdvisor/utils/tooltipPayload.ts b/src/components/TipAdvisor/utils/tooltipPayload.ts new file mode 100644 index 0000000..1ebe36f --- /dev/null +++ b/src/components/TipAdvisor/utils/tooltipPayload.ts @@ -0,0 +1,39 @@ +import type { ParsedTooltipData } from '../../../types'; +import type { TipAdvisorItem } from '../../../types/tipAdvisor'; +import { parseDataAttributes } from '../../../utils/parseDataAttributes'; + +/** + * Payload structure for showing a tooltip + */ +export interface TooltipPayload { + target: Element; + content: string; + parsedData: ParsedTooltipData; +} + +/** + * Builds the tooltip payload for a TipAdvisor item. + * + * When previewing an item in the advisor, we show the shortcut as the tooltip + * content (since the main content is already visible in the menu). + * + * @param item - The TipAdvisor item to build a payload for (must have an element) + * @returns The tooltip payload ready for dispatch, or null if item has no element + */ +export function buildTooltipPayload(item: TipAdvisorItem): TooltipPayload | null { + if (!item.element) { + return null; + } + + const parsedData = parseDataAttributes(item.element); + + return { + target: item.element, + content: item.shortcut || '', + parsedData: { + ...parsedData, + content: item.shortcut || '', + shortcut: undefined, + }, + }; +} diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index fb4395f..c9a1aae 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -12,7 +12,6 @@ import { ANIMATION, CSS_CLASSES } from '../../constants'; import { useTipMagicContext } from '../../context/TipMagicContext'; import type { TooltipTransitionBehavior } from '../../types'; import { areGroupsCompatible, shouldAnimatePosition } from '../../utils/groupCompatibility'; -import { parseContent } from '../../utils/parseDataAttributes'; import { buildTooltipClassNames, getArrowStaticSide, @@ -121,14 +120,10 @@ export function Tooltip() { return null; } - // Parse content to extract shortcut (per-element separator overrides provider default) - // Skip parsing for HTML content - HTML should be rendered as-is without separator splitting - // This prevents semicolons in tour titles/messages/labels from breaking the HTML + // Get content and shortcut from parsed data const isHtmlContent = tooltip.parsedData?.html ?? false; - const separator = tooltip.parsedData?.contentSeparator ?? config.contentSeparator; - const parsedContent = isHtmlContent - ? { main: tooltip.content, shortcut: undefined } - : parseContent(tooltip.content, separator); + const mainContent = tooltip.content; + const shortcut = tooltip.parsedData?.shortcut; // Extract text display options const isInteractive = tooltip.parsedData?.interactive ?? false; @@ -177,15 +172,12 @@ export function Tooltip() { >
{isHtmlContent ? ( - + ) : ( - {parsedContent.main} + {mainContent} )} - {parsedContent.shortcut && config.enableShortcutStyle && ( - {parsedContent.shortcut} + {shortcut && config.enableShortcutStyle && ( + {shortcut} )}
{showArrow && ( diff --git a/src/components/Tooltip/__tests__/Tooltip.test.tsx b/src/components/Tooltip/__tests__/Tooltip.test.tsx index 7fc43c7..d0692b7 100644 --- a/src/components/Tooltip/__tests__/Tooltip.test.tsx +++ b/src/components/Tooltip/__tests__/Tooltip.test.tsx @@ -142,14 +142,12 @@ describe('Tooltip', () => { expect(kbdElement).toBeNull(); }); - it('should still parse non-HTML content for shortcuts', () => { - // Non-HTML content should still be parsed for keyboard shortcuts - const contentWithShortcut = 'Copy; ⌘C'; - + it('should display keyboard shortcut from data-tip-shortcut attribute', () => { + // Shortcuts are now specified via data-tip-shortcut attribute const state = createMockState({ - content: contentWithShortcut, + content: 'Copy', parsedData: { - content: contentWithShortcut, + content: 'Copy', placement: 'top', delay: 200, disabled: false, @@ -158,9 +156,10 @@ describe('Tooltip', () => { wordWrap: false, textBreak: 'normal', maxWidth: 300, - html: false, // Not HTML mode + html: false, interactive: false, showArrow: true, + shortcut: '⌘C', // Shortcut from data-tip-shortcut attribute } as ParsedTooltipData, }); @@ -169,7 +168,7 @@ describe('Tooltip', () => { renderTooltip(state); - // Should have the main text without the shortcut + // Should have the main text const textElement = document.querySelector('.tip-magic-text'); expect(textElement).not.toBeNull(); expect(textElement?.textContent).toBe('Copy'); diff --git a/src/components/index.ts b/src/components/index.ts index 9d68f2d..9c6f5de 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ export { TipMagicProvider } from './TipMagicProvider'; export type { TipMagicProviderProps } from './TipMagicProvider'; export { Tooltip } from './Tooltip'; +export { TipAdvisor } from './TipAdvisor'; diff --git a/src/constants/index.ts b/src/constants/index.ts index a73c0ab..b04d574 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -38,7 +38,11 @@ export const DATA_ATTRIBUTES = { TIP_MOVE_DURATION: 'data-tip-move-duration', TIP_GROUP: 'data-tip-group', TIP_NO_ARROW: 'data-tip-no-arrow', - TIP_SEPARATOR: 'data-tip-separator', + /** + * Keyboard shortcut to display alongside the tooltip content. + * @example + */ + TIP_SHORTCUT: 'data-tip-shortcut', TIP_SHOW_ON_FOCUS: 'data-tip-show-on-focus', /** * Elements with this attribute will remain visible (not masked) during tour focus. @@ -82,7 +86,6 @@ export const DEFAULT_OPTIONS: Required = { zIndex: 9999, disabled: false, portalContainer: null as unknown as HTMLElement, - contentSeparator: ';', enableShortcutStyle: true, respectReducedMotion: true, transitionBehavior: 'jump' as TooltipTransitionBehavior, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 74432fd..9789bba 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,6 +1,7 @@ // Main hooks export { useTipMagic } from './useTipMagic'; export { useTour } from './useTour/useTour'; +export { useTipAdvisor } from './useTipAdvisor'; // Internal hooks (exported for advanced usage) export { useFlowState } from './useFlowState'; diff --git a/src/hooks/useTipAdvisor.ts b/src/hooks/useTipAdvisor.ts new file mode 100644 index 0000000..f58c4ab --- /dev/null +++ b/src/hooks/useTipAdvisor.ts @@ -0,0 +1,52 @@ +import { useCallback, useRef } from 'react'; +import type { TipAdvisorAPI } from '../types/tipAdvisor'; + +/** + * Hook for programmatic control of the TipAdvisor component. + * + * This hook provides a ref that can be passed to a TipAdvisor component + * to control it from a parent component. + * + * @example + * ```tsx + * function App() { + * const advisor = useTipAdvisor(); + * + * return ( + * + * + * + * + * + * ); + * } + * ``` + */ +export function useTipAdvisor() { + const ref = useRef(null); + + const open = useCallback(() => { + ref.current?.open(); + }, []); + + const close = useCallback(() => { + ref.current?.close(); + }, []); + + const toggle = useCallback(() => { + ref.current?.toggle(); + }, []); + + return { + /** Ref to pass to TipAdvisor component */ + ref, + /** Open the TipAdvisor */ + open, + /** Close the TipAdvisor */ + close, + /** Toggle the TipAdvisor */ + toggle, + }; +} diff --git a/src/hooks/useTooltipAPI.ts b/src/hooks/useTooltipAPI.ts index e29454c..0cc2c80 100644 --- a/src/hooks/useTooltipAPI.ts +++ b/src/hooks/useTooltipAPI.ts @@ -70,9 +70,7 @@ export function useTooltipAPI( moveTransitionDuration: options.moveTransitionDuration, }), ...(options.showArrow !== undefined && { showArrow: options.showArrow }), - ...(options.contentSeparator !== undefined && { - contentSeparator: options.contentSeparator, - }), + ...(options.shortcut !== undefined && { shortcut: options.shortcut }), }; if (state.tooltip.visible) { diff --git a/src/index.ts b/src/index.ts index b4d304a..5d171d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,10 +6,12 @@ // Components export { TipMagicProvider } from './components/TipMagicProvider'; export type { TipMagicProviderProps } from './components/TipMagicProvider'; +export { TipAdvisor } from './components/TipAdvisor'; // Hooks export { useTipMagic } from './hooks/useTipMagic'; export { useTour } from './hooks/useTour'; +export { useTipAdvisor } from './hooks/useTipAdvisor'; // Context (for advanced usage) export { TipMagicContext, useTipMagicContext } from './context/TipMagicContext'; @@ -43,6 +45,12 @@ export type { TourStep, UseTipMagicReturn, UseTourReturn, + // TipAdvisor types + TipAdvisorAPI, + TipAdvisorItem, + TipAdvisorPosition, + TipAdvisorPresetItem, + TipAdvisorProps, } from './types'; // Constants (for customization) @@ -51,5 +59,4 @@ export { ANIMATION, CSS_CLASSES, DEFAULT_OPTIONS } from './constants'; // Utilities export { getTipProps } from './utils/getTipProps'; export type { TipPropsOptions, TipPropsResult } from './utils/getTipProps'; -export { generateTooltipId, parseContent, parseDataAttributes } from './utils/parseDataAttributes'; -export type { ParsedContent } from './utils/parseDataAttributes'; +export { generateTooltipId, parseDataAttributes } from './utils/parseDataAttributes'; diff --git a/src/stories/Tooltip/KeyboardShortcuts.stories.tsx b/src/stories/Tooltip/KeyboardShortcuts.stories.tsx index d481996..0b10ddd 100644 --- a/src/stories/Tooltip/KeyboardShortcuts.stories.tsx +++ b/src/stories/Tooltip/KeyboardShortcuts.stories.tsx @@ -18,12 +18,15 @@ type Story = StoryObj; * Tooltips can display keyboard shortcuts alongside the main content. * * **How it works:** - * - Use the `contentSeparator` (default: `;`) to separate text from the shortcut - * - Format: `"Main text; Shortcut"` β†’ displays "Main text" with a styled `[Shortcut]` badge - * - The shortcut is rendered in a `` element with special styling + * - Use `data-tip-shortcut` attribute to specify the keyboard shortcut + * - The shortcut is rendered in a styled `` badge next to the tooltip text + * + * **Example:** + * ```html + * + * ``` * * **Customization:** - * - Change separator: `` * - Disable shortcut styling: `` */ export const WithShortcuts: Story = { @@ -31,29 +34,54 @@ export const WithShortcuts: Story = {

- Use a semicolon ; to separate tooltip text from keyboard shortcuts. + Use data-tip-shortcut to add a styled keyboard shortcut badge.
- Example: data-tip="Copy; ⌘C" β†’ "Copy" + styled shortcut badge + Example: data-tip="Copy" data-tip-shortcut="⌘C"

- - - - -

- πŸ’‘ Without a semicolon, the entire text is shown as the main content (no shortcut badge). + Without data-tip-shortcut, only the main text is shown (no shortcut badge).

@@ -75,13 +103,28 @@ export const EditorToolbar: Story = {

- - - +`; + + return ( +
+

+ Press {triggerKey} to toggle the TipAdvisor, then start typing to search. +
+ Use arrow keys to navigate, Enter to select, Escape to close. +

+ + {/* Demo toolbar */} +
+ {items.map((item, index) => ( + + ))} +
+ + {/* Manual toggle button */} + + + {/* Code preview */} +
+        {codePreview}
+      
+ + {/* TipAdvisor component */} + +
+ ); +}; + +/** + * Interactive TipAdvisor playground. + * + * The menu is shown automatically - use the controls panel to experiment + * with all available options. Try typing in the search box to filter shortcuts! + */ +export const TheTipAdvisor: Story = { + args: { + position: 'center', + triggerKey: 'F1', + showCloseButton: true, + showBackdrop: true, + closeOnBackdropClick: true, + searchPlaceholder: 'Search shortcuts...', + itemCount: 8, + }, + argTypes: { + position: { + control: 'select', + options: ['center', 'bottom-right', 'bottom-left', 'top-right', 'top-left'], + description: 'Position of the menu on screen', + }, + triggerKey: { + control: 'text', + description: 'Key to toggle the TipAdvisor menu', + }, + showCloseButton: { + control: 'boolean', + description: 'Show close button in header', + }, + showBackdrop: { + control: 'boolean', + description: 'Show backdrop overlay behind the menu', + }, + closeOnBackdropClick: { + control: 'boolean', + description: 'Close when clicking the backdrop', + }, + searchPlaceholder: { + control: 'text', + description: 'Placeholder text for the search input', + }, + itemCount: { + control: { type: 'range', min: 1, max: 8, step: 1 }, + description: 'Number of items in the demo toolbar', + }, + }, + render: (args) => ( + + + + ), +}; diff --git a/src/stories/Tooltip/TheTooltip.stories.tsx b/src/stories/Tooltip/TheTooltip.stories.tsx index 334a49b..61e5bd1 100644 --- a/src/stories/Tooltip/TheTooltip.stories.tsx +++ b/src/stories/Tooltip/TheTooltip.stories.tsx @@ -11,7 +11,7 @@ import './tooltip-stories.css'; * ## Features * - **Single instance**: One tooltip moves between targets for smooth transitions * - **Data attribute based**: Just add `data-tip` to any element - * - **Keyboard shortcut support**: Use `; ` to separate content from shortcuts + * - **Keyboard shortcut support**: Use `data-tip-shortcut` attribute for shortcuts * - **Floating UI powered**: Smart positioning with flip/shift * - **Accessible**: Keyboard and screen reader support * - **TypeScript support**: Use `getTipProps()` for typed tooltip configuration @@ -58,13 +58,13 @@ export const TheTooltip: Story = { transitionBehavior: 'jump', moveTransitionDuration: 100, showArrow: true, - contentSeparator: ';', + shortcut: '', showOnFocus: false, }, argTypes: { tip: { control: 'text', - description: 'Tooltip content. Use "; " to add keyboard shortcuts (e.g., "Save; ⌘S")', + description: 'Tooltip content text', }, placement: { control: 'select', @@ -139,10 +139,9 @@ export const TheTooltip: Story = { control: 'boolean', description: 'Show or hide the tooltip arrow', }, - contentSeparator: { + shortcut: { control: 'text', - description: - 'Character(s) to separate main text from keyboard shortcut (e.g., "Save; ⌘S" uses ";")', + description: 'Keyboard shortcut to display (e.g., "⌘S")', }, showOnFocus: { control: 'boolean', @@ -152,6 +151,14 @@ export const TheTooltip: Story = { render: (args) => { const tipProps = getTipProps(args); + // Filter out empty/default values for cleaner code preview + const displayArgs = Object.fromEntries( + Object.entries(args).filter(([key, value]) => { + if (key === 'shortcut' && value === '') return false; + return true; + }) + ); + return (
@@ -159,6 +166,8 @@ export const TheTooltip: Story = { Using getTipProps() for typed tooltip configuration.
Adjust the controls below to see different tooltip behaviors. +
+ Try adding a shortcut like "⌘S" or "Ctrl+C" to see it styled!

-            {`const tipProps = getTipProps(${JSON.stringify(args, null, 2)});
+            {`const tipProps = getTipProps(${JSON.stringify(displayArgs, null, 2)});
 
 `}
           
diff --git a/src/stories/Tooltip/TipAdvisor.stories.tsx b/src/stories/Tooltip/TipAdvisor.stories.tsx new file mode 100644 index 0000000..1a95873 --- /dev/null +++ b/src/stories/Tooltip/TipAdvisor.stories.tsx @@ -0,0 +1,901 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { TipAdvisor } from '../../components/TipAdvisor'; +import { TipMagicProvider } from '../../components/TipMagicProvider'; +import { useTipAdvisor } from '../../hooks/useTipAdvisor'; +import '../../styles/index.css'; +import type { TipAdvisorPresetItem } from '../../types'; +import './tooltip-stories.css'; + +const meta: Meta = { + title: 'Tooltip/TipAdvisor', + tags: ['!autodocs'], + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================================================================= +// Toolbar Shortcuts +// ============================================================================= + +/** + * Toolbar Shortcuts + * + * A common use case: text editor toolbar with formatting actions. + * Press F1 to see all available shortcuts in a discoverable menu with fuzzy search. + */ +const ToolbarShortcutsDemo = () => { + return ( +
+

+ Press F1 to open the TipAdvisor. Start typing to search! +
+ Hover over menu items to highlight the corresponding toolbar button. +

+ +
+
+ + + +
+
+
+ + +
+
+ + +
+ ); +}; + +export const ToolbarShortcuts: Story = { + render: () => ( + + + + ), +}; + +// ============================================================================= +// Navigation Menu +// ============================================================================= + +/** + * Navigation Menu + * + * Application navigation with keyboard shortcuts for quick access. + */ +const NavigationMenuDemo = () => { + return ( + + ); +}; + +export const NavigationMenu: Story = { + render: () => ( + + + + ), +}; + +// ============================================================================= +// Custom Toggle Button +// ============================================================================= + +/** + * Custom Toggle Button + * + * Using the `useTipAdvisor` hook for programmatic control. + * You can create your own UI to open/close the TipAdvisor. + */ +const CustomToggleDemo = () => { + const advisor = useTipAdvisor(); + + return ( +
+

+ Use useTipAdvisor() hook for custom toggle buttons. +

+ +
+ + +
+ +
+ + + +
+ +
+        {`const advisor = useTipAdvisor();
+
+
+
+
+
+`}
+      
+ + +
+ ); +}; + +export const CustomToggleButton: Story = { + render: () => ( + + + + ), +}; + +// ============================================================================= +// Different Positions +// ============================================================================= + +/** + * Different Positions + * + * The TipAdvisor can be positioned in different corners or centered. + */ +const DifferentPositionsDemo = () => { + return ( +
+

+ Click a button to see the TipAdvisor in different positions. +

+ +
+ + + + + + + +
+ + {/* Demo items */} +
+ + + +
+
+ ); +}; + +const PositionButton = ({ + position, +}: { + position: 'center' | 'top' | 'bottom' | 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; +}) => { + const advisor = useTipAdvisor(); + + return ( + <> + + + + ); +}; + +export const DifferentPositions: Story = { + render: () => ( + + + + ), +}; + +// ============================================================================= +// Custom Styling +// ============================================================================= + +/** + * Custom Styling + * + * Override CSS custom properties to customize the appearance. + */ +const CustomStylingDemo = () => { + return ( +
+

+ Override CSS custom properties to customize the TipAdvisor. +
+ Press F1 to see the custom styled menu. +

+ + {/* Custom styles applied via inline style for demo */} + + +
+ + + +
+ +
+        {`:root {
+  --tip-advisor-bg: rgba(99, 102, 241, 0.95);
+  --tip-advisor-item-hover-bg: rgba(255, 255, 255, 0.2);
+  --tip-advisor-border-radius: 16px;
+  /* Search input */
+  --tip-advisor-search-bg: rgba(255, 255, 255, 0.15);
+  --tip-advisor-search-border: rgba(255, 255, 255, 0.25);
+  /* Highlight */
+  --tip-advisor-highlight-bg: rgba(255, 255, 255, 0.3);
+}`}
+      
+ + +
+ ); +}; + +export const CustomStyling: Story = { + render: () => ( + + + + ), +}; + +// ============================================================================= +// Many Items with Scrolling +// ============================================================================= + +/** + * With Scrolling + * + * When there are many items, the menu becomes scrollable. + * Hovering an item scrolls it into view and shows the tooltip. + */ +const WithScrollingDemo = () => { + const items = [ + { icon: 'πŸ“‹', label: 'Copy', shortcut: '⌘C' }, + { icon: 'πŸ“„', label: 'Paste', shortcut: '⌘V' }, + { icon: 'βœ‚οΈ', label: 'Cut', shortcut: '⌘X' }, + { icon: '↩️', label: 'Undo', shortcut: '⌘Z' }, + { icon: 'β†ͺ️', label: 'Redo', shortcut: 'β‡§βŒ˜Z' }, + { icon: 'πŸ’Ύ', label: 'Save', shortcut: '⌘S' }, + { icon: 'πŸ“‚', label: 'Open', shortcut: '⌘O' }, + { icon: 'πŸ”', label: 'Find', shortcut: '⌘F' }, + { icon: 'πŸ”„', label: 'Replace', shortcut: '⌘H' }, + { icon: 'πŸ“', label: 'New', shortcut: '⌘N' }, + { icon: 'πŸ—‘οΈ', label: 'Delete', shortcut: '⌫' }, + { icon: 'πŸ“€', label: 'Share', shortcut: 'βŒ˜β‡§S' }, + { icon: 'πŸ–¨οΈ', label: 'Print', shortcut: '⌘P' }, + { icon: 'πŸ“Š', label: 'Statistics', shortcut: 'βŒ˜β‡§T' }, + { icon: 'βš™οΈ', label: 'Settings', shortcut: '⌘,' }, + ]; + + return ( +
+

+ Press F1 to open the menu with many items. +
+ Use arrow keys to navigate through the scrollable list. +

+ +
+ {items.map((item, index) => ( + + ))} +
+ + +
+ ); +}; + +export const WithScrolling: Story = { + render: () => ( + + + + ), +}; + +// ============================================================================= +// Command Palette Style +// ============================================================================= + +/** + * Command Palette + * + * VS Code-style command palette using TipAdvisor with predefined items. + * Uses the `items` prop for actions not tied to DOM elements. + */ +const CommandPaletteDemo = () => { + const advisor = useTipAdvisor(); + const [lastAction, setLastAction] = useState(null); + + const commandItems: TipAdvisorPresetItem[] = [ + { + id: 'toggle-sidebar', + label: 'Toggle sidebar', + shortcut: '⌘B', + onSelect: () => setLastAction('Toggled sidebar'), + }, + { + id: 'go-to-file', + label: 'Go to file', + shortcut: '⌘P', + onSelect: () => setLastAction('Opening file picker...'), + }, + { + id: 'find-in-files', + label: 'Find in files', + shortcut: 'βŒ˜β‡§F', + onSelect: () => setLastAction('Opening search...'), + }, + { + id: 'toggle-terminal', + label: 'Toggle terminal', + shortcut: '⌘`', + onSelect: () => setLastAction('Toggled terminal'), + }, + { + id: 'command-palette', + label: 'Command palette', + shortcut: 'βŒ˜β‡§P', + onSelect: () => setLastAction('Opening command palette...'), + }, + { + id: 'settings', + label: 'Open settings', + shortcut: '⌘,', + onSelect: () => setLastAction('Opening settings...'), + }, + { + id: 'new-file', + label: 'New file', + shortcut: '⌘N', + onSelect: () => setLastAction('Creating new file...'), + }, + { + id: 'save-all', + label: 'Save all', + shortcut: '⌘βŒ₯S', + onSelect: () => setLastAction('All files saved!'), + }, + ]; + + return ( +
+

+ Press ⌘K or click the button to open a command palette. +
+ Uses items prop with predefined actions (no DOM elements needed). +

+ + + + {lastAction && ( +
+ βœ“ {lastAction} +
+ )} + + + + +
+ ); +}; + +export const CommandPalette: Story = { + render: () => ( + + + + ), +}; + +// ============================================================================= +// Without Backdrop +// ============================================================================= + +/** + * Without Backdrop + * + * The TipAdvisor can be displayed without the backdrop overlay. + * This is useful for floating panels that don't block the page. + */ +const WithoutBackdropDemo = () => { + return ( +
+

+ Press F1 to open the TipAdvisor without a backdrop. +
+ The page behind remains interactive. +

+ +
+ + + +
+ + +
+ ); +}; + +export const WithoutBackdrop: Story = { + render: () => ( + + + + ), +}; + +// ============================================================================= +// Dual Advisors: Shortcuts + Command Palette +// ============================================================================= + +/** + * Dual Advisors + * + * This demonstrates using two TipAdvisors on the same page: + * - One for element-based keyboard shortcuts (F1) + * - One for preset command palette actions (F2) + */ +const DualAdvisorsDemo = () => { + const commandPalette = useTipAdvisor(); + const [lastAction, setLastAction] = useState(null); + + // Preset items for the command palette (not tied to DOM elements) + const commandItems = [ + { + id: 'cmd-new-file', + label: 'New File', + shortcut: '⌘N', + onSelect: () => setLastAction('Created new file'), + }, + { + id: 'cmd-open-file', + label: 'Open File', + shortcut: '⌘O', + onSelect: () => setLastAction('Opening file picker...'), + }, + { + id: 'cmd-save', + label: 'Save', + shortcut: '⌘S', + onSelect: () => setLastAction('File saved!'), + }, + { + id: 'cmd-save-as', + label: 'Save As...', + shortcut: 'β‡§βŒ˜S', + onSelect: () => setLastAction('Opening save dialog...'), + }, + { + id: 'cmd-find', + label: 'Find in Files', + shortcut: 'β‡§βŒ˜F', + onSelect: () => setLastAction('Opening search...'), + }, + { + id: 'cmd-terminal', + label: 'Toggle Terminal', + shortcut: '⌘`', + onSelect: () => setLastAction('Terminal toggled'), + }, + { + id: 'cmd-settings', + label: 'Open Settings', + onSelect: () => setLastAction('Opening settings...'), + }, + { + id: 'cmd-themes', + label: 'Change Theme', + onSelect: () => setLastAction('Theme picker opened'), + }, + ]; + + return ( +
+

+ Two TipAdvisors on the same page: +
+ F1 β€” Toolbar shortcuts (element-based) +
+ F2 β€” Command palette (preset actions) +

+ + {/* Toolbar with element-based shortcuts */} +
+

+ TOOLBAR (F1 to see shortcuts) +

+
+ + + + +
+
+ + {/* Command palette trigger */} +
+

+ COMMAND PALETTE (F2 to open) +

+ +
+ + {/* Action feedback */} + {lastAction && ( +
+ βœ“ {lastAction} +
+ )} + + {/* TipAdvisor for toolbar shortcuts */} + + + {/* TipAdvisor for command palette (preset items only) */} + + + +
+ ); +}; + +export const DualAdvisors: Story = { + render: () => ( + + + + ), +}; diff --git a/src/styles/index.css b/src/styles/index.css index 1dc5075..3c8f1cb 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -11,3 +11,6 @@ /* Tour styles (navigation, images, backdrop) */ @import './tour.css'; + +/* TipAdvisor styles (optional menu component) */ +@import './tip-advisor.css'; diff --git a/src/styles/tip-advisor.css b/src/styles/tip-advisor.css new file mode 100644 index 0000000..36006f7 --- /dev/null +++ b/src/styles/tip-advisor.css @@ -0,0 +1,552 @@ +/** + * TipAdvisor Styles + * + * Styles for the optional TipAdvisor menu component that displays + * all tooltips with keyboard shortcuts for discoverability. + * + * All colors and sizing are controlled via CSS custom properties. + * Override these in your own stylesheet to customize the appearance. + */ + +/* ========================================================================== + CSS CUSTOM PROPERTIES + ========================================================================== */ + +:root { + /* -------------------------------------------------------------------------- + BACKGROUND COLORS + -------------------------------------------------------------------------- */ + + /** + * Background color of the menu panel + * @default Semi-transparent dark gray + */ + --tip-advisor-bg: rgba(24, 24, 27, 0.95); + + /** + * Background color of the backdrop overlay + * @default Semi-transparent black + */ + --tip-advisor-backdrop-bg: rgba(0, 0, 0, 0.5); + + /** + * Background color of menu items (default state) + * @default Transparent + */ + --tip-advisor-item-bg: transparent; + + /** + * Background color of menu items on hover + * @default Semi-transparent white + */ + --tip-advisor-item-hover-bg: rgba(255, 255, 255, 0.1); + + /** + * Background color of menu items on keyboard focus + * @default Slightly more opaque white + */ + --tip-advisor-item-focus-bg: rgba(255, 255, 255, 0.15); + + /* -------------------------------------------------------------------------- + TEXT COLORS + -------------------------------------------------------------------------- */ + + /** + * Primary text color + * @default Near-white + */ + --tip-advisor-text: #fafafa; + + /** + * Secondary/muted text color + * @default Light gray + */ + --tip-advisor-text-secondary: #a1a1aa; + + /** + * Text color for shortcut badges + * @default Light gray + */ + --tip-advisor-shortcut-text: #e5e7eb; + + /** + * Background color for shortcut badges + * @default Semi-transparent white + */ + --tip-advisor-shortcut-bg: rgba(255, 255, 255, 0.15); + + /* -------------------------------------------------------------------------- + BORDER & SHAPE + -------------------------------------------------------------------------- */ + + /** + * Border style for the menu panel + * @default Subtle white border + */ + --tip-advisor-border: 1px solid rgba(255, 255, 255, 0.1); + + /** + * Border radius for the menu panel + * @default 12px (rounded corners) + */ + --tip-advisor-border-radius: 12px; + + /** + * Border radius for menu items + * @default 8px + */ + --tip-advisor-item-border-radius: 8px; + + /* -------------------------------------------------------------------------- + SHADOWS + -------------------------------------------------------------------------- */ + + /** + * Box shadow for the menu panel + * @default Large soft shadow + */ + --tip-advisor-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + + /* -------------------------------------------------------------------------- + SIZING + -------------------------------------------------------------------------- */ + + /** + * Width of the menu panel + * @default 320px + */ + --tip-advisor-width: 320px; + + /** + * Maximum height of the menu panel (scrollable if exceeded) + * @default 400px + */ + --tip-advisor-max-height: 400px; + + /** + * Padding inside the menu panel + * @default 8px + */ + --tip-advisor-padding: 8px; + + /** + * Padding inside menu items + * @default 10px 12px + */ + --tip-advisor-item-padding: 10px 12px; + + /** + * Gap between menu items + * @default 0px + */ + --tip-advisor-gap: 0px; + + /* -------------------------------------------------------------------------- + ANIMATION + -------------------------------------------------------------------------- */ + + /** + * Duration of show/hide animations + * @default 150ms + */ + --tip-advisor-animation-duration: 150ms; + + /* -------------------------------------------------------------------------- + SEARCH INPUT + -------------------------------------------------------------------------- */ + + /** + * Background color for the search input + * @default Semi-transparent white + */ + --tip-advisor-search-bg: rgba(255, 255, 255, 0.1); + + /** + * Border color for the search input + * @default Semi-transparent white + */ + --tip-advisor-search-border: rgba(255, 255, 255, 0.15); + + /** + * Border color for the search input when focused + * @default Semi-transparent white (more visible) + */ + --tip-advisor-search-focus-border: rgba(255, 255, 255, 0.3); + + /** + * Text color for the search input + * @default Near-white + */ + --tip-advisor-search-text: #fafafa; + + /** + * Placeholder text color for the search input + * @default Light gray + */ + --tip-advisor-search-placeholder: #71717a; + + /* -------------------------------------------------------------------------- + HIGHLIGHT (SEARCH MATCH) + -------------------------------------------------------------------------- */ + + /** + * Background color for highlighted/matched text + * @default Semi-transparent yellow + */ + --tip-advisor-highlight-bg: rgba(250, 204, 21, 0.4); + + /** + * Text color for highlighted/matched text + * @default Inherit (same as surrounding text) + */ + --tip-advisor-highlight-text: inherit; +} + +/* ========================================================================== + MAIN CONTAINER + ========================================================================== */ + +.tip-advisor { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +/* ========================================================================== + BACKDROP + ========================================================================== */ + +.tip-advisor-backdrop { + position: absolute; + inset: 0; + background: var(--tip-advisor-backdrop-bg); + animation: tip-advisor-fade-in var(--tip-advisor-animation-duration) ease-out; +} + +@keyframes tip-advisor-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* ========================================================================== + MENU PANEL + ========================================================================== */ + +.tip-advisor-menu { + position: relative; + width: var(--tip-advisor-width); + max-height: var(--tip-advisor-max-height); + background: var(--tip-advisor-bg); + border: var(--tip-advisor-border); + border-radius: var(--tip-advisor-border-radius); + box-shadow: var(--tip-advisor-shadow); + overflow: hidden; + display: flex; + flex-direction: column; + animation: tip-advisor-scale-in var(--tip-advisor-animation-duration) ease-out; +} + +@keyframes tip-advisor-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* ========================================================================== + POSITION VARIANTS + ========================================================================== */ + +/* Center (default) - already centered via flexbox */ +.tip-advisor-menu--center { + /* No additional styles needed */ +} + +/* Bottom right */ +.tip-advisor-menu--bottom-right { + position: fixed; + bottom: 20px; + right: 20px; + top: auto; + left: auto; +} + +/* Bottom left */ +.tip-advisor-menu--bottom-left { + position: fixed; + bottom: 20px; + left: 20px; + top: auto; + right: auto; +} + +/* Top right */ +.tip-advisor-menu--top-right { + position: fixed; + top: 20px; + right: 20px; + bottom: auto; + left: auto; +} + +/* Top left */ +.tip-advisor-menu--top-left { + position: fixed; + top: 20px; + left: 20px; + bottom: auto; + right: auto; +} + +/* Top (centered horizontally) */ +.tip-advisor-menu--top { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + bottom: auto; + right: auto; + animation: tip-advisor-scale-in-centered var(--tip-advisor-animation-duration) ease-out; +} + +/* Bottom (centered horizontally) */ +.tip-advisor-menu--bottom { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + top: auto; + right: auto; + animation: tip-advisor-scale-in-centered var(--tip-advisor-animation-duration) ease-out; +} + +/* Animation for horizontally centered positions (preserves translateX) */ +@keyframes tip-advisor-scale-in-centered { + from { + opacity: 0; + transform: translateX(-50%) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) scale(1); + } +} + +/* ========================================================================== + HEADER + ========================================================================== */ + +.tip-advisor-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + border-bottom: var(--tip-advisor-border); + flex-shrink: 0; +} + +/* ========================================================================== + SEARCH INPUT + ========================================================================== */ + +.tip-advisor-search { + flex: 1; + height: 36px; + padding: 0 12px; + background: var(--tip-advisor-search-bg); + border: 1px solid var(--tip-advisor-search-border); + border-radius: 8px; + color: var(--tip-advisor-search-text); + font-size: 13px; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + outline: none; + transition: + border-color 150ms ease, + background-color 150ms ease; +} + +.tip-advisor-search:focus { + border-color: var(--tip-advisor-search-focus-border); + background: rgba(255, 255, 255, 0.15); +} + +.tip-advisor-search::placeholder { + color: var(--tip-advisor-search-placeholder); +} + +.tip-advisor-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--tip-advisor-text-secondary); + font-size: 18px; + line-height: 1; + cursor: pointer; + border-radius: 4px; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.tip-advisor-close:hover { + background: var(--tip-advisor-item-hover-bg); + color: var(--tip-advisor-text); +} + +.tip-advisor-close:focus { + outline: none; + background: var(--tip-advisor-item-focus-bg); + color: var(--tip-advisor-text); +} + +/* ========================================================================== + LIST CONTAINER + ========================================================================== */ + +.tip-advisor-list { + flex: 1; + overflow-y: auto; + padding: var(--tip-advisor-padding); + display: flex; + flex-direction: column; + gap: var(--tip-advisor-gap); +} + +/* Custom scrollbar */ +.tip-advisor-list::-webkit-scrollbar { + width: 6px; +} + +.tip-advisor-list::-webkit-scrollbar-track { + background: transparent; +} + +.tip-advisor-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.tip-advisor-list::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* ========================================================================== + MENU ITEMS + ========================================================================== */ + +.tip-advisor-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--tip-advisor-item-padding); + background: var(--tip-advisor-item-bg); + border-radius: var(--tip-advisor-item-border-radius); + cursor: pointer; + transition: background-color 150ms ease; + user-select: none; +} + +.tip-advisor-item:hover, +.tip-advisor-item--hover { + background: var(--tip-advisor-item-hover-bg); +} + +.tip-advisor-item--focus { + background: var(--tip-advisor-item-focus-bg); +} + +.tip-advisor-item-content { + font-size: 13px; + color: var(--tip-advisor-text); + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tip-advisor-item-shortcut { + display: inline-flex; + align-items: center; + padding: 3px 6px; + margin-left: 12px; + background: var(--tip-advisor-shortcut-bg); + color: var(--tip-advisor-shortcut-text); + border-radius: 4px; + font-size: 11px; + line-height: 1; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; + font-weight: 500; + letter-spacing: 0.02em; + flex-shrink: 0; +} + +/* ========================================================================== + EMPTY STATE + ========================================================================== */ + +.tip-advisor-empty { + padding: 24px 16px; + text-align: center; + color: var(--tip-advisor-text-secondary); + font-size: 13px; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +/* ========================================================================== + SEARCH HIGHLIGHT + ========================================================================== */ + +.tip-advisor-item-content mark, +.tip-advisor-item-shortcut mark { + background: var(--tip-advisor-highlight-bg); + color: var(--tip-advisor-highlight-text); + border-radius: 2px; + padding: 0 1px; +} + +/* ========================================================================== + NO BACKDROP VARIANT + ========================================================================== */ + +.tip-advisor--no-backdrop { + pointer-events: none; +} + +.tip-advisor--no-backdrop .tip-advisor-menu { + pointer-events: auto; +} + +/* ========================================================================== + ACCESSIBILITY + ========================================================================== */ + +@media (prefers-reduced-motion: reduce) { + .tip-advisor-backdrop, + .tip-advisor-menu { + animation: none; + } + + .tip-advisor-item, + .tip-advisor-close { + transition: none; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index fd37091..73d663a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -83,8 +83,6 @@ export interface TipMagicOptions { disabled?: boolean; /** Portal container element */ portalContainer?: HTMLElement; - /** Separator for tooltip content parsing */ - contentSeparator?: string; /** Enable keyboard shortcut styling */ enableShortcutStyle?: boolean; /** Respect prefers-reduced-motion */ @@ -140,8 +138,8 @@ export interface TooltipShowOptions { moveTransitionDuration?: number; /** Show/hide arrow */ showArrow?: boolean; - /** Content separator for keyboard shortcuts */ - contentSeparator?: string; + /** Keyboard shortcut to display */ + shortcut?: string; } /** @@ -310,8 +308,8 @@ export interface ParsedTooltipData { moveTransitionDuration?: number; /** Show or hide the arrow (default: true) */ showArrow: boolean; - /** Override content separator for keyboard shortcuts (default: ';') */ - contentSeparator?: string; + /** Keyboard shortcut parsed from data-tip-shortcut attribute */ + shortcut?: string; /** Group identifier for controlling move transitions between grouped elements */ group?: string; /** Whether to show tooltip when element receives focus */ @@ -381,3 +379,15 @@ export type { TourStep, UseTourReturn, } from './tour'; + +// ============================================================================= +// TipAdvisor Types +// ============================================================================= + +export type { + TipAdvisorAPI, + TipAdvisorItem, + TipAdvisorPosition, + TipAdvisorPresetItem, + TipAdvisorProps, +} from './tipAdvisor'; diff --git a/src/types/tipAdvisor.ts b/src/types/tipAdvisor.ts new file mode 100644 index 0000000..2106aa7 --- /dev/null +++ b/src/types/tipAdvisor.ts @@ -0,0 +1,103 @@ +/** + * TipAdvisor Types + * + * Types for the optional TipAdvisor component that displays a menu + * of all tooltips with keyboard shortcuts for discoverability. + */ + +/** + * Position options for the TipAdvisor menu + */ +export type TipAdvisorPosition = + | 'center' + | 'top' + | 'bottom' + | 'bottom-right' + | 'bottom-left' + | 'top-right' + | 'top-left'; + +/** + * A preset item that can be added to TipAdvisor without being tied to a DOM element. + * Useful for command palette-style menus with custom actions. + */ +export interface TipAdvisorPresetItem { + /** Unique identifier for the item */ + id: string; + /** Label/content to display */ + label: string; + /** Optional keyboard shortcut to display */ + shortcut?: string; + /** Callback executed when the item is clicked */ + onSelect: () => void; +} + +/** + * Represents a single item in the TipAdvisor menu. + * Can be either element-based (from DOM) or preset (from props). + */ +export interface TipAdvisorItem { + /** Unique identifier (from data-tip-id or generated) */ + id: string; + /** The DOM element this item refers to (undefined for preset items) */ + element?: Element; + /** Tooltip content / label */ + content: string; + /** Keyboard shortcut (from data-tip-shortcut or preset) */ + shortcut?: string; + /** Callback for preset items (undefined for element-based items) */ + onSelect?: () => void; +} + +/** + * Props for the TipAdvisor component + */ +export interface TipAdvisorProps { + /** Key to trigger the advisor (default: 'F1') */ + triggerKey?: string; + /** Position on screen (default: 'center') */ + position?: TipAdvisorPosition; + /** Show close button in header (default: true) */ + showCloseButton?: boolean; + /** Show backdrop overlay behind the menu (default: true) */ + showBackdrop?: boolean; + /** Close when clicking backdrop (default: true, only applies if showBackdrop is true) */ + closeOnBackdropClick?: boolean; + /** Custom CSS class for the menu container */ + className?: string; + /** Custom CSS class for menu items */ + itemClassName?: string; + /** + * Selector for elements to include (default: '[data-tip][data-tip-shortcut]'). + * Set to empty string or null to disable DOM scanning (useful for preset-only menus). + */ + selector?: string | null; + /** Callback when advisor opens */ + onOpen?: () => void; + /** Callback when advisor closes */ + onClose?: () => void; + /** Placeholder text for the search input (default: 'Search shortcuts...') */ + searchPlaceholder?: string; + /** + * Preset items to display in the menu. + * These are command palette-style items not tied to DOM elements. + * They appear alongside any DOM-scanned items. + */ + items?: TipAdvisorPresetItem[]; +} + +/** + * API exposed by TipAdvisor via ref for programmatic control + */ +export interface TipAdvisorAPI { + /** Open the TipAdvisor menu */ + open: () => void; + /** Close the TipAdvisor menu */ + close: () => void; + /** Toggle the TipAdvisor menu */ + toggle: () => void; + /** Whether the menu is currently open */ + isOpen: boolean; + /** Current list of items in the menu */ + items: TipAdvisorItem[]; +} diff --git a/src/utils/__tests__/getTipProps.test.ts b/src/utils/__tests__/getTipProps.test.ts index c631a7c..1fee86b 100644 --- a/src/utils/__tests__/getTipProps.test.ts +++ b/src/utils/__tests__/getTipProps.test.ts @@ -133,17 +133,28 @@ describe('getTipProps', () => { }); }); - describe('contentSeparator property', () => { - it('should include separator when provided', () => { - const result = getTipProps({ tip: 'Hello', contentSeparator: '|' }); - expect(result['data-tip-separator']).toBe('|'); + describe('shortcut property', () => { + it('should include shortcut when provided', () => { + const result = getTipProps({ tip: 'Copy', shortcut: '⌘C' }); + expect(result['data-tip-shortcut']).toBe('⌘C'); + }); + + it('should not include shortcut when empty string', () => { + const result = getTipProps({ tip: 'Hello', shortcut: '' }); + expect(result['data-tip-shortcut']).toBeUndefined(); + }); + + it('should not include shortcut when not provided', () => { + const result = getTipProps({ tip: 'Hello' }); + expect(result['data-tip-shortcut']).toBeUndefined(); }); }); describe('complex scenarios', () => { it('should handle all properties together', () => { const result = getTipProps({ - tip: 'Save; ⌘S', + tip: 'Save', + shortcut: '⌘S', id: 'save-btn', placement: 'bottom', showDelay: 100, @@ -157,7 +168,8 @@ describe('getTipProps', () => { moveTransitionDuration: 150, }); - expect(result['data-tip']).toBe('Save; ⌘S'); + expect(result['data-tip']).toBe('Save'); + expect(result['data-tip-shortcut']).toBe('⌘S'); expect(result['data-tip-id']).toBe('save-btn'); expect(result['data-tip-placement']).toBe('bottom'); expect(result['data-tip-delay']).toBe('100'); diff --git a/src/utils/__tests__/parseDataAttributes.test.ts b/src/utils/__tests__/parseDataAttributes.test.ts index abb646c..119f067 100644 --- a/src/utils/__tests__/parseDataAttributes.test.ts +++ b/src/utils/__tests__/parseDataAttributes.test.ts @@ -1,57 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { generateTooltipId, parseContent } from '../parseDataAttributes'; - -describe('parseContent', () => { - describe('with default separator (;)', () => { - it('should return main content when no shortcut is present', () => { - const result = parseContent('Save changes'); - expect(result).toEqual({ main: 'Save changes' }); - }); - - it('should extract shortcut after separator', () => { - const result = parseContent('Copy; ⌘C'); - expect(result).toEqual({ main: 'Copy', shortcut: '⌘C' }); - }); - - it('should trim whitespace from main and shortcut', () => { - const result = parseContent(' Save ; ⌘S '); - expect(result).toEqual({ main: 'Save', shortcut: '⌘S' }); - }); - - it('should handle multiple separators by joining remaining parts', () => { - const result = parseContent('Info; Part 1; Part 2'); - // Note: parts are trimmed individually, then joined with separator (no spaces added) - expect(result).toEqual({ main: 'Info', shortcut: 'Part 1;Part 2' }); - }); - - it('should handle empty content', () => { - const result = parseContent(''); - expect(result).toEqual({ main: '' }); - }); - - it('should handle content with only separator', () => { - const result = parseContent(';'); - expect(result).toEqual({ main: '', shortcut: '' }); - }); - }); - - describe('with custom separator', () => { - it('should use custom separator for splitting', () => { - const result = parseContent('Copy | ⌘C', '|'); - expect(result).toEqual({ main: 'Copy', shortcut: '⌘C' }); - }); - - it('should handle multi-character separators', () => { - const result = parseContent('Copy :: ⌘C', '::'); - expect(result).toEqual({ main: 'Copy', shortcut: '⌘C' }); - }); - - it('should not split on default separator when custom is provided', () => { - const result = parseContent('Save; ⌘S', '|'); - expect(result).toEqual({ main: 'Save; ⌘S' }); - }); - }); -}); +import { generateTooltipId } from '../parseDataAttributes'; describe('generateTooltipId', () => { it('should generate unique IDs', () => { diff --git a/src/utils/getTipProps.ts b/src/utils/getTipProps.ts index 85ebb8c..a98c3f2 100644 --- a/src/utils/getTipProps.ts +++ b/src/utils/getTipProps.ts @@ -41,8 +41,8 @@ export interface TipPropsOptions { moveTransitionDuration?: number; /** Show or hide the arrow (default: true) */ showArrow?: boolean; - /** Override content separator for keyboard shortcuts (default: ';') */ - contentSeparator?: string; + /** Keyboard shortcut to display alongside the tooltip content */ + shortcut?: string; /** Show tooltip when element receives keyboard focus */ showOnFocus?: boolean; } @@ -68,7 +68,7 @@ export interface TipPropsResult { 'data-tip-jump'?: ''; 'data-tip-move-duration'?: string; 'data-tip-no-arrow'?: ''; - 'data-tip-separator'?: string; + 'data-tip-shortcut'?: string; 'data-tip-show-on-focus'?: ''; } @@ -91,7 +91,8 @@ export interface TipPropsResult { * ```tsx * // With keyboard shortcut * const tipProps = getTipProps({ - * tip: 'Copy; ⌘C', + * tip: 'Copy', + * shortcut: '⌘C', * hideDelay: 500, * }); * @@ -165,8 +166,8 @@ export function getTipProps(options: TipPropsOptions): TipPropsResult { result['data-tip-no-arrow'] = ''; } - if (options.contentSeparator !== undefined) { - result['data-tip-separator'] = options.contentSeparator; + if (options.shortcut !== undefined && options.shortcut !== '') { + result['data-tip-shortcut'] = options.shortcut; } if (options.showOnFocus) { diff --git a/src/utils/parseDataAttributes.ts b/src/utils/parseDataAttributes.ts index 16ae5c3..899e276 100644 --- a/src/utils/parseDataAttributes.ts +++ b/src/utils/parseDataAttributes.ts @@ -1,5 +1,5 @@ import { DEFAULT_OPTIONS } from '../constants'; -import type { ParsedTooltipData, Placement, TextBreak, TooltipTransitionBehavior } from '../types'; +import type { ParsedTooltipData, Placement, TextBreak } from '../types'; /** * Valid placement values @@ -50,7 +50,7 @@ function parseBooleanAttribute(value: string | undefined): boolean { * Parse transition behavior from data attributes * data-tip-move takes precedence over data-tip-jump */ -function parseTransitionBehavior(dataset: DOMStringMap): TooltipTransitionBehavior | undefined { +function parseTransitionBehavior(dataset: DOMStringMap): 'move' | 'jump' | undefined { if (dataset.tipMove !== undefined) { return 'move'; } @@ -107,40 +107,12 @@ export function parseDataAttributes(element: Element): ParsedTooltipData { transitionBehavior: parseTransitionBehavior(dataset), moveTransitionDuration: parseOptionalInt(dataset.tipMoveDuration), showArrow: !parseBooleanAttribute(dataset.tipNoArrow), - contentSeparator: dataset.tipSeparator, + shortcut: dataset.tipShortcut, group: dataset.tipGroup, showOnFocus: parseBooleanAttribute(dataset.tipShowOnFocus), }; } -/** - * Parsed content with optional keyboard shortcut - */ -export interface ParsedContent { - main: string; - shortcut?: string; -} - -/** - * Parse tooltip content to extract keyboard shortcut - * Format: "Main text; keyboard shortcut" - */ -export function parseContent( - content: string, - separator: string = DEFAULT_OPTIONS.contentSeparator -): ParsedContent { - const parts = content.split(separator).map((s) => s.trim()); - - if (parts.length >= 2) { - return { - main: parts[0], - shortcut: parts.slice(1).join(separator), - }; - } - - return { main: content }; -} - /** * Generate a unique ID for a tooltip target if not provided */