update packages and add valign

This commit is contained in:
2026-04-05 20:00:27 +02:00
parent b062fb98e3
commit 03fb00e374
640 changed files with 109768 additions and 39311 deletions

View File

@@ -0,0 +1,138 @@
import { useContext, useLayoutEffect, useMemo, useRef } from 'react';
import { RevealContext } from './context';
import type { CodeProps } from './types';
type HighlightPlugin = {
highlightBlock?: (block: HTMLElement) => void;
};
function normalizeCode(code: string) {
const lines = code.replace(/\r\n/g, '\n').split('\n');
while (lines.length && lines[0].trim().length === 0) lines.shift();
while (lines.length && lines[lines.length - 1].trim().length === 0) lines.pop();
if (!lines.length) return '';
const minIndent = lines
.filter((line) => line.trim().length > 0)
.reduce(
(acc, line) => Math.min(acc, line.match(/^\s*/)?.[0].length ?? 0),
Number.POSITIVE_INFINITY
);
return lines.map((line) => line.slice(minIndent)).join('\n');
}
function cleanupGeneratedFragments(block: HTMLElement) {
const pre = block.parentElement;
if (!pre) return;
// RevealHighlight creates extra <code.fragment> nodes for each highlight step (e.g. "1|3").
// Remove previously generated nodes before re-highlighting to avoid duplicate steps.
Array.from(pre.children).forEach((child) => {
if (
child !== block &&
child instanceof HTMLElement &&
child.tagName === 'CODE' &&
child.classList.contains('fragment')
) {
child.remove();
}
});
}
export function Code({
children,
code,
language,
trim = true,
lineNumbers,
startFrom,
noEscape,
codeClassName,
codeStyle,
codeProps,
className,
style,
...rest
}: CodeProps) {
const deck = useContext(RevealContext);
const codeRef = useRef<HTMLElement>(null);
const lastHighlightSignatureRef = useRef<string>('');
const rawCode = typeof code === 'string' ? code : typeof children === 'string' ? children : '';
const normalizedCode = useMemo(() => (trim ? normalizeCode(rawCode) : rawCode), [rawCode, trim]);
const lineNumbersValue =
lineNumbers === true
? ''
: lineNumbers === false || lineNumbers == null
? undefined
: String(lineNumbers);
const codeClasses = [language, codeClassName].filter(Boolean).join(' ');
const preClasses = ['code-wrapper', className].filter(Boolean).join(' ');
useLayoutEffect(() => {
const block = codeRef.current;
if (!block || !deck) return;
const plugin = deck.getPlugin?.('highlight') as HighlightPlugin | undefined;
if (!plugin || typeof plugin.highlightBlock !== 'function') return;
const highlightSignature = [
normalizedCode,
language || '',
codeClassName || '',
lineNumbersValue == null ? '__none__' : `lineNumbers:${lineNumbersValue}`,
startFrom == null ? '' : String(startFrom),
noEscape ? '1' : '0',
].join('::');
if (
lastHighlightSignatureRef.current === highlightSignature &&
block.getAttribute('data-highlighted') === 'yes'
) {
return;
}
cleanupGeneratedFragments(block);
block.textContent = normalizedCode;
block.removeAttribute('data-highlighted');
block.classList.remove('hljs');
block.classList.remove('has-highlights');
// Restore source attributes before each highlight call since RevealHighlight mutates
// data-line-numbers on the original block when it expands multi-step highlights.
if (lineNumbersValue == null) block.removeAttribute('data-line-numbers');
else block.setAttribute('data-line-numbers', lineNumbersValue);
if (startFrom == null) block.removeAttribute('data-ln-start-from');
else block.setAttribute('data-ln-start-from', String(startFrom));
if (noEscape) block.setAttribute('data-noescape', '');
else block.removeAttribute('data-noescape');
plugin.highlightBlock(block);
const slide = typeof block.closest === 'function' ? block.closest('section') : null;
if (slide && typeof deck.syncFragments === 'function') {
deck.syncFragments(slide as HTMLElement);
}
lastHighlightSignatureRef.current = highlightSignature;
}, [deck, normalizedCode, language, codeClassName, lineNumbersValue, startFrom, noEscape]);
return (
<pre className={preClasses} style={style} {...rest}>
<code
{...codeProps}
ref={codeRef}
className={codeClasses || undefined}
style={codeStyle}
data-line-numbers={lineNumbersValue}
data-ln-start-from={startFrom}
data-noescape={noEscape ? '' : undefined}
>
{normalizedCode}
</code>
</pre>
);
}

View File

