@aarongustafson/img-alt-overlay 0.0.0-stage → 0.0.1
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 +443 -2
- package/custom-elements.json +196 -0
- package/define.d.ts +4 -0
- package/define.js +19 -0
- package/img-alt-overlay.css +134 -0
- package/img-alt-overlay.d.ts +28 -0
- package/img-alt-overlay.js +274 -0
- package/index.js +1 -0
- package/package.json +89 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Aaron Gustafson
|
|
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
CHANGED
|
@@ -1,3 +1,444 @@
|
|
|
1
|
-
#
|
|
1
|
+
# img-alt-overlay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@aarongustafson/img-alt-overlay)
|
|
4
|
+
[](https://github.com/aarongustafson/img-alt-overlay/actions/workflows/ci.yml)
|
|
5
|
+
[](https://bundlejs.com/?q=@aarongustafson/img-alt-overlay)
|
|
6
|
+
[](https://github.com/aarongustafson/img-alt-overlay/blob/main/LICENSE)
|
|
7
|
+
[](https://github.com/aarongustafson/img-alt-overlay/blob/main/img-alt-overlay.d.ts)
|
|
8
|
+
|
|
9
|
+
`<img-alt-overlay>` wraps an image and adds a button that reveals its alternative
|
|
10
|
+
text in a non-modal dialog over the image. It leaves the image, its `alt` attribute, responsive
|
|
11
|
+
sources, and links alone. Without JavaScript, the original image still works.
|
|
12
|
+
|
|
13
|
+
The interaction builds on the alt-text overlay on
|
|
14
|
+
[Aaron’s website](https://www.aaron-gustafson.com/notebook/there-can-be-only-one-redux/).
|
|
15
|
+
Like that panel, it stays inside the image’s block and leaves the rest of the
|
|
16
|
+
page interactive. It has no backdrop and doesn’t trap keyboard focus. Activate
|
|
17
|
+
the opener again to close the panel; its label changes to the dismissal wording
|
|
18
|
+
while open.
|
|
19
|
+
|
|
20
|
+
## Try it
|
|
21
|
+
|
|
22
|
+
This package is in development and hasn’t been published. From a checkout:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm ci
|
|
26
|
+
node scripts/serve.js
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Open [the demo](http://127.0.0.1:4177/demo/). It covers default controls,
|
|
30
|
+
translation, right-to-left layout, linked responsive images, theming, decorative
|
|
31
|
+
images, and failed image loading.
|
|
32
|
+
|
|
33
|
+
## Load and register
|
|
34
|
+
|
|
35
|
+
Once published, import the registration entry point:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import '@aarongustafson/img-alt-overlay/define.js';
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Load the optional stylesheet using your bundler, or serve it as a normal stylesheet:
|
|
42
|
+
|
|
43
|
+
```css
|
|
44
|
+
@import '@aarongustafson/img-alt-overlay/img-alt-overlay.css';
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For a local checkout, put these in the document head:
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<link rel="stylesheet" href="./img-alt-overlay/img-alt-overlay.css" />
|
|
51
|
+
<script type="module" src="./img-alt-overlay/define.js"></script>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The package root and class file are **class-only imports**; neither registers
|
|
55
|
+
an element:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { ImgAltOverlayElement } from '@aarongustafson/img-alt-overlay';
|
|
59
|
+
|
|
60
|
+
customElements.define('img-alt-overlay', ImgAltOverlayElement);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Importing `define.js` registers `img-alt-overlay` when a browser registry exists.
|
|
64
|
+
It also exports `defineImgAltOverlay()`, which returns `false` without a registry
|
|
65
|
+
and `true` when the name is already registered or registration succeeds. It
|
|
66
|
+
doesn’t overwrite an existing registration. Class and registration imports are
|
|
67
|
+
safe without a browser DOM; constructing the class requires one.
|
|
68
|
+
|
|
69
|
+
## Wrap an image
|
|
70
|
+
|
|
71
|
+
```html
|
|
72
|
+
<img-alt-overlay>
|
|
73
|
+
<img
|
|
74
|
+
src="bicycle.jpg"
|
|
75
|
+
width="640"
|
|
76
|
+
height="360"
|
|
77
|
+
alt="A red bicycle beside a blue wall, with a basket on its handlebars."
|
|
78
|
+
/>
|
|
79
|
+
</img-alt-overlay>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Use exactly one image belonging to the wrapper. A `picture` element and an image
|
|
83
|
+
link are supported:
|
|
84
|
+
|
|
85
|
+
```html
|
|
86
|
+
<figure>
|
|
87
|
+
<img-alt-overlay>
|
|
88
|
+
<a href="bicycle-large.jpg">
|
|
89
|
+
<picture>
|
|
90
|
+
<source media="(min-width: 40em)" srcset="bicycle-wide.jpg" />
|
|
91
|
+
<img
|
|
92
|
+
src="bicycle.jpg"
|
|
93
|
+
width="640"
|
|
94
|
+
height="360"
|
|
95
|
+
alt="View the red bicycle photograph."
|
|
96
|
+
/>
|
|
97
|
+
</picture>
|
|
98
|
+
</a>
|
|
99
|
+
</img-alt-overlay>
|
|
100
|
+
<figcaption>A neighborhood bicycle.</figcaption>
|
|
101
|
+
</figure>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Put links **inside** the wrapper, not around it. The generated button must not be
|
|
105
|
+
nested in a link, button, or label. Keep captions outside the wrapper so the
|
|
106
|
+
overlay is positioned over the image rather than the caption.
|
|
107
|
+
|
|
108
|
+
Empty, missing, or whitespace-only alt text produces no controls. Empty `alt=""`
|
|
109
|
+
is appropriate for decorative images; missing alt text isn’t a substitute for
|
|
110
|
+
writing an appropriate alternative. This component doesn’t generate descriptions,
|
|
111
|
+
infer them from filenames, or repair missing alt text.
|
|
112
|
+
|
|
113
|
+
## Native commands, with a small fallback
|
|
114
|
+
|
|
115
|
+
The component adds light-DOM markup equivalent to:
|
|
116
|
+
|
|
117
|
+
```html
|
|
118
|
+
<button
|
|
119
|
+
data-alt-trigger
|
|
120
|
+
type="button"
|
|
121
|
+
commandfor="image-alt-dialog"
|
|
122
|
+
command="--toggle-alt"
|
|
123
|
+
aria-controls="image-alt-dialog"
|
|
124
|
+
aria-haspopup="dialog"
|
|
125
|
+
aria-expanded="false"
|
|
126
|
+
>
|
|
127
|
+
View alt text
|
|
128
|
+
</button>
|
|
129
|
+
<div data-alt-ui>
|
|
130
|
+
<dialog
|
|
131
|
+
data-alt-dialog
|
|
132
|
+
id="image-alt-dialog"
|
|
133
|
+
aria-labelledby="image-alt-heading"
|
|
134
|
+
>
|
|
135
|
+
<h2 id="image-alt-heading">Image description</h2>
|
|
136
|
+
<p tabindex="-1" autofocus>The image’s alternative text.</p>
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
commandfor="image-alt-dialog"
|
|
140
|
+
command="close"
|
|
141
|
+
aria-label="Close"
|
|
142
|
+
>
|
|
143
|
+
<span aria-hidden="true">✕</span>
|
|
144
|
+
</button>
|
|
145
|
+
</dialog>
|
|
146
|
+
</div>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
IDs are generated per instance. Supporting browsers handle the
|
|
150
|
+
[`command` and `commandfor` attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#command).
|
|
151
|
+
The opener uses the custom `--toggle-alt` command: its `command` event calls
|
|
152
|
+
`dialog.show()` when closed and `dialog.close()` when open. HTML has no built-in
|
|
153
|
+
command for toggling a non-modal dialog. The separate Close button uses the
|
|
154
|
+
built-in `close` command. The opener’s wording and `aria-expanded` stay in sync
|
|
155
|
+
when either control or Escape closes the panel.
|
|
156
|
+
Where the button’s `commandForElement` API is absent, a delegated click listener
|
|
157
|
+
calls the native dialog’s `show()` and `close()` methods instead. It respects
|
|
158
|
+
canceled clicks and never runs alongside the supported native command path.
|
|
159
|
+
|
|
160
|
+
Escape closes the dialog while focus is inside the wrapper, unless the key event
|
|
161
|
+
has already been canceled. The listener belongs to the wrapper, not the document;
|
|
162
|
+
Escape elsewhere on the page doesn’t dismiss an unrelated image’s description.
|
|
163
|
+
|
|
164
|
+
The fallback is for **commands**, not for dialog itself. If the native dialog
|
|
165
|
+
methods aren’t available, enhancement reports an error and adds no controls.
|
|
166
|
+
There’s no dialog polyfill, popover mode, image enlargement, or remote request.
|
|
167
|
+
|
|
168
|
+
## Labels and configuration
|
|
169
|
+
|
|
170
|
+
| Attribute | Default | Purpose |
|
|
171
|
+
| -------------- | ------------------- | ---------------------------------------------------------------------------------------- |
|
|
172
|
+
| `button-label` | `View alt text` | Visible and accessible name of the opener |
|
|
173
|
+
| `dialog-label` | `Image description` | Dialog heading and accessible name; visually hidden by the default stylesheet |
|
|
174
|
+
| `close-label` | `Close` | Dismissal wording for the open-state opener and accessible name of the icon Close button |
|
|
175
|
+
|
|
176
|
+
Authored attributes override defaults. All labels must contain non-whitespace
|
|
177
|
+
text. Labels and alt text are inserted as plain text, never interpreted as HTML.
|
|
178
|
+
There are no separate ARIA-only labels that could diverge from visible wording.
|
|
179
|
+
|
|
180
|
+
```html
|
|
181
|
+
<img-alt-overlay
|
|
182
|
+
lang="fr"
|
|
183
|
+
button-label="Voir le texte alternatif"
|
|
184
|
+
dialog-label="Description de l’image"
|
|
185
|
+
close-label="Fermer"
|
|
186
|
+
>
|
|
187
|
+
<img
|
|
188
|
+
src="bicycle.jpg"
|
|
189
|
+
alt="Un vélo rouge devant un mur bleu, avec un panier sur le guidon."
|
|
190
|
+
/>
|
|
191
|
+
</img-alt-overlay>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Supply translations for all three labels and the image alternative. `lang` and
|
|
195
|
+
`dir` inherit normally. Changing a label attribute schedules a refresh, closing
|
|
196
|
+
any open dialog and rebuilding the controls with the new wording.
|
|
197
|
+
|
|
198
|
+
## Updates, lifecycle, and errors
|
|
199
|
+
|
|
200
|
+
Setup runs in a queued microtask after connection. Normal module scripts in the
|
|
201
|
+
document head defer until parsing finishes; don’t hide the image while waiting
|
|
202
|
+
for definition or successful setup.
|
|
203
|
+
|
|
204
|
+
The component observes only the controlled image’s `alt` attribute. Text changes
|
|
205
|
+
update the existing dialog without closing it. Emptying or removing `alt` closes
|
|
206
|
+
and removes the controls; adding nonempty text creates them again.
|
|
207
|
+
|
|
208
|
+
Call `refresh()` after replacing an image or changing the authored structure:
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
const wrapper = document.querySelector('img-alt-overlay');
|
|
212
|
+
wrapper.refresh();
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`refresh()` closes any open dialog and rereads the image and labels. It doesn’t
|
|
216
|
+
change authored nodes. Disconnecting closes and removes generated UI and
|
|
217
|
+
disconnects the observer and fallback listener. Reconnecting initializes again.
|
|
218
|
+
|
|
219
|
+
The read-only `state` property is:
|
|
220
|
+
|
|
221
|
+
| Value | Meaning |
|
|
222
|
+
| ---------- | -------------------------------------------- |
|
|
223
|
+
| `idle` | Not connected or not yet initialized |
|
|
224
|
+
| `inactive` | Valid image, but no nonempty alt text |
|
|
225
|
+
| `ready` | Dialog controls are available |
|
|
226
|
+
| `error` | Enhancement failed; authored content remains |
|
|
227
|
+
|
|
228
|
+
| Event | When | Detail |
|
|
229
|
+
| ----------------------- | --------------------------------------- | ----------- |
|
|
230
|
+
| `img-alt-overlay:ready` | A new set of usable controls is created | None |
|
|
231
|
+
| `img-alt-overlay:error` | Invalid configuration or setup failure | `{ error }` |
|
|
232
|
+
|
|
233
|
+
Both events bubble. Listen before inserting the element or loading the
|
|
234
|
+
registration module if you need initial events:
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
document.addEventListener('img-alt-overlay:error', (event) => {
|
|
238
|
+
console.error('Alt-text enhancement failed:', event.detail.error);
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Invalid image counts, wrappers inside interactive controls, empty labels, and
|
|
243
|
+
unsupported dialog APIs are errors. Fix the markup and call `refresh()` to retry.
|
|
244
|
+
An image load failure isn’t an enhancement failure: its authored alternative
|
|
245
|
+
text remains available in the dialog.
|
|
246
|
+
|
|
247
|
+
## Styling
|
|
248
|
+
|
|
249
|
+
All generated UI is light DOM. Without the optional stylesheet, controls remain
|
|
250
|
+
in normal flow and the browser supplies the dialog presentation. With it, the
|
|
251
|
+
opener sits at the image’s block-start/inline-end corner and the non-modal dialog
|
|
252
|
+
is vertically centered in the space below the opener within the image wrapper.
|
|
253
|
+
Its width and maximum height are bounded by that block, not the viewport.
|
|
254
|
+
The default overlay area reserves 3.75rem above the panel for the opener.
|
|
255
|
+
If you move the opener or use larger, multiline labels, adjust the overlay
|
|
256
|
+
insets to leave enough room. Long descriptions
|
|
257
|
+
wrap and scroll inside the panel; it moves with the image as the page scrolls
|
|
258
|
+
or resizes. The layout layer lets pointer clicks pass through to the image or
|
|
259
|
+
its link outside the controls and open panel.
|
|
260
|
+
|
|
261
|
+
The description stays centered across and along the panel, with no paragraph
|
|
262
|
+
margin or visible focus ring. It still receives initial focus so the browser can
|
|
263
|
+
present the dialog and its text. Long content starts at the top and scrolls
|
|
264
|
+
rather than being clipped above the panel.
|
|
265
|
+
|
|
266
|
+
The separate Close button is a ✕ with its accessible name taken from
|
|
267
|
+
`close-label`. It stays visually hidden until focused and remains in the keyboard
|
|
268
|
+
tab order and accessibility tree. When revealed, it sits at the dialog’s block
|
|
269
|
+
start/inline end, has a 44px minimum target at the default font size, and keeps
|
|
270
|
+
its visible focus ring. Its space is reserved, so revealing it doesn’t enlarge
|
|
271
|
+
the panel or cover the text. It remains visible during pointer interaction once
|
|
272
|
+
revealed. Without the stylesheet, the icon button stays visible in normal flow.
|
|
273
|
+
|
|
274
|
+
Use `--img-alt-dialog-min-block-size` to change the panel’s minimum height.
|
|
275
|
+
The default is `30%` of the available image-overlay area, rather than the whole
|
|
276
|
+
image; the opener’s reserved space is outside that area. The minimum is capped
|
|
277
|
+
at the available area so small images and long descriptions remain bounded.
|
|
278
|
+
When space permits, a `4rem` floor keeps the Close button and its focus ring
|
|
279
|
+
inside the panel, even with a short description or a smaller configured minimum.
|
|
280
|
+
The percentage-based cap requires an overlay area with a definite block size,
|
|
281
|
+
even when the custom property contains a length. For an auto-sized,
|
|
282
|
+
edge-anchored area, set the dialog’s `min-block-size` directly instead, as in
|
|
283
|
+
the example below.
|
|
284
|
+
|
|
285
|
+
The [website-style demo](./demo/index.html#site-style-example) shows a full-image-width,
|
|
286
|
+
borderless black panel with white text. It sets the overlay’s inline insets to
|
|
287
|
+
zero, keeps spacing around the opener, and themes only that instance through
|
|
288
|
+
an authored class.
|
|
289
|
+
|
|
290
|
+
Stable hooks:
|
|
291
|
+
|
|
292
|
+
- `[data-alt-ui]`: positioned area containing the panel, separate from the opener.
|
|
293
|
+
- `[data-alt-trigger]`: opener button.
|
|
294
|
+
- `[data-alt-dialog]`: native non-modal dialog; there is no backdrop.
|
|
295
|
+
- `[data-alt-text]`: alternative-text paragraph.
|
|
296
|
+
- `[data-alt-close]`: close button.
|
|
297
|
+
|
|
298
|
+
| CSS custom property | Default |
|
|
299
|
+
| --------------------------------- | ------------ |
|
|
300
|
+
| `--img-alt-button-color` | `#fff` |
|
|
301
|
+
| `--img-alt-button-background` | `#111` |
|
|
302
|
+
| `--img-alt-dialog-color` | `CanvasText` |
|
|
303
|
+
| `--img-alt-dialog-background` | `Canvas` |
|
|
304
|
+
| `--img-alt-focus-color` | `Highlight` |
|
|
305
|
+
| `--img-alt-dialog-min-block-size` | `30%` |
|
|
306
|
+
|
|
307
|
+
### Position the opener and overlay
|
|
308
|
+
|
|
309
|
+
These properties accept CSS inset values, such as lengths, percentages, and
|
|
310
|
+
`auto`. They position the opener and overlay area independently, relative to the
|
|
311
|
+
image wrapper. Logical sides follow the writing mode and direction:
|
|
312
|
+
`inline-start` is left in horizontal left-to-right text and right in
|
|
313
|
+
right-to-left text.
|
|
314
|
+
|
|
315
|
+
| CSS custom property | Default |
|
|
316
|
+
| -------------------------------------- | --------- |
|
|
317
|
+
| `--img-alt-button-inset-block-start` | `0.5rem` |
|
|
318
|
+
| `--img-alt-button-inset-block-end` | `auto` |
|
|
319
|
+
| `--img-alt-button-inset-inline-start` | `auto` |
|
|
320
|
+
| `--img-alt-button-inset-inline-end` | `0.5rem` |
|
|
321
|
+
| `--img-alt-overlay-inset-block-start` | `3.75rem` |
|
|
322
|
+
| `--img-alt-overlay-inset-block-end` | `0.5rem` |
|
|
323
|
+
| `--img-alt-overlay-inset-inline-start` | `0.5rem` |
|
|
324
|
+
| `--img-alt-overlay-inset-inline-end` | `0.5rem` |
|
|
325
|
+
|
|
326
|
+
When both block insets have values, the dialog centers in the bounded area
|
|
327
|
+
between them. Set the opposite inset to `auto` to anchor the natural-height
|
|
328
|
+
area to one block edge instead. The same CSS positioning rules apply to the
|
|
329
|
+
opener: when moving it to another corner, clear its opposing insets.
|
|
330
|
+
The [repositioning demo](./demo/index.html#inset-example) moves the opener to
|
|
331
|
+
block end/inline start and leaves room for it below the overlay.
|
|
332
|
+
|
|
333
|
+
For a panel anchored to block end and both inline edges:
|
|
334
|
+
|
|
335
|
+
```css
|
|
336
|
+
img-alt-overlay.bottom-panel {
|
|
337
|
+
--img-alt-overlay-inset-block-start: auto;
|
|
338
|
+
--img-alt-overlay-inset-block-end: 0;
|
|
339
|
+
--img-alt-overlay-inset-inline-start: 0;
|
|
340
|
+
--img-alt-overlay-inset-inline-end: 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
img-alt-overlay.bottom-panel [data-alt-ui] {
|
|
344
|
+
max-block-size: calc(100% - 3.75rem);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
img-alt-overlay.bottom-panel [data-alt-dialog] {
|
|
348
|
+
min-block-size: 4rem;
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The height limit leaves room for the default opener when text needs to scroll.
|
|
353
|
+
The minimum height fits the Close button and its focus ring even when the
|
|
354
|
+
description is short.
|
|
355
|
+
The [slide-in demo](./demo/index.html#slide-up-example) adds 300ms CSS transitions
|
|
356
|
+
to this layout, sliding and fading the panel in and out. It clips motion within
|
|
357
|
+
the image and skips transitions when the reader requests reduced motion.
|
|
358
|
+
[`@starting-style`](https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style)
|
|
359
|
+
defines the entry state, and a
|
|
360
|
+
[`display` transition with `allow-discrete`](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior)
|
|
361
|
+
keeps the closing panel rendered until the exit finishes. Browsers without
|
|
362
|
+
discrete-transition support open and close it immediately. Some engines parse
|
|
363
|
+
`allow-discrete` but can’t transition a native dialog’s `display`; those still
|
|
364
|
+
animate entry but close immediately. This non-modal dialog
|
|
365
|
+
doesn’t enter the top layer or use a backdrop, so it needs no `overlay` or
|
|
366
|
+
`::backdrop` transition. The component’s default stylesheet adds no motion.
|
|
367
|
+
|
|
368
|
+
Keep visible focus indicators, legible contrast, and a reachable Close control.
|
|
369
|
+
Don’t override the native dialog’s closed-state visibility. If you manually
|
|
370
|
+
register the class under another tag name, adapt the stylesheet’s
|
|
371
|
+
`img-alt-overlay` selectors to that tag.
|
|
372
|
+
|
|
373
|
+
## Accessibility behavior and limits
|
|
374
|
+
|
|
375
|
+
- The original image keeps its native alternative text. The dialog provides an
|
|
376
|
+
optional visible copy; it doesn’t replace the image’s accessible name.
|
|
377
|
+
- The opener is a real `type="button"` with `aria-haspopup="dialog"` and a
|
|
378
|
+
synchronized `aria-expanded` state. The dialog is named by its heading, which
|
|
379
|
+
the default stylesheet visually hides to keep the panel compact.
|
|
380
|
+
- Opening focuses the alternative-text paragraph, which is programmatically
|
|
381
|
+
focusable but not an extra Tab stop. This keeps a long description’s beginning
|
|
382
|
+
visible rather than scrolling immediately to Close.
|
|
383
|
+
- The dialog is non-modal: the rest of the document stays interactive, and Tab
|
|
384
|
+
can leave the panel. Close or Escape within the wrapper dismisses it; native
|
|
385
|
+
dialog behavior restores prior keyboard focus when focus is still inside.
|
|
386
|
+
Pointer focus follows browser conventions: Safari, for example, doesn’t always
|
|
387
|
+
focus clicked buttons. The component doesn’t force a different pointer-focus
|
|
388
|
+
policy.
|
|
389
|
+
- This is a named dialog with an interactive Close control, not `role="tooltip"`.
|
|
390
|
+
It opens by explicit activation rather than hover alone.
|
|
391
|
+
- No extra live region announces the same description, and no animation is added.
|
|
392
|
+
- Images, links, sources, dimensions, and loading hints remain unchanged. Without
|
|
393
|
+
JavaScript, or after setup failure, no dead controls are added.
|
|
394
|
+
|
|
395
|
+
An alt-text dialog isn’t a replacement for visible captions, nearby explanations,
|
|
396
|
+
or a proper long description. Authors remain responsible for the alternative’s
|
|
397
|
+
quality and context. Don’t include sensitive information in alt text expecting it
|
|
398
|
+
to remain hidden.
|
|
399
|
+
|
|
400
|
+
Chromium, Firefox, and WebKit have automated browser checks for the native
|
|
401
|
+
interaction, fallback, focus, layout, and failure states. Automated checks don’t
|
|
402
|
+
establish full accessibility conformance; a manual screen-reader review is still
|
|
403
|
+
needed before release.
|
|
404
|
+
|
|
405
|
+
## Development
|
|
406
|
+
|
|
407
|
+
```sh
|
|
408
|
+
npm ci
|
|
409
|
+
npm run test:run
|
|
410
|
+
npm run test:types
|
|
411
|
+
npx playwright install --with-deps chromium firefox webkit
|
|
412
|
+
npm run test:browser
|
|
413
|
+
npm run lint
|
|
414
|
+
npm run test:coverage
|
|
415
|
+
npm audit --omit=dev
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Use test-driven development. Tests cover image preservation, commands, labels,
|
|
419
|
+
text-only insertion, lifecycle cleanup, declaration imports, package contents,
|
|
420
|
+
and release automation. Browser checks cover native focus and keyboard behavior,
|
|
421
|
+
long content, narrow screens, translations, and no-JavaScript/module-failure
|
|
422
|
+
fallbacks. The CDN tests route unpublished URLs to the local source; they don’t
|
|
423
|
+
claim a future package is available from the CDN.
|
|
424
|
+
|
|
425
|
+
## Publishing
|
|
426
|
+
|
|
427
|
+
The starter’s release hooks remain intact:
|
|
428
|
+
|
|
429
|
+
- `version`: `node scripts/update-demo-versions.js && git add demo/esm.html`
|
|
430
|
+
- `postversion`: `git push --follow-tags`
|
|
431
|
+
|
|
432
|
+
When explicitly releasing, `npm version minor` updates package metadata and the
|
|
433
|
+
versioned CDN demo, creates the version commit and annotated tag, and pushes the
|
|
434
|
+
branch and tag. Don’t run it merely to test readiness.
|
|
435
|
+
|
|
436
|
+
The tag-triggered `publish.yml` workflow uses npm trusted publishing. Reserve
|
|
437
|
+
the package and configure npm’s trusted publisher before the first automated
|
|
438
|
+
release: owner `aarongustafson`, repository `img-alt-overlay`, workflow filename
|
|
439
|
+
`publish.yml`, and no environment. The workflow’s manual dry-run option doesn’t
|
|
440
|
+
publish. No version bump or publication is part of this initial implementation.
|
|
441
|
+
|
|
442
|
+
## License
|
|
443
|
+
|
|
444
|
+
[MIT](./LICENSE).
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0.0",
|
|
3
|
+
"readme": "README.md",
|
|
4
|
+
"modules": [
|
|
5
|
+
{
|
|
6
|
+
"kind": "javascript-module",
|
|
7
|
+
"path": "img-alt-overlay.js",
|
|
8
|
+
"declarations": [
|
|
9
|
+
{
|
|
10
|
+
"kind": "class",
|
|
11
|
+
"name": "ImgAltOverlayElement",
|
|
12
|
+
"description": "A light-DOM image wrapper with a non-modal, in-image alt-text dialog, custom toggle command, native closing command, and JavaScript command fallback. The opener reflects aria-expanded; the default stylesheet reveals the separate Close control on focus.",
|
|
13
|
+
"tagName": "img-alt-overlay",
|
|
14
|
+
"customElement": true,
|
|
15
|
+
"superclass": { "name": "HTMLElement" },
|
|
16
|
+
"attributes": [
|
|
17
|
+
{
|
|
18
|
+
"name": "button-label",
|
|
19
|
+
"description": "Visible and accessible opener label. Must be nonempty.",
|
|
20
|
+
"type": { "text": "string" },
|
|
21
|
+
"default": "\"View alt text\""
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "dialog-label",
|
|
25
|
+
"description": "Dialog heading and accessible dialog name. Visually hidden by the default stylesheet. Must be nonempty.",
|
|
26
|
+
"type": { "text": "string" },
|
|
27
|
+
"default": "\"Image description\""
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "close-label",
|
|
31
|
+
"description": "Dismissal wording for the opener while open and accessible name of the separate icon Close button. Must be nonempty.",
|
|
32
|
+
"type": { "text": "string" },
|
|
33
|
+
"default": "\"Close\""
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"members": [
|
|
37
|
+
{
|
|
38
|
+
"kind": "field",
|
|
39
|
+
"name": "state",
|
|
40
|
+
"readonly": true,
|
|
41
|
+
"type": {
|
|
42
|
+
"text": "'idle' | 'inactive' | 'ready' | 'error'"
|
|
43
|
+
},
|
|
44
|
+
"description": "Current enhancement state.",
|
|
45
|
+
"default": "\"idle\""
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"kind": "method",
|
|
49
|
+
"name": "refresh",
|
|
50
|
+
"description": "Closes any open dialog, restores authored markup, and rereads image structure and labels.",
|
|
51
|
+
"return": { "type": { "text": "void" } }
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"events": [
|
|
55
|
+
{
|
|
56
|
+
"name": "img-alt-overlay:ready",
|
|
57
|
+
"type": { "text": "CustomEvent<void>" },
|
|
58
|
+
"description": "Bubbles when a new set of usable controls has been created."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "img-alt-overlay:error",
|
|
62
|
+
"type": { "text": "CustomEvent<{ error: Error }>" },
|
|
63
|
+
"description": "Bubbles on configuration or setup failure. Authored content remains intact."
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"cssProperties": [
|
|
67
|
+
{
|
|
68
|
+
"name": "--img-alt-dialog-min-block-size",
|
|
69
|
+
"default": "30%",
|
|
70
|
+
"description": "Minimum dialog block size within the available overlay area, with a 4rem control-sized floor when space permits and capped at the area's size. Accepts CSS lengths or percentages and requires a definite overlay block size. For auto-sized, edge-anchored areas, set the dialog's min-block-size directly."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "--img-alt-button-inset-block-start",
|
|
74
|
+
"default": "0.5rem",
|
|
75
|
+
"description": "Logical block-start inset of the independently positioned opener."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "--img-alt-button-inset-block-end",
|
|
79
|
+
"default": "auto",
|
|
80
|
+
"description": "Logical block-end inset of the opener. Unset block-start to auto when anchoring here."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "--img-alt-button-inset-inline-start",
|
|
84
|
+
"default": "auto",
|
|
85
|
+
"description": "Logical inline-start inset of the opener."
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "--img-alt-button-inset-inline-end",
|
|
89
|
+
"default": "0.5rem",
|
|
90
|
+
"description": "Logical inline-end inset of the opener."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "--img-alt-overlay-inset-block-start",
|
|
94
|
+
"default": "3.75rem",
|
|
95
|
+
"description": "Logical block-start inset of the overlay area. Default reserves room below the opener."
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "--img-alt-overlay-inset-block-end",
|
|
99
|
+
"default": "0.5rem",
|
|
100
|
+
"description": "Logical block-end inset of the overlay area."
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "--img-alt-overlay-inset-inline-start",
|
|
104
|
+
"default": "0.5rem",
|
|
105
|
+
"description": "Logical inline-start inset of the overlay area."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "--img-alt-overlay-inset-inline-end",
|
|
109
|
+
"default": "0.5rem",
|
|
110
|
+
"description": "Logical inline-end inset of the overlay area."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"name": "--img-alt-button-color",
|
|
114
|
+
"default": "#fff",
|
|
115
|
+
"description": "Opener text color in the optional stylesheet."
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "--img-alt-button-background",
|
|
119
|
+
"default": "#111",
|
|
120
|
+
"description": "Opener background."
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"name": "--img-alt-dialog-color",
|
|
124
|
+
"default": "CanvasText",
|
|
125
|
+
"description": "Dialog text color."
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"name": "--img-alt-dialog-background",
|
|
129
|
+
"default": "Canvas",
|
|
130
|
+
"description": "Dialog background."
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"name": "--img-alt-focus-color",
|
|
134
|
+
"default": "Highlight",
|
|
135
|
+
"description": "Visible focus outline color."
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
],
|
|
140
|
+
"exports": [
|
|
141
|
+
{
|
|
142
|
+
"kind": "js",
|
|
143
|
+
"name": "ImgAltOverlayElement",
|
|
144
|
+
"declaration": {
|
|
145
|
+
"name": "ImgAltOverlayElement",
|
|
146
|
+
"module": "img-alt-overlay.js"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"kind": "javascript-module",
|
|
153
|
+
"path": "index.js",
|
|
154
|
+
"exports": [
|
|
155
|
+
{
|
|
156
|
+
"kind": "js",
|
|
157
|
+
"name": "ImgAltOverlayElement",
|
|
158
|
+
"declaration": {
|
|
159
|
+
"name": "ImgAltOverlayElement",
|
|
160
|
+
"module": "img-alt-overlay.js"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"kind": "javascript-module",
|
|
167
|
+
"path": "define.js",
|
|
168
|
+
"declarations": [
|
|
169
|
+
{
|
|
170
|
+
"kind": "function",
|
|
171
|
+
"name": "defineImgAltOverlay",
|
|
172
|
+
"description": "Registers img-alt-overlay when a browser registry exists; also called on import.",
|
|
173
|
+
"return": { "type": { "text": "boolean" } }
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
"exports": [
|
|
177
|
+
{
|
|
178
|
+
"kind": "js",
|
|
179
|
+
"name": "defineImgAltOverlay",
|
|
180
|
+
"declaration": {
|
|
181
|
+
"name": "defineImgAltOverlay",
|
|
182
|
+
"module": "define.js"
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"kind": "custom-element-definition",
|
|
187
|
+
"name": "img-alt-overlay",
|
|
188
|
+
"declaration": {
|
|
189
|
+
"name": "ImgAltOverlayElement",
|
|
190
|
+
"module": "img-alt-overlay.js"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
}
|
package/define.d.ts
ADDED
package/define.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ImgAltOverlayElement } from './img-alt-overlay.js';
|
|
2
|
+
|
|
3
|
+
export function defineImgAltOverlay() {
|
|
4
|
+
const tagName = 'img-alt-overlay';
|
|
5
|
+
const hasWindow = typeof window !== 'undefined';
|
|
6
|
+
const registry = hasWindow ? window.customElements : undefined;
|
|
7
|
+
|
|
8
|
+
if (!registry || typeof registry.define !== 'function') {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!registry.get(tagName)) {
|
|
13
|
+
registry.define(tagName, ImgAltOverlayElement);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
defineImgAltOverlay();
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
img-alt-overlay {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
position: relative;
|
|
4
|
+
max-inline-size: 100%;
|
|
5
|
+
vertical-align: top;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
img-alt-overlay[hidden],
|
|
9
|
+
img-alt-overlay [hidden] {
|
|
10
|
+
display: none;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
img-alt-overlay img {
|
|
14
|
+
display: block;
|
|
15
|
+
max-inline-size: 100%;
|
|
16
|
+
block-size: auto;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
img-alt-overlay [data-alt-trigger],
|
|
20
|
+
img-alt-overlay [data-alt-close] {
|
|
21
|
+
font: inherit;
|
|
22
|
+
min-block-size: 2.75rem;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
img-alt-overlay [data-alt-trigger] {
|
|
27
|
+
color: var(--img-alt-button-color, #fff);
|
|
28
|
+
background: var(--img-alt-button-background, #111);
|
|
29
|
+
border: 2px solid currentColor;
|
|
30
|
+
border-radius: 0.25rem;
|
|
31
|
+
padding: 0.5rem 0.75rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
img-alt-overlay [data-alt-trigger] {
|
|
35
|
+
position: absolute;
|
|
36
|
+
inset-block-start: var(--img-alt-button-inset-block-start, 0.5rem);
|
|
37
|
+
inset-block-end: var(--img-alt-button-inset-block-end, auto);
|
|
38
|
+
inset-inline-start: var(--img-alt-button-inset-inline-start, auto);
|
|
39
|
+
inset-inline-end: var(--img-alt-button-inset-inline-end, 0.5rem);
|
|
40
|
+
z-index: 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
img-alt-overlay [data-alt-ui] {
|
|
44
|
+
position: absolute;
|
|
45
|
+
inset-block-start: var(--img-alt-overlay-inset-block-start, 3.75rem);
|
|
46
|
+
inset-block-end: var(--img-alt-overlay-inset-block-end, 0.5rem);
|
|
47
|
+
inset-inline-start: var(--img-alt-overlay-inset-inline-start, 0.5rem);
|
|
48
|
+
inset-inline-end: var(--img-alt-overlay-inset-inline-end, 0.5rem);
|
|
49
|
+
max-inline-size: 100%;
|
|
50
|
+
max-block-size: 100%;
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
pointer-events: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
img-alt-overlay [data-alt-ui] > * {
|
|
57
|
+
pointer-events: auto;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
img-alt-overlay [data-alt-dialog] {
|
|
61
|
+
position: relative;
|
|
62
|
+
flex: 0 1 auto;
|
|
63
|
+
margin: auto 0;
|
|
64
|
+
box-sizing: border-box;
|
|
65
|
+
inline-size: 100%;
|
|
66
|
+
max-inline-size: 100%;
|
|
67
|
+
max-block-size: 100%;
|
|
68
|
+
min-block-size: min(
|
|
69
|
+
max(var(--img-alt-dialog-min-block-size, 30%), 4rem),
|
|
70
|
+
100%
|
|
71
|
+
);
|
|
72
|
+
overflow: auto;
|
|
73
|
+
overscroll-behavior: contain;
|
|
74
|
+
color: var(--img-alt-dialog-color, CanvasText);
|
|
75
|
+
background: var(--img-alt-dialog-background, Canvas);
|
|
76
|
+
border: 2px solid currentColor;
|
|
77
|
+
border-radius: 0.5rem;
|
|
78
|
+
padding: 0.75rem 3.75rem;
|
|
79
|
+
overflow-wrap: anywhere;
|
|
80
|
+
text-align: start;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
img-alt-overlay [data-alt-dialog][open] {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
align-items: center;
|
|
87
|
+
justify-content: safe center;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
img-alt-overlay [data-alt-text] {
|
|
91
|
+
flex: 0 0 auto;
|
|
92
|
+
max-inline-size: 100%;
|
|
93
|
+
white-space: pre-wrap;
|
|
94
|
+
margin: 0;
|
|
95
|
+
text-align: center;
|
|
96
|
+
outline: none;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
img-alt-overlay [data-alt-close] {
|
|
100
|
+
position: absolute;
|
|
101
|
+
inset-block-start: 0.5rem;
|
|
102
|
+
inset-inline-end: 0.5rem;
|
|
103
|
+
display: grid;
|
|
104
|
+
place-items: center;
|
|
105
|
+
inline-size: 2.75rem;
|
|
106
|
+
block-size: 2.75rem;
|
|
107
|
+
margin: 0;
|
|
108
|
+
padding: 0;
|
|
109
|
+
border: 0;
|
|
110
|
+
background: transparent;
|
|
111
|
+
color: inherit;
|
|
112
|
+
font-size: 1.25em;
|
|
113
|
+
line-height: 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Keep a focused control stable if Safari blurs it during pointer activation. */
|
|
117
|
+
img-alt-overlay [data-alt-dialog] h2,
|
|
118
|
+
img-alt-overlay [data-alt-close]:not(:focus, :active) {
|
|
119
|
+
position: absolute;
|
|
120
|
+
inline-size: 1px;
|
|
121
|
+
block-size: 1px;
|
|
122
|
+
min-block-size: 0;
|
|
123
|
+
margin: -1px;
|
|
124
|
+
padding: 0;
|
|
125
|
+
border: 0;
|
|
126
|
+
overflow: hidden;
|
|
127
|
+
clip-path: inset(50%);
|
|
128
|
+
white-space: nowrap;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
img-alt-overlay button:focus-visible {
|
|
132
|
+
outline: 3px solid var(--img-alt-focus-color, Highlight);
|
|
133
|
+
outline-offset: 3px;
|
|
134
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A light-DOM image wrapper with a non-modal, in-image alt-text dialog.
|
|
3
|
+
* @cssprop --img-alt-button-inset-block-start - Opener block-start inset (0.5rem).
|
|
4
|
+
* @cssprop --img-alt-button-inset-block-end - Opener block-end inset (auto).
|
|
5
|
+
* @cssprop --img-alt-button-inset-inline-start - Opener inline-start inset (auto).
|
|
6
|
+
* @cssprop --img-alt-button-inset-inline-end - Opener inline-end inset (0.5rem).
|
|
7
|
+
* @cssprop --img-alt-overlay-inset-block-start - Overlay area block-start inset (3.75rem).
|
|
8
|
+
* @cssprop --img-alt-overlay-inset-block-end - Overlay area block-end inset (0.5rem).
|
|
9
|
+
* @cssprop --img-alt-overlay-inset-inline-start - Overlay area inline-start inset (0.5rem).
|
|
10
|
+
* @cssprop --img-alt-overlay-inset-inline-end - Overlay area inline-end inset (0.5rem).
|
|
11
|
+
* @cssprop --img-alt-dialog-min-block-size - Dialog minimum block size within the overlay area (30%, with a 4rem control-sized floor when space permits).
|
|
12
|
+
*/
|
|
13
|
+
export class ImgAltOverlayElement extends HTMLElement {
|
|
14
|
+
static readonly observedAttributes: string[];
|
|
15
|
+
readonly state: 'idle' | 'inactive' | 'ready' | 'error';
|
|
16
|
+
/** Reread image structure and labels. Closes any open dialog. */
|
|
17
|
+
refresh(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
interface HTMLElementTagNameMap {
|
|
22
|
+
'img-alt-overlay': ImgAltOverlayElement;
|
|
23
|
+
}
|
|
24
|
+
interface HTMLElementEventMap {
|
|
25
|
+
'img-alt-overlay:ready': CustomEvent<void>;
|
|
26
|
+
'img-alt-overlay:error': CustomEvent<{ error: Error }>;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
const BaseElement = globalThis.HTMLElement ?? class {};
|
|
2
|
+
let nextId = 0;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reveal an image's alternative text in a non-modal, in-image dialog.
|
|
6
|
+
* @element img-alt-overlay
|
|
7
|
+
* @cssprop --img-alt-button-inset-block-start - Opener block-start inset (0.5rem).
|
|
8
|
+
* @cssprop --img-alt-button-inset-block-end - Opener block-end inset (auto).
|
|
9
|
+
* @cssprop --img-alt-button-inset-inline-start - Opener inline-start inset (auto).
|
|
10
|
+
* @cssprop --img-alt-button-inset-inline-end - Opener inline-end inset (0.5rem).
|
|
11
|
+
* @cssprop --img-alt-overlay-inset-block-start - Overlay area block-start inset (3.75rem).
|
|
12
|
+
* @cssprop --img-alt-overlay-inset-block-end - Overlay area block-end inset (0.5rem).
|
|
13
|
+
* @cssprop --img-alt-overlay-inset-inline-start - Overlay area inline-start inset (0.5rem).
|
|
14
|
+
* @cssprop --img-alt-overlay-inset-inline-end - Overlay area inline-end inset (0.5rem).
|
|
15
|
+
* @cssprop --img-alt-dialog-min-block-size - Dialog minimum block size within the overlay area (30%, with a 4rem control-sized floor when space permits).
|
|
16
|
+
*/
|
|
17
|
+
export class ImgAltOverlayElement extends BaseElement {
|
|
18
|
+
static observedAttributes = ['button-label', 'dialog-label', 'close-label'];
|
|
19
|
+
|
|
20
|
+
constructor() {
|
|
21
|
+
super();
|
|
22
|
+
if (!this.ownerDocument) {
|
|
23
|
+
throw new Error('ImgAltOverlayElement requires a browser DOM.');
|
|
24
|
+
}
|
|
25
|
+
this._state = 'idle';
|
|
26
|
+
this._pending = false;
|
|
27
|
+
this._image = null;
|
|
28
|
+
this._ui = null;
|
|
29
|
+
this._observer = null;
|
|
30
|
+
this._revision = 0;
|
|
31
|
+
this._onClick = (event) => {
|
|
32
|
+
if (event.defaultPrevented || !this._ui) return;
|
|
33
|
+
const button = event.target.closest?.('button');
|
|
34
|
+
const { trigger, close, dialog } = this._ui;
|
|
35
|
+
if (button === trigger) this._toggleDialog();
|
|
36
|
+
else if (button === close) dialog.close();
|
|
37
|
+
};
|
|
38
|
+
this._onCommand = (event) => {
|
|
39
|
+
const ui = this._ui;
|
|
40
|
+
if (
|
|
41
|
+
!event.defaultPrevented &&
|
|
42
|
+
ui &&
|
|
43
|
+
event.target === ui.dialog &&
|
|
44
|
+
event.source === ui.trigger &&
|
|
45
|
+
event.command === '--toggle-alt'
|
|
46
|
+
) {
|
|
47
|
+
this._toggleDialog();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
this._onKeyDown = (event) => {
|
|
51
|
+
if (
|
|
52
|
+
event.defaultPrevented ||
|
|
53
|
+
event.key !== 'Escape' ||
|
|
54
|
+
!this._ui?.dialog.open
|
|
55
|
+
)
|
|
56
|
+
return;
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
this._toggleDialog();
|
|
59
|
+
};
|
|
60
|
+
this._onClose = (event) => {
|
|
61
|
+
if (event.target === this._ui?.dialog) this._updateTrigger();
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_toggleDialog() {
|
|
66
|
+
const ui = this._ui;
|
|
67
|
+
if (!ui) return;
|
|
68
|
+
if (ui.dialog.open) ui.dialog.close();
|
|
69
|
+
else ui.dialog.show();
|
|
70
|
+
if (this._ui === ui) this._updateTrigger();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_updateTrigger() {
|
|
74
|
+
const { trigger, dialog } = this._ui;
|
|
75
|
+
trigger.textContent =
|
|
76
|
+
this._labels[dialog.open ? 'close-label' : 'button-label'];
|
|
77
|
+
trigger.setAttribute('aria-expanded', String(dialog.open));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get state() {
|
|
81
|
+
return this._state;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
connectedCallback() {
|
|
85
|
+
this._schedule();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
disconnectedCallback() {
|
|
89
|
+
this._teardown();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
attributeChangedCallback(name, previous, value) {
|
|
93
|
+
if (previous !== value && this.isConnected) this._schedule();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_schedule() {
|
|
97
|
+
if (this._pending) return;
|
|
98
|
+
this._pending = true;
|
|
99
|
+
queueMicrotask(() => {
|
|
100
|
+
this._pending = false;
|
|
101
|
+
if (this.isConnected) this.refresh();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reread image markup and labels, closing any open dialog. */
|
|
106
|
+
refresh() {
|
|
107
|
+
const revision = this._teardown();
|
|
108
|
+
if (!this.isConnected || revision !== this._revision) return;
|
|
109
|
+
try {
|
|
110
|
+
const images = [...this.querySelectorAll('img')].filter((image) => {
|
|
111
|
+
for (
|
|
112
|
+
let parent = image.parentElement;
|
|
113
|
+
parent;
|
|
114
|
+
parent = parent.parentElement
|
|
115
|
+
) {
|
|
116
|
+
if (parent instanceof ImgAltOverlayElement)
|
|
117
|
+
return parent === this;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
});
|
|
121
|
+
if (images.length !== 1) {
|
|
122
|
+
throw new Error('img-alt-overlay requires exactly one image.');
|
|
123
|
+
}
|
|
124
|
+
if (this.closest('a, button, label')) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
'Place the image link inside img-alt-overlay, not around it; controls cannot be nested in a link, button, or label.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
this._labels = {};
|
|
130
|
+
for (const [attribute, fallback] of [
|
|
131
|
+
['button-label', 'View alt text'],
|
|
132
|
+
['dialog-label', 'Image description'],
|
|
133
|
+
['close-label', 'Close'],
|
|
134
|
+
]) {
|
|
135
|
+
const value = this.getAttribute(attribute) ?? fallback;
|
|
136
|
+
if (!value.trim())
|
|
137
|
+
throw new Error(`${attribute} must not be empty.`);
|
|
138
|
+
this._labels[attribute] = value;
|
|
139
|
+
}
|
|
140
|
+
this._image = images[0];
|
|
141
|
+
const { MutationObserver } = this.ownerDocument.defaultView;
|
|
142
|
+
this._observer = new MutationObserver(() => this._sync());
|
|
143
|
+
this._observer.observe(this._image, {
|
|
144
|
+
attributes: true,
|
|
145
|
+
attributeFilter: ['alt'],
|
|
146
|
+
});
|
|
147
|
+
this._sync();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
this._fail(error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_sync() {
|
|
154
|
+
const revision = this._revision;
|
|
155
|
+
const text = this._image?.getAttribute('alt') ?? '';
|
|
156
|
+
if (!text.trim()) {
|
|
157
|
+
this._removeUI();
|
|
158
|
+
if (revision === this._revision) this._state = 'inactive';
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const created = !this._ui;
|
|
163
|
+
if (created) this._createUI();
|
|
164
|
+
this._ui.text.textContent = text;
|
|
165
|
+
this._state = 'ready';
|
|
166
|
+
if (created) {
|
|
167
|
+
this.dispatchEvent(
|
|
168
|
+
new CustomEvent('img-alt-overlay:ready', {
|
|
169
|
+
bubbles: true,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this._fail(error);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_createUI() {
|
|
179
|
+
const document = this.ownerDocument;
|
|
180
|
+
const dialog = document.createElement('dialog');
|
|
181
|
+
if (
|
|
182
|
+
typeof dialog.show !== 'function' ||
|
|
183
|
+
typeof dialog.close !== 'function'
|
|
184
|
+
) {
|
|
185
|
+
throw new Error('img-alt-overlay requires the native dialog API.');
|
|
186
|
+
}
|
|
187
|
+
const root = this.getRootNode();
|
|
188
|
+
let id;
|
|
189
|
+
do {
|
|
190
|
+
id = `img-alt-overlay-${++nextId}`;
|
|
191
|
+
} while (
|
|
192
|
+
root.getElementById(`${id}-dialog`) ||
|
|
193
|
+
root.getElementById(`${id}-heading`)
|
|
194
|
+
);
|
|
195
|
+
dialog.id = `${id}-dialog`;
|
|
196
|
+
dialog.setAttribute('data-alt-dialog', '');
|
|
197
|
+
dialog.setAttribute('aria-labelledby', `${id}-heading`);
|
|
198
|
+
|
|
199
|
+
const heading = document.createElement('h2');
|
|
200
|
+
heading.id = `${id}-heading`;
|
|
201
|
+
heading.textContent = this._labels['dialog-label'];
|
|
202
|
+
const text = document.createElement('p');
|
|
203
|
+
text.tabIndex = -1;
|
|
204
|
+
text.setAttribute('autofocus', '');
|
|
205
|
+
text.setAttribute('data-alt-text', '');
|
|
206
|
+
const trigger = document.createElement('button');
|
|
207
|
+
trigger.type = 'button';
|
|
208
|
+
trigger.textContent = this._labels['button-label'];
|
|
209
|
+
trigger.setAttribute('data-alt-trigger', '');
|
|
210
|
+
trigger.setAttribute('commandfor', dialog.id);
|
|
211
|
+
trigger.setAttribute('command', '--toggle-alt');
|
|
212
|
+
trigger.setAttribute('aria-controls', dialog.id);
|
|
213
|
+
trigger.setAttribute('aria-haspopup', 'dialog');
|
|
214
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
215
|
+
const close = document.createElement('button');
|
|
216
|
+
close.type = 'button';
|
|
217
|
+
close.setAttribute('aria-label', this._labels['close-label']);
|
|
218
|
+
const icon = document.createElement('span');
|
|
219
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
220
|
+
icon.textContent = '\u2715';
|
|
221
|
+
close.append(icon);
|
|
222
|
+
close.setAttribute('data-alt-close', '');
|
|
223
|
+
close.setAttribute('commandfor', dialog.id);
|
|
224
|
+
close.setAttribute('command', 'close');
|
|
225
|
+
dialog.append(heading, text, close);
|
|
226
|
+
const container = document.createElement('div');
|
|
227
|
+
container.setAttribute('data-alt-ui', '');
|
|
228
|
+
container.append(dialog);
|
|
229
|
+
this._ui = { dialog, trigger, close, text, container };
|
|
230
|
+
this.append(trigger, container);
|
|
231
|
+
dialog.addEventListener('close', this._onClose);
|
|
232
|
+
this.addEventListener('keydown', this._onKeyDown);
|
|
233
|
+
if ('commandForElement' in trigger) {
|
|
234
|
+
dialog.addEventListener('command', this._onCommand);
|
|
235
|
+
} else {
|
|
236
|
+
this.addEventListener('click', this._onClick);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_removeUI() {
|
|
241
|
+
this.removeEventListener('click', this._onClick);
|
|
242
|
+
this.removeEventListener('keydown', this._onKeyDown);
|
|
243
|
+
if (!this._ui) return;
|
|
244
|
+
const { dialog, trigger, container } = this._ui;
|
|
245
|
+
this._ui = null;
|
|
246
|
+
dialog.removeEventListener('command', this._onCommand);
|
|
247
|
+
dialog.removeEventListener('close', this._onClose);
|
|
248
|
+
if (dialog.open) dialog.close();
|
|
249
|
+
trigger.remove();
|
|
250
|
+
container.remove();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_teardown() {
|
|
254
|
+
const revision = ++this._revision;
|
|
255
|
+
this._observer?.disconnect();
|
|
256
|
+
this._observer = null;
|
|
257
|
+
this._image = null;
|
|
258
|
+
this._state = 'idle';
|
|
259
|
+
this._removeUI();
|
|
260
|
+
return revision;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_fail(error) {
|
|
264
|
+
const revision = this._teardown();
|
|
265
|
+
if (!this.isConnected || revision !== this._revision) return;
|
|
266
|
+
this._state = 'error';
|
|
267
|
+
this.dispatchEvent(
|
|
268
|
+
new CustomEvent('img-alt-overlay:error', {
|
|
269
|
+
detail: { error },
|
|
270
|
+
bubbles: true,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ImgAltOverlayElement } from './img-alt-overlay.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,90 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@aarongustafson/img-alt-overlay",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Reveal an image's alternative text in a non-modal, in-image dialog",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"html",
|
|
7
|
+
"custom element",
|
|
8
|
+
"web component"
|
|
9
|
+
],
|
|
10
|
+
"author": "Aaron Gustafson <aaron@easy-designs.net> (https://www.aaron-gustafson.com/)",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"homepage": "https://github.com/aarongustafson/img-alt-overlay#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/aarongustafson/img-alt-overlay.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/aarongustafson/img-alt-overlay/issues"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"module": "img-alt-overlay.js",
|
|
23
|
+
"customElements": "custom-elements.json",
|
|
24
|
+
"types": "img-alt-overlay.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./img-alt-overlay.d.ts",
|
|
28
|
+
"import": "./index.js"
|
|
29
|
+
},
|
|
30
|
+
"./img-alt-overlay.js": {
|
|
31
|
+
"types": "./img-alt-overlay.d.ts",
|
|
32
|
+
"import": "./img-alt-overlay.js"
|
|
33
|
+
},
|
|
34
|
+
"./define.js": {
|
|
35
|
+
"types": "./define.d.ts",
|
|
36
|
+
"import": "./define.js"
|
|
37
|
+
},
|
|
38
|
+
"./img-alt-overlay.css": "./img-alt-overlay.css",
|
|
39
|
+
"./custom-elements.json": "./custom-elements.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"img-alt-overlay.js",
|
|
43
|
+
"img-alt-overlay.d.ts",
|
|
44
|
+
"define.js",
|
|
45
|
+
"define.d.ts",
|
|
46
|
+
"img-alt-overlay.css",
|
|
47
|
+
"index.js",
|
|
48
|
+
"custom-elements.json",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22.0.0"
|
|
54
|
+
},
|
|
55
|
+
"packageManager": "npm@11.15.0",
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "vitest",
|
|
58
|
+
"test:ui": "vitest --ui",
|
|
59
|
+
"test:run": "vitest run",
|
|
60
|
+
"test:types": "tsc --noEmit --strict --lib DOM,ES2022 --module NodeNext --moduleResolution NodeNext test/types.ts && tsc --noEmit --strict --lib DOM,ES2022 --module NodeNext --moduleResolution NodeNext test/registration-types.ts",
|
|
61
|
+
"test:browser": "playwright test",
|
|
62
|
+
"test:coverage": "vitest run --coverage",
|
|
63
|
+
"lint": "npm run lint:eslint && npm run lint:prettier",
|
|
64
|
+
"format": "npm run format:eslint && npm run format:prettier",
|
|
65
|
+
"lint:eslint": "eslint .",
|
|
66
|
+
"format:eslint": "eslint . --fix",
|
|
67
|
+
"lint:prettier": "prettier \"**/*.{js,ts,html,css,md,json,yml}\" --check --ignore-path .gitignore",
|
|
68
|
+
"format:prettier": "prettier \"**/*.{js,ts,html,css,md,json,yml}\" --write --ignore-path .gitignore",
|
|
69
|
+
"version": "node scripts/update-demo-versions.js && git add demo/esm.html",
|
|
70
|
+
"postversion": "git push --follow-tags"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@playwright/test": "^1.64.1",
|
|
74
|
+
"@open-wc/eslint-config": "^13.1.0",
|
|
75
|
+
"@testing-library/dom": "^10.4.2",
|
|
76
|
+
"@testing-library/user-event": "^14.6.7",
|
|
77
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
78
|
+
"@vitest/ui": "^5.0.5",
|
|
79
|
+
"eslint": "^10.12.0",
|
|
80
|
+
"eslint-config-prettier": "^10.1.8",
|
|
81
|
+
"happy-dom": "^21.0.4",
|
|
82
|
+
"marked": "18.1.0",
|
|
83
|
+
"prettier": "^3.9.9",
|
|
84
|
+
"vitest": "^5.0.5",
|
|
85
|
+
"typescript": "^7.0.2"
|
|
86
|
+
},
|
|
87
|
+
"publishConfig": {
|
|
88
|
+
"access": "public"
|
|
89
|
+
}
|
|
90
|
+
}
|