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/LICENSE +21 -0
- package/README.md +576 -0
- package/package.json +64 -0
- package/react.d.ts +67 -0
- package/react.jsx +168 -0
- package/spoiler.css +217 -0
- package/spoiler.d.ts +100 -0
- package/spoiler.js +331 -0
- package/style.d.ts +3 -0
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 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;
|