diff --git a/docs/theme-usage.md b/docs/theme-usage.md new file mode 100644 index 000000000..aa6416944 --- /dev/null +++ b/docs/theme-usage.md @@ -0,0 +1,388 @@ +# IntroJS Theme System - Complete Usage Guide + +## Overview + +The IntroJS theme system allows you to easily customize the look and feel of your tours by loading different CSS themes. You can use pre-registered themes, create custom themes, or even load themes dynamically from external CSS files. + +## Features + +- ✅ **Dynamic CSS Loading** - Themes automatically load their CSS files +- ✅ **Pre-registered Themes** - Built-in themes: dark, light, modern, flattener, nassim, nazanin, royal +- ✅ **Auto Theme Detection** - Automatically adapts to system dark/light mode preference +- ✅ **Custom Themes** - Load any CSS file as a theme +- ✅ **Theme Registration** - Register custom themes for easy reuse +- ✅ **Tour Options Integration** - Set theme directly in tour options +- ✅ **Live Theme Switching** - Change the theme of a running tour with `tour.setTheme()` + +## Quick Start + +### 1. Using Pre-registered Themes in Tour Options + +The simplest way to use themes is by setting the `theme` option when creating a tour: + +```javascript +import introJs from 'intro.js'; + +// Use dark theme +introJs.tour().setOptions({ + theme: 'dark' +}).start(); + +// Use light theme +introJs.tour().setOptions({ + theme: 'light' +}).start(); + +// Use system preference (default) +introJs.tour().setOptions({ + theme: 'auto' +}).start(); + +// Use any pre-registered theme +introJs.tour().setOptions({ + theme: 'modern' +}).start(); +``` + +### 2. Loading Custom CSS Files + +You can load a custom CSS file by providing both `theme` and `themePath` options: + +```javascript +import introJs from 'intro.js'; + +// Load a custom theme from a CSS file +introJs.tour().setOptions({ + theme: 'ocean', + themePath: 'path/to/themes/introjs-ocean.css' +}).start(); + +// Load from CDN +introJs.tour().setOptions({ + theme: 'custom', + themePath: 'https://cdn.example.com/intro-custom-theme.css' +}).start(); +``` + +## Available Pre-registered Themes + +The following themes are available out of the box: + +| Theme Name | Description | CSS Path | +|------------|-------------|----------| +| `light` | Light theme (default style) | N/A - uses CSS custom properties, no file to load | +| `dark` | Dark theme | N/A - uses CSS custom properties, no file to load | +| `auto` | Follows the system preference, switches automatically | N/A - uses CSS custom properties, no file to load | +| `modern` | Modern theme | `themes/introjs-modern.css` | +| `flattener` | Flat design theme | `themes/introjs-flattener.css` | +| `nassim` | Nassim theme | `themes/introjs-nassim.css` | +| `nazanin` | Nazanin theme (RTL support) | `themes/introjs-nazanin.css` | +| `royal` | Royal theme | `themes/introjs-royal.css` | + +> Note: `light`, `dark` and `auto` are implemented purely with CSS classes/custom properties on the tour's root element, so no extra network request happens when you use them. The other themes are loaded on demand from the CSS file shown above. + +## Advanced Usage + +### 1. Registering Custom Themes + +You can register custom themes globally so they can be used by name: + +```javascript +import introJs from 'intro.js'; + +// Register a single theme +introJs.registerTheme('ocean', 'themes/introjs-ocean.css'); + +// Now you can use it by name +introJs.tour().setOptions({ + theme: 'ocean' +}).start(); + +// Register multiple themes at once +introJs.registerThemes([ + { name: 'sunset', cssPath: 'themes/introjs-sunset.css' }, + { name: 'forest', cssPath: 'themes/introjs-forest.css' }, + { name: 'corporate', cssPath: 'themes/introjs-corporate.css' } +]); +``` + +### 2. Changing the Theme of a Running Tour + +Every `Tour` instance exposes `setTheme()`/`getTheme()` so you can react to in-app theme toggles without restarting the tour: + +```javascript +import introJs from 'intro.js'; + +const tour = introJs.tour().setOptions({ theme: 'light' }); +await tour.start(); + +console.log(tour.getTheme()); // 'light' + +// Switch the running tour to dark mode +await tour.setTheme('dark'); +console.log(tour.getTheme()); // 'dark' + +// Switch to a custom CSS theme +await tour.setTheme('ocean', 'themes/introjs-ocean.css'); +``` + +Calling `setTheme()` before the tour has started simply stores the choice; it's applied the next time `start()` runs. + +### 3. Getting Theme Information + +```javascript +import introJs from 'intro.js'; + +// Get CSS path for a registered theme +const modernPath = introJs.getThemePath('modern'); +console.log(modernPath); // 'themes/introjs-modern.css' + +// Built-in themes (light/dark/auto) are not file-based, so this is undefined +console.log(introJs.getThemePath('dark')); // undefined + +// Get all registered theme names +const allThemes = introJs.getRegisteredThemes(); +console.log(allThemes); // ['modern', 'flattener', 'nassim', 'nazanin', 'royal', ...] +``` + +## Complete Examples + +### Example 1: Basic Tour with Dark Theme + +```javascript +import introJs from 'intro.js'; + +introJs.tour().setOptions({ + steps: [ + { + element: '#step1', + intro: 'Welcome to our app!' + }, + { + element: '#step2', + intro: 'This is a dark-themed tour.' + } + ], + theme: 'dark' +}).start(); +``` + +### Example 2: Custom Theme with Dynamic Loading + +```javascript +import introJs from 'intro.js'; + +// Register your custom theme +introJs.registerTheme('mycompany', 'assets/css/intro-mycompany-theme.css'); + +// Use it in your tour +introJs.tour().setOptions({ + steps: [ + { + element: '#welcome', + intro: 'Welcome to our custom-themed tour!' + } + ], + theme: 'mycompany' +}).start(); +``` + +### Example 3: System-Adaptive Theme + +```javascript +import introJs from 'intro.js'; + +// This tour will automatically use dark theme in dark mode +// and light theme in light mode, and keeps reacting if the +// user's OS theme changes while the tour is open +introJs.tour().setOptions({ + steps: [ + { + intro: 'This tour adapts to your system theme!' + } + ], + theme: 'auto' // This is the default +}).start(); +``` + +### Example 4: Changing Theme Mid-Tour + +```javascript +import introJs from 'intro.js'; + +const tour = introJs.tour().setOptions({ + steps: [ + { + intro: 'Starting with light theme' + }, + { + intro: 'Now switching to dark theme' + } + ], + theme: 'light' +}); + +tour.onBeforeChange(async function (targetElement) { + if (this.getCurrentStep() === 1) { + // Switch the already-running tour to dark theme on step 2 + await this.setTheme('dark'); + } +}); + +tour.start(); +``` + +## Creating Custom Theme CSS + +To create your own theme, create a CSS file with the following structure: + +```css +/* Custom Theme Example - themes/introjs-ocean.css */ + +.introjs-overlay { + background: #004d7a; + opacity: 0.8; +} + +.introjs-helperLayer { + background: #00a3cc; +} + +.introjs-tooltip { + background-color: #006994; + color: #ffffff; +} + +.introjs-tooltipbuttons { + background: #004d7a; +} + +.introjs-button { + color: #ffffff; + border: 2px solid #00a3cc; + background: transparent; +} + +.introjs-button:hover { + background: #00a3cc; + color: #ffffff; +} + +.introjs-disabled { + color: #7d7d7d; + border-color: #7d7d7d; +} + +/* Add more custom styles as needed */ +``` + +Then use it: + +```javascript +import introJs from 'intro.js'; + +introJs.tour().setOptions({ + theme: 'ocean', + themePath: 'themes/introjs-ocean.css' +}).start(); +``` + +## TypeScript Support + +The theme system is fully typed: + +```typescript +import introJs, { type ThemeType } from 'intro.js'; + +// Theme types +const theme: ThemeType = 'dark'; // 'light' | 'dark' | 'auto' | string + +// Register theme with types +introJs.registerTheme('custom', 'path/to/custom.css'); + +// Use in tour with types +introJs.tour().setOptions({ + theme: 'dark', + themePath: 'path/to/theme.css' // optional +}).start(); +``` + +## Best Practices + +1. **Use `auto` for Better UX**: Let users' system preferences determine the theme + ```javascript + introJs.tour().setOptions({ theme: 'auto' }).start(); + ``` + +2. **Register Themes Early**: Register all custom themes at app initialization + ```javascript + // app-init.js + import introJs from 'intro.js'; + + introJs.registerThemes([ + { name: 'brand', cssPath: 'themes/brand.css' }, + { name: 'seasonal', cssPath: 'themes/seasonal.css' } + ]); + ``` + +3. **Keep CSS Files Small**: Only include necessary styles in theme CSS files + +4. **Test Themes**: Test your themes in both light and dark system modes + +5. **Provide Fallbacks**: Always have a fallback theme in case custom CSS fails to load + +## Troubleshooting + +### Theme CSS Not Loading + +**Problem**: Custom theme CSS file is not loading + +**Solutions**: +- Check that the CSS file path is correct +- Verify the CSS file is accessible from your web server +- Check browser console for 404 errors +- Ensure CORS headers are set if loading from a different domain + +### Theme Not Applying + +**Problem**: Theme is registered but not applying + +**Solutions**: +- Make sure you call `introJs.registerTheme()` before starting the tour +- Verify the theme name matches exactly +- Check that the CSS selectors in your theme file are correct +- Ensure no other CSS is overriding your theme styles + +### Multiple CSS Files Loading + +**Problem**: Multiple theme CSS files are being loaded + +**Solutions**: +- The system automatically prevents duplicate loading of the same CSS file +- When you call `tour.setTheme()` with a different custom theme, the previous theme's `` is removed automatically +- Use the same theme name consistently + +## API Reference + +### Tour Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `theme` | `ThemeType` | `'auto'` | Theme name ('light', 'dark', 'auto', or custom) | +| `themePath` | `string` | `undefined` | Path to custom CSS file | + +### Tour Instance Methods + +| Method | Parameters | Returns | Description | +|--------|------------|---------|-------------| +| `tour.setTheme(theme, themePath?)` | `theme: ThemeType, themePath?: string` | `Promise` | Change the theme; applies immediately if the tour is running | +| `tour.getTheme()` | - | `'light' \| 'dark' \| undefined` | The currently resolved theme, or `undefined` before the tour starts | + +### `introJs` Theme Functions + +| Function | Parameters | Returns | Description | +|----------|------------|---------|-------------| +| `introJs.registerTheme(name, cssPath)` | `name: string, cssPath: string` | `void` | Register a custom theme | +| `introJs.registerThemes(themes)` | `themes: ThemeRegistration[]` | `void` | Register multiple themes | +| `introJs.getThemePath(name)` | `name: string` | `string \| undefined` | Get the CSS path for a theme (built-in themes return `undefined`) | +| `introJs.getRegisteredThemes()` | - | `string[]` | List the names of all registered themes | diff --git a/example/bootstrap/v3/index.html b/example/bootstrap/v3/index.html index 36ff7a0e5..150fe21ce 100755 --- a/example/bootstrap/v3/index.html +++ b/example/bootstrap/v3/index.html @@ -25,7 +25,7 @@ - + diff --git a/example/hello-world/index.html b/example/hello-world/index.html index 8f9eea0f4..bc383f5a7 100644 --- a/example/hello-world/index.html +++ b/example/hello-world/index.html @@ -35,7 +35,20 @@

