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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dzmitry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
# spoiler-ui
|
|
2
|
+
|
|
3
|
+
A lightweight, zero-dependency spoiler / content-reveal element — like **Threads**, **Discord**, and **Telegram**.
|
|
4
|
+
|
|
5
|
+
Blurs hidden content behind a pill overlay. Click (or press Enter / Space) to reveal with a smooth transition.
|
|
6
|
+
|
|
7
|
+
**MIT license · zero dependencies · Vanilla JS + optional React wrapper · TypeScript types included**
|
|
8
|
+
|
|
9
|
+
<!-- SCREENSHOT: demo of all three modes (idle blurred, loading, revealed) -->
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install spoiler-ui
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Three modes
|
|
22
|
+
|
|
23
|
+
| Mode | Content in DOM before reveal? | Requires server? |
|
|
24
|
+
|------|-------------------------------|-----------------|
|
|
25
|
+
| [UI only](#mode-1--ui-only) | Yes, blurred | No |
|
|
26
|
+
| [UI + encoding](#mode-3--ui--encoding) | Yes, as Base64 | No |
|
|
27
|
+
| [UI + backend](#mode-2--ui--backend) | No | Yes |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Mode 1 — UI only
|
|
32
|
+
|
|
33
|
+
The simplest mode. Content is blurred in the DOM; revealing is purely client-side. A determined user can find the text in DevTools, but it stops casual glances.
|
|
34
|
+
|
|
35
|
+
### Vanilla JS
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import { createSpoiler, initSpoilers } from 'spoiler-ui';
|
|
39
|
+
import 'spoiler-ui/style';
|
|
40
|
+
|
|
41
|
+
// Programmatic
|
|
42
|
+
const spoiler = createSpoiler('Snape kills Dumbledore.', {
|
|
43
|
+
label: 'Book spoiler',
|
|
44
|
+
});
|
|
45
|
+
document.querySelector('p').appendChild(spoiler);
|
|
46
|
+
|
|
47
|
+
// Custom label, button-only reveal
|
|
48
|
+
const strictSpoiler = createSpoiler('He was dead the whole time.', {
|
|
49
|
+
label: 'Major spoiler',
|
|
50
|
+
revealOnClick: false, // only the pill button reveals, not the whole element
|
|
51
|
+
blurAmount: 10,
|
|
52
|
+
animationDuration: 500,
|
|
53
|
+
onReveal: (text) => console.log('Revealed:', text),
|
|
54
|
+
onError: (err) => console.error('Failed:', err),
|
|
55
|
+
});
|
|
56
|
+
document.body.appendChild(strictSpoiler);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Auto-init from HTML attributes** — no JS needed per element:
|
|
60
|
+
|
|
61
|
+
```html
|
|
62
|
+
<script type="module">
|
|
63
|
+
import { initSpoilers } from 'spoiler-ui';
|
|
64
|
+
import 'spoiler-ui/style';
|
|
65
|
+
initSpoilers();
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<!-- Basic -->
|
|
69
|
+
<span data-spoiler>Jon Snow is resurrected.</span>
|
|
70
|
+
|
|
71
|
+
<!-- Custom label, blur, and icon -->
|
|
72
|
+
<span data-spoiler="Huge spoiler" data-spoiler-blur="10" data-spoiler-icon="🔒">
|
|
73
|
+
The planet explodes at the end.
|
|
74
|
+
</span>
|
|
75
|
+
|
|
76
|
+
<!-- Extra CSS class for custom theming -->
|
|
77
|
+
<span data-spoiler="Spoiler" data-spoiler-class="my-theme">
|
|
78
|
+
The twist is revealed in chapter 12.
|
|
79
|
+
</span>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### React
|
|
83
|
+
|
|
84
|
+
```jsx
|
|
85
|
+
import { Spoiler } from 'spoiler-ui/react';
|
|
86
|
+
import 'spoiler-ui/style';
|
|
87
|
+
|
|
88
|
+
export default function ReviewPost() {
|
|
89
|
+
return (
|
|
90
|
+
<p>
|
|
91
|
+
The film was stunning, but I did not expect{' '}
|
|
92
|
+
<Spoiler label="ending spoiler">
|
|
93
|
+
the hero to sacrifice herself in the final act
|
|
94
|
+
</Spoiler>
|
|
95
|
+
.
|
|
96
|
+
</p>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
With callbacks and a custom ref:
|
|
102
|
+
|
|
103
|
+
```jsx
|
|
104
|
+
import { useRef } from 'react';
|
|
105
|
+
import { Spoiler } from 'spoiler-ui/react';
|
|
106
|
+
|
|
107
|
+
const ref = useRef(null);
|
|
108
|
+
|
|
109
|
+
<Spoiler
|
|
110
|
+
ref={ref}
|
|
111
|
+
label="Major spoiler"
|
|
112
|
+
blurAmount={10}
|
|
113
|
+
revealOnClick={false}
|
|
114
|
+
animationDuration={500}
|
|
115
|
+
onReveal={(text) => analytics.track('spoiler_revealed', { text })}
|
|
116
|
+
onError={(err) => console.error('Reveal failed:', err)}
|
|
117
|
+
>
|
|
118
|
+
The murderer was the detective all along.
|
|
119
|
+
</Spoiler>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Mode 2 — UI + backend
|
|
125
|
+
|
|
126
|
+
The text is **never sent to the browser** until the user clicks reveal. On click, the component fetches the content from your server, shows a loading indicator, then reveals. A user inspecting DevTools before clicking will find nothing.
|
|
127
|
+
|
|
128
|
+
On network failure, the pill shows "Failed — retry?" and re-clicking retries the fetch automatically.
|
|
129
|
+
|
|
130
|
+
### Vanilla JS
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
import { createSpoiler } from 'spoiler-ui';
|
|
134
|
+
import 'spoiler-ui/style';
|
|
135
|
+
|
|
136
|
+
const spoiler = createSpoiler(null, {
|
|
137
|
+
src: '/api/spoilers/42',
|
|
138
|
+
label: 'Spoiler',
|
|
139
|
+
onReveal: (text) => console.log('Fetched and revealed:', text),
|
|
140
|
+
onError: (err) => reportError(err),
|
|
141
|
+
});
|
|
142
|
+
document.querySelector('.post').appendChild(spoiler);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
With authentication headers:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
const spoiler = createSpoiler(null, {
|
|
149
|
+
src: '/api/spoilers/42',
|
|
150
|
+
fetchOptions: {
|
|
151
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
152
|
+
credentials: 'include',
|
|
153
|
+
},
|
|
154
|
+
onReveal: (text) => markAsRead(42),
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
With an optional blurred placeholder shown while loading:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
// 'Loading content…' is shown blurred until the fetch completes,
|
|
162
|
+
// then replaced with the real server response.
|
|
163
|
+
const spoiler = createSpoiler('Loading content\u2026', {
|
|
164
|
+
src: '/api/spoilers/42',
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**HTML attributes:**
|
|
169
|
+
|
|
170
|
+
```html
|
|
171
|
+
<span
|
|
172
|
+
data-spoiler="Spoiler"
|
|
173
|
+
data-spoiler-src="/api/spoilers/42"
|
|
174
|
+
>
|
|
175
|
+
</span>
|
|
176
|
+
|
|
177
|
+
<!-- Server returns HTML markup -->
|
|
178
|
+
<span
|
|
179
|
+
data-spoiler="Spoiler"
|
|
180
|
+
data-spoiler-src="/api/spoilers/43"
|
|
181
|
+
data-spoiler-response-type="html"
|
|
182
|
+
>
|
|
183
|
+
</span>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
> **Note on `responseType: 'html'`:** The server response is injected via `innerHTML`. You are responsible for sanitising HTML before serving it. Use `responseType: 'text'` (the default) whenever the content is plain text.
|
|
187
|
+
|
|
188
|
+
### React
|
|
189
|
+
|
|
190
|
+
```jsx
|
|
191
|
+
import { Spoiler } from 'spoiler-ui/react';
|
|
192
|
+
import 'spoiler-ui/style';
|
|
193
|
+
|
|
194
|
+
export default function Post({ spoilerId }) {
|
|
195
|
+
return (
|
|
196
|
+
<p>
|
|
197
|
+
The twist is{' '}
|
|
198
|
+
<Spoiler
|
|
199
|
+
src={`/api/spoilers/${spoilerId}`}
|
|
200
|
+
label="Spoiler"
|
|
201
|
+
loadingLabel="Fetching…"
|
|
202
|
+
errorLabel="Load failed — retry?"
|
|
203
|
+
onReveal={(text) => console.log('Revealed:', text)}
|
|
204
|
+
onError={(err) => reportError(err)}
|
|
205
|
+
/>
|
|
206
|
+
.
|
|
207
|
+
</p>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
With auth and HTML response:
|
|
213
|
+
|
|
214
|
+
```jsx
|
|
215
|
+
<Spoiler
|
|
216
|
+
src="/api/spoilers/99"
|
|
217
|
+
fetchOptions={{ headers: { Authorization: `Bearer ${token}` } }}
|
|
218
|
+
responseType="html"
|
|
219
|
+
onReveal={(html) => markSpoilerSeen(99)}
|
|
220
|
+
onError={(err) => toast.error('Could not load spoiler')}
|
|
221
|
+
/>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**State behaviour:**
|
|
225
|
+
|
|
226
|
+
| State | Pill shows | Cursor |
|
|
227
|
+
|-------|-----------|--------|
|
|
228
|
+
| Idle | label + 👁 | pointer |
|
|
229
|
+
| Loading | "Loading…" + spinner | wait |
|
|
230
|
+
| Error | "Failed — retry?" + ⚠ | pointer |
|
|
231
|
+
| Revealed | *(overlay removed)* | auto |
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Mode 3 — UI + encoding
|
|
236
|
+
|
|
237
|
+
The content is Base64-encoded and stored in a `data-` attribute. The plaintext **never appears in the DOM** — it is decoded in memory only when the user clicks reveal, then injected and displayed. A DevTools inspection before clicking shows only the encoded payload, not readable text.
|
|
238
|
+
|
|
239
|
+
> **Security note:** Base64 is obscurity, not encryption. Anyone who spots the encoded attribute can decode it in seconds with `atob()`. This is meaningfully better than plaintext for casual spoiler protection, but not suitable for genuinely sensitive data. Use [Mode 2](#mode-2--ui--backend) if the content must stay secret.
|
|
240
|
+
|
|
241
|
+
### Prepare the payload
|
|
242
|
+
|
|
243
|
+
Use the bundled `encodeBase64` utility anywhere that runs JS — a build script, a server, or the browser console:
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
import { encodeBase64 } from 'spoiler-ui';
|
|
247
|
+
|
|
248
|
+
const payload = encodeBase64('Darth Vader is Luke\'s father.');
|
|
249
|
+
// → 'RGFydGggVmFkZXIgaXMgTHVrZSdzIGZhdGhlci4='
|
|
250
|
+
|
|
251
|
+
// Full Unicode support — emoji and non-Latin scripts work fine
|
|
252
|
+
encodeBase64('王子 is the killer 🗡️');
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Vanilla JS
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
import { createSpoiler, encodeBase64 } from 'spoiler-ui';
|
|
259
|
+
import 'spoiler-ui/style';
|
|
260
|
+
|
|
261
|
+
const payload = encodeBase64('The real treasure was the friends we made along the way.');
|
|
262
|
+
|
|
263
|
+
const spoiler = createSpoiler(payload, {
|
|
264
|
+
encoded: true,
|
|
265
|
+
label: 'Spoiler',
|
|
266
|
+
onReveal: (text) => console.log('Decoded and revealed:', text),
|
|
267
|
+
onError: (err) => console.error('Decode failed:', err),
|
|
268
|
+
});
|
|
269
|
+
document.body.appendChild(spoiler);
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**What DevTools shows** (vanilla JS — React produces the same structure but as a React-managed DOM):
|
|
273
|
+
|
|
274
|
+
```html
|
|
275
|
+
<!-- Before reveal — only the encoded payload is in the DOM -->
|
|
276
|
+
<span class="spoiler-ui" data-state="idle" data-revealed="false"
|
|
277
|
+
data-spoiler-payload="VGhlIHJlYWwgdHJlYXN1cmUgd2FzIHRoZSBmcmllbmRzIHdlIG1hZGUgYWxvbmcgdGhlIHdheS4=">
|
|
278
|
+
<span class="spoiler-ui__inner"></span>
|
|
279
|
+
<span class="spoiler-ui__overlay" role="button" tabindex="0" aria-label="Reveal Spoiler">
|
|
280
|
+
<span class="spoiler-ui__pill">Spoiler</span>
|
|
281
|
+
</span>
|
|
282
|
+
</span>
|
|
283
|
+
|
|
284
|
+
<!-- After reveal — payload attribute removed, plaintext injected -->
|
|
285
|
+
<span class="spoiler-ui" data-state="revealed" data-revealed="true">
|
|
286
|
+
<span class="spoiler-ui__inner">The real treasure was the friends we made along the way.</span>
|
|
287
|
+
</span>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**HTML attributes:**
|
|
291
|
+
|
|
292
|
+
Encode your content and embed it in `data-spoiler-encoded`:
|
|
293
|
+
|
|
294
|
+
```html
|
|
295
|
+
<span
|
|
296
|
+
data-spoiler="Spoiler"
|
|
297
|
+
data-spoiler-encoded="VGhlIHJlYWwgdHJlYXN1cmUgd2FzIHRoZSBmcmllbmRzIHdlIG1hZGUgYWxvbmcgdGhlIHdheS4="
|
|
298
|
+
>
|
|
299
|
+
</span>
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
import { initSpoilers } from 'spoiler-ui';
|
|
304
|
+
initSpoilers();
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### React
|
|
308
|
+
|
|
309
|
+
Pass the Base64 string as `children` and set `encoded`:
|
|
310
|
+
|
|
311
|
+
```jsx
|
|
312
|
+
import { Spoiler } from 'spoiler-ui/react';
|
|
313
|
+
import 'spoiler-ui/style';
|
|
314
|
+
|
|
315
|
+
// Payload prepared at build time or server-side
|
|
316
|
+
const PAYLOAD = 'RGFydGggVmFkZXIgaXMgTHVrZSdzIGZhdGhlci4=';
|
|
317
|
+
|
|
318
|
+
export default function FilmReview() {
|
|
319
|
+
return (
|
|
320
|
+
<p>
|
|
321
|
+
The most iconic twist in cinema history:{' '}
|
|
322
|
+
<Spoiler encoded onReveal={(text) => console.log(text)}>
|
|
323
|
+
{PAYLOAD}
|
|
324
|
+
</Spoiler>
|
|
325
|
+
.
|
|
326
|
+
</p>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Theming
|
|
334
|
+
|
|
335
|
+
Override CSS variables on `.spoiler-ui` or any ancestor to retheme without touching the stylesheet.
|
|
336
|
+
|
|
337
|
+
### Stylesheet
|
|
338
|
+
|
|
339
|
+
```css
|
|
340
|
+
.spoiler-ui {
|
|
341
|
+
--spoiler-pill-bg: #6366f1;
|
|
342
|
+
--spoiler-pill-color: #fff;
|
|
343
|
+
--spoiler-bg: rgba(99, 102, 241, 0.1);
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### React — per-instance via `style` prop
|
|
348
|
+
|
|
349
|
+
```jsx
|
|
350
|
+
<Spoiler
|
|
351
|
+
label="Critical"
|
|
352
|
+
style={{
|
|
353
|
+
'--spoiler-pill-bg': '#e11d48',
|
|
354
|
+
'--spoiler-pill-color': '#fff',
|
|
355
|
+
'--spoiler-bg': 'rgba(225, 29, 72, 0.08)',
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
CVE-2024-1234 — remote code execution on /api/upload
|
|
359
|
+
</Spoiler>
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### `renderPill` — full pill customisation (React)
|
|
363
|
+
|
|
364
|
+
```jsx
|
|
365
|
+
<Spoiler
|
|
366
|
+
label="Reveal lineage"
|
|
367
|
+
renderPill={({ state, label }) => (
|
|
368
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
369
|
+
{state === 'idle' ? '🌑' : state === 'loading' ? '⏳' : '⚠️'}
|
|
370
|
+
<em>{label}</em>
|
|
371
|
+
</span>
|
|
372
|
+
)}
|
|
373
|
+
>
|
|
374
|
+
Luke, I am your father.
|
|
375
|
+
</Spoiler>
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## API reference
|
|
381
|
+
|
|
382
|
+
### `createSpoiler(content, options?)`
|
|
383
|
+
|
|
384
|
+
```js
|
|
385
|
+
import { createSpoiler } from 'spoiler-ui';
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
| Parameter | Type | Description |
|
|
389
|
+
|-----------|------|-------------|
|
|
390
|
+
| `content` | `string \| Element \| null` | Content to hide. Pass `null` when using `src` or `encoded`. |
|
|
391
|
+
| `options.label` | `string` | Pill text. Default: `'Spoiler'` |
|
|
392
|
+
| `options.blurAmount` | `number` | Blur in px while hidden. Default: `6` |
|
|
393
|
+
| `options.revealOnClick` | `boolean` | Click anywhere on element to reveal. Default: `true` |
|
|
394
|
+
| `options.animationDuration` | `number` | Transition in ms. Default: `300` |
|
|
395
|
+
| `options.className` | `string` | Extra class on root element. Default: `''` |
|
|
396
|
+
| `options.src` | `string` | URL to fetch content from on reveal. |
|
|
397
|
+
| `options.fetchOptions` | `RequestInit` | Passed directly to `fetch()`. Default: `{}` |
|
|
398
|
+
| `options.responseType` | `'text' \| 'html'` | How to inject fetched/decoded content. Default: `'text'` |
|
|
399
|
+
| `options.encoded` | `boolean` | Treat `content` as Base64; decode on reveal. Default: `false` |
|
|
400
|
+
| `options.icon` | `string \| false \| null` | Override the pill icon. String replaces the default 👁; `false`/`null` hides it entirely. Default: uses `--spoiler-icon` CSS variable. |
|
|
401
|
+
| `options.showPill` | `boolean` | Show the pill label button. Default: `true` |
|
|
402
|
+
| `options.loadingLabel` | `string` | Pill text while fetching. Default: `'Loading…'` |
|
|
403
|
+
| `options.errorLabel` | `string` | Pill text on fetch/decode failure. Default: `'Failed — retry?'` |
|
|
404
|
+
| `options.onReveal` | `(content: string) => void` | Called after successful reveal with the revealed text. |
|
|
405
|
+
| `options.onError` | `(err: Error) => void` | Called when a fetch or Base64 decode fails. |
|
|
406
|
+
|
|
407
|
+
Returns an `HTMLElement` ready to insert into the DOM.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
### `initSpoilers(scope?)`
|
|
412
|
+
|
|
413
|
+
```js
|
|
414
|
+
import { initSpoilers } from 'spoiler-ui';
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Scans `scope` (default: `document`) for `[data-spoiler]` elements and converts them.
|
|
418
|
+
|
|
419
|
+
**Supported HTML attributes:**
|
|
420
|
+
|
|
421
|
+
| Attribute | Corresponds to |
|
|
422
|
+
|-----------|---------------|
|
|
423
|
+
| `data-spoiler` | `label` (required to activate) |
|
|
424
|
+
| `data-spoiler-blur` | `blurAmount` |
|
|
425
|
+
| `data-spoiler-duration` | `animationDuration` |
|
|
426
|
+
| `data-spoiler-reveal-on-click` | `revealOnClick` — set to `'false'` to restrict reveal to the pill button only |
|
|
427
|
+
| `data-spoiler-show-pill` | `showPill` — set to `'false'` to hide the pill label button |
|
|
428
|
+
| `data-spoiler-loading-label` | `loadingLabel` — pill text while fetching |
|
|
429
|
+
| `data-spoiler-error-label` | `errorLabel` — pill text on failure |
|
|
430
|
+
| `data-spoiler-src` | `src` |
|
|
431
|
+
| `data-spoiler-response-type` | `responseType` (`'text'` or `'html'`) |
|
|
432
|
+
| `data-spoiler-encoded` | Base64 payload; enables `encoded` mode |
|
|
433
|
+
| `data-spoiler-class` | `className` — extra CSS class on the root element |
|
|
434
|
+
| `data-spoiler-icon` | `icon` — `'false'` hides the icon; any other string replaces the default 👁 |
|
|
435
|
+
|
|
436
|
+
> `onReveal`, `onError`, and `fetchOptions` cannot be set via HTML attributes — use `createSpoiler()` or the React `<Spoiler>` component for callbacks and custom fetch configuration.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
### `<Spoiler>` (React)
|
|
441
|
+
|
|
442
|
+
```js
|
|
443
|
+
import { Spoiler } from 'spoiler-ui/react';
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Accepts the same options as `createSpoiler` as props, plus `children` in place of the `content` parameter.
|
|
447
|
+
|
|
448
|
+
| Prop | Type | Default |
|
|
449
|
+
|------|------|---------|
|
|
450
|
+
| `children` | `ReactNode` | — |
|
|
451
|
+
| `label` | `string` | `'Spoiler'` |
|
|
452
|
+
| `blurAmount` | `number` | `6` |
|
|
453
|
+
| `revealOnClick` | `boolean` | `true` |
|
|
454
|
+
| `animationDuration` | `number` | `300` |
|
|
455
|
+
| `className` | `string` | `''` |
|
|
456
|
+
| `src` | `string` | — |
|
|
457
|
+
| `fetchOptions` | `RequestInit` | `{}` |
|
|
458
|
+
| `responseType` | `'text' \| 'html'` | `'text'` |
|
|
459
|
+
| `encoded` | `boolean` | `false` |
|
|
460
|
+
| `icon` | `string \| false \| null` | CSS `--spoiler-icon` (👁 by default) |
|
|
461
|
+
| `showPill` | `boolean` | `true` |
|
|
462
|
+
| `loadingLabel` | `string` | `'Loading…'` |
|
|
463
|
+
| `errorLabel` | `string` | `'Failed — retry?'` |
|
|
464
|
+
| `renderPill` | `(ctx: { state, label }) => ReactNode` | Render prop for full pill customisation. |
|
|
465
|
+
| `onReveal` | `(content?: string) => void` | Called after successful reveal. |
|
|
466
|
+
| `onError` | `(err: Error) => void` | Called when a fetch or decode fails. |
|
|
467
|
+
| `style` | `CSSProperties & { [key: \`--${string}\`]: string }` | Inline styles merged onto root. Use to override CSS variables per instance. |
|
|
468
|
+
| `ref` | `RefObject<HTMLSpanElement>` | Forwarded to the root `<span>` via `forwardRef`. |
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
### `encodeBase64(str)` / `decodeBase64(b64)`
|
|
473
|
+
|
|
474
|
+
```js
|
|
475
|
+
import { encodeBase64, decodeBase64 } from 'spoiler-ui';
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Full Unicode support via `TextEncoder` / `TextDecoder`. Use `encodeBase64` at build time or server-side to prepare payloads for encoded mode.
|
|
479
|
+
|
|
480
|
+
```js
|
|
481
|
+
const payload = encodeBase64('Snape kills Dumbledore.');
|
|
482
|
+
// → 'U25hcGUga2lsbHMgRHVtYmxlZG9yZS4='
|
|
483
|
+
|
|
484
|
+
decodeBase64('U25hcGUga2lsbHMgRHVtYmxlZG9yZS4=');
|
|
485
|
+
// → 'Snape kills Dumbledore.'
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## CSS customisation
|
|
491
|
+
|
|
492
|
+
Import the stylesheet and override variables on `.spoiler-ui` or any parent:
|
|
493
|
+
|
|
494
|
+
```js
|
|
495
|
+
import 'spoiler-ui/style';
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
```css
|
|
499
|
+
.spoiler-ui {
|
|
500
|
+
--spoiler-blur: 8px; /* blur amount while hidden */
|
|
501
|
+
--spoiler-duration: 400ms; /* reveal transition speed */
|
|
502
|
+
--spoiler-bg: rgba(0,0,0,0.2); /* overlay background */
|
|
503
|
+
--spoiler-pill-bg: #1a1a2e; /* pill background (idle) */
|
|
504
|
+
--spoiler-pill-color: #e0e0ff; /* pill text colour */
|
|
505
|
+
--spoiler-pill-font: inherit; /* pill font family */
|
|
506
|
+
--spoiler-radius: 6px; /* corner radius on overlay */
|
|
507
|
+
--spoiler-error-bg: #c0392b; /* pill background on error */
|
|
508
|
+
--spoiler-icon: '🔒'; /* icon shown before pill text */
|
|
509
|
+
--spoiler-pill-padding: 1px 8px;
|
|
510
|
+
--spoiler-pill-font-size: 0.72em;
|
|
511
|
+
--spoiler-pill-font-weight: 600;
|
|
512
|
+
--spoiler-pill-letter-spacing: 0.02em;
|
|
513
|
+
--spoiler-pill-gap: 4px; /* gap between icon and label */
|
|
514
|
+
--spoiler-pill-radius: 100px; /* pill border radius */
|
|
515
|
+
--spoiler-shimmer-color: rgba(255,255,255,0.5);
|
|
516
|
+
--spoiler-shimmer-duration: 2.4s;
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Dark mode is **not** applied automatically — override the variables in your own stylesheet:
|
|
521
|
+
|
|
522
|
+
```css
|
|
523
|
+
@media (prefers-color-scheme: dark) {
|
|
524
|
+
.spoiler-ui {
|
|
525
|
+
--spoiler-bg: rgba(255, 255, 255, 0.08);
|
|
526
|
+
--spoiler-pill-bg: rgba(255, 255, 255, 0.18);
|
|
527
|
+
--spoiler-pill-color: #fff;
|
|
528
|
+
--spoiler-error-bg: #e74c3c;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Animations are disabled automatically via `@media (prefers-reduced-motion: reduce)`.
|
|
534
|
+
|
|
535
|
+
### Custom overlay animation
|
|
536
|
+
|
|
537
|
+
The overlay is `.spoiler-ui__overlay`. Target it directly to replace or extend the built-in shimmer:
|
|
538
|
+
|
|
539
|
+
```css
|
|
540
|
+
/* Disable default shimmer */
|
|
541
|
+
.spoiler-ui {
|
|
542
|
+
--spoiler-shimmer-color: transparent;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/* Add your own animation */
|
|
546
|
+
.spoiler-ui__overlay::before {
|
|
547
|
+
content: '';
|
|
548
|
+
position: absolute;
|
|
549
|
+
inset: 0;
|
|
550
|
+
border-radius: inherit;
|
|
551
|
+
background-image: url('noise.png');
|
|
552
|
+
animation: my-particles 3s linear infinite;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
@keyframes my-particles {
|
|
556
|
+
to { background-position: 200px 200px; }
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
The overlay switches to `opacity: 0; pointer-events: none` on reveal — your custom animation cleans up automatically.
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## Accessibility
|
|
565
|
+
|
|
566
|
+
- The reveal button has `role="button"`, `tabindex="0"`, and `aria-label="Reveal {label}"`.
|
|
567
|
+
- `aria-expanded="false"` is set while hidden; the attribute is removed after reveal.
|
|
568
|
+
- Keyboard: `Enter` or `Space` to reveal — works regardless of `revealOnClick`.
|
|
569
|
+
- After reveal the overlay is removed from tab order and marked `aria-hidden`.
|
|
570
|
+
- Focus ring shown via `:focus-visible` (no ring on mouse click).
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## License
|
|
575
|
+
|
|
576
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spoiler-ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight spoiler/content-reveal UI element — like Threads & Discord. Vanilla JS + optional React wrapper.",
|
|
5
|
+
"keywords": ["spoiler", "ui", "reveal", "blur", "discord", "threads", "component"],
|
|
6
|
+
"author": "Dzmitry Ihnatovich",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"types": "./spoiler.d.ts",
|
|
10
|
+
"main": "./spoiler.js",
|
|
11
|
+
"module": "./spoiler.js",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./spoiler.js",
|
|
15
|
+
"types": "./spoiler.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./react": {
|
|
18
|
+
"import": "./react.jsx",
|
|
19
|
+
"types": "./react.d.ts"
|
|
20
|
+
},
|
|
21
|
+
"./style": {
|
|
22
|
+
"import": "./spoiler.css",
|
|
23
|
+
"types": "./style.d.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"spoiler.js",
|
|
28
|
+
"spoiler.d.ts",
|
|
29
|
+
"react.jsx",
|
|
30
|
+
"react.d.ts",
|
|
31
|
+
"spoiler.css",
|
|
32
|
+
"style.d.ts",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"sideEffects": [
|
|
36
|
+
"./spoiler.css"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=17.0.0",
|
|
43
|
+
"react-dom": ">=17.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"react": { "optional": true },
|
|
47
|
+
"react-dom": { "optional": true }
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"vite": "^5.0.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"dev": "vite demo",
|
|
54
|
+
"build": "vite build"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "https://github.com/guestDI/spoiler-ui"
|
|
59
|
+
},
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/guestDI/spoiler-ui/issues"
|
|
62
|
+
},
|
|
63
|
+
"homepage": "https://github.com/guestDI/spoiler-ui#readme"
|
|
64
|
+
}
|