@aceshooting/lyra-ui 1.0.1 → 1.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/README.md +8 -9
- package/custom-elements.json +12479 -6535
- package/dist/components/chart/chart.d.ts +46 -1
- package/dist/components/chart/chart.js +148 -10
- package/dist/components/chart/chart.stories.d.ts +32 -0
- package/dist/components/chart/chart.stories.js +141 -0
- package/dist/components/chart/chart.styles.js +16 -0
- package/dist/components/chart/histogram.d.ts +2 -0
- package/dist/components/chart/histogram.js +18 -2
- package/dist/components/chart/line-chart.stories.d.ts +17 -0
- package/dist/components/chart/line-chart.stories.js +75 -0
- package/dist/components/chart/lite-chart.d.ts +74 -0
- package/dist/components/chart/lite-chart.js +384 -0
- package/dist/components/chart/lite-chart.stories.d.ts +12 -0
- package/dist/components/chart/lite-chart.stories.js +98 -0
- package/dist/components/chart/lite-chart.styles.d.ts +1 -0
- package/dist/components/chart/lite-chart.styles.js +83 -0
- package/dist/components/combobox/combobox.d.ts +9 -0
- package/dist/components/combobox/combobox.js +75 -12
- package/dist/components/combobox/combobox.stories.d.ts +18 -0
- package/dist/components/combobox/combobox.stories.js +75 -0
- package/dist/components/date-picker/calendar-core.js +13 -2
- package/dist/components/date-picker/date-input.d.ts +9 -1
- package/dist/components/date-picker/date-input.js +55 -7
- package/dist/components/date-picker/date-input.stories.d.ts +5 -0
- package/dist/components/date-picker/date-input.stories.js +36 -0
- package/dist/components/date-picker/date-picker.d.ts +11 -3
- package/dist/components/date-picker/date-picker.js +87 -37
- package/dist/components/date-picker/date-picker.stories.d.ts +4 -0
- package/dist/components/date-picker/date-picker.stories.js +12 -0
- package/dist/components/date-picker/date-picker.styles.js +1 -1
- package/dist/components/empty/empty.d.ts +12 -1
- package/dist/components/empty/empty.js +20 -3
- package/dist/components/empty/empty.stories.d.ts +2 -0
- package/dist/components/empty/empty.stories.js +20 -0
- package/dist/components/empty/empty.styles.js +11 -0
- package/dist/components/export-button/csv.js +6 -2
- package/dist/components/export-button/export-button.d.ts +15 -1
- package/dist/components/export-button/export-button.js +89 -12
- package/dist/components/export-button/export-button.stories.d.ts +1 -0
- package/dist/components/export-button/export-button.stories.js +5 -0
- package/dist/components/export-button/export-button.styles.js +11 -3
- package/dist/components/file-input/accept.d.ts +8 -2
- package/dist/components/file-input/accept.js +9 -3
- package/dist/components/file-input/file-input.d.ts +5 -2
- package/dist/components/file-input/file-input.js +23 -8
- package/dist/components/file-input/file-input.stories.d.ts +2 -0
- package/dist/components/file-input/file-input.stories.js +9 -0
- package/dist/components/flag/flag.d.ts +48 -3
- package/dist/components/flag/flag.js +65 -29
- package/dist/components/flag/flag.styles.js +1 -1
- package/dist/components/flag/language-map.d.ts +11 -0
- package/dist/components/flag/language-map.js +12 -1
- package/dist/components/gauge/gauge.d.ts +2 -0
- package/dist/components/gauge/gauge.js +19 -5
- package/dist/components/gauge/gauge.stories.d.ts +2 -0
- package/dist/components/gauge/gauge.stories.js +6 -0
- package/dist/components/graph/graph-loader.d.ts +28 -0
- package/dist/components/graph/graph-loader.js +44 -0
- package/dist/components/graph/graph.d.ts +41 -15
- package/dist/components/graph/graph.js +146 -71
- package/dist/components/graph/graph.stories.d.ts +3 -0
- package/dist/components/graph/graph.stories.js +38 -0
- package/dist/components/graph/graph.styles.js +6 -1
- package/dist/components/heatmap/calendar-grid.d.ts +7 -0
- package/dist/components/heatmap/calendar-grid.js +27 -10
- package/dist/components/heatmap/heatmap.d.ts +137 -12
- package/dist/components/heatmap/heatmap.js +427 -27
- package/dist/components/heatmap/heatmap.stories.d.ts +17 -0
- package/dist/components/heatmap/heatmap.stories.js +130 -0
- package/dist/components/heatmap/heatmap.styles.js +60 -2
- package/dist/components/map/map.d.ts +17 -1
- package/dist/components/map/map.js +68 -21
- package/dist/components/map/map.stories.d.ts +10 -0
- package/dist/components/map/map.stories.js +107 -12
- package/dist/components/map/map.styles.js +2 -2
- package/dist/components/playback/playback.d.ts +6 -2
- package/dist/components/playback/playback.js +36 -12
- package/dist/components/playback/playback.stories.d.ts +2 -0
- package/dist/components/playback/playback.stories.js +6 -0
- package/dist/components/playback/playback.styles.js +10 -2
- package/dist/components/select/select.d.ts +122 -0
- package/dist/components/select/select.js +516 -0
- package/dist/components/select/select.stories.d.ts +19 -0
- package/dist/components/select/select.stories.js +75 -0
- package/dist/components/select/select.styles.d.ts +1 -0
- package/dist/components/select/select.styles.js +188 -0
- package/dist/components/skeleton/skeleton.js +4 -7
- package/dist/components/sparkline/sparkline.d.ts +1 -1
- package/dist/components/sparkline/sparkline.js +40 -13
- package/dist/components/sparkline/sparkline.stories.d.ts +4 -0
- package/dist/components/sparkline/sparkline.stories.js +22 -0
- package/dist/components/split/split.d.ts +24 -3
- package/dist/components/split/split.js +155 -31
- package/dist/components/split/split.stories.d.ts +2 -0
- package/dist/components/split/split.stories.js +20 -0
- package/dist/components/stat/stat.d.ts +27 -1
- package/dist/components/stat/stat.js +56 -3
- package/dist/components/stat/stat.stories.d.ts +3 -0
- package/dist/components/stat/stat.stories.js +49 -1
- package/dist/components/stat/stat.styles.js +44 -0
- package/dist/components/table/table.d.ts +64 -2
- package/dist/components/table/table.js +213 -17
- package/dist/components/table/table.stories.d.ts +6 -0
- package/dist/components/table/table.stories.js +45 -0
- package/dist/components/table/table.styles.js +51 -4
- package/dist/components/time-range/time-range.d.ts +34 -5
- package/dist/components/time-range/time-range.js +217 -36
- package/dist/components/time-range/time-range.stories.d.ts +2 -0
- package/dist/components/time-range/time-range.stories.js +13 -0
- package/dist/components/time-range/time-range.styles.js +44 -2
- package/dist/components/toast/toast-item.d.ts +14 -2
- package/dist/components/toast/toast-item.js +134 -17
- package/dist/components/toast/toast-item.styles.js +28 -2
- package/dist/components/toast/toast.stories.d.ts +3 -0
- package/dist/components/toast/toast.stories.js +41 -1
- package/dist/components/toast/toast.styles.js +11 -11
- package/dist/components/toast/toaster.d.ts +1 -1
- package/dist/components/toast/toaster.js +13 -6
- package/dist/components/tree/tree-node.d.ts +13 -7
- package/dist/components/tree/tree-node.js +24 -10
- package/dist/components/tree/tree.d.ts +64 -15
- package/dist/components/tree/tree.js +113 -22
- package/dist/components/tree/tree.stories.d.ts +4 -0
- package/dist/components/tree/tree.stories.js +31 -1
- package/dist/components/tree/update-cascade.d.ts +13 -0
- package/dist/components/tree/update-cascade.js +13 -0
- package/dist/components/widget/widget.d.ts +15 -1
- package/dist/components/widget/widget.js +140 -5
- package/dist/components/widget/widget.stories.d.ts +1 -0
- package/dist/components/widget/widget.stories.js +10 -0
- package/dist/components/widget/widget.styles.js +11 -1
- package/dist/components/word-cloud/word-cloud-layout.d.ts +63 -0
- package/dist/components/word-cloud/word-cloud-layout.js +145 -0
- package/dist/components/word-cloud/word-cloud.d.ts +65 -0
- package/dist/components/word-cloud/word-cloud.js +255 -0
- package/dist/components/word-cloud/word-cloud.stories.d.ts +10 -0
- package/dist/components/word-cloud/word-cloud.stories.js +55 -0
- package/dist/components/word-cloud/word-cloud.styles.d.ts +1 -0
- package/dist/components/word-cloud/word-cloud.styles.js +54 -0
- package/dist/internal/a11y.js +2 -1
- package/dist/internal/rtl.d.ts +7 -0
- package/dist/internal/rtl.js +9 -0
- package/dist/lyra.d.ts +9 -1
- package/dist/lyra.js +6 -0
- package/llms-full.txt +892 -276
- package/llms.txt +12 -9
- package/package.json +6 -4
|
@@ -9,8 +9,45 @@ import { property, state } from 'lit/decorators.js';
|
|
|
9
9
|
import { LyraElement } from '../../internal/lyra-element.js';
|
|
10
10
|
import { defineElement } from '../../internal/prefix.js';
|
|
11
11
|
import { lockScroll } from '../../internal/scroll-lock.js';
|
|
12
|
+
import { nextId } from '../../internal/a11y.js';
|
|
12
13
|
import { chevronIcon, closeIcon, expandIcon } from '../../internal/icons.js';
|
|
13
14
|
import { styles } from './widget.styles.js';
|
|
15
|
+
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
16
|
+
// Shadow-piercing so a slotted custom element's real focusable target (e.g.
|
|
17
|
+
// an <input> inside its own shadow root) is found even though the host tag
|
|
18
|
+
// itself doesn't match FOCUSABLE_SELECTOR.
|
|
19
|
+
function collectFocusable(el) {
|
|
20
|
+
const result = [];
|
|
21
|
+
if (el.matches(FOCUSABLE_SELECTOR)) {
|
|
22
|
+
result.push(el);
|
|
23
|
+
}
|
|
24
|
+
if (el instanceof HTMLSlotElement) {
|
|
25
|
+
for (const assigned of el.assignedElements({ flatten: true })) {
|
|
26
|
+
result.push(...collectFocusable(assigned));
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
const container = el.shadowRoot ?? el;
|
|
31
|
+
for (const child of Array.from(container.children)) {
|
|
32
|
+
result.push(...collectFocusable(child));
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether `el` is actually laid out/paintable — used to drop e.g. a
|
|
38
|
+
* `[collapsed]` body's slotted content from the fullscreen tab order.
|
|
39
|
+
* `el.offsetParent !== null` is the usual shorthand for this, but it's
|
|
40
|
+
* unreliable here: it resolves `null` for an element whose nearest
|
|
41
|
+
* *positioned* ancestor (fullscreen's `[part="base"] { position: fixed }`)
|
|
42
|
+
* lives across a slot-projection boundary from the element itself (true for
|
|
43
|
+
* every element this scans, since they're all slotted content), even though
|
|
44
|
+
* the element is genuinely rendered. `checkVisibility()` (falling back to
|
|
45
|
+
* `getClientRects().length` on engines without it) correctly follows
|
|
46
|
+
* flattened-tree/slot assignment instead.
|
|
47
|
+
*/
|
|
48
|
+
function isRendered(el) {
|
|
49
|
+
return el.checkVisibility ? el.checkVisibility() : el.getClientRects().length > 0;
|
|
50
|
+
}
|
|
14
51
|
/**
|
|
15
52
|
* `<lyra-widget>` — a titled panel shell with an optional collapse toggle and
|
|
16
53
|
* an optional fullscreen-expand toggle. Fullscreen promotes the same host
|
|
@@ -22,7 +59,16 @@ import { styles } from './widget.styles.js';
|
|
|
22
59
|
* @slot actions - Header action controls, rendered before the collapse/expand buttons.
|
|
23
60
|
* @event lyra-collapse-change - `detail: boolean` (the new `collapsed` state).
|
|
24
61
|
* @event lyra-fullscreen-change - `detail: boolean` (the new `fullscreen` state).
|
|
25
|
-
* @csspart base
|
|
62
|
+
* @csspart base - The panel root (dialog role + backdrop when fullscreen).
|
|
63
|
+
* @csspart header - The header row containing the title, actions, and toggle buttons.
|
|
64
|
+
* @csspart title - The wrapper around the label/sublabel.
|
|
65
|
+
* @csspart label - The panel title text.
|
|
66
|
+
* @csspart sublabel - The panel subtitle text.
|
|
67
|
+
* @csspart actions - The wrapper around the `actions` slot.
|
|
68
|
+
* @csspart collapse-button - The collapse/expand toggle button.
|
|
69
|
+
* @csspart fullscreen-button - The fullscreen toggle button.
|
|
70
|
+
* @csspart body - The wrapper around the default slot (the panel body).
|
|
71
|
+
* @csspart backdrop - The fullscreen scrim behind the panel.
|
|
26
72
|
*/
|
|
27
73
|
export class LyraWidget extends LyraElement {
|
|
28
74
|
constructor() {
|
|
@@ -34,6 +80,7 @@ export class LyraWidget extends LyraElement {
|
|
|
34
80
|
this.expandable = false;
|
|
35
81
|
this.fullscreen = false;
|
|
36
82
|
this.hasActionsSlot = false;
|
|
83
|
+
this.bodyId = nextId('widget-body');
|
|
37
84
|
this.onActionsSlotChange = (e) => {
|
|
38
85
|
this.hasActionsSlot = e.target.assignedElements({ flatten: true }).length > 0;
|
|
39
86
|
};
|
|
@@ -50,11 +97,32 @@ export class LyraWidget extends LyraElement {
|
|
|
50
97
|
}
|
|
51
98
|
};
|
|
52
99
|
this.onDocKeyDown = (e) => {
|
|
53
|
-
if (
|
|
100
|
+
if (!this.fullscreen)
|
|
101
|
+
return;
|
|
102
|
+
if (e.key === 'Escape') {
|
|
54
103
|
e.preventDefault();
|
|
55
104
|
this.fullscreen = false;
|
|
56
105
|
this.emit('lyra-fullscreen-change', false);
|
|
57
106
|
this.lastTrigger?.focus();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (e.key !== 'Tab')
|
|
110
|
+
return;
|
|
111
|
+
const focusable = this.getFocusableElements();
|
|
112
|
+
if (focusable.length === 0) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const first = focusable[0];
|
|
117
|
+
const last = focusable[focusable.length - 1];
|
|
118
|
+
const active = this.getActiveElement();
|
|
119
|
+
if (e.shiftKey && active === first) {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
last.focus();
|
|
122
|
+
}
|
|
123
|
+
else if (!e.shiftKey && active === last) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
first.focus();
|
|
58
126
|
}
|
|
59
127
|
};
|
|
60
128
|
this.onBackdropClick = () => {
|
|
@@ -62,7 +130,6 @@ export class LyraWidget extends LyraElement {
|
|
|
62
130
|
return;
|
|
63
131
|
this.fullscreen = false;
|
|
64
132
|
this.emit('lyra-fullscreen-change', false);
|
|
65
|
-
document.removeEventListener('keydown', this.onDocKeyDown);
|
|
66
133
|
this.lastTrigger?.focus();
|
|
67
134
|
};
|
|
68
135
|
}
|
|
@@ -83,18 +150,85 @@ export class LyraWidget extends LyraElement {
|
|
|
83
150
|
}
|
|
84
151
|
}
|
|
85
152
|
}
|
|
153
|
+
// Runs after render (not willUpdate) so `[part="base"]`'s fullscreen-only
|
|
154
|
+
// tabindex has already landed in the DOM before the fallback .focus() call
|
|
155
|
+
// below can rely on it. WAI-ARIA APG's dialog pattern requires opening a
|
|
156
|
+
// modal to move focus inside it; without this it only happened to work
|
|
157
|
+
// because a mouse click natively focuses the button that triggered it --
|
|
158
|
+
// not true for a directly-set `fullscreen` property, and not guaranteed
|
|
159
|
+
// for every input method/browser even in the click case.
|
|
160
|
+
updated(changed) {
|
|
161
|
+
if (changed.has('fullscreen') && this.fullscreen) {
|
|
162
|
+
const first = this.getFocusableElements()[0];
|
|
163
|
+
if (first) {
|
|
164
|
+
first.focus();
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
this.shadowRoot?.querySelector('[part="base"]')?.focus();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
connectedCallback() {
|
|
172
|
+
super.connectedCallback();
|
|
173
|
+
// A reconnect (e.g. a drag-and-drop reparent that keeps this same
|
|
174
|
+
// element instance) fires disconnectedCallback then connectedCallback
|
|
175
|
+
// synchronously with no update in between, so willUpdate never reruns
|
|
176
|
+
// to notice `fullscreen` is still true. Restore the scroll lock/trap it
|
|
177
|
+
// dropped. `hasUpdated` excludes the initial mount, where willUpdate's
|
|
178
|
+
// first pass already establishes them from the starting property value.
|
|
179
|
+
if (this.hasUpdated && this.fullscreen && !this.releaseScrollLock) {
|
|
180
|
+
this.releaseScrollLock = lockScroll();
|
|
181
|
+
document.addEventListener('keydown', this.onDocKeyDown);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
86
184
|
disconnectedCallback() {
|
|
87
185
|
super.disconnectedCallback();
|
|
88
186
|
this.releaseScrollLock?.();
|
|
89
187
|
this.releaseScrollLock = undefined;
|
|
90
188
|
document.removeEventListener('keydown', this.onDocKeyDown);
|
|
91
189
|
}
|
|
190
|
+
// Bounds Tab/Shift+Tab to the panel while fullscreen (a modal presentation)
|
|
191
|
+
// so keyboard focus can't escape to page content hidden behind the
|
|
192
|
+
// backdrop. Order follows the header's actions slot, then the collapse/
|
|
193
|
+
// fullscreen buttons, then the body slot -- the same order the flattened
|
|
194
|
+
// tree already tabs through.
|
|
195
|
+
getFocusableElements() {
|
|
196
|
+
const root = this.shadowRoot;
|
|
197
|
+
if (!root)
|
|
198
|
+
return [];
|
|
199
|
+
const fromSlot = (selector) => {
|
|
200
|
+
const slot = root.querySelector(selector);
|
|
201
|
+
return slot ? slot.assignedElements({ flatten: true }).flatMap(collectFocusable) : [];
|
|
202
|
+
};
|
|
203
|
+
const shadowButtons = Array.from(root.querySelectorAll('[part="collapse-button"], [part="fullscreen-button"]'));
|
|
204
|
+
return [
|
|
205
|
+
...fromSlot('slot[name="actions"]'),
|
|
206
|
+
...shadowButtons,
|
|
207
|
+
...fromSlot('[part="body"] slot:not([name])'),
|
|
208
|
+
].filter(isRendered);
|
|
209
|
+
}
|
|
210
|
+
getActiveElement() {
|
|
211
|
+
let active = document.activeElement;
|
|
212
|
+
while (active) {
|
|
213
|
+
const inner = active.shadowRoot?.activeElement ?? null;
|
|
214
|
+
if (!inner)
|
|
215
|
+
break;
|
|
216
|
+
active = inner;
|
|
217
|
+
}
|
|
218
|
+
return active;
|
|
219
|
+
}
|
|
92
220
|
render() {
|
|
93
221
|
const hasLabel = this.label.length > 0;
|
|
94
222
|
const hasSublabel = this.sublabel.length > 0;
|
|
95
223
|
return html `
|
|
96
224
|
${this.fullscreen ? html `<div part="backdrop" @click=${this.onBackdropClick}></div>` : nothing}
|
|
97
|
-
<div
|
|
225
|
+
<div
|
|
226
|
+
part="base"
|
|
227
|
+
role=${this.fullscreen ? 'dialog' : nothing}
|
|
228
|
+
aria-modal=${this.fullscreen ? 'true' : nothing}
|
|
229
|
+
aria-label=${this.fullscreen ? (hasLabel ? this.label : 'Fullscreen panel') : nothing}
|
|
230
|
+
tabindex=${this.fullscreen ? '-1' : nothing}
|
|
231
|
+
>
|
|
98
232
|
<div part="header">
|
|
99
233
|
<div part="title">
|
|
100
234
|
${hasLabel ? html `<span part="label">${this.label}</span>` : nothing}
|
|
@@ -109,6 +243,7 @@ export class LyraWidget extends LyraElement {
|
|
|
109
243
|
type="button"
|
|
110
244
|
aria-expanded=${this.collapsed ? 'false' : 'true'}
|
|
111
245
|
aria-label=${this.collapsed ? 'Expand panel' : 'Collapse panel'}
|
|
246
|
+
aria-controls=${this.bodyId}
|
|
112
247
|
@click=${this.toggleCollapsed}
|
|
113
248
|
>
|
|
114
249
|
<span style="display:inline-flex;transform:rotate(${this.collapsed ? '0deg' : '90deg'})"
|
|
@@ -128,7 +263,7 @@ export class LyraWidget extends LyraElement {
|
|
|
128
263
|
</button>`
|
|
129
264
|
: nothing}
|
|
130
265
|
</div>
|
|
131
|
-
<div part="body" ?hidden=${this.collapsed}>
|
|
266
|
+
<div part="body" id=${this.bodyId} ?hidden=${this.collapsed}>
|
|
132
267
|
<slot></slot>
|
|
133
268
|
</div>
|
|
134
269
|
</div>
|
|
@@ -29,3 +29,13 @@ export const CollapsedInitially = {
|
|
|
29
29
|
</lyra-widget>
|
|
30
30
|
`,
|
|
31
31
|
};
|
|
32
|
+
export const FullscreenInitially = {
|
|
33
|
+
render: () => html `
|
|
34
|
+
<lyra-widget label="Load profile" sublabel="Last 7 days" expandable fullscreen style="max-width: 28rem;">
|
|
35
|
+
<span slot="actions"><button>Refresh</button></span>
|
|
36
|
+
<div style="padding: 1rem;">
|
|
37
|
+
<p style="margin: 0;">Rendered already fullscreen — backdrop, fixed panel, and dialog semantics.</p>
|
|
38
|
+
</div>
|
|
39
|
+
</lyra-widget>
|
|
40
|
+
`,
|
|
41
|
+
};
|
|
@@ -2,6 +2,10 @@ import { css } from 'lit';
|
|
|
2
2
|
export const styles = css `
|
|
3
3
|
:host {
|
|
4
4
|
display: block;
|
|
5
|
+
/* Fullscreen scrim color -- component-specific so a host can retheme it
|
|
6
|
+
without a raw literal leaking into the public API (no shared
|
|
7
|
+
--wa-*-overlay token exists in the design system to resolve through). */
|
|
8
|
+
--lyra-widget-overlay-color: rgb(0 0 0 / 0.5);
|
|
5
9
|
}
|
|
6
10
|
[part='base'] {
|
|
7
11
|
display: flex;
|
|
@@ -74,7 +78,7 @@ export const styles = css `
|
|
|
74
78
|
[part='backdrop'] {
|
|
75
79
|
position: fixed;
|
|
76
80
|
inset: 0;
|
|
77
|
-
background:
|
|
81
|
+
background: var(--lyra-widget-overlay-color);
|
|
78
82
|
z-index: 999;
|
|
79
83
|
}
|
|
80
84
|
:host([fullscreen]) [part='base'] {
|
|
@@ -87,4 +91,10 @@ export const styles = css `
|
|
|
87
91
|
overflow: auto;
|
|
88
92
|
block-size: 100%;
|
|
89
93
|
}
|
|
94
|
+
@media (prefers-reduced-motion: reduce) {
|
|
95
|
+
[part='collapse-button'],
|
|
96
|
+
[part='fullscreen-button'] {
|
|
97
|
+
transition: none !important;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
90
100
|
`;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** One word to place, before layout. */
|
|
2
|
+
export interface WordCloudWord {
|
|
3
|
+
text: string;
|
|
4
|
+
weight: number;
|
|
5
|
+
color?: string;
|
|
6
|
+
group?: string;
|
|
7
|
+
}
|
|
8
|
+
/** A word after layout — its original data (weight untouched, even if it was
|
|
9
|
+
* negative/non-finite in the input — see `effectiveWeight` internally) plus
|
|
10
|
+
* computed geometry. */
|
|
11
|
+
export interface PlacedWord extends WordCloudWord {
|
|
12
|
+
/** Index of this word in the original (pre-sort) `words` array — stable
|
|
13
|
+
* across layout re-runs, so callers can key a color/group map off it
|
|
14
|
+
* instead of off placement order (which is sorted by weight). */
|
|
15
|
+
originalIndex: number;
|
|
16
|
+
/** Center x, in an unbounded coordinate space centered on the origin. */
|
|
17
|
+
x: number;
|
|
18
|
+
/** Center y, in the same space as `x`. */
|
|
19
|
+
y: number;
|
|
20
|
+
fontSize: number;
|
|
21
|
+
/** `true` if rotated 90°. */
|
|
22
|
+
rotated: boolean;
|
|
23
|
+
/** Unrotated rendered width, in px. */
|
|
24
|
+
width: number;
|
|
25
|
+
/** Unrotated rendered height, in px (== fontSize; layout doesn't model ascent/descent). */
|
|
26
|
+
height: number;
|
|
27
|
+
}
|
|
28
|
+
export interface WordCloudLayoutOptions {
|
|
29
|
+
minFontSize: number;
|
|
30
|
+
maxFontSize: number;
|
|
31
|
+
scale: 'linear' | 'sqrt';
|
|
32
|
+
orientations: 'horizontal' | 'mixed';
|
|
33
|
+
/** Measures the rendered width of `text` set at `fontSize`, e.g. via a canvas 2D
|
|
34
|
+
* context — the font string passed to the context must match the actual
|
|
35
|
+
* rendered `[part="word"]` font (weight included), or collision boxes end
|
|
36
|
+
* up narrower than what's actually painted. */
|
|
37
|
+
measureText: (text: string, fontSize: number) => number;
|
|
38
|
+
/** `[0, 1)` — defaults to `Math.random`; inject a stub for deterministic tests. */
|
|
39
|
+
random?: () => number;
|
|
40
|
+
}
|
|
41
|
+
export interface WordCloudLayoutResult {
|
|
42
|
+
placed: PlacedWord[];
|
|
43
|
+
/** Words left out of `placed`, for any of three reasons: blank/whitespace-only
|
|
44
|
+
* `text`; capacity overflow (input has more than `MAX_WORDS` entries, so the
|
|
45
|
+
* lowest-weight excess is dropped, regardless of where it fell in the input
|
|
46
|
+
* array); or the spiral search exhausted its radius bound (pathological
|
|
47
|
+
* inputs only, e.g. one huge word repeated many times). */
|
|
48
|
+
skipped: WordCloudWord[];
|
|
49
|
+
/** Gap-padded bounding box of `placed` — 0 if nothing was placed. */
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
}
|
|
53
|
+
/** DOM-node/compute-time safety cap — mirrors lyra-sparkline's `MAX_BARS`. */
|
|
54
|
+
export declare const MAX_WORDS = 150;
|
|
55
|
+
/** Minimum gap enforced both between placed words and around the returned bounding box. */
|
|
56
|
+
export declare const GAP = 3;
|
|
57
|
+
/**
|
|
58
|
+
* Places `words` via an Archimedean-spiral search (the standard word-cloud
|
|
59
|
+
* layout heuristic — heaviest words placed first, each one walking an
|
|
60
|
+
* outward spiral from the center until it finds a gap that doesn't overlap
|
|
61
|
+
* any word already placed). Deterministic given a deterministic `random`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function layoutWordCloud(words: WordCloudWord[], options: WordCloudLayoutOptions): WordCloudLayoutResult;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** DOM-node/compute-time safety cap — mirrors lyra-sparkline's `MAX_BARS`. */
|
|
2
|
+
export const MAX_WORDS = 150;
|
|
3
|
+
/** Minimum gap enforced both between placed words and around the returned bounding box. */
|
|
4
|
+
export const GAP = 3;
|
|
5
|
+
const ROTATE_PROBABILITY = 0.25;
|
|
6
|
+
const ANGLE_STEP = 0.15;
|
|
7
|
+
const RADIUS_STEP_PER_RADIAN = 3;
|
|
8
|
+
/** Multiplier on the summed word-box area used to bound the spiral search —
|
|
9
|
+
* generous enough that only a pathological input (e.g. one giant word
|
|
10
|
+
* repeated many times) ever exhausts it. */
|
|
11
|
+
const MAX_RADIUS_AREA_FACTOR = 6;
|
|
12
|
+
/** Clamps a (possibly negative/non-finite) input weight for scale math only —
|
|
13
|
+
* never fed back to callers, who should still see their own original `weight`. */
|
|
14
|
+
function effectiveWeight(weight) {
|
|
15
|
+
return Number.isFinite(weight) ? Math.max(0, weight) : 0;
|
|
16
|
+
}
|
|
17
|
+
function scaledWeight(weight, minWeight, maxWeight, scale) {
|
|
18
|
+
const span = maxWeight - minWeight || 1;
|
|
19
|
+
const t = Math.min(1, Math.max(0, (weight - minWeight) / span));
|
|
20
|
+
return scale === 'sqrt' ? Math.sqrt(t) : t;
|
|
21
|
+
}
|
|
22
|
+
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
|
|
23
|
+
return Math.abs(ax - bx) < (aw + bw) / 2 + GAP && Math.abs(ay - by) < (ah + bh) / 2 + GAP;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Places `words` via an Archimedean-spiral search (the standard word-cloud
|
|
27
|
+
* layout heuristic — heaviest words placed first, each one walking an
|
|
28
|
+
* outward spiral from the center until it finds a gap that doesn't overlap
|
|
29
|
+
* any word already placed). Deterministic given a deterministic `random`.
|
|
30
|
+
*/
|
|
31
|
+
export function layoutWordCloud(words, options) {
|
|
32
|
+
const { minFontSize, maxFontSize, scale, orientations, measureText } = options;
|
|
33
|
+
const random = options.random ?? Math.random;
|
|
34
|
+
const skipped = [];
|
|
35
|
+
const decorated = words
|
|
36
|
+
.map((w, originalIndex) => ({ ...w, originalIndex }))
|
|
37
|
+
.filter((w) => {
|
|
38
|
+
const blank = w.text.trim().length === 0;
|
|
39
|
+
if (blank)
|
|
40
|
+
skipped.push(w);
|
|
41
|
+
return !blank;
|
|
42
|
+
});
|
|
43
|
+
// Cap placement eligibility by actual weight (heaviest MAX_WORDS survive),
|
|
44
|
+
// not by array position — a heavy word late in the input must not be
|
|
45
|
+
// dropped just because lighter words happened to come first.
|
|
46
|
+
const byWeightDesc = [...decorated].sort((a, b) => effectiveWeight(b.weight) - effectiveWeight(a.weight));
|
|
47
|
+
const eligible = byWeightDesc.slice(0, MAX_WORDS);
|
|
48
|
+
skipped.push(...byWeightDesc.slice(MAX_WORDS));
|
|
49
|
+
if (eligible.length === 0)
|
|
50
|
+
return { placed: [], skipped, width: 0, height: 0 };
|
|
51
|
+
let minWeight = effectiveWeight(eligible[0].weight);
|
|
52
|
+
let maxWeight = minWeight;
|
|
53
|
+
for (const w of eligible) {
|
|
54
|
+
const ew = effectiveWeight(w.weight);
|
|
55
|
+
if (ew < minWeight)
|
|
56
|
+
minWeight = ew;
|
|
57
|
+
if (ew > maxWeight)
|
|
58
|
+
maxWeight = ew;
|
|
59
|
+
}
|
|
60
|
+
// eligible is already weight-descending (a suffix of byWeightDesc), which
|
|
61
|
+
// is also the placement order the algorithm wants (heaviest first).
|
|
62
|
+
const placed = [];
|
|
63
|
+
let totalArea = 0;
|
|
64
|
+
for (const word of eligible) {
|
|
65
|
+
const t = scaledWeight(effectiveWeight(word.weight), minWeight, maxWeight, scale);
|
|
66
|
+
const fontSize = minFontSize + t * (maxFontSize - minFontSize);
|
|
67
|
+
const measuredWidth = Math.max(1, measureText(word.text, fontSize));
|
|
68
|
+
const measuredHeight = fontSize;
|
|
69
|
+
const rotated = orientations === 'mixed' && random() < ROTATE_PROBABILITY;
|
|
70
|
+
const boxW = rotated ? measuredHeight : measuredWidth;
|
|
71
|
+
const boxH = rotated ? measuredWidth : measuredHeight;
|
|
72
|
+
totalArea += boxW * boxH;
|
|
73
|
+
const maxRadius = Math.sqrt(totalArea * MAX_RADIUS_AREA_FACTOR) + Math.max(boxW, boxH);
|
|
74
|
+
let theta = 0;
|
|
75
|
+
let radius = 0;
|
|
76
|
+
let x = 0;
|
|
77
|
+
let y = 0;
|
|
78
|
+
let foundSpot = placed.length === 0;
|
|
79
|
+
while (!foundSpot && radius < maxRadius) {
|
|
80
|
+
x = radius * Math.cos(theta);
|
|
81
|
+
y = radius * Math.sin(theta);
|
|
82
|
+
let collides = false;
|
|
83
|
+
for (const other of placed) {
|
|
84
|
+
const otherW = other.rotated ? other.height : other.width;
|
|
85
|
+
const otherH = other.rotated ? other.width : other.height;
|
|
86
|
+
if (rectsOverlap(x, y, boxW, boxH, other.x, other.y, otherW, otherH)) {
|
|
87
|
+
collides = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!collides) {
|
|
92
|
+
foundSpot = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
theta += ANGLE_STEP;
|
|
96
|
+
radius += RADIUS_STEP_PER_RADIAN * ANGLE_STEP;
|
|
97
|
+
}
|
|
98
|
+
if (!foundSpot) {
|
|
99
|
+
skipped.push(word);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
placed.push({
|
|
103
|
+
...word,
|
|
104
|
+
x,
|
|
105
|
+
y,
|
|
106
|
+
fontSize,
|
|
107
|
+
rotated,
|
|
108
|
+
width: measuredWidth,
|
|
109
|
+
height: measuredHeight,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (placed.length === 0)
|
|
113
|
+
return { placed, skipped, width: 0, height: 0 };
|
|
114
|
+
let minX = Infinity;
|
|
115
|
+
let maxX = -Infinity;
|
|
116
|
+
let minY = Infinity;
|
|
117
|
+
let maxY = -Infinity;
|
|
118
|
+
for (const p of placed) {
|
|
119
|
+
const w = p.rotated ? p.height : p.width;
|
|
120
|
+
const h = p.rotated ? p.width : p.height;
|
|
121
|
+
minX = Math.min(minX, p.x - w / 2);
|
|
122
|
+
maxX = Math.max(maxX, p.x + w / 2);
|
|
123
|
+
minY = Math.min(minY, p.y - h / 2);
|
|
124
|
+
maxY = Math.max(maxY, p.y + h / 2);
|
|
125
|
+
}
|
|
126
|
+
// Pad the bounding box itself by GAP too, matching the same margin already
|
|
127
|
+
// enforced between words (see rectsOverlap) — otherwise the outermost
|
|
128
|
+
// words sit flush against the SVG's own edge with no room for error.
|
|
129
|
+
minX -= GAP;
|
|
130
|
+
minY -= GAP;
|
|
131
|
+
maxX += GAP;
|
|
132
|
+
maxY += GAP;
|
|
133
|
+
// Shift every word into a top-left-origin coordinate space so callers can
|
|
134
|
+
// render straight into `viewBox="0 0 width height"` with no further offset math.
|
|
135
|
+
for (const p of placed) {
|
|
136
|
+
p.x -= minX;
|
|
137
|
+
p.y -= minY;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
placed,
|
|
141
|
+
skipped,
|
|
142
|
+
width: maxX - minX,
|
|
143
|
+
height: maxY - minY,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type PropertyValues, type TemplateResult } from 'lit';
|
|
2
|
+
import { LyraElement } from '../../internal/lyra-element.js';
|
|
3
|
+
import { type WordCloudWord } from './word-cloud-layout.js';
|
|
4
|
+
export type { WordCloudWord };
|
|
5
|
+
/**
|
|
6
|
+
* `<lyra-word-cloud>` — a zero-dependency SVG word/tag cloud. First-party
|
|
7
|
+
* invention (no Web Awesome equivalent). Each word's rendered size is scaled
|
|
8
|
+
* from its `weight` and placed via an outward Archimedean-spiral search (the
|
|
9
|
+
* standard word-cloud heuristic: heaviest words placed first, each one
|
|
10
|
+
* spiraling out from the center until it clears every word already placed).
|
|
11
|
+
*
|
|
12
|
+
* Unlike sibling `lyra-sparkline`/`lyra-heatmap` (one `role="img"` glyph
|
|
13
|
+
* standing in for an aggregate value), the individual words here are the
|
|
14
|
+
* meaningful interactive content — but with up to `MAX_WORDS` of them, making
|
|
15
|
+
* every single one its own tab stop would be a poor keyboard experience.
|
|
16
|
+
* Instead, like `lyra-heatmap`'s cells, the whole `[part="svg"]` is one tab
|
|
17
|
+
* stop with roving arrow-key focus (Home/End jump to the first/last word,
|
|
18
|
+
* Enter/Space activates the focused one), a drawn `[part="focus-ring"]`, and
|
|
19
|
+
* a visually-hidden `aria-live="polite"` status announcement.
|
|
20
|
+
*
|
|
21
|
+
* @customElement lyra-word-cloud
|
|
22
|
+
* @event lyra-word-click - Fired on click, or Enter/Space on the focused word.
|
|
23
|
+
* `detail: { text, weight, group }`.
|
|
24
|
+
* @csspart base, svg, word, focus-ring, live-region, empty
|
|
25
|
+
*/
|
|
26
|
+
export declare class LyraWordCloud extends LyraElement {
|
|
27
|
+
static styles: import("lit").CSSResultGroup[];
|
|
28
|
+
/** The words to lay out. Re-laid-out whenever this (or a sizing property) changes. */
|
|
29
|
+
words: WordCloudWord[];
|
|
30
|
+
/** Font size, in px, for the lowest-weight word. */
|
|
31
|
+
minFontSize: number;
|
|
32
|
+
/** Font size, in px, for the highest-weight word. */
|
|
33
|
+
maxFontSize: number;
|
|
34
|
+
/** `sqrt` compresses the weight->font-size mapping so one heavy word doesn't dwarf the rest. */
|
|
35
|
+
scale: 'linear' | 'sqrt';
|
|
36
|
+
/** `mixed` lets some words render rotated 90° for denser packing. */
|
|
37
|
+
orientations: 'horizontal' | 'mixed';
|
|
38
|
+
/** Custom categorical palette, cycled by word index (or by `group`, see `words`). Defaults to the `--lyra-word-cloud-color-*` tokens. */
|
|
39
|
+
palette?: string[];
|
|
40
|
+
private svgEl?;
|
|
41
|
+
private cachedLayout;
|
|
42
|
+
/** Roving-focus cursor -- an index into `navOrder()`, not into `cachedLayout.placed`. */
|
|
43
|
+
private focusedIndex;
|
|
44
|
+
/** Text of the visually-hidden `aria-live="polite"` status announcement. */
|
|
45
|
+
private liveText;
|
|
46
|
+
private measureText;
|
|
47
|
+
private fontFamily;
|
|
48
|
+
private paletteColors;
|
|
49
|
+
/** Stable keyboard tab order -- the order words were declared in `words`,
|
|
50
|
+
* independent of the weight-sorted placement order. */
|
|
51
|
+
private navOrder;
|
|
52
|
+
protected willUpdate(changed: PropertyValues): void;
|
|
53
|
+
private activate;
|
|
54
|
+
private announce;
|
|
55
|
+
private onWordClick;
|
|
56
|
+
private onKeyDown;
|
|
57
|
+
/** Axis-aligned focus-ring rect for `w`, already accounting for its rotation. */
|
|
58
|
+
private focusRingRect;
|
|
59
|
+
render(): TemplateResult;
|
|
60
|
+
}
|
|
61
|
+
declare global {
|
|
62
|
+
interface HTMLElementTagNameMap {
|
|
63
|
+
'lyra-word-cloud': LyraWordCloud;
|
|
64
|
+
}
|
|
65
|
+
}
|