@@ -0,0 +1,256 @@
import { useEffect, useLayoutEffect, useRef, useState, type Ref, type RefObject } from 'react';
import Reveal from 'reveal.js';
import type { RevealApi } from 'reveal.js';
import { RevealContext } from './context';
import type { DeckProps } from './types';
const DEFAULT_PLUGINS: NonNullable<DeckProps['plugins']> = [];
type DeckEventHandler = NonNullable<DeckProps['onSync']>;
type SlideStructureNode = number | [number, SlideStructureNode[]];
type CurrentRef<T> = { current: T };
// Shallow-compare config objects so that re-renders where the parent creates a new object
// literal with identical values do not trigger an unnecessary configure() call.
function hasShallowConfigChanges(prev: DeckProps['config'], next: DeckProps['config']) {
if (prev === next) return false;
if (!prev || !next) return prev !== next;
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return true;
for (const key of prevKeys) {
if (!(key in next)) return true;
if ((prev as Record<string, unknown>)[key] !== (next as Record<string, unknown>)[key]) {
return true;
}
}
return false;
}
function setRef<T>(ref: Ref<T | null> | undefined, value: T | null) {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
} else {
(ref as RefObject<T | null>).current = value;
}
}
function isSectionElement(element: Element): element is HTMLElement {
return element.tagName === 'SECTION';
}
function getSectionStructure(
container: Element,
slideIds: WeakMap<HTMLElement, number>,
nextSlideIdRef: CurrentRef<number>
): SlideStructureNode[] {
return Array.from(container.children)
.filter(isSectionElement)
.map((section) => {
let id = slideIds.get(section);
if (id === undefined) {
id = nextSlideIdRef.current++;
slideIds.set(section, id);
}
const childSlides = getSectionStructure(section, slideIds, nextSlideIdRef);
return childSlides.length > 0 ? [id, childSlides] : id;
});
}
function getSlidesStructureSignature(
slidesElement: HTMLElement | null,
slideIds: WeakMap<HTMLElement, number>,
nextSlideIdRef: CurrentRef<number>
) {
if (!slidesElement) return '[]';
return JSON.stringify(getSectionStructure(slidesElement, slideIds, nextSlideIdRef));
}
export function Deck({
config,
plugins = DEFAULT_PLUGINS,
onReady,
onSync,
onSlideSync,
onSlideChange,
onSlideTransitionEnd,
onFragmentShown,
onFragmentHidden,
onOverviewShown,
onOverviewHidden,
onPaused,
onResumed,
deckRef,
className,
style,
children,
}: DeckProps) {
const deckDivRef = useRef<HTMLDivElement>(null);
const slidesDivRef = useRef<HTMLDivElement>(null);
const revealRef = useRef<RevealApi | null>(null);
const [deck, setDeck] = useState<RevealApi | null>(null);
// Plugins are init-only in reveal.js; we register them once when creating the instance.
const initialPluginsRef = useRef<NonNullable<DeckProps['plugins']>>(plugins);
// configure() performs its own sync in Reveal; this flag prevents us from running an
// immediate second sync in the next layout effect pass.
const skipNextSyncRef = useRef(false);
// Track the last config reference we applied so we can skip redundant configure() calls.
const appliedConfigRef = useRef<DeckProps['config']>(config);
const lastSyncedSlidesSignatureRef = useRef<string | null>(null);
const slideIdsRef = useRef(new WeakMap<HTMLElement, number>());
const nextSlideIdRef = useRef(1);
const mountedRef = useRef(false);
const teardownRequestRef = useRef(0);
// Create the Reveal instance once on mount and destroy it on unmount.
useEffect(() => {
mountedRef.current = true;
teardownRequestRef.current += 1;
if (!revealRef.current) {
const instance = new Reveal(deckDivRef.current!, {
...config,
plugins: initialPluginsRef.current,
});
// Capture the config that was passed to the constructor so the configure
// effect can later detect whether anything actually changed.
appliedConfigRef.current = config;
revealRef.current = instance;
instance.initialize().then(() => {
if (!mountedRef.current || revealRef.current !== instance) return;
setDeck(instance);
onReady?.(instance);
});
} else if (revealRef.current.isReady()) {
// React StrictMode unmounts and remounts every effect. On the second mount
// the instance is already live, so skip construction. The isReady() guard
// ensures we only expose it once initialization has fully completed.
setDeck(revealRef.current);
}
return () => {
mountedRef.current = false;
const instance = revealRef.current;
if (!instance) return;
// Defer teardown to the next microtask. In StrictMode the component
// remounts immediately, incrementing teardownRequestRef before the
// microtask runs. The stale request number causes the callback to bail
// out, preventing the instance from being destroyed on a live component.
const teardownRequest = ++teardownRequestRef.current;
Promise.resolve().then(() => {
if (mountedRef.current || teardownRequestRef.current !== teardownRequest) return;
if (revealRef.current !== instance) return;
try {
instance.destroy();
} catch (e) {
// Ignore errors during cleanup
}
if (revealRef.current === instance) {
revealRef.current = null;
}
});
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Keep consumer refs in sync, including when the ref prop itself changes.
useEffect(() => {
setRef(deckRef, deck);
return () => setRef(deckRef, null);
}, [deckRef, deck]);
// Attach and detach Reveal event listeners from the provided callbacks.
useEffect(() => {
if (!deck) return;
const events: [string, DeckEventHandler | undefined][] = [
['sync', onSync],
['slidesync', onSlideSync],
['slidechanged', onSlideChange],
['slidetransitionend', onSlideTransitionEnd],
['fragmentshown', onFragmentShown],
['fragmenthidden', onFragmentHidden],
['overviewshown', onOverviewShown],
['overviewhidden', onOverviewHidden],
['paused', onPaused],
['resumed', onResumed],
];
const bound = events.filter((e): e is [string, DeckEventHandler] => e[1] != null);
for (const [name, handler] of bound) {
deck.on(name, handler);
}
return () => {
for (const [name, handler] of bound) {
deck.off(name, handler);
}
};
}, [
deck,
onSync,
onSlideSync,
onSlideChange,
onSlideTransitionEnd,
onFragmentShown,
onFragmentHidden,
onOverviewShown,
onOverviewHidden,
onPaused,
onResumed,
]);
// Re-apply config after init and mark that configure already performed a sync.
useLayoutEffect(() => {
if (!deck || !revealRef.current?.isReady()) return;
if (!hasShallowConfigChanges(appliedConfigRef.current, config)) return;
skipNextSyncRef.current = true;
revealRef.current.configure(config ?? {});
appliedConfigRef.current = config;
}, [deck, config]);
// Sync Reveal's internal slide bookkeeping only when the rendered slide
// structure changes. Avoid triggering sync for child changes.
useLayoutEffect(() => {
const shouldSkip = skipNextSyncRef.current;
skipNextSyncRef.current = false;
const slidesStructureSignature = getSlidesStructureSignature(
slidesDivRef.current,
slideIdsRef.current,
nextSlideIdRef
);
if (shouldSkip) {
lastSyncedSlidesSignatureRef.current = slidesStructureSignature;
return;
}
if (!revealRef.current?.isReady()) return;
if (lastSyncedSlidesSignatureRef.current === slidesStructureSignature) return;
revealRef.current.sync();
lastSyncedSlidesSignatureRef.current = slidesStructureSignature;
});
return (
<RevealContext.Provider value={deck}>
<div className={className ? `reveal ${className}` : 'reveal'} style={style} ref={deckDivRef}>
<div className="slides" ref={slidesDivRef}>
{children}
</div>
</div>
</RevealContext.Provider>
);
}

View File

@@ -0,0 +1,76 @@
import {
Children,
Fragment as ReactFragment,
cloneElement,
isValidElement,
type CSSProperties,
type ReactElement,
} from 'react';
import type { FragmentProps } from './types';
type FragmentChildProps = {
className?: string;
style?: CSSProperties;
'data-fragment-index'?: number;
};
function mergeClassNames(...classNames: Array<string | undefined>) {
return classNames.filter(Boolean).join(' ');
}
function mergeStyles(
childStyle: CSSProperties | undefined,
style: CSSProperties | undefined
) {
if (!childStyle) return style;
if (!style) return childStyle;
return {
...childStyle,
...style,
};
}
export function Fragment({
animation,
index,
as,
asChild,
className,
style,
children,
}: FragmentProps) {
const classes = mergeClassNames('fragment', animation, className);
if (asChild) {
let child: ReactElement<FragmentChildProps>;
try {
child = Children.only(children) as ReactElement<FragmentChildProps>;
} catch {
throw new Error('Fragment with asChild expects exactly one React element child.');
}
if (!isValidElement(child) || child.type === ReactFragment) {
throw new Error('Fragment with asChild expects exactly one non-Fragment React element child.');
}
const fragmentChildProps: FragmentChildProps = {
className: mergeClassNames(child.props.className, classes),
style: mergeStyles(child.props.style, style),
};
if (index !== undefined) {
fragmentChildProps['data-fragment-index'] = index;
}
return cloneElement(child, fragmentChildProps);
}
const Tag = as ?? 'span';
return (
<Tag className={classes} style={style} data-fragment-index={index}>
{children}
</Tag>
);
}

View File

@@ -0,0 +1,180 @@
import { useContext, useLayoutEffect, useRef } from 'react';
import type { RevealApi } from 'reveal.js';
import { RevealContext } from './context';
import type {
SlideAutoAnimateProps,
SlideBackgroundProps,
SlideDataAttributeValue,
SlideProps,
SlideRevealProps,
} from './types';
const EMPTY_DATA_ATTRIBUTES_SIGNATURE = '[]';
type SlideShorthandProps = SlideBackgroundProps & SlideAutoAnimateProps & SlideRevealProps;
const SLIDE_DATA_ATTRIBUTES: Record<keyof SlideShorthandProps, `data-${string}`> = {
background: 'data-background',
backgroundImage: 'data-background-image',
backgroundVideo: 'data-background-video',
backgroundVideoLoop: 'data-background-video-loop',
backgroundVideoMuted: 'data-background-video-muted',
backgroundIframe: 'data-background-iframe',
backgroundColor: 'data-background-color',
backgroundGradient: 'data-background-gradient',
backgroundSize: 'data-background-size',
backgroundPosition: 'data-background-position',
backgroundRepeat: 'data-background-repeat',
backgroundOpacity: 'data-background-opacity',
backgroundTransition: 'data-background-transition',
visibility: 'data-visibility',
autoAnimate: 'data-auto-animate',
autoAnimateId: 'data-auto-animate-id',
autoAnimateRestart: 'data-auto-animate-restart',
autoAnimateUnmatched: 'data-auto-animate-unmatched',
autoAnimateEasing: 'data-auto-animate-easing',
autoAnimateDuration: 'data-auto-animate-duration',
autoAnimateDelay: 'data-auto-animate-delay',
transition: 'data-transition',
transitionSpeed: 'data-transition-speed',
autoSlide: 'data-autoslide',
notes: 'data-notes',
backgroundInteractive: 'data-background-interactive',
preload: 'data-preload',
};
type SlideElementProps = Omit<SlideProps, 'children' | keyof SlideShorthandProps>;
function getDataAttributesSignature(attributes: SlideElementProps) {
return JSON.stringify(
Object.entries(attributes)
.filter(([key]) => key.startsWith('data-'))
.sort(([a], [b]) => a.localeCompare(b))
);
}
function getSlideAttributes(
attributes: SlideElementProps,
shorthandProps: SlideShorthandProps
): SlideElementProps {
const resolvedAttributes = { ...attributes } as SlideElementProps;
const resolvedDataAttributes = resolvedAttributes as unknown as Record<
string,
SlideDataAttributeValue
>;
for (const [propName, dataAttributeName] of Object.entries(SLIDE_DATA_ATTRIBUTES) as Array<
[keyof SlideShorthandProps, `data-${string}`]
>) {
if (resolvedDataAttributes[dataAttributeName] !== undefined) continue;
const value = shorthandProps[propName];
if (value === undefined) continue;
if (value === false) {
if (propName === 'autoAnimateUnmatched') {
resolvedDataAttributes[dataAttributeName] = 'false';
}
continue;
}
resolvedDataAttributes[dataAttributeName] = typeof value === 'boolean' ? '' : value;
}
return resolvedAttributes;
}
export function Slide({
children,
background,
backgroundImage,
backgroundVideo,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundIframe,
backgroundColor,
backgroundGradient,
backgroundSize,
backgroundPosition,
backgroundRepeat,
backgroundOpacity,
backgroundTransition,
visibility,
autoAnimate,
autoAnimateId,
autoAnimateRestart,
autoAnimateUnmatched,
autoAnimateEasing,
autoAnimateDuration,
autoAnimateDelay,
transition,
transitionSpeed,
autoSlide,
notes,
backgroundInteractive,
preload,
...rest
}: SlideProps) {
const deck = useContext(RevealContext);
const slideRef = useRef<HTMLElement>(null);
const lastSyncedDeckRef = useRef<RevealApi | null>(null);
const lastSyncedSignatureRef = useRef<string | null>(null);
const slideAttributes = getSlideAttributes(rest, {
background,
backgroundImage,
backgroundVideo,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundIframe,
backgroundColor,
backgroundGradient,
backgroundSize,
backgroundPosition,
backgroundRepeat,
backgroundOpacity,
backgroundTransition,
visibility,
autoAnimate,
autoAnimateId,
autoAnimateRestart,
autoAnimateUnmatched,
autoAnimateEasing,
autoAnimateDuration,
autoAnimateDelay,
transition,
transitionSpeed,
autoSlide,
notes,
backgroundInteractive,
preload,
});
const dataAttributesSignature = getDataAttributesSignature(slideAttributes);
useLayoutEffect(() => {
const slide = slideRef.current;
if (!deck || !slide || typeof deck.syncSlide !== 'function') return;
// The first render for a given deck instance is handled by the parent Deck.sync() pass.
// syncSlide() is only safe once Reveal is aware of this slide.
if (lastSyncedDeckRef.current !== deck) {
lastSyncedDeckRef.current = deck;
lastSyncedSignatureRef.current = dataAttributesSignature;
return;
}
if (dataAttributesSignature === EMPTY_DATA_ATTRIBUTES_SIGNATURE) return;
const sameDeck = lastSyncedDeckRef.current === deck;
const sameDataAttributes = lastSyncedSignatureRef.current === dataAttributesSignature;
if (sameDeck && sameDataAttributes) return;
deck.syncSlide(slide);
lastSyncedDeckRef.current = deck;
lastSyncedSignatureRef.current = dataAttributesSignature;
}, [deck, dataAttributesSignature]);
return (
<section ref={slideRef} {...slideAttributes}>
{children}
</section>
);
}

View File

@@ -0,0 +1,9 @@
import type { StackProps } from './types';
export function Stack({ className, style, children }: StackProps) {
return (
<section className={className} style={style}>
{children}
</section>
);
}

View File

@@ -0,0 +1,142 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Code } from '../Code';
import { RevealContext } from '../context';
describe('Code', () => {
it('renders pre/code and trims multiline template literals by default', () => {
const { container } = render(
<Code language="javascript">{`
function add(a, b) {
return a + b;
}
`}</Code>
);
const pre = container.querySelector('pre');
const code = container.querySelector('pre > code');
expect(pre).toBeInTheDocument();
expect(pre).toHaveClass('code-wrapper');
expect(code).toHaveClass('javascript');
expect(code?.textContent).toBe('function add(a, b) {\n\treturn a + b;\n}');
});
it('can disable trimming', () => {
const source = '\n\tconst value = 1;\n';
const { container } = render(<Code trim={false}>{source}</Code>);
const code = container.querySelector('pre > code');
expect(code?.textContent).toBe(source);
});
it('maps line number props to reveal data attributes', () => {
const { container } = render(
<Code language="js" lineNumbers="|2,4-6|8" startFrom={10}>
{`console.log('hello')`}
</Code>
);
const code = container.querySelector('pre > code');
expect(code).toHaveAttribute('data-line-numbers', '|2,4-6|8');
expect(code).toHaveAttribute('data-ln-start-from', '10');
});
it('invokes the Reveal highlight plugin when registered on the deck', () => {
const highlightBlock = vi.fn((block: HTMLElement) => {
block.setAttribute('data-highlighted', 'yes');
});
const deck = {
getPlugin: vi.fn().mockReturnValue({ highlightBlock }),
syncFragments: vi.fn(),
} as any;
const { container } = render(
<RevealContext.Provider value={deck}>
<section>
<Code language="javascript">{`console.log('hello')`}</Code>
</section>
</RevealContext.Provider>
);
const code = container.querySelector('pre > code');
expect(deck.getPlugin).toHaveBeenCalledWith('highlight');
expect(highlightBlock).toHaveBeenCalledWith(code);
expect(deck.syncFragments).toHaveBeenCalledWith(expect.any(HTMLElement));
});
it('rehighlights updated code without accumulating generated fragments', () => {
const highlightBlock = vi.fn((block: HTMLElement) => {
block.setAttribute('data-highlighted', 'yes');
const fragment = block.cloneNode(true) as HTMLElement;
fragment.classList.add('fragment');
block.parentElement?.appendChild(fragment);
});
const deck = {
getPlugin: vi.fn().mockReturnValue({ highlightBlock }),
syncFragments: vi.fn(),
} as any;
const { container, rerender } = render(
<RevealContext.Provider value={deck}>
<section>
<Code lineNumbers="|1|2">{`console.log('one')`}</Code>
</section>
</RevealContext.Provider>
);
expect(container.querySelectorAll('pre > code.fragment')).toHaveLength(1);
rerender(
<RevealContext.Provider value={deck}>
<section>
<Code lineNumbers="|1|2">{`console.log('two')`}</Code>
</section>
</RevealContext.Provider>
);
expect(highlightBlock).toHaveBeenCalledTimes(2);
expect(container.querySelectorAll('pre > code.fragment')).toHaveLength(1);
expect(container.querySelector('pre > code:not(.fragment)')).toHaveTextContent("console.log('two')");
});
it('restores full line-number steps before rehighlighting after plugin mutation', () => {
const seenLineNumbers: string[] = [];
const highlightBlock = vi.fn((block: HTMLElement) => {
seenLineNumbers.push(block.getAttribute('data-line-numbers') || '');
block.setAttribute('data-highlighted', 'yes');
// Mimic RevealHighlight: original block is rewritten to the first step and
// an extra fragment block is appended for following steps.
block.setAttribute('data-line-numbers', '1');
const fragment = block.cloneNode(true) as HTMLElement;
fragment.classList.add('fragment');
fragment.setAttribute('data-line-numbers', '3');
block.parentElement?.appendChild(fragment);
});
const deck = {
getPlugin: vi.fn().mockReturnValue({ highlightBlock }),
syncFragments: vi.fn(),
} as any;
const { rerender } = render(
<RevealContext.Provider value={deck}>
<section>
<Code language="javascript" lineNumbers="1|3">{`console.log('one')`}</Code>
</section>
</RevealContext.Provider>
);
rerender(
<RevealContext.Provider value={deck}>
<section>
<Code language="ts" lineNumbers="1|3">{`console.log('one')`}</Code>
</section>
</RevealContext.Provider>
);
expect(seenLineNumbers).toEqual(['1|3', '1|3']);
});
});

View File

@@ -0,0 +1,450 @@
import { render, act, cleanup } from '@testing-library/react';
import { StrictMode } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockApi = vi.hoisted(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn(),
sync: vi.fn(),
isReady: vi.fn().mockReturnValue(true),
on: vi.fn(),
off: vi.fn(),
configure: vi.fn(),
}));
const RevealConstructor = vi.hoisted(() =>
vi.fn(function () {
return mockApi;
})
);
vi.mock('reveal.js', () => ({ default: RevealConstructor }));
import { Deck, Slide, useReveal } from '../index';
beforeEach(() => {
vi.clearAllMocks();
mockApi.initialize.mockResolvedValue(undefined);
mockApi.isReady.mockReturnValue(true);
});
describe('Deck', () => {
it('renders .reveal > .slides structure', async () => {
let container: HTMLElement;
await act(async () => {
({ container } = render(
<Deck>
<Slide>Hello</Slide>
</Deck>
));
});
const reveal = container!.querySelector('.reveal');
expect(reveal).toBeInTheDocument();
const slides = reveal?.querySelector('.slides');
expect(slides).toBeInTheDocument();
const section = slides?.querySelector('section');
expect(section).toHaveTextContent('Hello');
});
it('calls new Reveal() and initialize() on mount', async () => {
await act(async () => {
render(
<Deck config={{ transition: 'slide', hash: true }}>
<Slide>Test</Slide>
</Deck>
);
});
expect(RevealConstructor).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({
transition: 'slide',
hash: true,
plugins: [],
})
);
expect(mockApi.initialize).toHaveBeenCalledTimes(1);
});
it('calls destroy() on unmount', async () => {
await act(async () => {
render(
<Deck>
<Slide>Test</Slide>
</Deck>
);
});
await act(async () => {
cleanup();
});
expect(mockApi.destroy).toHaveBeenCalledTimes(1);
});
it('calls sync() after render when ready', async () => {
await act(async () => {
render(
<Deck>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.sync).toHaveBeenCalled();
});
it('does not call sync() when slide content changes without structural changes', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck>
<Slide>
<p>Tick 1</p>
</Slide>
</Deck>
));
});
mockApi.sync.mockClear();
await act(async () => {
rerender(
<Deck>
<Slide>
<p>Tick 2</p>
</Slide>
</Deck>
);
});
expect(mockApi.sync).not.toHaveBeenCalled();
});
it('does not call sync() twice when configure() already syncs', async () => {
mockApi.configure.mockImplementation(() => {
mockApi.sync();
});
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck config={{ transition: 'slide' }}>
<Slide>Test</Slide>
</Deck>
));
});
mockApi.configure.mockClear();
mockApi.sync.mockClear();
await act(async () => {
rerender(
<Deck config={{ transition: 'convex' }}>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.configure).toHaveBeenCalledTimes(1);
expect(mockApi.sync).toHaveBeenCalledTimes(1);
});
it('calls sync() when keyed slides are reordered', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck>
{['first', 'second'].map((label) => (
<Slide key={label}>{label}</Slide>
))}
</Deck>
));
});
mockApi.sync.mockClear();
await act(async () => {
rerender(
<Deck>
{['second', 'first'].map((label) => (
<Slide key={label}>{label}</Slide>
))}
</Deck>
);
});
expect(mockApi.sync).toHaveBeenCalledTimes(1);
});
it('calls sync() after children change even when isReady() was false during the config-change render', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck config={{ transition: 'slide' }}>
<Slide>Slide 1</Slide>
</Deck>
));
});
// Make isReady() return true for the configure effect but false for the sync effect,
// simulating Reveal briefly being non-ready during configuration. Without the fix the
// skip flag would never be reset and the subsequent sync() call would be wrongly skipped.
mockApi.isReady.mockReturnValueOnce(true).mockReturnValueOnce(false);
await act(async () => {
rerender(
<Deck config={{ transition: 'convex' }}>
<Slide>Slide 1</Slide>
</Deck>
);
});
mockApi.isReady.mockReturnValue(true);
mockApi.sync.mockClear();
await act(async () => {
rerender(
<Deck config={{ transition: 'convex' }}>
<Slide>Slide 1</Slide>
<Slide>Slide 2</Slide>
</Deck>
);
});
expect(mockApi.sync).toHaveBeenCalledTimes(1);
});
it('does not reconfigure when config object is recreated with same values', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck config={{ transition: 'slide', hash: true }}>
<Slide>Test</Slide>
</Deck>
));
});
mockApi.configure.mockClear();
await act(async () => {
rerender(
<Deck config={{ transition: 'slide', hash: true }}>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.configure).not.toHaveBeenCalled();
});
it('fires onReady callback after initialization', async () => {
const onReady = vi.fn();
await act(async () => {
render(
<Deck onReady={onReady}>
<Slide>Test</Slide>
</Deck>
);
});
expect(onReady).toHaveBeenCalledWith(mockApi);
});
it('wires onSlideChange to the slidechanged event', async () => {
const onSlideChange = vi.fn();
await act(async () => {
render(
<Deck onSlideChange={onSlideChange}>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.on).toHaveBeenCalledWith('slidechanged', onSlideChange);
});
it('wires onSlideSync to the slidesync event', async () => {
const onSlideSync = vi.fn();
await act(async () => {
render(
<Deck onSlideSync={onSlideSync}>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.on).toHaveBeenCalledWith('slidesync', onSlideSync);
});
it('cleans up event listeners on unmount', async () => {
const onSlideChange = vi.fn();
await act(async () => {
render(
<Deck onSlideChange={onSlideChange}>
<Slide>Test</Slide>
</Deck>
);
});
await act(async () => {
cleanup();
});
expect(mockApi.off).toHaveBeenCalledWith('slidechanged', onSlideChange);
});
it('passes plugins to the Reveal constructor', async () => {
const fakePlugin = () => ({ id: 'fake', init: () => {} });
await act(async () => {
render(
<Deck plugins={[fakePlugin]}>
<Slide>Test</Slide>
</Deck>
);
});
expect(RevealConstructor).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({ plugins: [fakePlugin] })
);
});
it('registers plugins only on initialization', async () => {
const pluginA = () => ({ id: 'a', init: () => {} });
const pluginB = () => ({ id: 'b', init: () => {} });
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck plugins={[pluginA]}>
<Slide>Test</Slide>
</Deck>
));
});
mockApi.configure.mockClear();
await act(async () => {
rerender(
<Deck plugins={[pluginB]}>
<Slide>Test</Slide>
</Deck>
);
});
expect(RevealConstructor).toHaveBeenCalledTimes(1);
expect(RevealConstructor).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({ plugins: [pluginA] })
);
expect(mockApi.configure).not.toHaveBeenCalled();
});
it('applies className and style to the .reveal div', async () => {
let container: HTMLElement;
await act(async () => {
({ container } = render(
<Deck className="custom" style={{ height: '400px' }}>
<Slide>Test</Slide>
</Deck>
));
});
const reveal = container!.querySelector('.reveal');
expect(reveal).toHaveClass('reveal', 'custom');
expect(reveal).toHaveStyle({ height: '400px' });
});
it('does not initialize twice in StrictMode', async () => {
await act(async () => {
render(
<StrictMode>
<Deck>
<Slide>Test</Slide>
</Deck>
</StrictMode>
);
});
expect(RevealConstructor).toHaveBeenCalledTimes(1);
expect(mockApi.initialize).toHaveBeenCalledTimes(1);
});
it('updates deckRef when the ref prop changes', async () => {
const refA = vi.fn();
const refB = vi.fn();
let rerender: ReturnType<typeof render>['rerender'];
let unmount: ReturnType<typeof render>['unmount'];
await act(async () => {
({ rerender, unmount } = render(
<Deck deckRef={refA}>
<Slide>Test</Slide>
</Deck>
));
});
expect(refA).toHaveBeenCalledWith(mockApi);
await act(async () => {
rerender(
<Deck deckRef={refB}>
<Slide>Test</Slide>
</Deck>
);
});
expect(refA).toHaveBeenCalledWith(null);
expect(refB).toHaveBeenCalledWith(mockApi);
await act(async () => {
unmount();
});
expect(refB).toHaveBeenCalledWith(null);
});
});
describe('useReveal', () => {
it('returns the RevealApi instance inside a Deck', async () => {
let hookResult: any = undefined;
function Inspector() {
hookResult = useReveal();
return null;
}
await act(async () => {
render(
<Deck>
<Slide>
<Inspector />
</Slide>
</Deck>
);
});
expect(hookResult).toBe(mockApi);
});
it('returns null outside of a Deck', () => {
let hookResult: any = 'not-null';
function Inspector() {
hookResult = useReveal();
return null;
}
render(<Inspector />);
expect(hookResult).toBeNull();
});
});

