@profpowell/vb-design-system 0.1.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 +122 -0
- package/dist/vb-design-system.css +993 -0
- package/dist/vb-design-system.js +4482 -0
- package/dist/vb-design-system.min.css +1 -0
- package/dist/vb-design-system.min.js +513 -0
- package/package.json +78 -0
- package/src/index.css +21 -0
- package/src/index.js +24 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/vb-element.js +96 -0
- package/src/patterns/do-dont.css +86 -0
- package/src/patterns/token-table.css +67 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/accessibility-specimen/api.json +51 -0
- package/src/web-components/accessibility-specimen/logic.js +232 -0
- package/src/web-components/accessibility-specimen/static.html +41 -0
- package/src/web-components/accessibility-specimen/styles.css +156 -0
- package/src/web-components/brand-specimen/api.json +37 -0
- package/src/web-components/brand-specimen/logic.js +61 -0
- package/src/web-components/brand-specimen/static.html +77 -0
- package/src/web-components/brand-specimen/styles.css +10 -0
- package/src/web-components/breakpoint-specimen/api.json +44 -0
- package/src/web-components/breakpoint-specimen/logic.js +186 -0
- package/src/web-components/breakpoint-specimen/static.html +38 -0
- package/src/web-components/breakpoint-specimen/styles.css +180 -0
- package/src/web-components/color-palette/api.json +73 -0
- package/src/web-components/color-palette/logic.js +280 -0
- package/src/web-components/color-palette/static.html +54 -0
- package/src/web-components/color-palette/styles.css +113 -0
- package/src/web-components/color-picker/_color-utils.js +210 -0
- package/src/web-components/component-sampler/api.json +15 -0
- package/src/web-components/component-sampler/logic.js +120 -0
- package/src/web-components/component-sampler/static.html +47 -0
- package/src/web-components/component-sampler/styles.css +9 -0
- package/src/web-components/font-pairer/_font-data.js +95 -0
- package/src/web-components/font-pairer/api.json +56 -0
- package/src/web-components/font-pairer/logic.js +401 -0
- package/src/web-components/font-pairer/static.html +9 -0
- package/src/web-components/font-pairer/styles.css +10 -0
- package/src/web-components/gradient-builder/_gradient-utils.js +54 -0
- package/src/web-components/gradient-builder/api.json +63 -0
- package/src/web-components/gradient-builder/logic.js +261 -0
- package/src/web-components/gradient-builder/static.html +9 -0
- package/src/web-components/gradient-builder/styles.css +10 -0
- package/src/web-components/icon-set/api.json +39 -0
- package/src/web-components/icon-set/logic.js +133 -0
- package/src/web-components/icon-set/static.html +36 -0
- package/src/web-components/icon-set/styles.css +82 -0
- package/src/web-components/icon-specimen/api.json +46 -0
- package/src/web-components/icon-specimen/logic.js +67 -0
- package/src/web-components/icon-specimen/static.html +39 -0
- package/src/web-components/icon-specimen/styles.css +38 -0
- package/src/web-components/layout-specimen/api.json +12 -0
- package/src/web-components/layout-specimen/logic.js +219 -0
- package/src/web-components/layout-specimen/static.html +22 -0
- package/src/web-components/layout-specimen/styles.css +95 -0
- package/src/web-components/motion-specimen/api.json +54 -0
- package/src/web-components/motion-specimen/logic.js +146 -0
- package/src/web-components/motion-specimen/static.html +13 -0
- package/src/web-components/motion-specimen/styles.css +29 -0
- package/src/web-components/palette-generator/_palette-utils.js +133 -0
- package/src/web-components/palette-generator/api.json +79 -0
- package/src/web-components/palette-generator/logic.js +288 -0
- package/src/web-components/palette-generator/static.html +47 -0
- package/src/web-components/palette-generator/styles.css +36 -0
- package/src/web-components/semantic-palette/_color-math.js +149 -0
- package/src/web-components/semantic-palette/api.json +41 -0
- package/src/web-components/semantic-palette/logic.js +400 -0
- package/src/web-components/semantic-palette/static.html +42 -0
- package/src/web-components/semantic-palette/styles.css +20 -0
- package/src/web-components/spacing-specimen/api.json +63 -0
- package/src/web-components/spacing-specimen/logic.js +132 -0
- package/src/web-components/spacing-specimen/static.html +71 -0
- package/src/web-components/spacing-specimen/styles.css +58 -0
- package/src/web-components/theme-catalog/api.json +14 -0
- package/src/web-components/theme-catalog/logic.js +228 -0
- package/src/web-components/theme-catalog/static.html +15 -0
- package/src/web-components/theme-export/api.json +18 -0
- package/src/web-components/theme-export/dtcg-serialize.js +417 -0
- package/src/web-components/theme-export/logic.js +201 -0
- package/src/web-components/theme-export/static.html +12 -0
- package/src/web-components/theme-export/theme-css-parse.js +93 -0
- package/src/web-components/theme-import/api.json +14 -0
- package/src/web-components/theme-import/catalog/atlassian.tokens.json +303 -0
- package/src/web-components/theme-import/catalog/bootstrap.tokens.json +304 -0
- package/src/web-components/theme-import/catalog/carbon.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/catppuccin-mocha.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/govuk.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/manifest.json +132 -0
- package/src/web-components/theme-import/catalog/material.tokens.json +289 -0
- package/src/web-components/theme-import/catalog/salesforce.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/tailwind.tokens.json +302 -0
- package/src/web-components/theme-import/dtcg-deserialize.js +322 -0
- package/src/web-components/theme-import/logic.js +263 -0
- package/src/web-components/theme-import/static.html +15 -0
- package/src/web-components/token-specimen/api.json +24 -0
- package/src/web-components/token-specimen/logic.js +256 -0
- package/src/web-components/token-specimen/static.html +47 -0
- package/src/web-components/token-specimen/styles.css +10 -0
- package/src/web-components/type-specimen/api.json +91 -0
- package/src/web-components/type-specimen/logic.js +156 -0
- package/src/web-components/type-specimen/static.html +84 -0
- package/src/web-components/type-specimen/styles.css +131 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* copy-init: Clipboard copy + paste via data attributes + programmatic helpers
|
|
3
|
+
*
|
|
4
|
+
* Enhances buttons with data-copy / data-copy-target / data-paste-target
|
|
5
|
+
* to interact with the clipboard. Also exports copyText(), copyRich(),
|
|
6
|
+
* and pasteFromClipboard() for components that need clipboard access
|
|
7
|
+
* with VB's standard a11y + feedback semantics.
|
|
8
|
+
*
|
|
9
|
+
* @attr {string} data-copy - Static text to copy
|
|
10
|
+
* @attr {string} data-copy-target - CSS selector for element whose textContent to copy
|
|
11
|
+
* @attr {string} data-copy-attr - When paired with data-copy-target, copy the named
|
|
12
|
+
* attribute of the target instead of its textContent
|
|
13
|
+
* @attr {string} data-paste-target - CSS selector for element to receive pasted clipboard text
|
|
14
|
+
*
|
|
15
|
+
* @example Static text
|
|
16
|
+
* <button data-copy="npm install vanilla-breeze">Copy</button>
|
|
17
|
+
*
|
|
18
|
+
* @example Target element
|
|
19
|
+
* <button data-copy-target="#code-block">Copy code</button>
|
|
20
|
+
*
|
|
21
|
+
* @example Target attribute
|
|
22
|
+
* <output id="swatch" value="#3366cc">#3366cc</output>
|
|
23
|
+
* <button data-copy-target="#swatch" data-copy-attr="value">Copy hex</button>
|
|
24
|
+
*
|
|
25
|
+
* @example Paste into a target
|
|
26
|
+
* <input id="dest" type="text">
|
|
27
|
+
* <button data-paste-target="#dest">Paste</button>
|
|
28
|
+
*
|
|
29
|
+
* @example Programmatic
|
|
30
|
+
* import { copyText, copyRich, pasteFromClipboard } from '/src/utils/copy-init.js';
|
|
31
|
+
* await copyText('hello', { button: myButton });
|
|
32
|
+
* await copyRich({ text: '#3366cc', blob: pngBlob }, { button: myButton });
|
|
33
|
+
* await pasteFromClipboard(inputEl, { button: myButton });
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const COPIED_DURATION = 1500;
|
|
37
|
+
const ANNOUNCE_DURATION = 1000;
|
|
38
|
+
const SELECTOR = '[data-copy], [data-copy-target], [data-paste-target]';
|
|
39
|
+
const DEFAULT_ANNOUNCE = 'Copied to clipboard';
|
|
40
|
+
const DEFAULT_PASTE_ANNOUNCE = 'Pasted from clipboard';
|
|
41
|
+
|
|
42
|
+
const resetTimers = new WeakMap();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Copy text to the clipboard with VB's standard feedback semantics.
|
|
46
|
+
*
|
|
47
|
+
* Performs writeText, sets data-state="copied" on the button (if given),
|
|
48
|
+
* announces to screen readers, and dispatches a `copy` CustomEvent from
|
|
49
|
+
* the button. Safe to call when the Clipboard API is unavailable or
|
|
50
|
+
* permission is denied — returns false silently.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} text - The text to copy.
|
|
53
|
+
* @param {object} [options]
|
|
54
|
+
* @param {HTMLElement} [options.button] - Button to receive data-state and dispatch the event.
|
|
55
|
+
* @param {string} [options.announceMessage] - Screen-reader message. Defaults to "Copied to clipboard".
|
|
56
|
+
* @param {number} [options.duration] - Milliseconds to hold data-state="copied". Defaults to 1500.
|
|
57
|
+
* @returns {Promise<boolean>} True if the write succeeded.
|
|
58
|
+
*/
|
|
59
|
+
async function copyText(text, options = {}) {
|
|
60
|
+
if (text == null || text === '') return false;
|
|
61
|
+
const { button, announceMessage = DEFAULT_ANNOUNCE, duration = COPIED_DURATION } = options;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await navigator.clipboard.writeText(text);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
applyFeedback({ button, duration, announceMessage, eventDetail: { text } });
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Copy a multi-format payload to the clipboard.
|
|
75
|
+
*
|
|
76
|
+
* Writes a ClipboardItem with any combination of text/plain, text/html,
|
|
77
|
+
* and a binary blob (typically image/png). When ClipboardItem is
|
|
78
|
+
* unsupported or the browser rejects the specific MIME types, falls
|
|
79
|
+
* back to text-only copy via copyText.
|
|
80
|
+
*
|
|
81
|
+
* Common use cases: copying a color as hex text + a swatch PNG so it
|
|
82
|
+
* can be pasted into design tools as an actual color, or copying a
|
|
83
|
+
* QR code as an image alongside its encoded URL.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} payload
|
|
86
|
+
* @param {string} [payload.text] - text/plain content
|
|
87
|
+
* @param {string} [payload.html] - text/html content
|
|
88
|
+
* @param {Blob} [payload.blob] - Arbitrary blob (e.g. image/png). MIME type comes from blob.type.
|
|
89
|
+
* @param {object} [options]
|
|
90
|
+
* @param {HTMLElement} [options.button]
|
|
91
|
+
* @param {string} [options.announceMessage]
|
|
92
|
+
* @param {number} [options.duration]
|
|
93
|
+
* @returns {Promise<boolean>} True if any clipboard write succeeded (rich or text fallback).
|
|
94
|
+
*/
|
|
95
|
+
async function copyRich(payload, options = {}) {
|
|
96
|
+
const { text, html, blob } = payload ?? {};
|
|
97
|
+
if (!text && !html && !blob) return false;
|
|
98
|
+
const { button, announceMessage = DEFAULT_ANNOUNCE, duration = COPIED_DURATION } = options;
|
|
99
|
+
|
|
100
|
+
const richSupported = typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard?.write === 'function';
|
|
101
|
+
|
|
102
|
+
if (richSupported) {
|
|
103
|
+
/** @type {Record<string, Blob>} */
|
|
104
|
+
const items = {};
|
|
105
|
+
if (text) items['text/plain'] = new Blob([text], { type: 'text/plain' });
|
|
106
|
+
if (html) items['text/html'] = new Blob([html], { type: 'text/html' });
|
|
107
|
+
if (blob) items[blob.type] = blob;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await navigator.clipboard.write([new ClipboardItem(items)]);
|
|
111
|
+
applyFeedback({ button, duration, announceMessage, eventDetail: { payload } });
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
// Browser may support ClipboardItem but reject some MIME types
|
|
115
|
+
// (Safari historically restricts image types). Fall through to text.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (text) return copyText(text, options);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply the VB-standard post-write feedback: data-state, screen-reader
|
|
125
|
+
* announce, and a CustomEvent from the button.
|
|
126
|
+
* @param {{ button?: HTMLElement, duration: number, announceMessage: string, eventDetail: object, state?: string, eventName?: string }} args
|
|
127
|
+
*/
|
|
128
|
+
function applyFeedback({ button, duration, announceMessage, eventDetail, state = 'copied', eventName = 'copy' }) {
|
|
129
|
+
if (button) {
|
|
130
|
+
button.dataset.state = state;
|
|
131
|
+
const prior = resetTimers.get(button);
|
|
132
|
+
if (prior) clearTimeout(prior);
|
|
133
|
+
resetTimers.set(button, setTimeout(() => {
|
|
134
|
+
delete button.dataset.state;
|
|
135
|
+
resetTimers.delete(button);
|
|
136
|
+
}, duration));
|
|
137
|
+
|
|
138
|
+
button.dispatchEvent(new CustomEvent(eventName, {
|
|
139
|
+
bubbles: true,
|
|
140
|
+
detail: eventDetail,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
announce(announceMessage, button ?? document.body);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read text from the clipboard and write it into a target element.
|
|
149
|
+
*
|
|
150
|
+
* Form controls (input, textarea, select) receive `.value`; other
|
|
151
|
+
* elements receive `.textContent`. After writing, an `input` event
|
|
152
|
+
* is dispatched on the target so framework listeners notice the change.
|
|
153
|
+
* Sets data-state="pasted" on the button, announces to screen readers,
|
|
154
|
+
* and dispatches a `paste` CustomEvent from the button.
|
|
155
|
+
*
|
|
156
|
+
* Safe to call when the Clipboard API is unavailable or the user
|
|
157
|
+
* denies permission — returns null silently.
|
|
158
|
+
*
|
|
159
|
+
* @param {Element | null | undefined} target - Element to receive the pasted text.
|
|
160
|
+
* @param {object} [options]
|
|
161
|
+
* @param {HTMLElement} [options.button]
|
|
162
|
+
* @param {string} [options.announceMessage]
|
|
163
|
+
* @param {number} [options.duration]
|
|
164
|
+
* @returns {Promise<string | null>} The text that was pasted, or null on failure.
|
|
165
|
+
*/
|
|
166
|
+
async function pasteFromClipboard(target, options = {}) {
|
|
167
|
+
const { button, announceMessage = DEFAULT_PASTE_ANNOUNCE, duration = COPIED_DURATION } = options;
|
|
168
|
+
|
|
169
|
+
let text;
|
|
170
|
+
try {
|
|
171
|
+
text = await navigator.clipboard.readText();
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (target) {
|
|
177
|
+
if ('value' in target && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)) {
|
|
178
|
+
target.value = text;
|
|
179
|
+
} else {
|
|
180
|
+
target.textContent = text;
|
|
181
|
+
}
|
|
182
|
+
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
applyFeedback({
|
|
186
|
+
button,
|
|
187
|
+
duration,
|
|
188
|
+
announceMessage,
|
|
189
|
+
eventDetail: { text },
|
|
190
|
+
state: 'pasted',
|
|
191
|
+
eventName: 'paste',
|
|
192
|
+
});
|
|
193
|
+
return text;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Initialize copy/paste buttons within a root element
|
|
198
|
+
* @param {Element|Document} root - Root element to search within
|
|
199
|
+
*/
|
|
200
|
+
function initCopyButtons(root = document) {
|
|
201
|
+
root.querySelectorAll(SELECTOR).forEach(enhanceButton);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Enhance a single button with copy or paste behavior
|
|
206
|
+
* @param {HTMLElement} button - The button element to enhance
|
|
207
|
+
*/
|
|
208
|
+
function enhanceButton(button) {
|
|
209
|
+
if (button.hasAttribute('data-copy-init')) return;
|
|
210
|
+
button.setAttribute('data-copy-init', '');
|
|
211
|
+
|
|
212
|
+
button.addEventListener('click', () => {
|
|
213
|
+
if (button.dataset.pasteTarget) {
|
|
214
|
+
const target = document.querySelector(button.dataset.pasteTarget);
|
|
215
|
+
if (!target) return;
|
|
216
|
+
pasteFromClipboard(target, { button });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const text = getText(button);
|
|
221
|
+
if (!text) return;
|
|
222
|
+
copyText(text, { button });
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get text to copy from a button's data attributes
|
|
228
|
+
* @param {HTMLElement} button
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
function getText(button) {
|
|
232
|
+
if (button.dataset.copy) return button.dataset.copy;
|
|
233
|
+
|
|
234
|
+
if (button.dataset.copyTarget) {
|
|
235
|
+
const target = document.querySelector(button.dataset.copyTarget);
|
|
236
|
+
if (!target) return '';
|
|
237
|
+
const attr = button.dataset.copyAttr;
|
|
238
|
+
if (attr) return target.getAttribute(attr) ?? '';
|
|
239
|
+
return target.textContent ?? '';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Announce a message to screen readers
|
|
247
|
+
* @param {string} message
|
|
248
|
+
* @param {Element} context - Element to append announcement to
|
|
249
|
+
*/
|
|
250
|
+
function announce(message, context) {
|
|
251
|
+
const el = document.createElement('div');
|
|
252
|
+
el.setAttribute('role', 'status');
|
|
253
|
+
el.setAttribute('aria-live', 'polite');
|
|
254
|
+
el.className = 'visually-hidden';
|
|
255
|
+
el.textContent = message;
|
|
256
|
+
context.appendChild(el);
|
|
257
|
+
setTimeout(() => el.remove(), ANNOUNCE_DURATION);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Auto-init on DOMContentLoaded
|
|
261
|
+
if (document.readyState === 'loading') {
|
|
262
|
+
document.addEventListener('DOMContentLoaded', () => initCopyButtons());
|
|
263
|
+
} else {
|
|
264
|
+
initCopyButtons();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Watch for dynamically added copy buttons
|
|
268
|
+
const observer = new MutationObserver((mutations) => {
|
|
269
|
+
for (const mutation of mutations) {
|
|
270
|
+
for (const node of mutation.addedNodes) {
|
|
271
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
272
|
+
|
|
273
|
+
// Check the node itself
|
|
274
|
+
const el = /** @type {Element} */ (node);
|
|
275
|
+
if (el.matches(SELECTOR)) {
|
|
276
|
+
enhanceButton(/** @type {HTMLElement} */ (el));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check descendants
|
|
280
|
+
el.querySelectorAll(SELECTOR).forEach(child => enhanceButton(/** @type {HTMLElement} */ (child)));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
286
|
+
|
|
287
|
+
export { initCopyButtons, copyText, copyRich, pasteFromClipboard };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schemas/api.schema.json",
|
|
3
|
+
"element": "accessibility-specimen",
|
|
4
|
+
"type": "web-component",
|
|
5
|
+
"description": "WCAG contrast-ratio table + a11y checklist for design-system docs. Default mode: tabular display of color pairs with computed contrast ratio + AA/AAA badges. Checklist mode: slot-driven WCAG checklist with status icons.",
|
|
6
|
+
"htmlvalidate": {
|
|
7
|
+
"flow": true,
|
|
8
|
+
"permittedContent": [
|
|
9
|
+
"@flow"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"attributes": [
|
|
13
|
+
{
|
|
14
|
+
"name": "type",
|
|
15
|
+
"kind": "host-api",
|
|
16
|
+
"purpose": "config",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "\"contrast\" (default) | \"checklist\""
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"events": [],
|
|
22
|
+
"childAttributes": [
|
|
23
|
+
{
|
|
24
|
+
"name": "data-fg",
|
|
25
|
+
"on": "button",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"required": true,
|
|
28
|
+
"description": "Foreground color (hex / rgb() / named); contrast mode"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "data-bg",
|
|
32
|
+
"on": "button",
|
|
33
|
+
"type": "string",
|
|
34
|
+
"required": true,
|
|
35
|
+
"description": "Background color (hex / rgb() / named); contrast mode"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "data-label",
|
|
39
|
+
"on": "button",
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Display label for the pair; defaults to button text content"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "data-status",
|
|
45
|
+
"on": "li",
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Checklist mode: pass | fail | warn | na (default: na)"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"structure": []
|
|
51
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* accessibility-specimen: WCAG contrast-ratio table + a11y checklist
|
|
3
|
+
* for design-system docs.
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* - "contrast" (default): tabular display of foreground / background
|
|
7
|
+
* color pairs with computed WCAG 2.x contrast ratio + AA/AAA badges
|
|
8
|
+
* for normal- and large-text. Author provides pairs as direct
|
|
9
|
+
* `<button data-fg data-bg [data-label]>` children.
|
|
10
|
+
* - "checklist": author authors a `<ul>` of items with `data-status=
|
|
11
|
+
* pass|fail|warn|na` (or none → unset). Component normalizes the
|
|
12
|
+
* visual styling and adds an icon per status.
|
|
13
|
+
*
|
|
14
|
+
* @attr {string} type - "contrast" (default) | "checklist"
|
|
15
|
+
*
|
|
16
|
+
* Distinct from:
|
|
17
|
+
* - <semantic-palette> (palette colors with semantic roles, not pairs)
|
|
18
|
+
* - <token-specimen> (token-scale display, single axis)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
22
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
23
|
+
|
|
24
|
+
// ── Color parsing + WCAG 2.x contrast ─────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a CSS color string into [r, g, b] (0-255). Supports #hex, #hex8,
|
|
28
|
+
* rgb()/rgba() and named colors (via a temporary canvas trick). Returns
|
|
29
|
+
* null on failure so the consumer can show a "?" badge.
|
|
30
|
+
* @returns {[number, number, number] | null}
|
|
31
|
+
*/
|
|
32
|
+
function parseColor(str) {
|
|
33
|
+
const s = String(str || '').trim();
|
|
34
|
+
if (!s) return null;
|
|
35
|
+
// Hex shorthand / full
|
|
36
|
+
let m = s.match(/^#([0-9a-f]{3,8})$/i);
|
|
37
|
+
if (m) {
|
|
38
|
+
const h = m[1];
|
|
39
|
+
if (h.length === 3) return [parseInt(h[0]+h[0],16), parseInt(h[1]+h[1],16), parseInt(h[2]+h[2],16)];
|
|
40
|
+
if (h.length === 4) return [parseInt(h[0]+h[0],16), parseInt(h[1]+h[1],16), parseInt(h[2]+h[2],16)];
|
|
41
|
+
if (h.length === 6) return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
|
|
42
|
+
if (h.length === 8) return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
|
|
43
|
+
}
|
|
44
|
+
// rgb / rgba
|
|
45
|
+
m = s.match(/^rgba?\s*\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)/i);
|
|
46
|
+
if (m) return [Math.round(+m[1]), Math.round(+m[2]), Math.round(+m[3])];
|
|
47
|
+
// Fall back to letting the browser resolve via getComputedStyle on a probe.
|
|
48
|
+
// (Handles named colors, oklch(), color-mix(), etc. — but loses alpha.)
|
|
49
|
+
if (typeof document !== 'undefined') {
|
|
50
|
+
const probe = document.createElement('span');
|
|
51
|
+
probe.style.color = s;
|
|
52
|
+
probe.style.display = 'none';
|
|
53
|
+
document.body.appendChild(probe);
|
|
54
|
+
const cs = getComputedStyle(probe).color;
|
|
55
|
+
probe.remove();
|
|
56
|
+
const rgb = cs.match(/^rgba?\s*\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)/i);
|
|
57
|
+
if (rgb) return [Math.round(+rgb[1]), Math.round(+rgb[2]), Math.round(+rgb[3])];
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Linearize an sRGB channel per WCAG 2.x. */
|
|
63
|
+
function lin(c) {
|
|
64
|
+
const v = c / 255;
|
|
65
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Relative luminance per WCAG 2.x. */
|
|
69
|
+
function luminance([r, g, b]) {
|
|
70
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Contrast ratio per WCAG 2.x (returns 1..21, or null on parse failure). */
|
|
74
|
+
function contrastRatio(fg, bg) {
|
|
75
|
+
const f = parseColor(fg);
|
|
76
|
+
const b = parseColor(bg);
|
|
77
|
+
if (!f || !b) return null;
|
|
78
|
+
const l1 = luminance(f);
|
|
79
|
+
const l2 = luminance(b);
|
|
80
|
+
const lighter = Math.max(l1, l2);
|
|
81
|
+
const darker = Math.min(l1, l2);
|
|
82
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @returns {{ aaNormal: boolean, aaLarge: boolean, aaaNormal: boolean, aaaLarge: boolean }}
|
|
87
|
+
*/
|
|
88
|
+
function wcagBadges(ratio) {
|
|
89
|
+
if (ratio == null) return { aaNormal: false, aaLarge: false, aaaNormal: false, aaaLarge: false };
|
|
90
|
+
return {
|
|
91
|
+
aaNormal: ratio >= 4.5,
|
|
92
|
+
aaLarge: ratio >= 3.0,
|
|
93
|
+
aaaNormal: ratio >= 7.0,
|
|
94
|
+
aaaLarge: ratio >= 4.5,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Component ─────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
class AccessibilitySpecimen extends VBElement {
|
|
101
|
+
static observedAttributes = ['type'];
|
|
102
|
+
|
|
103
|
+
setup() {
|
|
104
|
+
const type = this.getAttribute('type') || 'contrast';
|
|
105
|
+
if (type === 'checklist') this.#renderChecklist();
|
|
106
|
+
else this.#renderContrast();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
attributeChangedCallback(_name, oldVal, newVal) {
|
|
110
|
+
if (!this.isConnected || oldVal === newVal) return;
|
|
111
|
+
this.setup();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Contrast mode ─────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
#renderContrast() {
|
|
117
|
+
// Capture authored pairs (button[data-fg][data-bg]) before rebuilding.
|
|
118
|
+
const pairs = /** @type {HTMLElement[]} */ ([...this.querySelectorAll(':scope > button[data-fg][data-bg]')]).map((b) => ({
|
|
119
|
+
fg: b.dataset.fg,
|
|
120
|
+
bg: b.dataset.bg,
|
|
121
|
+
label: b.dataset.label || b.textContent.trim() || `${b.dataset.fg} on ${b.dataset.bg}`,
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
const table = document.createElement('table');
|
|
125
|
+
table.className = 'as-contrast-table';
|
|
126
|
+
|
|
127
|
+
const thead = document.createElement('thead');
|
|
128
|
+
thead.innerHTML = `
|
|
129
|
+
<tr>
|
|
130
|
+
<th scope="col">Pair</th>
|
|
131
|
+
<th scope="col">Sample</th>
|
|
132
|
+
<th scope="col">Ratio</th>
|
|
133
|
+
<th scope="col">AA</th>
|
|
134
|
+
<th scope="col">AA Large</th>
|
|
135
|
+
<th scope="col">AAA</th>
|
|
136
|
+
<th scope="col">AAA Large</th>
|
|
137
|
+
</tr>`;
|
|
138
|
+
table.appendChild(thead);
|
|
139
|
+
|
|
140
|
+
const tbody = document.createElement('tbody');
|
|
141
|
+
if (pairs.length === 0) {
|
|
142
|
+
const tr = document.createElement('tr');
|
|
143
|
+
const td = document.createElement('td');
|
|
144
|
+
td.colSpan = 7;
|
|
145
|
+
td.className = 'as-empty';
|
|
146
|
+
td.textContent = 'No color pairs authored. Add <button data-fg="…" data-bg="…">Label</button> children.';
|
|
147
|
+
tr.appendChild(td);
|
|
148
|
+
tbody.appendChild(tr);
|
|
149
|
+
} else {
|
|
150
|
+
for (const p of pairs) tbody.appendChild(this.#renderPairRow(p));
|
|
151
|
+
}
|
|
152
|
+
table.appendChild(tbody);
|
|
153
|
+
|
|
154
|
+
this.replaceChildren(table);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#renderPairRow({ fg, bg, label }) {
|
|
158
|
+
const ratio = contrastRatio(fg, bg);
|
|
159
|
+
const badges = wcagBadges(ratio);
|
|
160
|
+
const ratioText = ratio == null ? '?' : `${ratio.toFixed(2)}:1`;
|
|
161
|
+
|
|
162
|
+
const tr = document.createElement('tr');
|
|
163
|
+
|
|
164
|
+
const tdLabel = document.createElement('th');
|
|
165
|
+
tdLabel.scope = 'row';
|
|
166
|
+
tdLabel.className = 'as-pair-label';
|
|
167
|
+
tdLabel.innerHTML = `<span class="as-swatch" style="background:${bg};border-color:${fg}"></span><span>${escapeText(label)}</span>`;
|
|
168
|
+
tr.appendChild(tdLabel);
|
|
169
|
+
|
|
170
|
+
const tdSample = document.createElement('td');
|
|
171
|
+
tdSample.className = 'as-pair-sample';
|
|
172
|
+
tdSample.style.background = bg;
|
|
173
|
+
tdSample.style.color = fg;
|
|
174
|
+
tdSample.textContent = 'Aa Bb 123';
|
|
175
|
+
tr.appendChild(tdSample);
|
|
176
|
+
|
|
177
|
+
const tdRatio = document.createElement('td');
|
|
178
|
+
tdRatio.className = 'as-pair-ratio';
|
|
179
|
+
tdRatio.textContent = ratioText;
|
|
180
|
+
tr.appendChild(tdRatio);
|
|
181
|
+
|
|
182
|
+
for (const key of ['aaNormal', 'aaLarge', 'aaaNormal', 'aaaLarge']) {
|
|
183
|
+
const td = document.createElement('td');
|
|
184
|
+
const ok = badges[key];
|
|
185
|
+
td.className = `as-badge ${ok ? 'as-pass' : 'as-fail'}`;
|
|
186
|
+
td.textContent = ok ? '✓' : '✗';
|
|
187
|
+
td.setAttribute('aria-label', ok ? 'Pass' : 'Fail');
|
|
188
|
+
tr.appendChild(td);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return tr;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Checklist mode ────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
#renderChecklist() {
|
|
197
|
+
// Author provides a <ul> with <li data-status="pass|fail|warn|na"> items.
|
|
198
|
+
const list = this.querySelector(':scope > ul, :scope > ol');
|
|
199
|
+
if (!list) return;
|
|
200
|
+
list.classList.add('as-checklist');
|
|
201
|
+
for (const item of list.querySelectorAll(':scope > li')) {
|
|
202
|
+
this.#decorateChecklistItem(item);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#decorateChecklistItem(item) {
|
|
207
|
+
if (item.dataset.decorated === '') return;
|
|
208
|
+
item.dataset.decorated = '';
|
|
209
|
+
const status = item.dataset.status || 'na';
|
|
210
|
+
item.classList.add(`as-status-${status}`);
|
|
211
|
+
|
|
212
|
+
const icon = document.createElement('span');
|
|
213
|
+
icon.className = 'as-checklist-icon';
|
|
214
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
215
|
+
icon.textContent = ({ pass: '✓', fail: '✗', warn: '!', na: '–' })[status] || '–';
|
|
216
|
+
|
|
217
|
+
const label = ({ pass: 'Pass', fail: 'Fail', warn: 'Warning', na: 'Not applicable' })[status] || 'Not applicable';
|
|
218
|
+
const sr = document.createElement('span');
|
|
219
|
+
sr.className = 'as-sr-only';
|
|
220
|
+
sr.textContent = `${label}: `;
|
|
221
|
+
|
|
222
|
+
item.prepend(icon, sr);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function escapeText(s) {
|
|
227
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
registerComponent('accessibility-specimen', AccessibilitySpecimen);
|
|
231
|
+
|
|
232
|
+
export { AccessibilitySpecimen };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>accessibility-specimen demo</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/main.css">
|
|
8
|
+
<style>
|
|
9
|
+
body { font-family: system-ui, sans-serif; max-inline-size: 48rem; margin-inline: auto; padding: 2rem 1rem; line-height: 1.5; }
|
|
10
|
+
section { margin-block: 1.5rem; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<h1>accessibility-specimen</h1>
|
|
15
|
+
|
|
16
|
+
<section>
|
|
17
|
+
<h2>Contrast pairs</h2>
|
|
18
|
+
<accessibility-specimen>
|
|
19
|
+
<button data-fg="#000000" data-bg="#ffffff" data-label="Black on white">Default text</button>
|
|
20
|
+
<button data-fg="#666" data-bg="#ffffff" data-label="Mid-gray on white">Muted text</button>
|
|
21
|
+
<button data-fg="#ffffff" data-bg="#0066cc" data-label="White on accent blue">Action button</button>
|
|
22
|
+
<button data-fg="#ffffff" data-bg="#ddd" data-label="White on light-gray">Bad pairing</button>
|
|
23
|
+
</accessibility-specimen>
|
|
24
|
+
</section>
|
|
25
|
+
|
|
26
|
+
<section>
|
|
27
|
+
<h2>Checklist</h2>
|
|
28
|
+
<accessibility-specimen type="checklist">
|
|
29
|
+
<ul>
|
|
30
|
+
<li data-status="pass">Form inputs have associated labels.</li>
|
|
31
|
+
<li data-status="pass">Focus indicators are visible at 2px width minimum.</li>
|
|
32
|
+
<li data-status="warn">Some images may be missing alt text.</li>
|
|
33
|
+
<li data-status="fail">Color contrast on muted text fails AA.</li>
|
|
34
|
+
<li data-status="na">No videos in this component (captions N/A).</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</accessibility-specimen>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<script type="module" src="/src/main.js"></script>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|