Intro.js

Basic Usage

This is the basic usage of IntroJs, with data-step and data-intro attributes.

- Show me how +
+ + +
+ Show me how

@@ -68,5 +81,31 @@

Section Six


+ diff --git a/src/index.test.ts b/src/index.test.ts index 46ec3f394..2cc0009c1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -26,4 +26,15 @@ describe("index", () => { // Assert expect(hintInstance).toBeInstanceOf(Hint); }); + + it("should expose theme registration and lookup helpers", () => { + introJs.registerTheme("sunset", "/themes/sunset.css"); + introJs.registerThemes([{ name: "forest", cssPath: "/themes/forest.css" }]); + + expect(introJs.getThemePath("sunset")).toBe("/themes/sunset.css"); + expect(introJs.getThemePath("forest")).toBe("/themes/forest.css"); + expect(introJs.getRegisteredThemes()).toEqual( + expect.arrayContaining(["sunset", "forest"]) + ); + }); }); diff --git a/src/index.ts b/src/index.ts index b315cb5c1..06950e3b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,13 @@ import { version } from "../package.json"; import { Hint } from "./packages/hint"; import { Tour } from "./packages/tour"; +import { + registerTheme, + registerThemes, + getThemePath, + getRegisteredThemes, +} from "./packages/tour/theme"; +export type { ThemeType, ThemeRegistration } from "./packages/tour/theme"; class LegacyIntroJs extends Tour { /** @@ -63,4 +70,24 @@ introJs.hint = (elementOrSelector?: string | HTMLElement) => */ introJs.version = version; +/** + * Register a custom theme so it can be referenced by name in the `theme` tour option + */ +introJs.registerTheme = registerTheme; + +/** + * Register multiple custom themes at once + */ +introJs.registerThemes = registerThemes; + +/** + * Get the CSS path registered for a given theme name, if any + */ +introJs.getThemePath = getThemePath; + +/** + * List the names of all currently registered themes + */ +introJs.getRegisteredThemes = getRegisteredThemes; + export default introJs; diff --git a/src/packages/tour/option.ts b/src/packages/tour/option.ts index 54ad1bfbf..a24278fb7 100644 --- a/src/packages/tour/option.ts +++ b/src/packages/tour/option.ts @@ -1,6 +1,7 @@ import { TooltipPosition } from "../../packages/tooltip"; import { TourStep, ScrollTo } from "./steps"; import { Translator, LanguageCode } from "../../i18n/language"; +import { ThemeType } from "./theme"; export interface TourOptions { steps: Partial[]; @@ -80,6 +81,10 @@ export interface TourOptions { Built-in language codes: "en_US", "es_ES", "fr_FR", "de_DE", "fa_IR" Defaults to the user's browser language if not provided. */ language?: LanguageCode; + /* Theme for the tour - light, dark, auto, or custom theme name */ + theme?: ThemeType; + /* Path to custom CSS file for theme (optional) */ + themePath?: string; } export function getDefaultTourOptions(translator?: Translator): TourOptions { @@ -129,5 +134,6 @@ export function getDefaultTourOptions(translator?: Translator): TourOptions { progressBarAdditionalClass: "", tooltipRenderAsHtml: true, language: activeTranslator.getLanguage(), + theme: "auto", }; } diff --git a/src/packages/tour/refresh.test.ts b/src/packages/tour/refresh.test.ts index 6da31501e..e994cbfc5 100644 --- a/src/packages/tour/refresh.test.ts +++ b/src/packages/tour/refresh.test.ts @@ -81,4 +81,41 @@ describe("refresh", () => { expect(mockTour.getStep(1).intro).toBe("second"); expect(document.querySelectorAll(".introjs-bullets ul li").length).toBe(2); }); + + test("keeps the theme class on the recreated root when refreshStep is true", async () => { + // Arrange + mockTour.setOptions({ theme: "dark" }); + mockTour.addStep({ + intro: "first", + }); + + await mockTour.start(); + await sleep(waitMsForDerivations); + + expect( + document + .querySelector(".introjs-tour") + ?.classList.contains("introjs-dark") + ).toBe(true); + + // Act - refreshing with refreshSteps recreates the root element + mockTour.setOptions({ + steps: [ + { + intro: "first", + }, + { + intro: "second", + }, + ], + }); + + mockTour.refresh(true); + await sleep(waitMsForDerivations); + + // Assert - the newly created root should still carry the theme class + const newRoot = document.querySelector(".introjs-tour"); + expect(newRoot).not.toBeNull(); + expect(newRoot?.classList.contains("introjs-dark")).toBe(true); + }); }); diff --git a/src/packages/tour/theme.test.ts b/src/packages/tour/theme.test.ts new file mode 100644 index 000000000..92a3acf2b --- /dev/null +++ b/src/packages/tour/theme.test.ts @@ -0,0 +1,321 @@ +import { + Theme, + registerTheme, + registerThemes, + getThemePath, + getRegisteredThemes, +} from "./theme"; + +const mockMatchMedia = (prefersDark: boolean) => { + const listeners: Set<() => void> = new Set(); + const mql = { + matches: prefersDark, + addEventListener: jest.fn((_: string, cb: () => void) => listeners.add(cb)), + removeEventListener: jest.fn((_: string, cb: () => void) => + listeners.delete(cb) + ), + dispatchChange: () => { + mql.matches = !mql.matches; + listeners.forEach((cb) => cb()); + }, + }; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockReturnValue(mql), + }); + return mql; +}; + +describe("Theme", () => { + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + jest.resetAllMocks(); + }); + + afterEach(() => { + root.remove(); + }); + + describe("constructor", () => { + test("defaults to document.documentElement when no root provided", () => { + mockMatchMedia(false); + const theme = new Theme({ theme: "light" }); + expect(document.documentElement.classList.contains("introjs-light")).toBe( + true + ); + theme.destroy(); + document.documentElement.classList.remove("introjs-light"); + }); + + test("applies introjs-light class for light theme", () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "light" }); + expect(root.classList.contains("introjs-light")).toBe(true); + expect(root.classList.contains("introjs-dark")).toBe(false); + theme.destroy(); + }); + + test("applies introjs-dark class for dark theme", () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "dark" }); + expect(root.classList.contains("introjs-dark")).toBe(true); + expect(root.classList.contains("introjs-light")).toBe(false); + theme.destroy(); + }); + + test("applies introjs-dark when auto and system is dark", () => { + mockMatchMedia(true); + const theme = new Theme({ root, theme: "auto" }); + expect(root.classList.contains("introjs-dark")).toBe(true); + theme.destroy(); + }); + + test("applies introjs-light when auto and system is light", () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "auto" }); + expect(root.classList.contains("introjs-light")).toBe(true); + theme.destroy(); + }); + + test("defaults to auto when no theme provided", () => { + mockMatchMedia(false); + const theme = new Theme({ root }); + expect(root.classList.contains("introjs-light")).toBe(true); + theme.destroy(); + }); + }); + + describe("auto mode - system theme change", () => { + test("updates class when system theme changes", () => { + const mql = mockMatchMedia(false); + const theme = new Theme({ root, theme: "auto" }); + + expect(root.classList.contains("introjs-light")).toBe(true); + + mql.dispatchChange(); + + expect(root.classList.contains("introjs-dark")).toBe(true); + expect(root.classList.contains("introjs-light")).toBe(false); + + theme.destroy(); + }); + + test("registers addEventListener on construction", () => { + const mql = mockMatchMedia(false); + new Theme({ root, theme: "auto" }).destroy(); + expect(mql.addEventListener).toHaveBeenCalledWith( + "change", + expect.any(Function) + ); + }); + }); + + describe("destroy", () => { + test("removes event listener on destroy", () => { + const mql = mockMatchMedia(false); + const theme = new Theme({ root, theme: "auto" }); + theme.destroy(); + expect(mql.removeEventListener).toHaveBeenCalledWith( + "change", + expect.any(Function) + ); + }); + + test("does not listen for changes after destroy", () => { + const mql = mockMatchMedia(false); + const theme = new Theme({ root, theme: "auto" }); + theme.destroy(); + + root.classList.remove("introjs-light", "introjs-dark"); + mql.dispatchChange(); + + expect(root.classList.contains("introjs-dark")).toBe(false); + }); + + test("does not throw when destroyed twice", () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "light" }); + expect(() => { + theme.destroy(); + theme.destroy(); + }).not.toThrow(); + }); + }); + + describe("setTheme", () => { + test("switches from light to dark", async () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "light" }); + await theme.setTheme("dark"); + expect(root.classList.contains("introjs-dark")).toBe(true); + expect(root.classList.contains("introjs-light")).toBe(false); + theme.destroy(); + }); + + test("switches from dark to light", async () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "dark" }); + await theme.setTheme("light"); + expect(root.classList.contains("introjs-light")).toBe(true); + expect(root.classList.contains("introjs-dark")).toBe(false); + theme.destroy(); + }); + + test("removes the previous custom theme's when switching to a new custom theme", async () => { + mockMatchMedia(false); + const theme = new Theme({ + root, + theme: "themeA", + themePath: "/themes/a.css", + }); + + const linkA = document.querySelector('link[data-introjs-theme="themeA"]'); + expect(linkA).not.toBeNull(); + linkA?.dispatchEvent(new Event("load")); + // let the pending loadCssFile promise resolve so _loadedThemeId is recorded + await Promise.resolve(); + + // start switching to another custom theme; the old link is removed + // synchronously before the new one finishes loading. + theme.setTheme("themeB", "/themes/b.css"); + + expect( + document.querySelector('link[data-introjs-theme="themeA"]') + ).toBeNull(); + expect(document.querySelectorAll("link[data-introjs-theme]").length).toBe( + 1 + ); + + document + .querySelector('link[data-introjs-theme="themeB"]') + ?.dispatchEvent(new Event("load")); + theme.destroy(); + }); + + test("cleans up the superseded theme's when setTheme is called again before the previous load finishes", async () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "light" }); + + const firstCall = theme.setTheme("themeA", "/themes/a.css"); + const secondCall = theme.setTheme("themeB", "/themes/b.css"); + + const linkA = document.querySelector('link[data-introjs-theme="themeA"]'); + const linkB = document.querySelector('link[data-introjs-theme="themeB"]'); + expect(linkA).not.toBeNull(); + expect(linkB).not.toBeNull(); + + // resolve out of order: the newer theme (B) finishes loading first, + // then the superseded one (A) finishes after. + linkB?.dispatchEvent(new Event("load")); + linkA?.dispatchEvent(new Event("load")); + + await Promise.all([firstCall, secondCall]); + + expect( + document.querySelector('link[data-introjs-theme="themeA"]') + ).toBeNull(); + expect( + document.querySelector('link[data-introjs-theme="themeB"]') + ).not.toBeNull(); + expect(document.querySelectorAll("link[data-introjs-theme]").length).toBe( + 1 + ); + + theme.destroy(); + }); + + test("starts reacting to system theme changes after switching to auto", async () => { + const mql = mockMatchMedia(false); + const theme = new Theme({ root, theme: "light" }); + + await theme.setTheme("auto"); + mql.dispatchChange(); + + expect(root.classList.contains("introjs-dark")).toBe(true); + theme.destroy(); + }); + + test("stops reacting to system theme changes after switching away from auto", async () => { + const mql = mockMatchMedia(false); + const theme = new Theme({ root, theme: "auto" }); + + await theme.setTheme("light"); + root.classList.remove("introjs-light", "introjs-dark"); + mql.dispatchChange(); + + expect(root.classList.contains("introjs-dark")).toBe(false); + theme.destroy(); + }); + }); + + describe("setRoot", () => { + test("moves theme class to new root", () => { + mockMatchMedia(false); + const newRoot = document.createElement("div"); + const theme = new Theme({ root, theme: "light" }); + + theme.setRoot(newRoot); + + expect(newRoot.classList.contains("introjs-light")).toBe(true); + theme.destroy(); + }); + + test("does nothing when same root is passed", () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "light" }); + theme.setRoot(root); + expect(root.classList.contains("introjs-light")).toBe(true); + theme.destroy(); + }); + }); + + describe("value getter", () => { + test("returns light for light theme", () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "light" }); + expect(theme.value).toBe("light"); + theme.destroy(); + }); + + test("returns dark for dark theme", () => { + mockMatchMedia(false); + const theme = new Theme({ root, theme: "dark" }); + expect(theme.value).toBe("dark"); + theme.destroy(); + }); + }); +}); + +describe("registerTheme / registerThemes", () => { + test("registers a custom theme", () => { + registerTheme("ocean", "/themes/ocean.css"); + expect(getThemePath("ocean")).toBe("/themes/ocean.css"); + }); + + test("registers multiple themes at once", () => { + registerThemes([ + { name: "sunset", cssPath: "/themes/sunset.css" }, + { name: "forest", cssPath: "/themes/forest.css" }, + ]); + expect(getThemePath("sunset")).toBe("/themes/sunset.css"); + expect(getThemePath("forest")).toBe("/themes/forest.css"); + }); + + test("getRegisteredThemes includes registered themes", () => { + registerTheme("coral", "/themes/coral.css"); + expect(getRegisteredThemes()).toContain("coral"); + }); + + test("returns undefined for unknown theme", () => { + expect(getThemePath("nonexistent-theme-xyz")).toBeUndefined(); + }); + + test("built-in themes are not in the registry", () => { + expect(getThemePath("light")).toBeUndefined(); + expect(getThemePath("dark")).toBeUndefined(); + expect(getThemePath("auto")).toBeUndefined(); + }); +}); diff --git a/src/packages/tour/theme.ts b/src/packages/tour/theme.ts new file mode 100644 index 000000000..ef488b1c1 --- /dev/null +++ b/src/packages/tour/theme.ts @@ -0,0 +1,253 @@ +export type ThemeType = "light" | "dark" | "auto" | string; + +export interface ThemeOptions { + theme?: ThemeType; + root?: HTMLElement; + themePath?: string; // Path to custom CSS file +} + +export interface ThemeRegistration { + name: string; + cssPath: string; +} + +// Built-in themes handled via CSS classes + variables — no external CSS needed. +const builtInThemes = new Set(["light", "dark", "auto"]); + +// Registry for external CSS-file-based themes (e.g. "modern", "nassim"). +const themeRegistry: Map = new Map([ + ["modern", "themes/introjs-modern.css"], + ["flattener", "themes/introjs-flattener.css"], + ["nassim", "themes/introjs-nassim.css"], + ["nazanin", "themes/introjs-nazanin.css"], + ["royal", "themes/introjs-royal.css"], +]); + +// Track loaded CSS files to avoid duplicate loads +const loadedCssFiles: Set = new Set(); + +function loadCssFile(cssPath: string, themeId: string): Promise { + return new Promise((resolve, reject) => { + if (loadedCssFiles.has(cssPath)) { + resolve(); + return; + } + + const existingLink = document.querySelector( + `link[data-introjs-theme="${themeId}"]` + ); + if (existingLink) { + resolve(); + return; + } + + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = cssPath; + link.setAttribute("data-introjs-theme", themeId); + + link.onload = () => { + loadedCssFiles.add(cssPath); + resolve(); + }; + + link.onerror = () => { + reject(new Error(`Failed to load theme CSS: ${cssPath}`)); + }; + + document.head.appendChild(link); + }); +} + +function unloadCssFile(themeId: string): void { + const link = document.querySelector(`link[data-introjs-theme="${themeId}"]`); + if (link) { + const cssPath = link.getAttribute("href"); + link.remove(); + if (cssPath) { + loadedCssFiles.delete(cssPath); + } + } +} + +export function registerTheme(name: string, cssPath: string): void { + themeRegistry.set(name, cssPath); +} + +export function registerThemes(themes: ThemeRegistration[]): void { + themes.forEach((theme) => { + registerTheme(theme.name, theme.cssPath); + }); +} + +export function getThemePath(themeName: string): string | undefined { + return themeRegistry.get(themeName); +} + +export function getRegisteredThemes(): string[] { + return Array.from(themeRegistry.keys()); +} + +export class Theme { + private _theme: "light" | "dark"; + private _root: HTMLElement; + private _currentThemeName: string; + // id of the external CSS file currently loaded (if any), used to unload it later. + // Kept separate from _currentThemeName because that field is updated to the *new* + // theme before the *old* theme's CSS needs to be unloaded. + private _loadedThemeId: string | null = null; + private mqlDark: MediaQueryList | null = null; + private boundHandleSystemThemeChange: () => void; + private themeType: ThemeType; + + constructor(options: ThemeOptions = {}) { + this._root = options.root ?? document.documentElement; + this.themeType = options.theme ?? "auto"; + this._currentThemeName = this.themeType; + + this.boundHandleSystemThemeChange = this.handleSystemThemeChange.bind(this); + + this._theme = this.resolveTheme(this.themeType); + + this.loadThemeCss(this.themeType, options.themePath).catch((error) => { + console.error("Failed to load theme:", error); + }); + + this.applyToRoot(); + + this.syncAutoListener(); + } + + private syncAutoListener() { + const shouldListen = this.themeType === "auto"; + + if (shouldListen && !this.mqlDark) { + this.mqlDark = window.matchMedia("(prefers-color-scheme: dark)"); + if ("addEventListener" in this.mqlDark) { + this.mqlDark.addEventListener( + "change", + this.boundHandleSystemThemeChange + ); + } else { + (this.mqlDark as any).addListener(this.boundHandleSystemThemeChange); + } + } else if (!shouldListen && this.mqlDark) { + this.removeAutoListener(); + } + } + + private removeAutoListener() { + if (!this.mqlDark) return; + + if ("removeEventListener" in this.mqlDark) { + this.mqlDark.removeEventListener( + "change", + this.boundHandleSystemThemeChange + ); + } else { + (this.mqlDark as any).removeListener(this.boundHandleSystemThemeChange); + } + this.mqlDark = null; + } + + private resolveTheme(theme: ThemeType): "light" | "dark" { + if (theme === "auto") { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + if (theme === "dark") { + return "dark"; + } + return "light"; + } + + private async loadThemeCss( + themeName: string, + customPath?: string + ): Promise { + if (this._loadedThemeId) { + unloadCssFile(this._loadedThemeId); + this._loadedThemeId = null; + } + + // Built-in themes (light, dark, auto) use CSS classes — no external file needed. + if (builtInThemes.has(themeName)) { + return; + } + + if (customPath) { + await loadCssFile(customPath, themeName); + this.onCssLoaded(themeName); + return; + } + + const registeredPath = getThemePath(themeName); + if (registeredPath) { + await loadCssFile(registeredPath, themeName); + this.onCssLoaded(themeName); + } + } + + // Called once a theme's CSS has finished loading. If setTheme() was called + // again in the meantime, this load has already been superseded — unload it + // immediately instead of tracking it, so it doesn't leak or get mistaken + // for the currently active theme. + private onCssLoaded(themeName: string): void { + if (this._currentThemeName === themeName) { + this._loadedThemeId = themeName; + } else { + unloadCssFile(themeName); + } + } + + private handleSystemThemeChange() { + if (this.themeType === "auto") { + this._theme = this.resolveTheme("auto"); + this.applyToRoot(); + } + } + + private applyToRoot() { + this._root.classList.remove("introjs-light", "introjs-dark"); + this._root.classList.add(`introjs-${this._theme}`); + } + + public async setTheme( + themeType: ThemeType, + themePath?: string + ): Promise { + this.themeType = themeType; + this._currentThemeName = themeType; + this._theme = this.resolveTheme(themeType); + + this.syncAutoListener(); + await this.loadThemeCss(themeType, themePath); + this.applyToRoot(); + } + + public setRoot(root: HTMLElement) { + if (this._root !== root) { + this._root = root; + this.applyToRoot(); + } + } + + public destroy() { + this.removeAutoListener(); + + if (this._loadedThemeId) { + unloadCssFile(this._loadedThemeId); + this._loadedThemeId = null; + } + } + + public get value(): "light" | "dark" { + return this._theme; + } + + public get currentTheme(): string { + return this._currentThemeName; + } +} diff --git a/src/packages/tour/tour.test.ts b/src/packages/tour/tour.test.ts index fe95afea9..0566115ad 100644 --- a/src/packages/tour/tour.test.ts +++ b/src/packages/tour/tour.test.ts @@ -784,4 +784,64 @@ describe("Tour", () => { expect(mockTour.getCurrentStep()).toBe(undefined); }); }); + + describe("setTheme / getTheme", () => { + beforeEach(() => { + // other tests in this file may leave a stray .introjs-tour element + // behind; start from a clean slate so querySelector below is unambiguous. + document.querySelectorAll(".introjs-tour").forEach((el) => el.remove()); + }); + + afterEach(async () => { + document + .querySelectorAll("[data-introjs-theme]") + .forEach((link) => link.remove()); + }); + + test("getTheme returns undefined before the tour starts", () => { + const mockTour = getMockTour(); + expect(mockTour.getTheme()).toBeUndefined(); + }); + + test("applies the theme option once the tour starts", async () => { + const mockTour = getMockTour(); + mockTour.setOptions({ theme: "dark" }); + mockTour.addStep({ intro: "first" }); + + await mockTour.start(); + await sleep(waitMsForDerivations); + + expect(mockTour.getTheme()).toBe("dark"); + expect( + document + .querySelector(".introjs-tour") + ?.classList.contains("introjs-dark") + ).toBe(true); + + await mockTour.exit(); + await sleep(waitMsForExitTransition); + }); + + test("switches the theme on a running tour", async () => { + const mockTour = getMockTour(); + mockTour.setOptions({ theme: "light" }); + mockTour.addStep({ intro: "first" }); + + await mockTour.start(); + await sleep(waitMsForDerivations); + + await mockTour.setTheme("dark"); + + expect(mockTour.getTheme()).toBe("dark"); + expect( + document + .querySelector(".introjs-tour") + ?.classList.contains("introjs-dark") + ).toBe(true); + expect(mockTour.getOption("theme")).toBe("dark"); + + await mockTour.exit(); + await sleep(waitMsForExitTransition); + }); + }); }); diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index 76daaa6cb..30b11bf74 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -22,6 +22,7 @@ import onKeyDown from "./onKeyDown"; import dom from "../dom"; import { TourRoot } from "./components/TourRoot"; import { FloatingElement } from "./components/FloatingElement"; +import { Theme, ThemeType } from "./theme"; /** * Intro.js Tour class @@ -35,6 +36,7 @@ export class Tour implements Package { private readonly _targetElement: HTMLElement; private _options: TourOptions; private _floatingElement: Element | undefined; + private _theme: Theme | undefined; private readonly callbacks: { beforeChange?: introBeforeChangeCallback; @@ -451,6 +453,11 @@ export class Tour implements Package { this._root.remove(); this._root = undefined; this.createRoot(); + + const root = this._root as Element | undefined; + if (root instanceof HTMLElement) { + this._theme?.setRoot(root); + } } } @@ -460,6 +467,7 @@ export class Tour implements Package { async start() { if (await start(this)) { this.createRoot(); + this.initTheme(); this.enableKeyboardNavigation(); this.enableRefreshOnResize(); } @@ -467,6 +475,42 @@ export class Tour implements Package { return this; } + private initTheme() { + if (!this._root || !(this._root instanceof HTMLElement)) return; + this._theme?.destroy(); + this._theme = new Theme({ + root: this._root, + theme: this._options.theme, + themePath: this._options.themePath, + }); + } + + /** + * Changes the tour's theme. If the tour is already running, the change is + * applied immediately; otherwise it's stored and used the next time the + * tour starts. + * @param {ThemeType} theme "light", "dark", "auto", or a registered/custom theme name + * @param {string} themePath Optional path to a custom CSS file for the theme + */ + async setTheme(theme: ThemeType, themePath?: string): Promise { + this._options.theme = theme; + this._options.themePath = themePath; + + if (this._theme) { + await this._theme.setTheme(theme, themePath); + } + + return this; + } + + /** + * Returns the currently resolved theme ("light" or "dark"), or undefined + * if the tour hasn't started yet. + */ + getTheme(): "light" | "dark" | undefined { + return this._theme?.value; + } + /** * Exit the tour * @param {boolean} force whether to force exit the tour @@ -475,6 +519,8 @@ export class Tour implements Package { if (await exitIntro(this, force ?? false)) { this.disableKeyboardNavigation(); this.disableRefreshOnResize(); + this._theme?.destroy(); + this._theme = undefined; } return this; diff --git a/src/styles/introjs.scss b/src/styles/introjs.scss index 087aefe05..1d4912637 100644 --- a/src/styles/introjs.scss +++ b/src/styles/introjs.scss @@ -8,11 +8,49 @@ $black200: #eeeeee; $black100: #f4f4f4; $white: #ffffff; -$font_family: "Helvetica Neue", Inter, ui-sans-serif, "Apple Color Emoji", - Helvetica, Arial, sans-serif; +$font_family: "Helvetica Neue", Inter, ui-sans-serif, "Apple Color Emoji", Helvetica, Arial, sans-serif; $background_color_9: #08c; $background_color_10: rgba(136, 136, 136, 0.24); +/* =================== THEME CLASSES =================== */ +.introjs-light { + --introjs-btn-bg: #{$black100}; + --introjs-btn-color: #{$black800}; + --introjs-btn-border: #{$black400}; + --introjs-btn-hover-bg: #{$black300}; + --introjs-btn-hover-color: #{$black900}; + --introjs-btn-focus-bg: #{$black200}; + --introjs-btn-focus-color: #{$black800}; + --introjs-btn-active-bg: #{$black300}; + --introjs-btn-active-color: #{$black900}; + --introjs-btn-active-border: #{$black500}; + --introjs-btn-textShadow: #{$white}; + + --introjs-arrow-color: #{$white}; + --introjs-overlay-bg: #{$black100}; + --introjs-bg: #{$white}; + --introjs-color: #{$black900}; +} + +.introjs-dark { + --introjs-btn-bg: #{$black600}; + --introjs-btn-color: #{$white}; + --introjs-btn-border: #{$white}; + --introjs-btn-hover-bg: #{$white}; + --introjs-btn-hover-color: #{$black800}; + --introjs-btn-focus-bg: #{$black500}; + --introjs-btn-focus-color: #{$white}; + --introjs-btn-active-bg: #{$black600}; + --introjs-btn-active-color: #{$white}; + --introjs-btn-active-border: #{$black400}; + --introjs-btn-textShadow: #{$black800}; + + --introjs-arrow-color: #{$black800}; + --introjs-overlay-bg: #{$black800}; + --introjs-bg: #{$black800}; + --introjs-color: #{$white}; +} + .introjs-tour { transition: all 0.3s ease-out; } @@ -104,70 +142,70 @@ tr.introjs-showElement { .introjs-arrow.top { top: -10px; left: 10px; - border-bottom-color: $white; + border-bottom-color: var(--introjs-arrow-color); } .introjs-arrow.top-right { top: -10px; right: 10px; - border-bottom-color: $white; + border-bottom-color: var(--introjs-arrow-color); } .introjs-arrow.top-middle { top: -10px; left: 50%; margin-left: -5px; - border-bottom-color: $white; + border-bottom-color: var(--introjs-arrow-color); } .introjs-arrow.right { right: -10px; top: 10px; - border-left-color: $white; + border-left-color: var(--introjs-arrow-color); } .introjs-arrow.right-bottom { bottom: 10px; right: -10px; - border-left-color: $white; + border-left-color: var(--introjs-arrow-color); } .introjs-arrow.bottom { bottom: -10px; left: 10px; - border-top-color: $white; + border-top-color: var(--introjs-arrow-color); } .introjs-arrow.bottom-right { bottom: -10px; right: 10px; - border-top-color: $white; + border-top-color: var(--introjs-arrow-color); } .introjs-arrow.bottom-middle { bottom: -10px; left: 50%; margin-left: -5px; - border-top-color: $white; + border-top-color: var(--introjs-arrow-color); } .introjs-arrow.left { left: -10px; top: 10px; - border-right-color: $white; + border-right-color: var(--introjs-arrow-color); } .introjs-arrow.left-bottom { left: -10px; bottom: 10px; - border-right-color: $white; + border-right-color: var(--introjs-arrow-color); } .introjs-tooltip { box-sizing: content-box; position: absolute; visibility: visible; - background-color: $white; + background-color: var(--introjs-bg); min-width: 250px; max-width: 300px; border-radius: 5px; @@ -177,6 +215,7 @@ tr.introjs-showElement { .introjs-tooltiptext { padding: 20px; + color: var(--introjs-color); } .introjs-dontShowAgain { @@ -199,7 +238,7 @@ tr.introjs-showElement { font-weight: normal; margin: 0 0 0 5px; padding: 0; - background-color: $white; + background-color: var(--introjs-bg); color: $black600; user-select: none; } @@ -212,6 +251,7 @@ tr.introjs-showElement { padding: 0; font-weight: 700; line-height: 1.5; + color: var(--introjs-color); } .introjs-tooltip-header { @@ -220,6 +260,7 @@ tr.introjs-showElement { padding-right: 20px; padding-top: 10px; min-height: 1.5em; + color: var(--introjs-color); } .introjs-tooltipbuttons { @@ -246,13 +287,13 @@ tr.introjs-showElement { border: 1px solid $black400; text-decoration: none; - text-shadow: 1px 1px 0 $white; + text-shadow: 1px 1px 0 var(--introjs-btn-textShadow); font-size: 14px; - color: $black800; + color: var(--introjs-btn-color); white-space: nowrap; cursor: pointer; outline: none; - background-color: $black100; + background-color: var(--introjs-btn-bg); border-radius: 0.2em; zoom: 1; display: inline; @@ -260,26 +301,26 @@ tr.introjs-showElement { &:hover { outline: none; text-decoration: none; - border-color: $black500; - background-color: $black300; - color: $black900; + border-color: var(--introjs-btn-border); + background-color: var(--introjs-btn-hover-bg); + color: var(--introjs-btn-hover-color);; } &:focus { outline: none; text-decoration: none; - background-color: $black200; + background-color: var(--introjs-btn-focus-bg); box-shadow: 0 0 0 0.2rem rgba($black500, 0.5); - border: 1px solid $black600; - color: $black900; + border: 1px solid var(--introjs-btn-border); + color: var(--introjs-btn-focus-color); } &:active { outline: none; text-decoration: none; - background-color: $black300; - border-color: $black500; - color: $black900; + background-color: var(--introjs-btn-active-bg); + border-color: var(--introjs-btn-active-border); + color: var(--introjs-btn-active-color); } &::-moz-focus-inner { @@ -298,7 +339,7 @@ tr.introjs-showElement { height: 45px; line-height: 45px; - color: $black600; + color: var(--introjs-btn-color); font-size: 22px; cursor: pointer; font-weight: bold; @@ -307,7 +348,7 @@ tr.introjs-showElement { &:hover, &:focus { - color: $black900; + color: var(--introjs-btn-focus-color); outline: none; text-decoration: none; } @@ -315,28 +356,30 @@ tr.introjs-showElement { .introjs-prevbutton { float: left; + color: var(--introjs-btn-color); } .introjs-nextbutton { float: right; + color: var(--introjs-btn-color); } .introjs-disabled { - color: $black500; - border-color: $black400; + color: var(--introjs-btn-color); + border-color: var(--introjs-btn-border); box-shadow: none; cursor: default; - background-color: $black100; + background-color: var(--introjs-btn-bg); background-image: none; text-decoration: none; &:hover, &:focus { - color: $black500; - border-color: $black400; + color: var(--introjs-btn-focus-color); + border-color: var(--introjs-btn-border); box-shadow: none; cursor: default; - background-color: $black100; + background-color: var(--introjs-btn-focus-bg); background-image: none; text-decoration: none; } diff --git a/tests/jest/setup.ts b/tests/jest/setup.ts index 65bced6d3..7607d63db 100644 --- a/tests/jest/setup.ts +++ b/tests/jest/setup.ts @@ -1,7 +1,6 @@ import "regenerator-runtime/runtime"; import { TextEncoder, TextDecoder } from "util"; -// ✅ Polyfill for environments missing these (keeps it safe) if (typeof global.TextEncoder === "undefined") { (global as any).TextEncoder = TextEncoder; } @@ -9,10 +8,15 @@ if (typeof global.TextDecoder === "undefined") { (global as any).TextDecoder = TextDecoder; } -// ✅ Use Jest's built-in JSDOM globals, not your own instance -// The testEnvironment: "jsdom" already provides `window`, `document`, `Element`, `SVGElement`, etc. -// You don’t need to create a new JSDOM manually. - -// ✅ Optional custom matchers (uncomment if you use them) -// import * as matchers from "jest-extended"; -// expect.extend(matchers); +// jsdom does not implement window.matchMedia — stub it so Theme doesn't throw +if (typeof window !== "undefined" && !window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + }); +} diff --git a/themes/introjs-modern.css b/themes/introjs-modern.css index fccca9729..22b564b70 100644 --- a/themes/introjs-modern.css +++ b/themes/introjs-modern.css @@ -1,5 +1,11 @@ .introjs-tooltip { - background-color: rgba(000, 0, 0, 0.5); + background-color: rgba(0, 0, 0, 0.85); + color: #fff; + backdrop-filter: blur(10px); +} + +.introjs-tooltiptext, +.introjs-tooltip-title { color: #fff; } @@ -26,15 +32,15 @@ } .introjs-arrow { - border: 10px solid #fff; + border: 10px solid transparent; } .introjs-arrow.top, .introjs-arrow.top-middle, .introjs-arrow.top-right { - border-color: transparent transparent rgba(000, 0, 0, 0.5); + border-color: transparent transparent rgba(0, 0, 0, 0.85); top: -20px; left: 20px; } .introjs-arrow.bottom, .introjs-arrow.bottom-middle, .introjs-arrow.bottom-right { - border-color: rgba(000, 0, 0, 0.5) transparent transparent; + border-color: rgba(0, 0, 0, 0.85) transparent transparent; bottom: -20px; left: 20px; } @@ -47,9 +53,16 @@ .introjs-arrow.left, .introjs-arrow.left-bottom { left: -20px; - border-color: transparent rgba(000, 0, 0, 0.5) transparent transparent; + border-color: transparent rgba(0, 0, 0, 0.85) transparent transparent; } .introjs-arrow.right, .introjs-arrow.right-bottom { right: -20px; - border-color: transparent transparent transparent rgba(000, 0, 0, 0.5); + border-color: transparent transparent transparent rgba(0, 0, 0, 0.85); +} + +.introjs-tooltip .introjs-dontShowAgain label { + background-color: #0202022e; + padding: 4px 6px; + color: #d66363; + border-radius: 4px; } diff --git a/themes/introjs-nassim.css b/themes/introjs-nassim.css index d3dbb938e..50c96285a 100644 --- a/themes/introjs-nassim.css +++ b/themes/introjs-nassim.css @@ -74,7 +74,7 @@ } .introjs-arrow { - border: 5px solid white; + border: 5px solid transparent; content:''; position: absolute; } @@ -127,14 +127,8 @@ } .introjs-tooltiptext { - margin-left: -10px; - - margin-right: -10px; - /* border-top: 1px solid #FFFFFF; */ - /* background: #FAFAFA; */ - color: #2c3e50; - padding: 25px 30px 15px; - /* border-bottom: 1px solid #FFFFFF; */ + color: #2c3e50; + padding: 20px; } .introjs-tooltipbuttons { @@ -156,46 +150,33 @@ /* box-shadow: 0 2px 0px -0px #306588; */ margin: 0; outline: none; - border: 2px solid; + border: 2px solid #2980b9; background: transparent; text-decoration: none; font: 11px/normal sans-serif; - color: #2980b9 !important; + color: #2980b9; + text-shadow: none; white-space: nowrap; cursor: pointer; - outline: none !important; - -webkit-background-clip: padding; - -moz-background-clip: padding; - -o-background-clip: padding-box; - /*background-clip: padding-box;*/ /* commented out due to Opera 11.10 bug */ - -webkit-border-radius: 0.2em; - -moz-border-radius: 0.2em; border-radius: 0.2em; - /* IE hacks */ - zoom: 1; - *display: inline; margin-top: 10px; - transition:all 0.25s ease; - -webkit-transition:all 0.25s ease; - -moz-transition:all 0.25s ease; - -ms-transition:all 0.25s ease; - -o-transition:all 0.25s ease; + transition: all 0.25s ease; } .introjs-button:hover { color: #fff; background: #2671A2; - text-decoration: none; border-color: #235677; + text-decoration: none; } .introjs-button:focus, .introjs-button:active { - background: #23587A; text-decoration: none; - color: #fff; - /* bottom: -1px; */ - box-shadow: none; - border-color: #173B53; + background: #23587A; + color: #fff; + border-color: #173B53; + box-shadow: none; + text-decoration: none; } /* overrides extra padding on button elements in Firefox */ @@ -205,9 +186,9 @@ } .introjs-skipbutton { - margin-right: 5px; color: #c00; background: transparent; + border: none; } .introjs-skipbutton:hover { @@ -222,17 +203,19 @@ } -.introjs-prevbutton { - -webkit-border-radius: 0.2em 0 0 0.2em; - -moz-border-radius: 0.2em 0 0 0.2em; - border-radius: 0.2em 0 0 0.2em; - border-right: none; +.introjs-tooltipbuttons .introjs-prevbutton { + border-radius: 0.2em; + color: #2980b9; } -.introjs-nextbutton { - -webkit-border-radius: 0 0.2em 0.2em 0; - -moz-border-radius: 0 0.2em 0.2em 0; - border-radius: 0 0.2em 0.2em 0; +.introjs-tooltipbuttons .introjs-nextbutton { + border-radius: 0.2em; + color: #2980b9; +} + +.introjs-tooltipbuttons .introjs-nextbutton:hover, +.introjs-tooltipbuttons .introjs-prevbutton:hover { + color: #fff; } .introjs-disabled, .introjs-disabled:hover, .introjs-disabled:focus { @@ -247,10 +230,6 @@ .introjs-bullets { text-align: center; - position: absolute; - left: 0; - right: 0; - top: -5px; } .introjs-bullets ul { clear: both; diff --git a/themes/introjs-nazanin.css b/themes/introjs-nazanin.css index 682252834..0b7cc82a6 100644 --- a/themes/introjs-nazanin.css +++ b/themes/introjs-nazanin.css @@ -60,7 +60,7 @@ } .introjs-arrow { - border: 5px solid white; + border: 5px solid transparent; content:''; position: absolute; } @@ -111,14 +111,8 @@ } .introjs-tooltiptext { - margin-left: -10px; - - margin-right: -10px; - border-top: 1px solid #FFFFFF; - background: #FAFAFA; - color: #2c3e50; - padding: 5px 10px; - border-bottom: 1px solid #FFFFFF; + color: #2c3e50; + padding: 20px; } .introjs-tooltipbuttons { @@ -130,30 +124,12 @@ Changed by Afshin Mehrabani */ .introjs-button { - position: relative; - overflow: visible; - display: inline-block; - padding: 0.5em 0.8em; - box-shadow: 0 2px 0px -0px #306588; - margin: 0; - outline: none; background: #2980b9; - text-decoration: none; - font: 11px/normal sans-serif; - color: #fff !important; - white-space: nowrap; - cursor: pointer; - outline: none !important; - -webkit-background-clip: padding; - -moz-background-clip: padding; - -o-background-clip: padding-box; - /*background-clip: padding-box;*/ /* commented out due to Opera 11.10 bug */ - -webkit-border-radius: 0.2em; - -moz-border-radius: 0.2em; + color: #fff; + text-shadow: none; + border: none; border-radius: 0.2em; - /* IE hacks */ - zoom: 1; - *display: inline; + box-shadow: 0 2px 0 #306588; margin-top: 10px; } @@ -178,21 +154,17 @@ } .introjs-skipbutton { - margin-right: 5px; - color: #fff; - background: #e74c3c; - box-shadow: 0 2px 0px -0px #B91D0D; + color: #e74c3c; + background: transparent; + border: none; + box-shadow: none; } -.introjs-skipbutton:hover { - background: #EB1540; box-shadow: 0 2px 0px -0px #B91D0D; - -} - -.introjs-skipbutton:active, .introjs-skipbutton:focus { - background: #C02312; - box-shadow: 0 1px 0px -0px #6F1309; - +.introjs-skipbutton:hover, +.introjs-skipbutton:focus { + color: #c0392b; + background: transparent; + box-shadow: none; } .introjs-prevbutton { diff --git a/themes/introjs-royal.css b/themes/introjs-royal.css index a2281adcf..50a01fa5d 100644 --- a/themes/introjs-royal.css +++ b/themes/introjs-royal.css @@ -1,285 +1,204 @@ -.introjs-overlay { - position: absolute; - z-index: 999999; - background: #525252; - opacity: 0; - - -webkit-transition: all 0.3s ease-out; - -moz-transition: all 0.3s ease-out; - -ms-transition: all 0.3s ease-out; - -o-transition: all 0.3s ease-out; - transition: all 0.3s ease-out; -} - -.introjs-fixParent { - z-index: auto !important; - opacity: 1.0 !important; -} - -.introjs-showElement { - z-index: 9999999 !important; -} - -.introjs-relativePosition { - position: relative; -} - -.introjs-helperLayer { - position: absolute; - z-index: 9999998; - background-color: #FFF; - background-color: rgba(255,255,255,.9); - border: 1px solid #777; - border: 3px solid rgba(255, 255, 255, 1); - border-radius: 0; - box-shadow: 0 8px 50px -10px rgba(0,0,0,.6); - -webkit-transition: all 0.3s ease-out; - -moz-transition: all 0.3s ease-out; - -ms-transition: all 0.3s ease-out; - -o-transition: all 0.3s ease-out; - transition: all 0.3s ease-out; -} - -.introjs-helperNumberLayer { - position: absolute; - top: -29px; - left: -29px; - z-index: 9999999999 !important; - padding: 3px; - font-family: Arial, verdana, tahoma; - font-size: 13px; - font-weight: bold; - color: #DA4433; /* Old browsers */ /* Chrome10+,Safari5.1+ */ - background: #FFFFFF; - width: 20px; - height:20px; - text-align: center; - line-height: 20px; - border: 3px solid #DA4433; - border-right: none; - border-bottom: none; /* IE6-9 */ /* IE10 text shadows */ - border-radius: 10px 0 0 0; -} - -.introjs-arrow { - border: 5px solid white; - content:''; - position: absolute; -} -.introjs-arrow.top { - top: -10px; - border-top-color:transparent; - border-right-color:transparent; - border-bottom-color: #ecf0f1; - border-left-color:transparent; - display: none !important; -} -.introjs-arrow.right { - right: -10px; - top: 10px; - border-top-color:transparent; - border-right-color:transparent; - border-bottom-color:transparent; - border-left-color:#ecf0f1; -} -.introjs-arrow.bottom { - bottom: -10px; - border-top-color:#ecf0f1; - border-right-color:transparent; - border-bottom-color:transparent; - border-left-color:transparent; -} -.introjs-arrow.left { - left: -10px; - top: 10px; - border-top-color:transparent; - border-right-color: #ecf0f1; - border-bottom-color:transparent; - border-left-color:transparent; -} - -.introjs-tooltip { - position: fixed; - padding: 10px 170px 30px 10px; - background-color: #ecf0f1; - min-width: 200px; - max-width: 300px; - /* border-radius: 3px; */ - border-top: 3px solid #236591; - box-shadow: 0 -6px 50px -4px rgba(0,0,0,.4); - -webkit-transition: opacity 0.1s ease-out; - -moz-transition: opacity 0.1s ease-out; - -ms-transition: opacity 0.1s ease-out; - -o-transition: opacity 0.1s ease-out; - transition: opacity 0.1s ease-out; - bottom: 0 !important; - left: 0 !Important; - top: initial !important; - right: 0 !Important; - max-width: initial; - width: auto !important; -} - -.introjs-tooltiptext { - margin-left: -10px; - - margin-right: -10px; - /* border-top: 1px solid #FFFFFF; */ - /* background: #FAFAFA; */ - color: #2c3e50; - padding: 5px 10px; - /* border-bottom: 1px solid #FFFFFF; */ -} - -.introjs-tooltipbuttons { - text-align: center; - position: absolute; - right: 10px; - top: 0; -} - -/* - Buttons style by http://nicolasgallagher.com/lab/css3-github-buttons/ - Changed by Afshin Mehrabani -*/ -.introjs-button { - position: relative; - overflow: visible; - display: inline-block; - padding: 0.5em 0.8em; - box-shadow: 0 2px 0px -0px #306588; - margin: 0; - outline: none; - background: #2980b9; - text-decoration: none; - font: 11px/normal sans-serif; - color: #fff !important; - white-space: nowrap; - cursor: pointer; - outline: none !important; - -webkit-background-clip: padding; - -moz-background-clip: padding; - -o-background-clip: padding-box; - /*background-clip: padding-box;*/ /* commented out due to Opera 11.10 bug */ - -webkit-border-radius: 0.2em; - -moz-border-radius: 0.2em; - border-radius: 0.2em; - /* IE hacks */ - zoom: 1; - *display: inline; - margin-top: 10px; -} - -.introjs-button:hover { - color: #fff; - background: #2671A2; - text-decoration: none; - box-shadow: 0 2px 0px -0px #235677; -} - -.introjs-button:focus, -.introjs-button:active { - background: #23587A; text-decoration: none; - /* bottom: -1px; */ - box-shadow: 0 2px 0px 0px #173B53; -} - -/* overrides extra padding on button elements in Firefox */ -.introjs-button::-moz-focus-inner { - padding: 0; - border: 0; -} - -.introjs-skipbutton { - margin-right: 5px; - color: #fff; - background: #e74c3c; - box-shadow: 0 2px 0px -0px #B91D0D; -} - -.introjs-skipbutton:hover { - background: #EB1540; box-shadow: 0 2px 0px -0px #B91D0D; - -} - -.introjs-skipbutton:active, .introjs-skipbutton:focus { - background: #C02312; - box-shadow: 0 1px 0px -0px #6F1309; - -} - -.introjs-prevbutton { - -webkit-border-radius: 0.2em 0 0 0.2em; - -moz-border-radius: 0.2em 0 0 0.2em; - border-radius: 0.2em 0 0 0.2em; - border-right: none; -} - -.introjs-nextbutton { - -webkit-border-radius: 0 0.2em 0.2em 0; - -moz-border-radius: 0 0.2em 0.2em 0; - border-radius: 0 0.2em 0.2em 0; -} - -.introjs-disabled, .introjs-disabled:hover, .introjs-disabled:focus { - color: #C2C2C2 !important; - border-color: #d4d4d4; - cursor: default; - box-shadow: 0 2px 0px -0px #CACED1; - background-color: #E6E6E6; - background-image: none; - text-decoration: none; -} - -.introjs-bullets { - text-align: center; - float: right; - position: absolute; - right: 10px; - bottom: 10px; -} -.introjs-bullets ul { - clear: both; - margin: 15px auto 0; - padding: 0; - display: inline-block; -} -.introjs-bullets ul li { - list-style: none; - float: left; - margin: 0 2px; -} -.introjs-bullets ul li a { - display: block; - width: 6px; - height: 6px; - background: #ccc; - border-radius: 10px; - -moz-border-radius: 10px; - -webkit-border-radius: 10px; - text-decoration: none; -} -.introjs-bullets ul li a:hover { - background: #999; -} -.introjs-bullets ul li a.active { - background: #999; -} - -.introjs-progress { - width: 20%; - position: absolute; - bottom: 10px; - background-color: #fff; -} -.introjs-progressbar { - background-color: #2980b9; -} - -.introjsFloatingElement { - position: absolute; - height: 0; - width: 0; - left: 50%; - top: 50%; -} +/* Royal theme — full-width bottom bar */ + +.introjs-overlay { + position: absolute; + z-index: 999999; + background: #525252; + opacity: 0; + transition: all 0.3s ease-out; +} + +.introjs-showElement { + z-index: 9999999 !important; +} + +.introjs-relativePosition { + position: relative; +} + +.introjs-helperLayer { + position: absolute; + z-index: 9999998; + background-color: rgba(255,255,255,.9); + border: 3px solid rgba(255,255,255,1); + border-radius: 0; + box-shadow: 0 8px 50px -10px rgba(0,0,0,.6); + transition: all 0.3s ease-out; +} + +.introjs-arrow { + border: 5px solid transparent; + content: ''; + position: absolute; +} +.introjs-arrow.top, .introjs-arrow.top-middle, .introjs-arrow.top-right { + display: none !important; +} +.introjs-arrow.bottom, .introjs-arrow.bottom-middle, .introjs-arrow.bottom-right { + bottom: -10px; + border-top-color: #ecf0f1; +} +.introjs-arrow.left, .introjs-arrow.left-bottom { + left: -10px; + border-right-color: #ecf0f1; +} +.introjs-arrow.right, .introjs-arrow.right-bottom { + right: -10px; + border-left-color: #ecf0f1; +} + +/* Full-width bottom bar */ +.introjs-tooltip { + position: fixed !important; + bottom: 0 !important; + left: 0 !important; + top: initial !important; + right: 0 !important; + max-width: initial !important; + width: auto !important; + min-width: unset !important; + border-radius: 0; + padding: 12px 220px 12px 20px; + background-color: #ecf0f1; + border-top: 3px solid #236591; + box-shadow: 0 -6px 50px -4px rgba(0,0,0,.3); + transition: opacity 0.1s ease-out; +} + +.introjs-tooltip-header { + padding: 0; + min-height: 0; +} + +.introjs-tooltip-title { + font-size: 15px; + font-weight: 700; + color: #2c3e50; + width: 100%; +} + +.introjs-tooltiptext { + color: #555; + padding: 4px 0 0 0; +} + +/* Buttons vertically centered on the right */ +.introjs-tooltipbuttons { + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + border-top: none; + padding: 0; + white-space: nowrap; +} + +.introjs-button { + background: #2980b9; + color: #fff; + text-shadow: none; + border: none; + border-radius: 4px; + box-shadow: 0 2px 0 #1a5276; + margin: 0 2px; + padding: 6px 14px; + font-size: 12px; + cursor: pointer; +} + +.introjs-button:hover { + background: #2471a3; + color: #fff; + text-decoration: none; +} + +.introjs-button:active, +.introjs-button:focus { + background: #1a6090; + color: #fff; + box-shadow: none; + text-decoration: none; +} + +.introjs-prevbutton, +.introjs-nextbutton { + float: none; + border-radius: 4px; + color: #fff; + text-shadow: none; +} + +.introjs-skipbutton { + color: #999; + background: transparent; + border: none; + box-shadow: none; + font-size: 20px; +} + +.introjs-skipbutton:hover, +.introjs-skipbutton:focus { + color: #555; + background: transparent; + box-shadow: none; +} + +.introjs-disabled, +.introjs-disabled:hover, +.introjs-disabled:focus { + background-color: #bdc3c7; + color: #fff; + box-shadow: none; + cursor: default; + text-decoration: none; +} + +.introjs-bullets { + position: absolute; + right: 20px; + bottom: 6px; + text-align: right; +} + +.introjs-bullets ul { + margin: 0; + padding: 0; + display: inline-block; +} + +.introjs-bullets ul li { + list-style: none; + float: left; + margin: 0 2px; +} + +.introjs-bullets ul li a { + display: block; + width: 6px; + height: 6px; + background: #bdc3c7; + border-radius: 10px; + text-decoration: none; + cursor: pointer; +} + +.introjs-bullets ul li a:hover, +.introjs-bullets ul li a.active { + background: #2980b9; +} + +.introjs-progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background-color: #d5d8dc; + border-radius: 0; + margin: 0; +} + +.introjs-progressbar { + background-color: #2980b9; + height: 100%; +}