View File

@@ -0,0 +1,122 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Fragment } from '../Fragment';
describe('Fragment', () => {
it('renders with the "fragment" class', () => {
const { container } = render(<Fragment>Hello</Fragment>);
const el = container.querySelector('.fragment');
expect(el).toBeInTheDocument();
expect(el).toHaveTextContent('Hello');
});
it('applies the animation class', () => {
const { container } = render(<Fragment animation="fade-up">Animated</Fragment>);
const el = container.querySelector('.fragment');
expect(el).toHaveClass('fragment', 'fade-up');
});
it('sets data-fragment-index when index is provided', () => {
const { container } = render(<Fragment index={2}>Indexed</Fragment>);
const el = container.querySelector('.fragment');
expect(el).toHaveAttribute('data-fragment-index', '2');
});
it('does not set data-fragment-index when index is omitted', () => {
const { container } = render(<Fragment>No index</Fragment>);
const el = container.querySelector('.fragment');
expect(el?.getAttribute('data-fragment-index')).toBeNull();
});
it('renders as a <span> by default', () => {
const { container } = render(<Fragment>Default</Fragment>);
const el = container.querySelector('.fragment');
expect(el?.tagName).toBe('SPAN');
});
it('renders as a custom element when "as" is provided', () => {
const { container } = render(<Fragment as="div">Block</Fragment>);
const el = container.querySelector('.fragment');
expect(el?.tagName).toBe('DIV');
});
it('combines fragment, animation, and custom className', () => {
const { container } = render(
<Fragment animation="grow" className="custom">
Combined
</Fragment>
);
const el = container.querySelector('.fragment');
expect(el).toHaveClass('fragment', 'grow', 'custom');
});
it('passes style through', () => {
const { container } = render(<Fragment style={{ opacity: 0.5 }}>Styled</Fragment>);
const el = container.querySelector('.fragment');
expect(el).toHaveStyle({ opacity: '0.5' });
});
it('renders the child element directly when asChild is provided', () => {
const { container } = render(
<Fragment asChild animation="fade-up">
<li>Item</li>
</Fragment>
);
expect(container.firstElementChild?.tagName).toBe('LI');
expect(container.firstElementChild).toHaveClass('fragment', 'fade-up');
expect(container.firstElementChild).toHaveTextContent('Item');
});
it('merges child className and style when asChild is provided', () => {
const { container } = render(
<Fragment asChild animation="grow" className="outer" style={{ opacity: 0.5 }}>
<div className="inner" style={{ color: 'red' }}>
Merged
</div>
</Fragment>
);
const el = container.firstElementChild;
expect(el).toHaveClass('inner', 'fragment', 'grow', 'outer');
expect(el).toHaveStyle({ color: 'rgb(255, 0, 0)', opacity: '0.5' });
});
it('sets data-fragment-index on the child when asChild is provided', () => {
const { container } = render(
<Fragment asChild index={2}>
<div data-fragment-index={1}>Indexed</div>
</Fragment>
);
expect(container.firstElementChild).toHaveAttribute('data-fragment-index', '2');
});
it('throws when asChild receives multiple children', () => {
expect(() =>
render(
<Fragment asChild>
<span>One</span>
<span>Two</span>
</Fragment>
)
).toThrow('Fragment with asChild expects exactly one React element child.');
});
it('throws when asChild receives a React.Fragment child', () => {
expect(() =>
render(
<Fragment asChild>
<>
<span>One</span>
</>
</Fragment>
)
).toThrow('Fragment with asChild expects exactly one non-Fragment React element child.');
});
});

