spoiler-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/react.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { ReactNode, ReactElement, CSSProperties, ForwardRefExoticComponent, RefAttributes } from 'react';
2
+
3
+ export interface SpoilerProps {
4
+ /** Content to hide. Optional when `src` or `encoded` is set. */
5
+ children?: ReactNode;
6
+ label?: string;
7
+ blurAmount?: number;
8
+ revealOnClick?: boolean;
9
+ animationDuration?: number;
10
+ className?: string;
11
+ /**
12
+ * URL to fetch content from on reveal. When set, `children` is optional
13
+ * and is shown blurred as a placeholder until the fetch completes.
14
+ */
15
+ src?: string;
16
+ /** Options passed directly to `fetch()`. Default: `{}` */
17
+ fetchOptions?: RequestInit;
18
+ /**
19
+ * How to inject fetched content.
20
+ * - `'text'` (default): rendered as a text node β€” safe against XSS
21
+ * - `'html'`: rendered via `dangerouslySetInnerHTML` β€” XSS opt-in
22
+ */
23
+ responseType?: 'text' | 'html';
24
+ /**
25
+ * When `true`, `children` must be a Base64-encoded string. It is decoded
26
+ * only when the user reveals β€” the plaintext is never rendered beforehand.
27
+ *
28
+ * Note: Base64 is obscurity, not encryption. A determined user can still
29
+ * decode it from page source. Use `src` for true server-gated hiding.
30
+ */
31
+ encoded?: boolean;
32
+ /**
33
+ * Override the pill icon shown before the label.
34
+ * - `string` β€” replaces the default πŸ‘ with the given character/emoji
35
+ * - `false` or `null` β€” hides the icon entirely
36
+ * - `undefined` (default) β€” uses the CSS `--spoiler-icon` variable (πŸ‘)
37
+ */
38
+ icon?: string | false | null;
39
+ /** Hide the pill label button. The overlay and shimmer still show; click still reveals. Default: `true` */
40
+ showPill?: boolean;
41
+ /** Text shown on the pill while a fetch is in progress. Default: `'Loading…'` */
42
+ loadingLabel?: string;
43
+ /** Text shown on the pill when a fetch or decode fails. Default: `'Failed β€” retry?'` */
44
+ errorLabel?: string;
45
+ /**
46
+ * Render prop for full pill customisation. Receives `{ state, label }` and returns a ReactNode.
47
+ * Rendered inside the existing `.spoiler-ui__pill` span, so CSS styling still applies.
48
+ */
49
+ renderPill?: (ctx: { state: 'idle' | 'loading' | 'error'; label: string }) => import('react').ReactNode;
50
+ /**
51
+ * Callback fired after a successful reveal.
52
+ * - Fetch mode: receives the fetched content string.
53
+ * - Encoded mode: receives the decoded content string.
54
+ * - Plain mode: called with no arguments.
55
+ */
56
+ onReveal?: (content?: string) => void;
57
+ /** Callback fired when a fetch or decode fails. Receives the Error object. */
58
+ onError?: (err: Error) => void;
59
+ /**
60
+ * Extra inline styles merged onto the root element. Useful for overriding
61
+ * CSS custom properties (e.g. `--spoiler-pill-bg`, `--spoiler-bg`).
62
+ */
63
+ style?: CSSProperties & { [key: `--${string}`]: string };
64
+ }
65
+
66
+ export const Spoiler: ForwardRefExoticComponent<SpoilerProps & RefAttributes<HTMLSpanElement>>;
67
+ export default Spoiler;
package/react.jsx ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * spoiler-ui/react
3
+ * Optional React wrapper β€” import only if you use React.
4
+ */
5
+
6
+ import { useState, useRef, useEffect, forwardRef, useCallback } from 'react';
7
+ import { decodeBase64 } from './spoiler.js';
8
+
9
+ export const Spoiler = forwardRef(function Spoiler({
10
+ children,
11
+ label = 'Spoiler',
12
+ blurAmount = 6,
13
+ revealOnClick = true,
14
+ animationDuration = 300,
15
+ className = '',
16
+ src = null,
17
+ fetchOptions = {},
18
+ responseType = 'text',
19
+ encoded = false,
20
+ icon = undefined,
21
+ showPill = true,
22
+ loadingLabel = 'Loading\u2026',
23
+ errorLabel = 'Failed \u2014 retry?',
24
+ renderPill = undefined,
25
+ onReveal = undefined,
26
+ onError = undefined,
27
+ style = {},
28
+ }, ref) {
29
+ // State machine: 'idle' | 'loading' | 'error' | 'revealed'
30
+ const [state, setState] = useState('idle');
31
+ const [revealedContent, setRevealedContent] = useState(null);
32
+ const abortRef = useRef(null);
33
+
34
+ // Always-current refs so async callbacks never go stale
35
+ const onRevealRef = useRef(onReveal);
36
+ const onErrorRef = useRef(onError);
37
+ useEffect(() => { onRevealRef.current = onReveal; onErrorRef.current = onError; });
38
+
39
+ // Abort in-flight fetch on unmount
40
+ useEffect(() => () => abortRef.current?.abort(), []);
41
+
42
+ const isRevealed = state === 'revealed';
43
+
44
+ // icon === false/null β†’ hide; icon === string β†’ custom; undefined β†’ CSS default (πŸ‘)
45
+ const noIcon = icon === false || icon === null;
46
+ const rootStyle = /** @type {React.CSSProperties} */ ({
47
+ '--spoiler-blur': `${blurAmount}px`,
48
+ '--spoiler-duration': `${animationDuration}ms`,
49
+ ...(typeof icon === 'string' && { '--spoiler-icon': `'${icon}'` }),
50
+ ...style,
51
+ });
52
+
53
+ const pillLabel = state === 'loading' ? loadingLabel
54
+ : state === 'error' ? errorLabel
55
+ : label;
56
+
57
+ const handleActivate = useCallback(async () => {
58
+ if (state !== 'idle' && state !== 'error') return;
59
+
60
+ if (src) {
61
+ abortRef.current?.abort();
62
+ abortRef.current = new AbortController();
63
+ setState('loading');
64
+
65
+ try {
66
+ const res = await fetch(src, { signal: abortRef.current.signal, ...fetchOptions });
67
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
68
+ const text = await res.text();
69
+
70
+ if (abortRef.current.signal.aborted) return;
71
+
72
+ setRevealedContent(text);
73
+ setState('revealed');
74
+ onRevealRef.current?.(text);
75
+ } catch (err) {
76
+ if (err.name === 'AbortError') return;
77
+ setState('error');
78
+ console.error('[spoiler-ui] Fetch failed:', err);
79
+ onErrorRef.current?.(err);
80
+ }
81
+ } else if (encoded) {
82
+ if (typeof children !== 'string') {
83
+ console.warn('[spoiler-ui] `encoded` prop requires `children` to be a Base64 string.');
84
+ setState('revealed');
85
+ onRevealRef.current?.();
86
+ return;
87
+ }
88
+ try {
89
+ const decoded = decodeBase64(children);
90
+ setRevealedContent(decoded);
91
+ setState('revealed');
92
+ onRevealRef.current?.(decoded);
93
+ } catch (err) {
94
+ console.error('[spoiler-ui] Failed to decode Base64 payload:', err);
95
+ setState('error');
96
+ onErrorRef.current?.(err);
97
+ }
98
+ } else {
99
+ setState('revealed');
100
+ onRevealRef.current?.();
101
+ }
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ }, [state, src, encoded, children, responseType, fetchOptions]);
104
+
105
+ const handleKeyDown = useCallback((e) => {
106
+ if (e.key === 'Enter' || e.key === ' ') {
107
+ e.preventDefault();
108
+ handleActivate();
109
+ }
110
+ }, [handleActivate]);
111
+
112
+ const handleOverlayClick = useCallback((e) => {
113
+ e.stopPropagation();
114
+ handleActivate();
115
+ }, [handleActivate]);
116
+
117
+ // Determine what to render inside the inner span.
118
+ // When encoded (pre-reveal) or src-only (no placeholder), render a non-breaking
119
+ // space so __inner has real line-height. The root also gets data-empty so CSS
120
+ // switches it to inline-block with min-width β€” the absolute overlay needs a
121
+ // proper rectangular containing block, not just an inline line box.
122
+ const noPlaceholder = !isRevealed && revealedContent === null && (encoded || (!children && src));
123
+ let innerContent;
124
+ if (revealedContent !== null) {
125
+ innerContent = responseType === 'html'
126
+ ? <span dangerouslySetInnerHTML={{ __html: revealedContent }} />
127
+ : revealedContent;
128
+ } else if (noPlaceholder) {
129
+ innerContent = '\u00A0';
130
+ } else {
131
+ innerContent = children;
132
+ }
133
+
134
+ return (
135
+ <span
136
+ ref={ref}
137
+ className={['spoiler-ui', className].filter(Boolean).join(' ')}
138
+ data-state={state}
139
+ data-revealed={isRevealed ? 'true' : 'false'}
140
+ {...(noPlaceholder && { 'data-empty': '' })}
141
+ {...(noIcon && { 'data-no-icon': '' })}
142
+ {...(!showPill && { 'data-no-pill': '' })}
143
+ style={rootStyle}
144
+ onClick={revealOnClick && !isRevealed ? handleActivate : undefined}
145
+ onKeyDown={!isRevealed ? handleKeyDown : undefined}
146
+ tabIndex={isRevealed ? undefined : 0}
147
+ role={isRevealed ? undefined : 'button'}
148
+ aria-label={isRevealed ? undefined : `Reveal ${label}`}
149
+ aria-expanded={isRevealed ? undefined : 'false'}
150
+ >
151
+ <span className="spoiler-ui__inner">{innerContent}</span>
152
+
153
+ {!isRevealed && (
154
+ <span
155
+ className="spoiler-ui__overlay"
156
+ onClick={!revealOnClick ? handleOverlayClick : undefined}
157
+ aria-hidden="true"
158
+ >
159
+ <span className="spoiler-ui__pill">
160
+ {renderPill ? renderPill({ state, label: pillLabel }) : pillLabel}
161
+ </span>
162
+ </span>
163
+ )}
164
+ </span>
165
+ );
166
+ });
167
+
168
+ export default Spoiler;
package/spoiler.css ADDED
@@ -0,0 +1,217 @@
1
+ /* spoiler-ui β€” core styles */
2
+
3
+ .spoiler-ui {
4
+ --spoiler-blur: 6px;
5
+ --spoiler-duration: 300ms;
6
+ --spoiler-bg: rgba(0, 0, 0, 0.05);
7
+ --spoiler-pill-bg: rgba(0, 0, 0, 0.72);
8
+ --spoiler-pill-color: #000;
9
+ --spoiler-pill-font: inherit;
10
+ --spoiler-radius: 4px;
11
+ --spoiler-error-bg: #c0392b;
12
+ --spoiler-pill-padding: 1px 8px;
13
+ --spoiler-pill-font-size: 0.72em;
14
+ --spoiler-pill-font-weight: 600;
15
+ --spoiler-pill-letter-spacing: 0.02em;
16
+ --spoiler-pill-gap: 4px;
17
+ --spoiler-pill-radius: 100px;
18
+ --spoiler-shimmer-color: rgba(255, 255, 255, 0.5);
19
+ --spoiler-shimmer-duration: 2.4s;
20
+
21
+ position: relative;
22
+ display: inline;
23
+ margin-inline: var(--spoiler-margin-inline, 3px);
24
+ cursor: pointer;
25
+ -webkit-user-select: none;
26
+ user-select: none;
27
+ }
28
+
29
+ /* ── Content layer ── */
30
+ .spoiler-ui__inner {
31
+ display: inline;
32
+ transition: filter var(--spoiler-duration) ease,
33
+ opacity var(--spoiler-duration) ease;
34
+ will-change: filter, opacity;
35
+ filter: blur(var(--spoiler-blur));
36
+ opacity: 0.35;
37
+ pointer-events: none;
38
+ }
39
+
40
+ /*
41
+ * No-placeholder mode (encoded or src-only).
42
+ * Switch root to inline-block so it forms a proper rectangular containing block
43
+ * for the absolutely-positioned overlay. The &nbsp; inside __inner provides height;
44
+ * min-width ensures the pill label fits.
45
+ */
46
+ .spoiler-ui[data-empty] {
47
+ display: inline-block;
48
+ min-width: 5em;
49
+ vertical-align: baseline;
50
+ }
51
+
52
+ /* ── Overlay ── */
53
+ .spoiler-ui__overlay {
54
+ position: absolute;
55
+ inset: -2px -4px;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ border-radius: var(--spoiler-radius);
60
+ background:
61
+ linear-gradient(
62
+ 105deg,
63
+ transparent 30%,
64
+ var(--spoiler-shimmer-color) 50%,
65
+ transparent 70%
66
+ )
67
+ -200% 0 / 200% 100% no-repeat,
68
+ var(--spoiler-bg);
69
+ animation: spoiler-shimmer var(--spoiler-shimmer-duration) ease-in-out infinite;
70
+ transition: opacity var(--spoiler-duration) ease;
71
+ outline: none;
72
+ }
73
+
74
+ @keyframes spoiler-shimmer {
75
+ 0% { background-position: -200% 0, 0 0; }
76
+ 60% { background-position: 200% 0, 0 0; }
77
+ 100% { background-position: 200% 0, 0 0; }
78
+ }
79
+
80
+ .spoiler-ui__overlay:focus-visible {
81
+ box-shadow: 0 0 0 2px currentColor;
82
+ }
83
+
84
+ /* ── Pill label ── */
85
+ .spoiler-ui__pill {
86
+ display: inline-flex;
87
+ align-items: center;
88
+ gap: var(--spoiler-pill-gap);
89
+ padding: var(--spoiler-pill-padding);
90
+ border-radius: var(--spoiler-pill-radius);
91
+ background: var(--spoiler-pill-bg);
92
+ color: var(--spoiler-pill-color);
93
+ font-family: var(--spoiler-pill-font);
94
+ font-size: var(--spoiler-pill-font-size);
95
+ font-weight: var(--spoiler-pill-font-weight);
96
+ letter-spacing: var(--spoiler-pill-letter-spacing);
97
+ white-space: nowrap;
98
+ pointer-events: none;
99
+ transition: transform 150ms ease, opacity 150ms ease, background 150ms ease;
100
+ }
101
+
102
+ /* Eye icon β€” default idle state */
103
+ .spoiler-ui__pill::before {
104
+ content: var(--spoiler-icon, 'πŸ‘');
105
+ font-size: 0.9em;
106
+ }
107
+
108
+ /* Hidden icon β€” set via icon={false} prop or data-no-icon attribute */
109
+ .spoiler-ui[data-no-icon] .spoiler-ui__pill::before {
110
+ display: none;
111
+ }
112
+
113
+ .spoiler-ui:hover .spoiler-ui__pill {
114
+ transform: scale(1.06);
115
+ }
116
+
117
+ /* ── No-pill mode ── */
118
+ .spoiler-ui[data-no-pill] .spoiler-ui__pill {
119
+ display: none;
120
+ }
121
+
122
+ /* ── Loading state ── */
123
+ .spoiler-ui[data-state='loading'] {
124
+ cursor: wait;
125
+ pointer-events: none; /* CSS-level guard against double-click */
126
+ }
127
+
128
+ .spoiler-ui[data-state='loading'] .spoiler-ui__pill {
129
+ opacity: 0.85;
130
+ }
131
+
132
+ .spoiler-ui[data-state='loading'] .spoiler-ui__pill::before {
133
+ content: '';
134
+ display: inline-block;
135
+ width: 0.7em;
136
+ height: 0.7em;
137
+ border: 1.5px solid currentColor;
138
+ border-top-color: transparent;
139
+ border-radius: 50%;
140
+ animation: spoiler-spin 0.6s linear infinite;
141
+ flex-shrink: 0;
142
+ }
143
+
144
+ @keyframes spoiler-spin {
145
+ to { transform: rotate(360deg); }
146
+ }
147
+
148
+ /* ── Error state ── */
149
+ .spoiler-ui[data-state='error'] .spoiler-ui__pill {
150
+ background: var(--spoiler-error-bg);
151
+ }
152
+
153
+ .spoiler-ui[data-state='error'] .spoiler-ui__pill::before {
154
+ content: '⚠';
155
+ font-size: 0.9em;
156
+ }
157
+
158
+ /* ── Revealed state ── */
159
+ .spoiler-ui[data-state='revealed'],
160
+ .spoiler-ui[data-revealed='true'] {
161
+ cursor: auto;
162
+ -webkit-user-select: auto;
163
+ user-select: auto;
164
+ }
165
+
166
+ .spoiler-ui[data-state='revealed'] .spoiler-ui__inner,
167
+ .spoiler-ui[data-revealed='true'] .spoiler-ui__inner {
168
+ filter: blur(0);
169
+ opacity: 1;
170
+ will-change: auto;
171
+ pointer-events: auto;
172
+ }
173
+
174
+ .spoiler-ui[data-state='revealed'] .spoiler-ui__overlay,
175
+ .spoiler-ui[data-revealed='true'] .spoiler-ui__overlay {
176
+ opacity: 0;
177
+ pointer-events: none;
178
+ }
179
+
180
+ /*
181
+ * ── Dark-mode ──
182
+ * No automatic dark-mode overrides are applied by the library.
183
+ * Override the CSS variables in your own stylesheet to handle dark mode:
184
+ *
185
+ * @media (prefers-color-scheme: dark) {
186
+ * .spoiler-ui {
187
+ * --spoiler-bg: rgba(255, 255, 255, 0.08);
188
+ * --spoiler-pill-bg: rgba(255, 255, 255, 0.18);
189
+ * --spoiler-pill-color: #fff;
190
+ * --spoiler-error-bg: #e74c3c;
191
+ * }
192
+ * }
193
+ */
194
+
195
+ /* ── Reduced motion ── */
196
+ @media (prefers-reduced-motion: reduce) {
197
+ .spoiler-ui__inner,
198
+ .spoiler-ui__overlay,
199
+ .spoiler-ui__pill {
200
+ transition: none;
201
+ animation: none;
202
+ }
203
+
204
+ /* Replace spin with pulse for loading indicator */
205
+ .spoiler-ui[data-state='loading'] .spoiler-ui__pill::before {
206
+ animation: spoiler-pulse 1s ease infinite;
207
+ border: none;
208
+ content: '…';
209
+ width: auto;
210
+ height: auto;
211
+ }
212
+ }
213
+
214
+ @keyframes spoiler-pulse {
215
+ 0%, 100% { opacity: 1; }
216
+ 50% { opacity: 0.3; }
217
+ }
package/spoiler.d.ts ADDED
@@ -0,0 +1,100 @@
1
+ export interface SpoilerOptions {
2
+ /** Label shown on the reveal pill. Default: `'Spoiler'` */
3
+ label?: string;
4
+ /** Blur amount in px while hidden. Default: `6` */
5
+ blurAmount?: number;
6
+ /** Click anywhere on the element to reveal. Default: `true` */
7
+ revealOnClick?: boolean;
8
+ /** CSS transition duration in ms. Default: `300` */
9
+ animationDuration?: number;
10
+ /** Extra CSS class added to the root element. Default: `''` */
11
+ className?: string;
12
+ /**
13
+ * URL to fetch content from on reveal. When set, the `content` parameter
14
+ * is optional (used as a blurred placeholder until the fetch completes).
15
+ */
16
+ src?: string;
17
+ /** Options passed directly to `fetch()`. Default: `{}` */
18
+ fetchOptions?: RequestInit;
19
+ /**
20
+ * How to inject fetched or decoded content into the DOM.
21
+ * - `'text'` (default): sets `textContent` β€” safe against XSS
22
+ * - `'html'`: sets `innerHTML` β€” XSS opt-in, caller's responsibility
23
+ */
24
+ responseType?: 'text' | 'html';
25
+ /**
26
+ * When `true`, the `content` string is treated as Base64-encoded text.
27
+ * It is stored in a data attribute and decoded only when the user reveals β€”
28
+ * plaintext never appears in the DOM beforehand.
29
+ *
30
+ * Note: Base64 is obscurity, not encryption. A determined user can still
31
+ * decode it from page source. Use `src` for true server-gated hiding.
32
+ */
33
+ encoded?: boolean;
34
+ /**
35
+ * Override the pill icon shown before the label.
36
+ * - `string` β€” replaces the default πŸ‘ with the given character/emoji
37
+ * - `false` or `null` β€” hides the icon entirely
38
+ * - `undefined` (default) β€” uses the CSS `--spoiler-icon` variable (πŸ‘)
39
+ */
40
+ icon?: string | false | null;
41
+ /** Hide the pill label button. The overlay and shimmer still show; click still reveals. Default: `true` */
42
+ showPill?: boolean;
43
+ /** Text shown on the pill while a fetch is in progress. Default: `'Loading…'` */
44
+ loadingLabel?: string;
45
+ /** Text shown on the pill when a fetch or decode fails. Default: `'Failed β€” retry?'` */
46
+ errorLabel?: string;
47
+ /**
48
+ * Callback fired after a successful reveal.
49
+ * - Fetch mode: receives the fetched content string.
50
+ * - Encoded mode: receives the decoded content string.
51
+ * - Plain mode: receives the element's text content.
52
+ */
53
+ onReveal?: (content: string) => void;
54
+ /** Callback fired when a fetch or decode fails. Receives the Error object. */
55
+ onError?: (err: Error) => void;
56
+ }
57
+
58
+ /**
59
+ * Creates a spoiler element wrapping the given content.
60
+ * Pass `null` or omit `content` when using the `src` option.
61
+ */
62
+ export function createSpoiler(
63
+ content: string | Element | null | undefined,
64
+ options?: SpoilerOptions
65
+ ): HTMLElement;
66
+
67
+ /**
68
+ * Scans the DOM for `[data-spoiler]` elements and converts them
69
+ * into interactive spoiler components.
70
+ *
71
+ * Supported data attributes:
72
+ * - `data-spoiler` β€” label text (required to activate)
73
+ * - `data-spoiler-blur` β€” blur amount in px
74
+ * - `data-spoiler-duration` β€” animation duration in ms
75
+ * - `data-spoiler-src` β€” URL to fetch content from on reveal
76
+ * - `data-spoiler-response-type` β€” `'text'` (default) or `'html'`
77
+ * - `data-spoiler-encoded` β€” Base64-encoded content string (decoded on reveal)
78
+ * - `data-spoiler-reveal-on-click` β€” set to `'false'` to restrict reveal to the pill button only
79
+ * - `data-spoiler-show-pill` β€” set to `'false'` to hide the pill label button
80
+ * - `data-spoiler-loading-label` β€” pill text while fetching (default: `'Loading…'`)
81
+ * - `data-spoiler-error-label` β€” pill text on failure (default: `'Failed β€” retry?'`)
82
+ * - `data-spoiler-class` β€” extra CSS class added to the root element
83
+ * - `data-spoiler-icon` β€” pill icon: `'false'` hides it, any other string replaces the default πŸ‘
84
+ *
85
+ * @param scope - Root element to search within. Defaults to `document`.
86
+ */
87
+ export function initSpoilers(scope?: Document | HTMLElement): void;
88
+
89
+ /**
90
+ * Encodes a string to Base64 with full Unicode support.
91
+ * Use this to prepare content before embedding it in your HTML or JS.
92
+ */
93
+ export function encodeBase64(str: string): string;
94
+
95
+ /**
96
+ * Decodes a Base64 string with full Unicode support.
97
+ */
98
+ export function decodeBase64(b64: string): string;
99
+
100
+ export default createSpoiler;