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/spoiler.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spoiler-ui
|
|
3
|
+
* A lightweight spoiler/content-reveal UI element — like Threads, Discord, Telegram.
|
|
4
|
+
* Vanilla JS, zero dependencies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
label: 'Spoiler',
|
|
9
|
+
blurAmount: 6,
|
|
10
|
+
revealOnClick: true,
|
|
11
|
+
animationDuration: 300,
|
|
12
|
+
className: '',
|
|
13
|
+
/** URL to fetch content from on reveal. When set, `content` param is optional. */
|
|
14
|
+
src: null,
|
|
15
|
+
/** Options passed directly to `fetch()`. Default: `{}` */
|
|
16
|
+
fetchOptions: {},
|
|
17
|
+
/** How to inject fetched content. 'html' uses innerHTML — XSS opt-in. Default: 'text' */
|
|
18
|
+
responseType: 'text',
|
|
19
|
+
/**
|
|
20
|
+
* When true, `content` is a Base64-encoded string. Decoded on reveal — never
|
|
21
|
+
* appears as plaintext in the DOM until the user clicks. Note: Base64 is
|
|
22
|
+
* obscurity, not encryption; a determined user can decode it from page source.
|
|
23
|
+
*/
|
|
24
|
+
encoded: false,
|
|
25
|
+
/** Text shown on the pill while a fetch is in progress. Default: 'Loading…' */
|
|
26
|
+
loadingLabel: 'Loading\u2026',
|
|
27
|
+
/** Text shown on the pill when a fetch fails. Default: 'Failed — retry?' */
|
|
28
|
+
errorLabel: 'Failed \u2014 retry?',
|
|
29
|
+
/** Fired after successful reveal. Receives the revealed content string. */
|
|
30
|
+
onReveal: null,
|
|
31
|
+
/** Fired when a fetch or decode fails. Receives the Error object. */
|
|
32
|
+
onError: null,
|
|
33
|
+
/**
|
|
34
|
+
* Override the pill icon. Pass a string (emoji or character) to replace the
|
|
35
|
+
* default 👁, or `false`/`null` to hide the icon entirely.
|
|
36
|
+
*/
|
|
37
|
+
icon: undefined,
|
|
38
|
+
/** Hide the pill label button (overlay + shimmer still show; click still reveals). */
|
|
39
|
+
showPill: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decodes a Base64 string with full Unicode support.
|
|
44
|
+
* @param {string} b64
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function decodeBase64(b64) {
|
|
48
|
+
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
49
|
+
return new TextDecoder().decode(bytes);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Encodes a string to Base64 with full Unicode support.
|
|
54
|
+
* Use this to prepare content before embedding it in your HTML or JS.
|
|
55
|
+
* @param {string} str
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function encodeBase64(str) {
|
|
59
|
+
const bytes = new TextEncoder().encode(str);
|
|
60
|
+
let binary = '';
|
|
61
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
62
|
+
return btoa(binary);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a spoiler element wrapping the given content.
|
|
67
|
+
*
|
|
68
|
+
* @param {string|Element|null} content - Text or DOM element to hide (optional when `src` or `encoded` is set)
|
|
69
|
+
* @param {object} options
|
|
70
|
+
* @param {string} [options.label='Spoiler'] - Label shown on the reveal button
|
|
71
|
+
* @param {number} [options.blurAmount=6] - px blur applied while hidden
|
|
72
|
+
* @param {boolean} [options.revealOnClick=true] - Click anywhere to reveal; if false, only the button reveals
|
|
73
|
+
* @param {number} [options.animationDuration=300] - Transition duration in ms
|
|
74
|
+
* @param {string} [options.className=''] - Extra CSS class on the root element
|
|
75
|
+
* @param {string} [options.src] - URL to fetch content from on reveal
|
|
76
|
+
* @param {RequestInit} [options.fetchOptions={}] - Options passed directly to fetch()
|
|
77
|
+
* @param {'text'|'html'} [options.responseType='text'] - How to inject fetched/decoded content
|
|
78
|
+
* @param {boolean} [options.encoded=false] - Treat `content` as Base64; decode on reveal
|
|
79
|
+
* @param {function} [options.onReveal] - Callback fired after successful reveal
|
|
80
|
+
* @param {function} [options.onError] - Callback fired on fetch or decode failure; receives the Error
|
|
81
|
+
* @returns {HTMLElement}
|
|
82
|
+
*/
|
|
83
|
+
export function createSpoiler(content, options = {}) {
|
|
84
|
+
const opts = { ...DEFAULTS, ...options };
|
|
85
|
+
|
|
86
|
+
// State: 'idle' | 'loading' | 'error' | 'revealed'
|
|
87
|
+
let state = 'idle';
|
|
88
|
+
let isFetching = false;
|
|
89
|
+
|
|
90
|
+
const root = document.createElement('span');
|
|
91
|
+
root.className = ['spoiler-ui', opts.className].filter(Boolean).join(' ');
|
|
92
|
+
root.dataset.state = 'idle';
|
|
93
|
+
root.dataset.revealed = 'false';
|
|
94
|
+
root.style.setProperty('--spoiler-blur', `${opts.blurAmount}px`);
|
|
95
|
+
root.style.setProperty('--spoiler-duration', `${opts.animationDuration}ms`);
|
|
96
|
+
|
|
97
|
+
const inner = document.createElement('span');
|
|
98
|
+
inner.className = 'spoiler-ui__inner';
|
|
99
|
+
|
|
100
|
+
if (opts.encoded && typeof content === 'string') {
|
|
101
|
+
// Store encoded payload in data attr; inner gets a non-breaking space for height.
|
|
102
|
+
// Root gets data-empty → CSS inline-block + min-width so the absolute overlay
|
|
103
|
+
// has a proper rectangular containing block (inline parent isn't enough).
|
|
104
|
+
root.dataset.spoilerPayload = content;
|
|
105
|
+
root.dataset.empty = '';
|
|
106
|
+
inner.textContent = '\u00A0';
|
|
107
|
+
} else if (typeof content === 'string') {
|
|
108
|
+
inner.textContent = content;
|
|
109
|
+
} else if (content instanceof Element) {
|
|
110
|
+
inner.appendChild(content);
|
|
111
|
+
} else {
|
|
112
|
+
// src-only with no placeholder — same treatment
|
|
113
|
+
root.dataset.empty = '';
|
|
114
|
+
inner.textContent = '\u00A0';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Icon customisation
|
|
118
|
+
if (opts.icon === false || opts.icon === null) {
|
|
119
|
+
root.dataset.noIcon = '';
|
|
120
|
+
} else if (typeof opts.icon === 'string') {
|
|
121
|
+
root.style.setProperty('--spoiler-icon', `'${opts.icon}'`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pill visibility
|
|
125
|
+
if (!opts.showPill) root.dataset.noPill = '';
|
|
126
|
+
|
|
127
|
+
const overlay = document.createElement('span');
|
|
128
|
+
overlay.className = 'spoiler-ui__overlay';
|
|
129
|
+
overlay.setAttribute('role', 'button');
|
|
130
|
+
overlay.setAttribute('tabindex', '0');
|
|
131
|
+
overlay.setAttribute('aria-label', `Reveal ${opts.label}`);
|
|
132
|
+
overlay.setAttribute('aria-expanded', 'false');
|
|
133
|
+
|
|
134
|
+
const pill = document.createElement('span');
|
|
135
|
+
pill.className = 'spoiler-ui__pill';
|
|
136
|
+
pill.textContent = opts.label;
|
|
137
|
+
|
|
138
|
+
overlay.appendChild(pill);
|
|
139
|
+
root.appendChild(inner);
|
|
140
|
+
root.appendChild(overlay);
|
|
141
|
+
|
|
142
|
+
function setState(next) {
|
|
143
|
+
state = next;
|
|
144
|
+
root.dataset.state = next;
|
|
145
|
+
|
|
146
|
+
if (next === 'loading') {
|
|
147
|
+
pill.textContent = opts.loadingLabel;
|
|
148
|
+
} else if (next === 'error') {
|
|
149
|
+
pill.textContent = opts.errorLabel;
|
|
150
|
+
} else if (next === 'revealed') {
|
|
151
|
+
root.dataset.revealed = 'true';
|
|
152
|
+
delete root.dataset.spoilerPayload; // remove encoded payload from DOM
|
|
153
|
+
delete root.dataset.empty; // content now in DOM, revert to inline
|
|
154
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
155
|
+
overlay.setAttribute('aria-expanded', 'true');
|
|
156
|
+
overlay.tabIndex = -1;
|
|
157
|
+
// Remove all event listeners — spoiler is terminal
|
|
158
|
+
root.removeEventListener('click', onRootClick);
|
|
159
|
+
root.removeEventListener('keydown', onRootKeydown);
|
|
160
|
+
overlay.removeEventListener('click', onOverlayClick);
|
|
161
|
+
overlay.removeEventListener('keydown', onOverlayKeydown);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function injectContent(text) {
|
|
166
|
+
if (opts.responseType === 'html') {
|
|
167
|
+
inner.innerHTML = text; // XSS opt-in — caller's responsibility
|
|
168
|
+
} else {
|
|
169
|
+
inner.textContent = text;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function doFetch() {
|
|
174
|
+
if (isFetching) return;
|
|
175
|
+
isFetching = true;
|
|
176
|
+
setState('loading');
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const res = await fetch(opts.src, opts.fetchOptions);
|
|
180
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
181
|
+
const text = await res.text();
|
|
182
|
+
injectContent(text);
|
|
183
|
+
isFetching = false;
|
|
184
|
+
setState('revealed');
|
|
185
|
+
opts.onReveal?.(text);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
isFetching = false;
|
|
188
|
+
setState('error');
|
|
189
|
+
console.error('[spoiler-ui] Fetch failed:', err);
|
|
190
|
+
opts.onError?.(err);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handleActivate() {
|
|
195
|
+
if (state === 'loading') return;
|
|
196
|
+
if (state !== 'idle' && state !== 'error') return;
|
|
197
|
+
|
|
198
|
+
if (opts.src) {
|
|
199
|
+
doFetch();
|
|
200
|
+
} else if (opts.encoded) {
|
|
201
|
+
const payload = root.dataset.spoilerPayload;
|
|
202
|
+
if (!payload) {
|
|
203
|
+
console.error('[spoiler-ui] Encoded mode enabled but no Base64 payload found. Pass a Base64 string as content.');
|
|
204
|
+
setState('error');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const decoded = decodeBase64(payload);
|
|
209
|
+
injectContent(decoded);
|
|
210
|
+
setState('revealed');
|
|
211
|
+
opts.onReveal?.(decoded);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error('[spoiler-ui] Failed to decode Base64 payload:', err);
|
|
214
|
+
setState('error');
|
|
215
|
+
opts.onError?.(err);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
setState('revealed');
|
|
219
|
+
opts.onReveal?.(inner.textContent);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function onRootClick() { handleActivate(); }
|
|
224
|
+
function onRootKeydown(e) {
|
|
225
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
handleActivate();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function onOverlayClick(e) {
|
|
231
|
+
e.stopPropagation();
|
|
232
|
+
handleActivate();
|
|
233
|
+
}
|
|
234
|
+
function onOverlayKeydown(e) {
|
|
235
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
handleActivate();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (opts.revealOnClick) {
|
|
242
|
+
root.addEventListener('click', onRootClick);
|
|
243
|
+
root.addEventListener('keydown', onRootKeydown);
|
|
244
|
+
} else {
|
|
245
|
+
overlay.addEventListener('click', onOverlayClick);
|
|
246
|
+
overlay.addEventListener('keydown', onOverlayKeydown);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return root;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Automatically initialises spoilers from `data-spoiler` attributes in the DOM.
|
|
254
|
+
*
|
|
255
|
+
* Supported attributes:
|
|
256
|
+
* data-spoiler - Label text (enables the component)
|
|
257
|
+
* data-spoiler-blur - Blur amount in px
|
|
258
|
+
* data-spoiler-duration - Animation duration in ms
|
|
259
|
+
* data-spoiler-src - URL to fetch content from on reveal
|
|
260
|
+
* data-spoiler-response-type - 'text' (default) or 'html'
|
|
261
|
+
* data-spoiler-encoded - Base64-encoded content (decoded on reveal)
|
|
262
|
+
* data-spoiler-reveal-on-click - Set to 'false' to restrict reveal to pill button only
|
|
263
|
+
* data-spoiler-class - Extra CSS class added to the root element
|
|
264
|
+
* data-spoiler-icon - Pill icon: 'false' hides it, any string replaces the default 👁
|
|
265
|
+
*
|
|
266
|
+
* @param {Document|HTMLElement} [scope=document] - Root element to search within
|
|
267
|
+
*/
|
|
268
|
+
export function initSpoilers(scope = document) {
|
|
269
|
+
const els = scope.querySelectorAll('[data-spoiler]');
|
|
270
|
+
|
|
271
|
+
els.forEach((el) => {
|
|
272
|
+
const label = el.dataset.spoiler || DEFAULTS.label;
|
|
273
|
+
|
|
274
|
+
const blurRaw = Number(el.dataset.spoilerBlur);
|
|
275
|
+
const blur = Number.isFinite(blurRaw) ? blurRaw : DEFAULTS.blurAmount;
|
|
276
|
+
|
|
277
|
+
const durationRaw = Number(el.dataset.spoilerDuration);
|
|
278
|
+
const duration = Number.isFinite(durationRaw) ? durationRaw : DEFAULTS.animationDuration;
|
|
279
|
+
|
|
280
|
+
const src = el.dataset.spoilerSrc || null;
|
|
281
|
+
const encodedPayload = el.dataset.spoilerEncoded ?? null;
|
|
282
|
+
const responseType = el.dataset.spoilerResponseType === 'html' ? 'html' : 'text';
|
|
283
|
+
const revealOnClick = el.dataset.spoilerRevealOnClick !== 'false';
|
|
284
|
+
const showPill = el.dataset.spoilerShowPill !== 'false';
|
|
285
|
+
const loadingLabel = el.dataset.spoilerLoadingLabel ?? DEFAULTS.loadingLabel;
|
|
286
|
+
const errorLabel = el.dataset.spoilerErrorLabel ?? DEFAULTS.errorLabel;
|
|
287
|
+
const className = el.dataset.spoilerClass ?? '';
|
|
288
|
+
|
|
289
|
+
// icon: absent → undefined (CSS default), 'false' → false (hide), else string
|
|
290
|
+
const iconRaw = el.dataset.spoilerIcon;
|
|
291
|
+
const icon = iconRaw === undefined ? undefined : iconRaw === 'false' ? false : iconRaw;
|
|
292
|
+
|
|
293
|
+
if (encodedPayload && src) {
|
|
294
|
+
console.warn('[spoiler-ui] Both data-spoiler-encoded and data-spoiler-src are set — data-spoiler-src will be ignored.');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let contentArg = null;
|
|
298
|
+
let encoded = false;
|
|
299
|
+
|
|
300
|
+
if (encodedPayload) {
|
|
301
|
+
// Encoded mode: content comes from the data attribute, not from children
|
|
302
|
+
contentArg = encodedPayload;
|
|
303
|
+
encoded = true;
|
|
304
|
+
} else if (!src) {
|
|
305
|
+
// Move children directly — avoids innerHTML serialize/re-parse cycle
|
|
306
|
+
const wrapper = document.createElement('span');
|
|
307
|
+
while (el.firstChild) wrapper.appendChild(el.firstChild);
|
|
308
|
+
contentArg = wrapper;
|
|
309
|
+
}
|
|
310
|
+
// When src is set, existing children are discarded (server provides content)
|
|
311
|
+
|
|
312
|
+
const spoiler = createSpoiler(contentArg, {
|
|
313
|
+
label,
|
|
314
|
+
blurAmount: blur,
|
|
315
|
+
animationDuration: duration,
|
|
316
|
+
revealOnClick,
|
|
317
|
+
showPill,
|
|
318
|
+
loadingLabel,
|
|
319
|
+
errorLabel,
|
|
320
|
+
src,
|
|
321
|
+
responseType,
|
|
322
|
+
encoded,
|
|
323
|
+
className,
|
|
324
|
+
icon,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
el.replaceWith(spoiler);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export { createSpoiler as default };
|
package/style.d.ts
ADDED