All files / packages/theme-selector/src integration.ts

100% Statements 23/23
100% Branches 14/14
100% Functions 6/6
100% Lines 22/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165                                            13x                                                                                           13x       13x                                                                       24x 24x 8x 8x     16x 16x 16x 13x   16x                           16x 16x 14x   2x                                   18x 7x 7x 6x     18x 18x    
// SPDX-License-Identifier: MIT
/**
 * Public integration API for driving theming from outside the package.
 *
 * Site pages (e.g. the homepage showcase) use these functions to apply a
 * theme, query the active theme, and subscribe to theme changes without
 * reaching into selector internals. The theme-selector package remains
 * the owner of persistence, the `data-theme`/`data-appearance` root
 * attributes, and dropdown state.
 */
 
import { resolveThemeAppearance, type ThemeAppearance } from './appearance.js';
import {
  applyTheme as applyThemeToDocument,
  getCurrentTheme as getCurrentThemeFromDocument,
} from './apply-theme.js';
import { DEFAULT_THEME } from './constants.js';
import { ThemeErrors, logThemeError } from './errors.js';
import { saveTheme } from './storage.js';
import { getValidThemeIds, isValidThemeId } from './theme-resolver.js';
 
/** Name of the CustomEvent dispatched on the document after a theme change. */
export const THEME_CHANGE_EVENT = 'turbo-theme-change';
 
/** Payload carried by theme-change notifications. */
export interface ThemeChangeDetail {
  /** ID of the theme that was applied (e.g. `catppuccin-mocha`). */
  themeId: string;
  /** Light/dark appearance of the applied theme. */
  appearance: ThemeAppearance;
}
 
/** Listener invoked with the payload of each theme change. */
export type ThemeChangeListener = (detail: ThemeChangeDetail) => void;
 
/**
 * Outcome of an applyTheme call, reporting each stage independently so
 * consumers can distinguish full success from partial application.
 */
export interface ApplyThemeResult {
  /**
   * True when the theme ID was valid and the `data-theme` /
   * `data-appearance` root attributes and theme class were applied.
   */
  applied: boolean;
  /**
   * True when the theme's stylesheet is confirmed loaded (fetched now or
   * already linked). False means the DOM attributes may have changed
   * while the previous theme's CSS is still in effect.
   */
  cssLoaded: boolean;
  /**
   * True when the selection was persisted to localStorage. False when
   * storage is unavailable (e.g. private browsing) or over quota.
   */
  persisted: boolean;
}
 
/** Removes a listener previously registered via subscribeToThemeChanges. */
export type Unsubscribe = () => void;
 
/**
 * Dispatches the theme-change CustomEvent on the document.
 *
 * @param documentObj - Document to dispatch the event on
 * @param themeId - ID of the theme that was applied
 */
function emitThemeChange(documentObj: Document, themeId: string): void {
  const detail: ThemeChangeDetail = {
    themeId,
    appearance: resolveThemeAppearance(themeId),
  };
  documentObj.dispatchEvent(new CustomEvent<ThemeChangeDetail>(THEME_CHANGE_EVENT, { detail }));
}
 
/**
 * Applies a theme by ID through the theme-selector pipeline.
 *
 * Persists the selection, updates the `data-theme`/`data-appearance`
 * attributes and theme CSS, syncs dropdown state, and notifies
 * subscribers via the theme-change event.
 *
 * Each stage is reported independently in the returned
 * {@link ApplyThemeResult} so consumers can distinguish full success
 * from partial application:
 *
 * - Unknown or malformed theme IDs are rejected: nothing is applied, no
 *   event is dispatched, and all result fields are false.
 * - `persisted` is false when localStorage is unavailable or over
 *   quota; the theme is still applied visually.
 * - `cssLoaded` is false when the stylesheet fetch fails; the root
 *   attributes have changed but the previous theme's CSS remains in
 *   effect, so the theme-change event is NOT dispatched.
 *
 * The theme-change event fires only when the theme is visually applied
 * (`applied && cssLoaded`); persistence failure alone does not suppress
 * it.
 *
 * @param themeId - ID of the theme to apply (e.g. `catppuccin-mocha`)
 * @param documentObj - Document to apply the theme to (defaults to global)
 * @param windowObj - Window used for persistence (defaults to global)
 * @returns Per-stage outcome of the application
 */
export async function applyTheme(
  themeId: string,
  documentObj: Document = document,
  windowObj: Window = window,
): Promise<ApplyThemeResult> {
  const validIds = getValidThemeIds();
  if (!isValidThemeId(themeId) || !validIds.has(themeId)) {
    logThemeError(ThemeErrors.INVALID_THEME_ID(themeId));
    return { applied: false, cssLoaded: false, persisted: false };
  }
 
  const persisted = saveTheme(windowObj, themeId, validIds);
  const cssLoaded = await applyThemeToDocument(documentObj, themeId);
  if (cssLoaded) {
    emitThemeChange(documentObj, themeId);
  }
  return { applied: true, cssLoaded, persisted };
}
 
/**
 * Gets the ID of the currently active theme.
 *
 * Prefers the `data-theme` root attribute (set by both the blocking
 * script and the theme-selector), falls back to the `theme-*` class,
 * and finally to the default theme.
 *
 * @param documentObj - Document to inspect (defaults to global)
 * @returns The active theme ID
 */
export function getCurrentTheme(documentObj: Document = document): string {
  const fromAttribute = documentObj.documentElement.getAttribute('data-theme');
  if (fromAttribute) {
    return fromAttribute;
  }
  return getCurrentThemeFromDocument(documentObj, DEFAULT_THEME);
}
 
/**
 * Subscribes to theme-change notifications.
 *
 * The listener fires after each successful theme application — whether
 * triggered through applyTheme or the dropdown UI — with the applied
 * theme ID and its light/dark appearance.
 *
 * @param listener - Callback invoked with each theme-change payload
 * @param documentObj - Document to listen on (defaults to global)
 * @returns Function that removes the listener when called
 */
export function subscribeToThemeChanges(
  listener: ThemeChangeListener,
  documentObj: Document = document,
): Unsubscribe {
  const handler = (event: Event): void => {
    const detail = (event as CustomEvent<ThemeChangeDetail>).detail;
    if (detail) {
      listener(detail);
    }
  };
  documentObj.addEventListener(THEME_CHANGE_EVENT, handler);
  return () => documentObj.removeEventListener(THEME_CHANGE_EVENT, handler);
}