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/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
@@ -0,0 +1,3 @@
1
+ // Type declaration for the spoiler-ui/style CSS export.
2
+ // Importing this file is a side-effect-only import that loads spoiler.css.
3
+ declare module 'spoiler-ui/style' {}