View File

@@ -0,0 +1,296 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Slide } from '../Slide';
import { RevealContext } from '../context';
describe('Slide', () => {
it('renders as a <section> element', () => {
const { container } = render(<Slide>Hello</Slide>);
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
expect(section).toHaveTextContent('Hello');
});
it('passes data-* attributes through', () => {
const { container } = render(
<Slide data-background="#ff0000" data-transition="zoom" data-auto-animate="">
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-background', '#ff0000');
expect(section).toHaveAttribute('data-transition', 'zoom');
expect(section).toHaveAttribute('data-auto-animate', '');
});
it('maps background shorthand props to slide data attributes', () => {
const { container } = render(
<Slide
background="#111827"
backgroundImage="https://example.com/hero.png"
backgroundColor="#000000"
backgroundOpacity={0.4}
backgroundTransition="zoom"
backgroundVideoLoop
>
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-background', '#111827');
expect(section).toHaveAttribute('data-background-image', 'https://example.com/hero.png');
expect(section).toHaveAttribute('data-background-color', '#000000');
expect(section).toHaveAttribute('data-background-opacity', '0.4');
expect(section).toHaveAttribute('data-background-transition', 'zoom');
expect(section).toHaveAttribute('data-background-video-loop', '');
});
it('maps visibility and auto-animate shorthand props to slide data attributes', () => {
const { container } = render(
<Slide
visibility="hidden"
autoAnimate
autoAnimateId="hero"
autoAnimateRestart
autoAnimateUnmatched={false}
autoAnimateEasing="ease-out"
autoAnimateDuration={0.6}
autoAnimateDelay={0.1}
>
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-visibility', 'hidden');
expect(section).toHaveAttribute('data-auto-animate', '');
expect(section).toHaveAttribute('data-auto-animate-id', 'hero');
expect(section).toHaveAttribute('data-auto-animate-restart', '');
expect(section).toHaveAttribute('data-auto-animate-unmatched', 'false');
expect(section).toHaveAttribute('data-auto-animate-easing', 'ease-out');
expect(section).toHaveAttribute('data-auto-animate-duration', '0.6');
expect(section).toHaveAttribute('data-auto-animate-delay', '0.1');
});
it('maps common Reveal slide shorthand props to data attributes', () => {
const { container } = render(
<Slide
transition="zoom-in fade-out"
transitionSpeed="fast"
autoSlide={1500}
notes="Speaker notes"
backgroundInteractive
preload
>
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-transition', 'zoom-in fade-out');
expect(section).toHaveAttribute('data-transition-speed', 'fast');
expect(section).toHaveAttribute('data-autoslide', '1500');
expect(section).toHaveAttribute('data-notes', 'Speaker notes');
expect(section).toHaveAttribute('data-background-interactive', '');
expect(section).toHaveAttribute('data-preload', '');
});
it('prefers explicit data-* attributes over shorthand props', () => {
const { container } = render(
<Slide
background="#111827"
backgroundColor="#000000"
autoAnimate
visibility="hidden"
transition="zoom"
notes="Shorthand notes"
preload
data-background="#222222"
data-background-color="#333333"
data-auto-animate=""
data-visibility="uncounted"
data-transition="fade"
data-notes="Raw notes"
data-preload=""
>
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-background', '#222222');
expect(section).toHaveAttribute('data-background-color', '#333333');
expect(section).toHaveAttribute('data-auto-animate', '');
expect(section).toHaveAttribute('data-visibility', 'uncounted');
expect(section).toHaveAttribute('data-transition', 'fade');
expect(section).toHaveAttribute('data-notes', 'Raw notes');
expect(section).toHaveAttribute('data-preload', '');
});
it('applies className and style', () => {
const { container } = render(
<Slide className="intro" style={{ fontSize: '20px' }}>
Styled
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveClass('intro');
expect(section).toHaveStyle({ fontSize: '20px' });
});
it('passes id and aria attributes', () => {
const { container } = render(
<Slide id="slide-1" aria-label="Introduction">
Accessible
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('id', 'slide-1');
expect(section).toHaveAttribute('aria-label', 'Introduction');
});
it('renders children of any type', () => {
const { container } = render(
<Slide>
<h1>Title</h1>
<p>Paragraph</p>
</Slide>
);
const section = container.querySelector('section');
expect(section?.querySelector('h1')).toHaveTextContent('Title');
expect(section?.querySelector('p')).toHaveTextContent('Paragraph');
});
it('calls syncSlide when data-* attributes change after mount', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
const { container, rerender } = render(
<RevealContext.Provider value={deck}>
<Slide data-background-color="#111">Content</Slide>
</RevealContext.Provider>
);
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
expect(deck.syncSlide).not.toHaveBeenCalled();
deck.syncSlide.mockClear();
rerender(
<RevealContext.Provider value={deck}>
<Slide data-background-color="#222">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).toHaveBeenCalledTimes(1);
expect(deck.syncSlide).toHaveBeenCalledWith(container.querySelector('section'));
});
it('calls syncSlide when shorthand background props change after mount', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
const { container, rerender } = render(
<RevealContext.Provider value={deck}>
<Slide backgroundColor="#111">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
deck.syncSlide.mockClear();
rerender(
<RevealContext.Provider value={deck}>
<Slide backgroundColor="#222">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).toHaveBeenCalledTimes(1);
expect(deck.syncSlide).toHaveBeenCalledWith(container.querySelector('section'));
});
it('calls syncSlide when shorthand Reveal slide props change after mount', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
const { container, rerender } = render(
<RevealContext.Provider value={deck}>
<Slide autoSlide={1000}>Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
deck.syncSlide.mockClear();
rerender(
<RevealContext.Provider value={deck}>
<Slide autoSlide={2000}>Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).toHaveBeenCalledTimes(1);
expect(deck.syncSlide).toHaveBeenCalledWith(container.querySelector('section'));
});
it('does not call syncSlide on first render even when data-* attributes are present', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
render(
<RevealContext.Provider value={deck}>
<Slide data-background="#111">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
});
it('does not call syncSlide for non-data prop updates', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
const { rerender } = render(
<RevealContext.Provider value={deck}>
<Slide data-background-color="#111" className="a">
Content
</Slide>
</RevealContext.Provider>
);
deck.syncSlide.mockClear();
rerender(
<RevealContext.Provider value={deck}>
<Slide data-background-color="#111" className="b">
Content
</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
});
it('does not call syncSlide on mount when no data-* attributes are present', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
render(
<RevealContext.Provider value={deck}>
<Slide className="plain">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Stack } from '../Stack';
import { Slide } from '../Slide';
describe('Stack', () => {
it('renders as a <section> element', () => {
const { container } = render(
<Stack>
<Slide>A</Slide>
<Slide>B</Slide>
</Stack>
);
const sections = container.querySelectorAll('section');
expect(sections).toHaveLength(3); // 1 outer (Stack) + 2 inner (Slides)
});
it('creates nested section structure for vertical slides', () => {
const { container } = render(
<Stack>
<Slide>First</Slide>
<Slide>Second</Slide>
</Stack>
);
const outer = container.querySelector('section');
expect(outer).toBeInTheDocument();
const inner = outer?.querySelectorAll(':scope > section');
expect(inner).toHaveLength(2);
expect(inner?.[0]).toHaveTextContent('First');
expect(inner?.[1]).toHaveTextContent('Second');
});
it('applies className and style to the outer section', () => {
const { container } = render(
<Stack className="my-stack" style={{ padding: '10px' }}>
<Slide>Content</Slide>
</Stack>
);
const outer = container.querySelector('section');
expect(outer).toHaveClass('my-stack');
expect(outer).toHaveStyle({ padding: '10px' });
});
});

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

View File

@@ -0,0 +1,4 @@
import { createContext } from 'react';
import type { RevealApi } from 'reveal.js';
export const RevealContext = createContext<RevealApi | null>(null);

View File

@@ -0,0 +1,15 @@
import { useContext } from 'react';
import { RevealContext } from './context';
export { Deck } from './Deck';
export { Slide } from './Slide';
export { Stack } from './Stack';
export { Fragment } from './Fragment';
export { Code } from './Code';
export { RevealContext } from './context';
export type { DeckProps, SlideProps, StackProps, FragmentProps, CodeProps } from './types';
export function useReveal() {
return useContext(RevealContext);
}

View File

@@ -0,0 +1,127 @@
import type { CSSProperties, ReactNode, ElementType, Ref, ReactElement } from 'react';
import type {
FragmentAnimation,
RevealApi,
RevealConfig,
RevealPlugin,
RevealPluginFactory,
TransitionSpeed,
TransitionStyle,
} from 'reveal.js';
type DeckConfig = RevealConfig;
type DeckPlugin = RevealPlugin | RevealPluginFactory;
type RevealEventHandler = Parameters<RevealApi['on']>[1];
export type DeckProps = {
config?: Omit<DeckConfig, 'plugins'>;
/** Registered during deck initialization only. Subsequent prop updates are ignored. */
plugins?: DeckPlugin[];
onReady?: (deck: RevealApi) => void;
onSync?: RevealEventHandler;
onSlideSync?: RevealEventHandler;
onSlideChange?: RevealEventHandler;
onSlideTransitionEnd?: RevealEventHandler;
onFragmentShown?: RevealEventHandler;
onFragmentHidden?: RevealEventHandler;
onOverviewShown?: RevealEventHandler;
onOverviewHidden?: RevealEventHandler;
onPaused?: RevealEventHandler;
onResumed?: RevealEventHandler;
deckRef?: Ref<RevealApi | null>;
className?: string;
style?: CSSProperties;
children?: ReactNode;
};
export type SlideDataAttributeValue = string | number | boolean | undefined;
export type SlideDataAttributes = {
[key: `data-${string}`]: SlideDataAttributeValue;
};
export type SlideBackgroundProps = {
background?: string;
backgroundImage?: string;
backgroundVideo?: string;
backgroundVideoLoop?: boolean;
backgroundVideoMuted?: boolean;
backgroundIframe?: string;
backgroundColor?: string;
backgroundGradient?: string;
backgroundSize?: string;
backgroundPosition?: string;
backgroundRepeat?: string;
backgroundOpacity?: number | string;
backgroundTransition?: TransitionStyle;
};
export type SlideVisibility = 'hidden' | 'uncounted';
export type SlideAutoAnimateProps = {
visibility?: SlideVisibility;
autoAnimate?: boolean;
autoAnimateId?: string;
autoAnimateRestart?: boolean;
autoAnimateUnmatched?: boolean;
autoAnimateEasing?: string;
autoAnimateDuration?: number | string;
autoAnimateDelay?: number | string;
};
export type SlideRevealProps = {
transition?: string;
transitionSpeed?: TransitionSpeed;
autoSlide?: number | string;
notes?: string;
backgroundInteractive?: boolean;
preload?: boolean;
};
export type SlideProps = React.HTMLAttributes<HTMLElement> &
SlideDataAttributes &
SlideBackgroundProps &
SlideAutoAnimateProps &
SlideRevealProps & {
children?: ReactNode;
};
export type StackProps = {
className?: string;
style?: CSSProperties;
children?: ReactNode;
};
type FragmentBaseProps = {
animation?: FragmentAnimation;
className?: string;
style?: CSSProperties;
index?: number;
};
type FragmentElementProps = FragmentBaseProps & {
asChild?: false;
as?: ElementType;
children?: ReactNode;
};
type FragmentAsChildProps = FragmentBaseProps & {
asChild: true;
as?: never;
children: ReactElement;
};
export type FragmentProps = FragmentElementProps | FragmentAsChildProps;
export type CodeProps = Omit<React.HTMLAttributes<HTMLPreElement>, 'children'> & {
children?: string;
code?: string;
language?: string;
trim?: boolean;
lineNumbers?: boolean | string;
startFrom?: number;
noEscape?: boolean;
codeClassName?: string;
codeStyle?: CSSProperties;
codeProps?: Omit<React.HTMLAttributes<HTMLElement>, 'children' | 'className' | 'style'>;
};