@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gradient-builder — Interactive CSS gradient tool
|
|
3
|
+
*
|
|
4
|
+
* Visual gradient preview with color stop management, angle/type controls,
|
|
5
|
+
* color space selection, and CSS export. Companion to palette-generator.
|
|
6
|
+
*
|
|
7
|
+
* @attr {string} colors - Comma-separated initial stop colors
|
|
8
|
+
* @attr {string} type - "linear" (default) or "radial"
|
|
9
|
+
* @attr {number} angle - Gradient angle in degrees (default: 90)
|
|
10
|
+
* @attr {string} interpolation - Color space: "oklab" (default), "oklch", "srgb"
|
|
11
|
+
* @attr {boolean} show-export - Show Copy CSS toolbar
|
|
12
|
+
* @attr {boolean} show-controls - Show type/angle/space controls (default: true)
|
|
13
|
+
*
|
|
14
|
+
* @fires gradient-builder:change - { css, stops, type, angle, interpolation }
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* <gradient-builder colors="#6366f1,#ec4899" show-export></gradient-builder>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
21
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
22
|
+
import { copyText } from '../../utils/copy-init.js';
|
|
23
|
+
import { buildGradientCSS, parseColorStops, defaultStops } from './_gradient-utils.js';
|
|
24
|
+
|
|
25
|
+
class GradientBuilder extends VBElement {
|
|
26
|
+
static observedAttributes = ['colors', 'type', 'angle', 'interpolation', 'show-export', 'show-controls'];
|
|
27
|
+
|
|
28
|
+
/** @type {import('./_gradient-utils.js').GradientStop[]} */
|
|
29
|
+
#stops = [];
|
|
30
|
+
#type = 'linear';
|
|
31
|
+
#angle = 90;
|
|
32
|
+
#interpolation = 'oklab';
|
|
33
|
+
|
|
34
|
+
setup() {
|
|
35
|
+
this.#stops = parseColorStops(this.getAttribute('colors') ?? '');
|
|
36
|
+
this.#type = this.getAttribute('type') || 'linear';
|
|
37
|
+
this.#angle = Number(this.getAttribute('angle')) || 90;
|
|
38
|
+
this.#interpolation = this.getAttribute('interpolation') || 'oklab';
|
|
39
|
+
this.#render();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
44
|
+
if (oldVal === newVal || !this.isConnected) return;
|
|
45
|
+
if (name === 'colors') this.#stops = parseColorStops(newVal);
|
|
46
|
+
if (name === 'type') this.#type = newVal || 'linear';
|
|
47
|
+
if (name === 'angle') this.#angle = Number(newVal) || 90;
|
|
48
|
+
if (name === 'interpolation') this.#interpolation = newVal || 'oklab';
|
|
49
|
+
this.#render();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get css() {
|
|
53
|
+
return buildGradientCSS(this.#stops, {
|
|
54
|
+
type: this.#type,
|
|
55
|
+
angle: this.#angle,
|
|
56
|
+
interpolation: this.#interpolation,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#render() {
|
|
61
|
+
const showExport = this.hasAttribute('show-export');
|
|
62
|
+
const showControls = this.getAttribute('show-controls') !== 'false';
|
|
63
|
+
const css = this.css;
|
|
64
|
+
|
|
65
|
+
const gap = 'var(--size-m, 1rem)';
|
|
66
|
+
const smGap = 'var(--size-s, 0.75rem)';
|
|
67
|
+
const xsGap = 'var(--size-xs, 0.5rem)';
|
|
68
|
+
const radius = 'var(--radius-m, 0.5rem)';
|
|
69
|
+
const border = 'var(--color-border, #ddd)';
|
|
70
|
+
const surface = 'var(--color-surface, #fff)';
|
|
71
|
+
const muted = 'var(--color-text-muted, #666)';
|
|
72
|
+
const mono = 'var(--font-mono, monospace)';
|
|
73
|
+
const smFont = 'var(--font-size-sm, 0.875rem)';
|
|
74
|
+
const xsFont = 'var(--font-size-xs, 0.75rem)';
|
|
75
|
+
|
|
76
|
+
// Preview strip
|
|
77
|
+
const preview = `<div class="gb-preview" style="height:4rem;border-radius:${radius};background:${css};border:1px solid ${border}"></div>`;
|
|
78
|
+
|
|
79
|
+
// Controls — Row 1: Type + Angle, Row 2: Color Space
|
|
80
|
+
const selectStyle = `font:inherit;font-size:${smFont};padding:0.25rem 0.5rem;border:1px solid ${border};border-radius:4px;background:${surface}`;
|
|
81
|
+
let controls = '';
|
|
82
|
+
if (showControls) {
|
|
83
|
+
controls = `<div style="display:flex;flex-direction:column;gap:${smGap};font-size:${smFont}">
|
|
84
|
+
<div style="display:flex;flex-wrap:wrap;gap:${gap};align-items:center">
|
|
85
|
+
<label style="display:flex;align-items:center;gap:${xsGap}">
|
|
86
|
+
Type
|
|
87
|
+
<select class="gb-type" style="${selectStyle}">
|
|
88
|
+
<option value="linear"${this.#type === 'linear' ? ' selected' : ''}>Linear</option>
|
|
89
|
+
<option value="radial"${this.#type === 'radial' ? ' selected' : ''}>Radial</option>
|
|
90
|
+
</select>
|
|
91
|
+
</label>
|
|
92
|
+
<label style="display:flex;align-items:center;gap:${xsGap}${this.#type === 'radial' ? ';opacity:0.4;pointer-events:none' : ''}">
|
|
93
|
+
Angle
|
|
94
|
+
<input type="range" class="gb-angle" min="0" max="360" value="${this.#angle}" style="width:8rem;accent-color:var(--color-interactive,oklch(55% .2 260))">
|
|
95
|
+
<span class="gb-angle-value" style="min-width:2.5em;font-family:${mono};font-size:${xsFont}">${this.#angle}°</span>
|
|
96
|
+
</label>
|
|
97
|
+
</div>
|
|
98
|
+
<label style="display:inline-flex;align-items:center;gap:${xsGap}">
|
|
99
|
+
Color Space
|
|
100
|
+
<select class="gb-space" style="${selectStyle}">
|
|
101
|
+
<option value="oklab"${this.#interpolation === 'oklab' ? ' selected' : ''}>oklab</option>
|
|
102
|
+
<option value="oklch"${this.#interpolation === 'oklch' ? ' selected' : ''}>oklch</option>
|
|
103
|
+
<option value="srgb"${this.#interpolation === 'srgb' ? ' selected' : ''}>sRGB</option>
|
|
104
|
+
</select>
|
|
105
|
+
</label>
|
|
106
|
+
</div>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Sort stops by position for display
|
|
110
|
+
const sorted = [...this.#stops].map((s, i) => ({ ...s, origIndex: i })).sort((a, b) => a.position - b.position);
|
|
111
|
+
|
|
112
|
+
// Stop editors — horizontal wrapping row
|
|
113
|
+
const stopRows = sorted.map((stop) => {
|
|
114
|
+
const i = stop.origIndex;
|
|
115
|
+
return `<div style="display:flex;align-items:center;gap:0.375rem" data-stop="${i}">
|
|
116
|
+
<input type="color" value="${stop.color}" class="gb-stop-color" data-i="${i}"
|
|
117
|
+
style="width:1.75rem;height:1.75rem;padding:0;border:1px solid ${border};border-radius:4px;cursor:pointer;flex-shrink:0">
|
|
118
|
+
<input type="range" min="0" max="100" value="${stop.position}" class="gb-stop-pos-range" data-i="${i}"
|
|
119
|
+
style="flex:1;min-width:3rem;max-width:8rem;accent-color:${stop.color}">
|
|
120
|
+
<span style="font-family:${mono};font-size:${xsFont};min-width:2.5em;text-align:right" class="gb-stop-pos-label" data-i="${i}">${stop.position}%</span>
|
|
121
|
+
${this.#stops.length > 2 ? `<button type="button" class="gb-remove" data-i="${i}"
|
|
122
|
+
style="all:unset;cursor:pointer;font-size:1rem;color:${muted};padding:0 0.25rem" title="Remove stop">×</button>` : ''}
|
|
123
|
+
</div>`;
|
|
124
|
+
}).join('');
|
|
125
|
+
|
|
126
|
+
const addBtn = `<button type="button" class="gb-add"
|
|
127
|
+
style="all:unset;cursor:pointer;font-size:${smFont};color:var(--color-interactive,oklch(55% .2 260));font-weight:600">+ Add Stop</button>`;
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
this.innerHTML = `
|
|
131
|
+
<div style="display:flex;flex-direction:column;gap:${gap}">
|
|
132
|
+
${preview}
|
|
133
|
+
${controls}
|
|
134
|
+
<div style="display:flex;flex-direction:column;gap:${xsGap}">
|
|
135
|
+
<span style="font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.05em">Color Stops</span>
|
|
136
|
+
${stopRows}
|
|
137
|
+
${addBtn}
|
|
138
|
+
</div>
|
|
139
|
+
<div style="display:flex;flex-direction:column;gap:${xsGap}">
|
|
140
|
+
<span style="font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.05em">Code</span>
|
|
141
|
+
<div style="display:flex;align-items:start;gap:${xsGap}">
|
|
142
|
+
<div style="flex:1;font-family:${mono};font-size:${xsFont};padding:${smGap};background:var(--color-surface-raised,#f5f5f5);border-radius:${radius};word-break:break-all;color:var(--color-text,#222)" class="gb-css-output">${css}</div>
|
|
143
|
+
${showExport ? `<button type="button" class="gb-copy"
|
|
144
|
+
style="all:unset;cursor:pointer;font-size:${xsFont};padding:0.35rem 0.75rem;border:1px solid ${border};border-radius:4px;background:${surface};white-space:nowrap;flex-shrink:0">Copy CSS</button>` : ''}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
this.#wire();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#wire() {
|
|
154
|
+
// Type select
|
|
155
|
+
this.querySelector('.gb-type')?.addEventListener('change', (e) => {
|
|
156
|
+
const t = /** @type {HTMLSelectElement} */ (e.target);
|
|
157
|
+
this.#type = t.value;
|
|
158
|
+
this.#render();
|
|
159
|
+
this.#emit();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Angle slider
|
|
163
|
+
const angleInput = this.querySelector('.gb-angle');
|
|
164
|
+
const angleValue = this.querySelector('.gb-angle-value');
|
|
165
|
+
angleInput?.addEventListener('input', (e) => {
|
|
166
|
+
const t = /** @type {HTMLInputElement} */ (e.target);
|
|
167
|
+
this.#angle = Number(t.value);
|
|
168
|
+
if (angleValue) angleValue.textContent = `${this.#angle}°`;
|
|
169
|
+
// Live update preview without full re-render
|
|
170
|
+
const preview = /** @type {HTMLElement | null} */ (this.querySelector('.gb-preview'));
|
|
171
|
+
if (preview) preview.style.background = this.css;
|
|
172
|
+
this.#emit();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Interpolation select
|
|
176
|
+
this.querySelector('.gb-space')?.addEventListener('change', (e) => {
|
|
177
|
+
const t = /** @type {HTMLSelectElement} */ (e.target);
|
|
178
|
+
this.#interpolation = t.value;
|
|
179
|
+
this.#render();
|
|
180
|
+
this.#emit();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Stop color inputs
|
|
184
|
+
this.querySelectorAll('.gb-stop-color').forEach((input) => {
|
|
185
|
+
input.addEventListener('input', (e) => {
|
|
186
|
+
const t = /** @type {HTMLInputElement} */ (e.target);
|
|
187
|
+
const i = Number(t.dataset.i);
|
|
188
|
+
this.#stops[i].color = t.value;
|
|
189
|
+
this.#updatePreview();
|
|
190
|
+
this.#emit();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Stop position range sliders
|
|
195
|
+
this.querySelectorAll('.gb-stop-pos-range').forEach((input) => {
|
|
196
|
+
input.addEventListener('input', (e) => {
|
|
197
|
+
const t = /** @type {HTMLInputElement} */ (e.target);
|
|
198
|
+
const i = Number(t.dataset.i);
|
|
199
|
+
const val = Math.max(0, Math.min(100, Number(t.value) || 0));
|
|
200
|
+
this.#stops[i].position = val;
|
|
201
|
+
const label = this.querySelector(`.gb-stop-pos-label[data-i="${i}"]`);
|
|
202
|
+
if (label) label.textContent = `${val}%`;
|
|
203
|
+
this.#updatePreview();
|
|
204
|
+
this.#emit();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Remove stop buttons
|
|
209
|
+
this.querySelectorAll('.gb-remove').forEach((btn) => {
|
|
210
|
+
btn.addEventListener('click', (e) => {
|
|
211
|
+
const t = /** @type {HTMLElement} */ (e.target);
|
|
212
|
+
const i = Number(t.dataset.i);
|
|
213
|
+
this.#stops.splice(i, 1);
|
|
214
|
+
this.#render();
|
|
215
|
+
this.#emit();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Add stop button
|
|
220
|
+
this.querySelector('.gb-add')?.addEventListener('click', () => {
|
|
221
|
+
// Insert a new stop at the midpoint between the last two stops
|
|
222
|
+
const last = this.#stops[this.#stops.length - 1];
|
|
223
|
+
const prev = this.#stops[this.#stops.length - 2];
|
|
224
|
+
const pos = Math.round((prev.position + last.position) / 2);
|
|
225
|
+
this.#stops.splice(this.#stops.length - 1, 0, { color: '#888888', position: pos });
|
|
226
|
+
this.#render();
|
|
227
|
+
this.#emit();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Copy CSS button
|
|
231
|
+
this.querySelector('.gb-copy')?.addEventListener('click', (e) => {
|
|
232
|
+
const btn = /** @type {HTMLElement | null} */ (e.target);
|
|
233
|
+
if (!btn) return;
|
|
234
|
+
copyText(this.css, { button: btn, announceMessage: 'Gradient CSS copied' });
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#updatePreview() {
|
|
239
|
+
const css = this.css;
|
|
240
|
+
const preview = /** @type {HTMLElement | null} */ (this.querySelector('.gb-preview'));
|
|
241
|
+
if (preview) preview.style.background = css;
|
|
242
|
+
const output = this.querySelector('.gb-css-output');
|
|
243
|
+
if (output) output.textContent = css;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#emit() {
|
|
247
|
+
this.dispatchEvent(new CustomEvent('gradient-builder:change', {
|
|
248
|
+
bubbles: true,
|
|
249
|
+
detail: {
|
|
250
|
+
css: this.css,
|
|
251
|
+
stops: [...this.#stops],
|
|
252
|
+
type: this.#type,
|
|
253
|
+
angle: this.#angle,
|
|
254
|
+
interpolation: this.#interpolation,
|
|
255
|
+
},
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
registerComponent('gradient-builder', GradientBuilder);
|
|
261
|
+
export { GradientBuilder };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><title>Gradient Builder (Static Fallback)</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<noscript>
|
|
6
|
+
<p>gradient-builder is an authoring tool for composing CSS gradients with live preview and copy-to-clipboard. It requires JS; no static fallback is meaningful.</p>
|
|
7
|
+
</noscript>
|
|
8
|
+
</body>
|
|
9
|
+
</html>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schemas/api.schema.json",
|
|
3
|
+
"element": "icon-set",
|
|
4
|
+
"type": "web-component",
|
|
5
|
+
"description": "Searchable grid of every icon in a set, with click-to-copy names.",
|
|
6
|
+
"htmlvalidate": {
|
|
7
|
+
"flow": true,
|
|
8
|
+
"permittedContent": [
|
|
9
|
+
"@flow"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"attributes": [
|
|
13
|
+
{
|
|
14
|
+
"name": "set",
|
|
15
|
+
"kind": "host-api",
|
|
16
|
+
"purpose": "config",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Icon set directory (default: \"lucide\")"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "names",
|
|
22
|
+
"kind": "host-api",
|
|
23
|
+
"purpose": "config",
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Optional space/comma-separated list of icon names. Overrides the {iconBase}/{set}.json manifest fetch."
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"childAttributes": [
|
|
29
|
+
{
|
|
30
|
+
"name": "name",
|
|
31
|
+
"kind": "host-api",
|
|
32
|
+
"purpose": "config",
|
|
33
|
+
"type": "string",
|
|
34
|
+
"element": "icon-wc",
|
|
35
|
+
"description": "Icon filename (without .svg), passed through from each grid cell."
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"structure": []
|
|
39
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* icon-set: Searchable icon catalog/browser
|
|
3
|
+
*
|
|
4
|
+
* Renders a filterable grid of icons for a given icon set, with
|
|
5
|
+
* click-to-copy names. Each cell renders the icon with the `[data-icon]`
|
|
6
|
+
* primitive (a currentColor CSS mask), lazy-mounted so a full set does not
|
|
7
|
+
* fetch every SVG at once.
|
|
8
|
+
*
|
|
9
|
+
* By default it fetches {iconBase}/{set}.json — a manifest listing every
|
|
10
|
+
* icon name in the set. The `names` attribute bypasses that fetch entirely
|
|
11
|
+
* (space/comma-separated list), which is useful for tests, offline demos,
|
|
12
|
+
* or curated subsets where no manifest exists.
|
|
13
|
+
*
|
|
14
|
+
* @attr {string} set - Icon set directory (default: "lucide")
|
|
15
|
+
* @attr {string} names - Optional space/comma-separated list of icon names.
|
|
16
|
+
* Overrides the {iconBase}/{set}.json manifest fetch.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* <icon-set names="star home search settings"></icon-set>
|
|
20
|
+
* <icon-set set="lucide"></icon-set>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
24
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
25
|
+
|
|
26
|
+
const iconBase = () => document.documentElement.dataset.iconPath || '/cdn/icons';
|
|
27
|
+
|
|
28
|
+
class IconSet extends VBElement {
|
|
29
|
+
static observedAttributes = ['set', 'names'];
|
|
30
|
+
|
|
31
|
+
/** @type {IntersectionObserver | null} Lazy-mounts [data-icon] icons as cells scroll in. */
|
|
32
|
+
#io = null;
|
|
33
|
+
|
|
34
|
+
setup() {
|
|
35
|
+
this.#render();
|
|
36
|
+
this.#load();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
attributeChangedCallback() {
|
|
40
|
+
if (this.isConnected) {
|
|
41
|
+
this.#render();
|
|
42
|
+
this.#load();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
disconnectedCallback() {
|
|
47
|
+
this.#io?.disconnect();
|
|
48
|
+
this.#io = null;
|
|
49
|
+
super.disconnectedCallback?.();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get set() {
|
|
53
|
+
return this.getAttribute('set') || 'lucide';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async #load() {
|
|
57
|
+
const attr = this.getAttribute('names');
|
|
58
|
+
let names = attr ? attr.split(/[\s,]+/).filter(Boolean) : null;
|
|
59
|
+
if (!names) {
|
|
60
|
+
try {
|
|
61
|
+
names = await (await fetch(`${iconBase()}/${this.set}.json`)).json();
|
|
62
|
+
} catch {
|
|
63
|
+
names = [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
this.#renderGrid(names);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#render() {
|
|
70
|
+
this.innerHTML = `
|
|
71
|
+
<div class="icon-set">
|
|
72
|
+
<label class="icon-set__search">
|
|
73
|
+
<span class="visually-hidden">Filter icons</span>
|
|
74
|
+
<input type="search" placeholder="Filter ${this.set} icons…">
|
|
75
|
+
</label>
|
|
76
|
+
<ul class="icon-set__grid" data-layout="grid" role="list"></ul>
|
|
77
|
+
</div>`;
|
|
78
|
+
const input = /** @type {HTMLInputElement} */ (this.querySelector('input'));
|
|
79
|
+
this.listen(input, 'input', (e) => this.#filter(/** @type {HTMLInputElement} */ (e.target).value));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#renderGrid(names) {
|
|
83
|
+
const ul = this.querySelector('.icon-set__grid');
|
|
84
|
+
if (!ul) return;
|
|
85
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
86
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
87
|
+
// Cells render with an empty, correctly-sized icon slot; the real icon
|
|
88
|
+
// (an <i data-icon>, whose CSS mask fetches the SVG) is mounted lazily
|
|
89
|
+
// below. Rendering a full set (~1900 icons) up front fires ~1900 concurrent
|
|
90
|
+
// fetches and exhausts the browser (net::ERR_INSUFFICIENT_RESOURCES).
|
|
91
|
+
ul.innerHTML = names.map((n) => `
|
|
92
|
+
<li data-icon-name="${esc(n)}">
|
|
93
|
+
<button type="button" title="Copy “${esc(n)}”" data-copy="${esc(n)}">
|
|
94
|
+
<span class="icon-set__icon" data-name="${esc(n)}"></span>
|
|
95
|
+
<span>${esc(n)}</span>
|
|
96
|
+
</button>
|
|
97
|
+
</li>`).join('');
|
|
98
|
+
this.listen(ul, 'click', (e) => {
|
|
99
|
+
const btn = /** @type {HTMLElement} */ (e.target).closest('[data-copy]');
|
|
100
|
+
if (btn) navigator.clipboard?.writeText(/** @type {HTMLElement} */ (btn).dataset.copy ?? '');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Lazy-mount the icon only when a cell nears the viewport, so only the
|
|
104
|
+
// handful of visible icons fetch at a time instead of the whole set at once.
|
|
105
|
+
const set = this.set;
|
|
106
|
+
this.#io?.disconnect();
|
|
107
|
+
this.#io = new IntersectionObserver((entries, obs) => {
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!entry.isIntersecting) continue;
|
|
110
|
+
const slot = /** @type {HTMLElement} */ (entry.target);
|
|
111
|
+
obs.unobserve(slot);
|
|
112
|
+
if (slot.firstElementChild) continue; // already mounted
|
|
113
|
+
const icon = document.createElement('i');
|
|
114
|
+
icon.setAttribute('data-icon', slot.dataset.name ?? '');
|
|
115
|
+
icon.setAttribute('data-icon-set', set);
|
|
116
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
117
|
+
slot.appendChild(icon);
|
|
118
|
+
}
|
|
119
|
+
}, { rootMargin: '400px' });
|
|
120
|
+
for (const slot of ul.querySelectorAll('.icon-set__icon')) this.#io.observe(slot);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#filter(q) {
|
|
124
|
+
const term = q.trim().toLowerCase();
|
|
125
|
+
for (const li of this.querySelectorAll('[data-icon-name]')) {
|
|
126
|
+
li.hidden = !!term && !(/** @type {HTMLElement} */ (li).dataset.iconName ?? '').toLowerCase().includes(term);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
registerComponent('icon-set', IconSet);
|
|
132
|
+
|
|
133
|
+
export { IconSet };
|
|
@@ -0,0 +1,36 @@
|
|
|
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>Icon Set — Static Fallback</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; padding: 2rem; }
|
|
9
|
+
ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); gap: 0.5rem; }
|
|
10
|
+
li { text-align: center; font-size: 0.75rem; color: #666; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<!--
|
|
15
|
+
Static fallback for <icon-set>.
|
|
16
|
+
|
|
17
|
+
Upgrade relationship:
|
|
18
|
+
- Without JS: a plain list of icon names remains readable, so the
|
|
19
|
+
catalog's contents are never hidden from no-JS readers.
|
|
20
|
+
- With JS: the component replaces this with a filterable grid where
|
|
21
|
+
each cell renders the actual icon via <icon-wc> and a click copies
|
|
22
|
+
the name to the clipboard.
|
|
23
|
+
-->
|
|
24
|
+
|
|
25
|
+
<icon-set names="star home search settings heart check">
|
|
26
|
+
<ul>
|
|
27
|
+
<li>star</li>
|
|
28
|
+
<li>home</li>
|
|
29
|
+
<li>search</li>
|
|
30
|
+
<li>settings</li>
|
|
31
|
+
<li>heart</li>
|
|
32
|
+
<li>check</li>
|
|
33
|
+
</ul>
|
|
34
|
+
</icon-set>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* icon-set: Searchable icon catalog/browser with click-to-copy names */
|
|
2
|
+
|
|
3
|
+
icon-set:not(:defined) {
|
|
4
|
+
display: block;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
icon-set {
|
|
8
|
+
display: block;
|
|
9
|
+
font-family: var(--font-sans, system-ui);
|
|
10
|
+
color: var(--color-text, #1a1a1a);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@scope (icon-set) {
|
|
14
|
+
.icon-set__search input {
|
|
15
|
+
inline-size: 100%;
|
|
16
|
+
padding: var(--size-s, 0.5rem);
|
|
17
|
+
border: 1px solid var(--color-border, #d4d4d8);
|
|
18
|
+
border-radius: var(--radius-s, 0.25rem);
|
|
19
|
+
font: inherit;
|
|
20
|
+
color: inherit;
|
|
21
|
+
background: var(--color-surface, #fff);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* data-layout="grid" on the <ul> lets vanilla-breeze reset the block-flow
|
|
25
|
+
li margin (which would otherwise skew the rows); these rules override the
|
|
26
|
+
primitive's defaults with the catalog's own dense column sizing + gap. */
|
|
27
|
+
.icon-set__grid {
|
|
28
|
+
list-style: none;
|
|
29
|
+
margin: var(--size-m, 1rem) 0 0;
|
|
30
|
+
padding: 0;
|
|
31
|
+
grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
|
|
32
|
+
gap: var(--size-2xs, 0.25rem);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.icon-set__grid [hidden] {
|
|
36
|
+
display: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.icon-set__grid button {
|
|
40
|
+
inline-size: 100%;
|
|
41
|
+
block-size: 100%; /* fill the cell so every card is the same height */
|
|
42
|
+
display: grid;
|
|
43
|
+
align-content: start; /* icon + name anchored at the top, so icons line up */
|
|
44
|
+
justify-items: center;
|
|
45
|
+
gap: var(--size-2xs, 0.25rem);
|
|
46
|
+
padding: var(--size-s, 0.75rem);
|
|
47
|
+
background: none;
|
|
48
|
+
border: 1px solid transparent;
|
|
49
|
+
border-radius: var(--radius-s, 0.25rem);
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
52
|
+
color: inherit;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Reserve two lines for the name so 1- and 2-line names produce equal-height
|
|
56
|
+
cards; long names wrap instead of overflowing their neighbours. */
|
|
57
|
+
.icon-set__grid button > span:last-child {
|
|
58
|
+
min-block-size: 2lh;
|
|
59
|
+
text-align: center;
|
|
60
|
+
overflow-wrap: anywhere;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.icon-set__grid button:hover {
|
|
64
|
+
border-color: var(--color-border, #d4d4d8);
|
|
65
|
+
background: var(--color-surface-2, #f4f4f5);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Reserve the icon box up front so the grid doesn't reflow as icons
|
|
69
|
+
lazily mount, and so each empty slot is observable/visible for the
|
|
70
|
+
IntersectionObserver. */
|
|
71
|
+
.icon-set__icon {
|
|
72
|
+
display: inline-flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
justify-content: center;
|
|
75
|
+
inline-size: 1.5rem;
|
|
76
|
+
block-size: 1.5rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.icon-set__grid [data-icon] {
|
|
80
|
+
font-size: 1.5rem;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schemas/api.schema.json",
|
|
3
|
+
"element": "icon-specimen",
|
|
4
|
+
"type": "web-component",
|
|
5
|
+
"description": "Curated icon-language specimen: a fixed set of icon names rendered across the project's sizing scale.",
|
|
6
|
+
"htmlvalidate": {
|
|
7
|
+
"flow": true,
|
|
8
|
+
"permittedContent": [
|
|
9
|
+
"@flow"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"attributes": [
|
|
13
|
+
{
|
|
14
|
+
"name": "set",
|
|
15
|
+
"kind": "host-api",
|
|
16
|
+
"purpose": "config",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Icon set directory (default: \"lucide\")"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "names",
|
|
22
|
+
"kind": "host-api",
|
|
23
|
+
"purpose": "config",
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Space/comma-separated list of icon names to display (required)."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "data-sizes",
|
|
29
|
+
"kind": "host-api",
|
|
30
|
+
"purpose": "config",
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Space-separated sizing scale, e.g. \"1rem 1.5rem 2rem\" (default)."
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"childAttributes": [
|
|
36
|
+
{
|
|
37
|
+
"name": "name",
|
|
38
|
+
"kind": "host-api",
|
|
39
|
+
"purpose": "config",
|
|
40
|
+
"type": "string",
|
|
41
|
+
"element": "icon-wc",
|
|
42
|
+
"description": "Icon filename (without .svg), passed through from each specimen cell."
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"structure": []
|
|
46
|
+
}
|