@aceshooting/lyra-ui 0.1.1 → 0.1.3

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.
Files changed (117) hide show
  1. package/README.md +77 -6
  2. package/custom-elements.json +12015 -0
  3. package/dist/components/chart/bar-chart.d.ts +14 -0
  4. package/dist/components/chart/bar-chart.js +14 -0
  5. package/dist/components/chart/box-plot.d.ts +46 -0
  6. package/dist/components/chart/box-plot.js +145 -0
  7. package/dist/components/chart/box-plot.styles.d.ts +1 -0
  8. package/dist/components/chart/box-plot.styles.js +22 -0
  9. package/dist/components/chart/bubble-chart.d.ts +19 -0
  10. package/dist/components/chart/bubble-chart.js +18 -0
  11. package/dist/components/chart/chart-loader.d.ts +8 -0
  12. package/dist/components/chart/chart-loader.js +26 -0
  13. package/dist/components/chart/chart.d.ts +91 -0
  14. package/dist/components/chart/chart.js +275 -0
  15. package/dist/components/chart/chart.styles.d.ts +1 -0
  16. package/dist/components/chart/chart.styles.js +33 -0
  17. package/dist/components/chart/doughnut-chart.d.ts +15 -0
  18. package/dist/components/chart/doughnut-chart.js +14 -0
  19. package/dist/components/chart/histogram-bin.d.ts +6 -0
  20. package/dist/components/chart/histogram-bin.js +19 -0
  21. package/dist/components/chart/histogram.d.ts +20 -0
  22. package/dist/components/chart/histogram.js +70 -0
  23. package/dist/components/chart/histogram.styles.d.ts +1 -0
  24. package/dist/components/chart/histogram.styles.js +1 -0
  25. package/dist/components/chart/line-chart.d.ts +14 -0
  26. package/dist/components/chart/line-chart.js +14 -0
  27. package/dist/components/chart/pie-chart.d.ts +16 -0
  28. package/dist/components/chart/pie-chart.js +15 -0
  29. package/dist/components/chart/polar-area-chart.d.ts +15 -0
  30. package/dist/components/chart/polar-area-chart.js +14 -0
  31. package/dist/components/chart/radar-chart.d.ts +15 -0
  32. package/dist/components/chart/radar-chart.js +14 -0
  33. package/dist/components/chart/scatter-chart.d.ts +15 -0
  34. package/dist/components/chart/scatter-chart.js +15 -0
  35. package/dist/components/combobox/combobox.d.ts +16 -0
  36. package/dist/components/combobox/combobox.js +113 -6
  37. package/dist/components/combobox/combobox.styles.js +68 -7
  38. package/dist/components/combobox/option.d.ts +2 -2
  39. package/dist/components/combobox/option.js +11 -7
  40. package/dist/components/date-picker/date-input.d.ts +12 -1
  41. package/dist/components/date-picker/date-input.js +68 -6
  42. package/dist/components/date-picker/date-input.styles.js +58 -6
  43. package/dist/components/date-picker/date-picker.js +5 -2
  44. package/dist/components/date-picker/date-picker.styles.js +9 -2
  45. package/dist/components/empty/empty.d.ts +1 -0
  46. package/dist/components/empty/empty.js +14 -2
  47. package/dist/components/export-button/csv.d.ts +10 -0
  48. package/dist/components/export-button/csv.js +28 -0
  49. package/dist/components/export-button/export-button.d.ts +41 -0
  50. package/dist/components/export-button/export-button.js +151 -0
  51. package/dist/components/export-button/export-button.styles.d.ts +1 -0
  52. package/dist/components/export-button/export-button.styles.js +79 -0
  53. package/dist/components/file-input/accept.d.ts +14 -0
  54. package/dist/components/file-input/accept.js +30 -0
  55. package/dist/components/file-input/file-input.d.ts +47 -0
  56. package/dist/components/file-input/file-input.js +182 -0
  57. package/dist/components/file-input/file-input.styles.d.ts +1 -0
  58. package/dist/components/file-input/file-input.styles.js +44 -0
  59. package/dist/components/flag/flag.d.ts +4 -0
  60. package/dist/components/flag/flag.js +24 -1
  61. package/dist/components/flag/flag.styles.js +1 -1
  62. package/dist/components/gauge/gauge.d.ts +29 -0
  63. package/dist/components/gauge/gauge.js +133 -0
  64. package/dist/components/gauge/gauge.styles.d.ts +1 -0
  65. package/dist/components/gauge/gauge.styles.js +60 -0
  66. package/dist/components/graph/graph.d.ts +68 -0
  67. package/dist/components/graph/graph.js +276 -0
  68. package/dist/components/graph/graph.styles.d.ts +1 -0
  69. package/dist/components/graph/graph.styles.js +34 -0
  70. package/dist/components/heatmap/heatmap-scale.d.ts +8 -0
  71. package/dist/components/heatmap/heatmap-scale.js +17 -0
  72. package/dist/components/heatmap/heatmap.d.ts +66 -0
  73. package/dist/components/heatmap/heatmap.js +252 -0
  74. package/dist/components/heatmap/heatmap.styles.d.ts +1 -0
  75. package/dist/components/heatmap/heatmap.styles.js +39 -0
  76. package/dist/components/map/map-loader.d.ts +8 -0
  77. package/dist/components/map/map-loader.js +17 -0
  78. package/dist/components/map/map.d.ts +49 -0
  79. package/dist/components/map/map.js +189 -0
  80. package/dist/components/map/map.styles.d.ts +1 -0
  81. package/dist/components/map/map.styles.js +48 -0
  82. package/dist/components/playback/playback.d.ts +51 -0
  83. package/dist/components/playback/playback.js +146 -0
  84. package/dist/components/playback/playback.styles.d.ts +1 -0
  85. package/dist/components/playback/playback.styles.js +40 -0
  86. package/dist/components/split/split.d.ts +41 -0
  87. package/dist/components/split/split.js +171 -0
  88. package/dist/components/split/split.styles.d.ts +1 -0
  89. package/dist/components/split/split.styles.js +51 -0
  90. package/dist/components/stat/stat.d.ts +9 -0
  91. package/dist/components/stat/stat.js +52 -7
  92. package/dist/components/stat/stat.styles.js +15 -5
  93. package/dist/components/table/table.d.ts +18 -6
  94. package/dist/components/table/table.js +76 -24
  95. package/dist/components/table/table.styles.js +32 -0
  96. package/dist/components/time-range/time-range.d.ts +40 -0
  97. package/dist/components/time-range/time-range.js +219 -0
  98. package/dist/components/time-range/time-range.styles.d.ts +1 -0
  99. package/dist/components/time-range/time-range.styles.js +78 -0
  100. package/dist/components/toast/toast-item.js +10 -2
  101. package/dist/components/toast/toast-item.styles.js +13 -4
  102. package/dist/components/toast/toast.styles.js +0 -1
  103. package/dist/components/tree/tree-node.d.ts +50 -0
  104. package/dist/components/tree/tree-node.js +196 -0
  105. package/dist/components/tree/tree.d.ts +65 -0
  106. package/dist/components/tree/tree.js +227 -0
  107. package/dist/components/tree/tree.styles.d.ts +1 -0
  108. package/dist/components/tree/tree.styles.js +13 -0
  109. package/dist/internal/form-associated.js +49 -1
  110. package/dist/internal/icons.d.ts +11 -0
  111. package/dist/internal/icons.js +64 -0
  112. package/dist/internal/tokens.styles.js +41 -1
  113. package/dist/lyra.d.ts +67 -0
  114. package/dist/lyra.js +55 -0
  115. package/llms-full.txt +1425 -0
  116. package/llms.txt +47 -0
  117. package/package.json +53 -4
@@ -0,0 +1,171 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { html } from 'lit';
8
+ import { property, state } from 'lit/decorators.js';
9
+ import { LyraElement } from '../../internal/lyra-element.js';
10
+ import { defineElement } from '../../internal/prefix.js';
11
+ import { styles } from './split.styles.js';
12
+ const KEYBOARD_STEP = 2;
13
+ /**
14
+ * `<lyra-split>` — resizable panels for dashboard layouts. Direct light-DOM
15
+ * children are the panels; a divider is auto-inserted between each pair.
16
+ *
17
+ * @customElement lyra-split
18
+ * @event lyra-resize - `detail: { sizes }`, fired on every drag step/release
19
+ * and every keyboard step.
20
+ * @csspart base, divider
21
+ */
22
+ export class LyraSplit extends LyraElement {
23
+ constructor() {
24
+ super(...arguments);
25
+ this.sizes = [];
26
+ this.min = 10;
27
+ this.orientation = 'horizontal';
28
+ this.panelCount = 0;
29
+ this.dragIndex = -1;
30
+ this.dragStartPos = 0;
31
+ this.dragStartSizes = [];
32
+ this.onPointerDown = (e, index) => {
33
+ this.dragIndex = index;
34
+ this.dragStartPos = this.orientation === 'vertical' ? e.clientY : e.clientX;
35
+ this.dragStartSizes = [...this.sizes];
36
+ e.target.setPointerCapture(e.pointerId);
37
+ window.addEventListener('pointermove', this.onPointerMove);
38
+ window.addEventListener('pointerup', this.onPointerUp);
39
+ };
40
+ this.onPointerMove = (e) => {
41
+ if (this.dragIndex < 0)
42
+ return;
43
+ const base = this.renderRoot.querySelector('[part="base"]');
44
+ const total = this.orientation === 'vertical' ? base.clientHeight : base.clientWidth;
45
+ const pos = this.orientation === 'vertical' ? e.clientY : e.clientX;
46
+ const deltaPercent = ((pos - this.dragStartPos) / total) * 100;
47
+ this.sizes = this.clampPair(this.dragStartSizes, this.dragIndex, deltaPercent);
48
+ this.emit('lyra-resize', { sizes: [...this.sizes] });
49
+ };
50
+ this.onPointerUp = () => {
51
+ this.dragIndex = -1;
52
+ this.persist();
53
+ window.removeEventListener('pointermove', this.onPointerMove);
54
+ window.removeEventListener('pointerup', this.onPointerUp);
55
+ };
56
+ this.onDividerKeyDown = (e, index) => {
57
+ const forwardKey = this.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
58
+ const backwardKey = this.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
59
+ if (e.key === forwardKey) {
60
+ e.preventDefault();
61
+ this.applyDelta(index, KEYBOARD_STEP, true);
62
+ }
63
+ else if (e.key === backwardKey) {
64
+ e.preventDefault();
65
+ this.applyDelta(index, -KEYBOARD_STEP, true);
66
+ }
67
+ };
68
+ }
69
+ static { this.styles = [LyraElement.styles, styles]; }
70
+ connectedCallback() {
71
+ super.connectedCallback();
72
+ this.panelCount = this.children.length;
73
+ this.ensureSizes();
74
+ this.loadPersisted();
75
+ }
76
+ disconnectedCallback() {
77
+ super.disconnectedCallback();
78
+ // Clean up any remaining event listeners
79
+ window.removeEventListener('pointermove', this.onPointerMove);
80
+ window.removeEventListener('pointerup', this.onPointerUp);
81
+ }
82
+ get storageFullKey() {
83
+ return this.storageKey ? `lyra-split:${this.storageKey}:${this.panelCount}` : undefined;
84
+ }
85
+ loadPersisted() {
86
+ const key = this.storageFullKey;
87
+ if (!key)
88
+ return;
89
+ const raw = localStorage.getItem(key);
90
+ if (!raw)
91
+ return;
92
+ try {
93
+ const parsed = JSON.parse(raw);
94
+ if (Array.isArray(parsed) && parsed.length === this.panelCount)
95
+ this.sizes = parsed;
96
+ }
97
+ catch {
98
+ /* ignore malformed persisted state */
99
+ }
100
+ }
101
+ persist() {
102
+ const key = this.storageFullKey;
103
+ if (key)
104
+ localStorage.setItem(key, JSON.stringify(this.sizes));
105
+ }
106
+ ensureSizes() {
107
+ if (this.sizes.length === this.panelCount && this.panelCount > 0)
108
+ return;
109
+ const equal = 100 / Math.max(1, this.panelCount);
110
+ this.sizes = Array.from({ length: this.panelCount }, () => equal);
111
+ }
112
+ clampPair(sizes, i, delta) {
113
+ const next = [...sizes];
114
+ const a = next[i] + delta;
115
+ const b = next[i + 1] - delta;
116
+ if (a < this.min || b < this.min)
117
+ return sizes;
118
+ next[i] = a;
119
+ next[i + 1] = b;
120
+ return next;
121
+ }
122
+ applyDelta(index, delta, commit) {
123
+ this.sizes = this.clampPair(this.sizes, index, delta);
124
+ this.emit('lyra-resize', { sizes: [...this.sizes] });
125
+ if (commit)
126
+ this.persist();
127
+ }
128
+ updated(changed) {
129
+ if (changed.has('sizes') || this.sizes.length === 0)
130
+ this.ensureSizes();
131
+ const panels = [...this.children];
132
+ panels.forEach((panel, i) => {
133
+ panel.style.flex = `0 0 ${this.sizes[i] ?? 0}%`;
134
+ panel.style.order = String(i * 2);
135
+ });
136
+ }
137
+ render() {
138
+ const dividers = [];
139
+ for (let i = 0; i < this.panelCount - 1; i++) {
140
+ dividers.push(html `<div
141
+ part="divider"
142
+ role="separator"
143
+ aria-orientation=${this.orientation === 'vertical' ? 'horizontal' : 'vertical'}
144
+ aria-valuenow=${Math.round(this.sizes[i] ?? 0)}
145
+ aria-valuemin=${this.min}
146
+ aria-valuemax=${100 - this.min}
147
+ tabindex="0"
148
+ style=${`order:${i * 2 + 1}`}
149
+ @pointerdown=${(e) => this.onPointerDown(e, i)}
150
+ @keydown=${(e) => this.onDividerKeyDown(e, i)}
151
+ ></div>`);
152
+ }
153
+ return html `<div part="base"><slot></slot>${dividers}</div>`;
154
+ }
155
+ }
156
+ __decorate([
157
+ property({ attribute: false })
158
+ ], LyraSplit.prototype, "sizes", void 0);
159
+ __decorate([
160
+ property({ type: Number })
161
+ ], LyraSplit.prototype, "min", void 0);
162
+ __decorate([
163
+ property({ reflect: true })
164
+ ], LyraSplit.prototype, "orientation", void 0);
165
+ __decorate([
166
+ property({ attribute: 'storage-key' })
167
+ ], LyraSplit.prototype, "storageKey", void 0);
168
+ __decorate([
169
+ state()
170
+ ], LyraSplit.prototype, "panelCount", void 0);
171
+ defineElement('split', LyraSplit);
@@ -0,0 +1 @@
1
+ export declare const styles: import("lit").CSSResult;
@@ -0,0 +1,51 @@
1
+ import { css } from 'lit';
2
+ export const styles = css `
3
+ :host {
4
+ display: block;
5
+ inline-size: 100%;
6
+ block-size: 100%;
7
+ }
8
+ [part='base'] {
9
+ display: flex;
10
+ inline-size: 100%;
11
+ block-size: 100%;
12
+ }
13
+ :host([orientation='vertical']) [part='base'] {
14
+ flex-direction: column;
15
+ }
16
+ [part='divider'] {
17
+ position: relative;
18
+ flex: 0 0 auto;
19
+ inline-size: 3px;
20
+ block-size: auto;
21
+ background: var(--lyra-color-border);
22
+ cursor: col-resize;
23
+ touch-action: none;
24
+ }
25
+ /* Transparent hit-slop: widens the draggable/tappable box along the resize
26
+ axis only, without changing the divider's visible 3px width. Generated
27
+ content is part of the originating element's hit-test box, so pointer
28
+ events here still resolve e.target to [part="divider"] itself. */
29
+ [part='divider']::before {
30
+ content: '';
31
+ position: absolute;
32
+ inset-block: 0;
33
+ inset-inline: -6px;
34
+ }
35
+ :host([orientation='vertical']) [part='divider'] {
36
+ inline-size: auto;
37
+ block-size: 3px;
38
+ cursor: row-resize;
39
+ }
40
+ :host([orientation='vertical']) [part='divider']::before {
41
+ inset-block: -6px;
42
+ inset-inline: 0;
43
+ }
44
+ [part='divider']:hover {
45
+ background: var(--lyra-color-brand);
46
+ }
47
+ [part='divider']:focus-visible {
48
+ outline: var(--lyra-focus-ring-width) solid var(--lyra-focus-ring-color);
49
+ outline-offset: var(--lyra-focus-ring-offset);
50
+ }
51
+ `;
@@ -1,6 +1,7 @@
1
1
  import { type TemplateResult } from 'lit';
2
2
  import { LyraElement } from '../../internal/lyra-element.js';
3
3
  export type StatVariant = 'neutral' | 'success' | 'warning' | 'danger';
4
+ export type StatGoodDirection = 'up' | 'down';
4
5
  /**
5
6
  * `<lyra-stat>` — a KPI/stat card. First-party invention consolidating the
6
7
  * "metric row" / "KPI card" pattern reinvented in every surveyed repo.
@@ -18,6 +19,14 @@ export declare class LyraStat extends LyraElement {
18
19
  variant: StatVariant;
19
20
  trend: number;
20
21
  caption: string;
22
+ /** Which trend direction counts as "good" — inverts arrow/color polarity for
23
+ * cost/latency/error-rate-style metrics where a decrease is the win. */
24
+ goodDirection: StatGoodDirection;
25
+ private hasIcon;
26
+ private hasCaptionSlot;
27
+ protected willUpdate(): void;
28
+ private onIconSlotChange;
29
+ private onCaptionSlotChange;
21
30
  render(): TemplateResult;
22
31
  }
23
32
  declare global {
@@ -5,9 +5,10 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  import { html, nothing } from 'lit';
8
- import { property } from 'lit/decorators.js';
8
+ 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
+ import { chevronIcon } from '../../internal/icons.js';
11
12
  import { styles } from './stat.styles.js';
12
13
  /**
13
14
  * `<lyra-stat>` — a KPI/stat card. First-party invention consolidating the
@@ -27,26 +28,61 @@ export class LyraStat extends LyraElement {
27
28
  this.variant = 'neutral';
28
29
  this.trend = NaN;
29
30
  this.caption = '';
31
+ /** Which trend direction counts as "good" — inverts arrow/color polarity for
32
+ * cost/latency/error-rate-style metrics where a decrease is the win. */
33
+ this.goodDirection = 'up';
34
+ // Same fix `lyra-empty` already established: `[part]:empty` never matches
35
+ // because the part always contains a literal `<slot>` child. Track real
36
+ // slot assignment in JS instead (2026-07-10 audit, "dashboard-atoms"
37
+ // §lyra-stat, High).
38
+ this.hasIcon = false;
39
+ this.hasCaptionSlot = false;
40
+ this.onIconSlotChange = (e) => {
41
+ this.hasIcon = e.target.assignedElements({ flatten: true }).length > 0;
42
+ };
43
+ this.onCaptionSlotChange = (e) => {
44
+ this.hasCaptionSlot = e.target.assignedElements({ flatten: true }).length > 0;
45
+ };
30
46
  }
31
47
  static { this.styles = [LyraElement.styles, styles]; }
48
+ willUpdate() {
49
+ if (!this.hasUpdated) {
50
+ this.hasIcon = Array.from(this.children).some((el) => !el.hasAttribute('slot'));
51
+ this.hasCaptionSlot = Array.from(this.children).some((el) => el.getAttribute('slot') === 'caption');
52
+ }
53
+ }
32
54
  render() {
33
55
  const hasTrend = !isNaN(this.trend);
34
- const direction = this.trend > 0 ? 'up' : this.trend < 0 ? 'down' : 'flat';
35
- const arrow = direction === 'up' ? '▲' : direction === 'down' ? '▼' : '–';
56
+ const rawDirection = this.trend > 0 ? 'up' : this.trend < 0 ? 'down' : 'flat';
57
+ const isGood = rawDirection === 'flat' ? null : rawDirection === this.goodDirection;
58
+ // 2026-07-10 design review, "dashboard-atoms" §lyra-stat: the trend
59
+ // pill rendered literal ▲/▼ glyphs — font-dependent and inconsistent
60
+ // with the rest of the icon set — swapped for the shared chevronIcon(),
61
+ // rotated per direction via CSS on the wrapping [part='trend'].
62
+ const arrow = rawDirection === 'flat' ? '–' : chevronIcon();
63
+ const hasCaption = this.hasCaptionSlot || this.caption.length > 0;
36
64
  return html `
37
65
  <div part="base">
38
- <span part="icon"><slot></slot></span>
66
+ <span part="icon" ?hidden=${!this.hasIcon}
67
+ ><slot @slotchange=${this.onIconSlotChange}></slot
68
+ ></span>
39
69
  <span part="label">${this.label}</span>
40
70
  <div part="value-row">
41
71
  <span part="value">${this.value}</span>
42
72
  <span part="unit">${this.unit}</span>
43
73
  </div>
44
74
  ${hasTrend
45
- ? html `<span part="trend" data-direction=${direction}>
46
- ${arrow} ${Math.abs(this.trend)}%
75
+ ? html `<span
76
+ part="trend"
77
+ data-direction=${rawDirection}
78
+ data-polarity=${isGood == null ? nothing : isGood ? 'good' : 'bad'}
79
+ >
80
+ <span aria-hidden="true">${arrow}</span> ${Math.abs(this.trend)}%
47
81
  </span>`
48
82
  : nothing}
49
- <div part="caption">${this.caption}<slot name="caption"></slot></div>
83
+ <div part="caption" ?hidden=${!hasCaption}>
84
+ <slot name="caption" @slotchange=${this.onCaptionSlotChange}>${this.caption}</slot>
85
+ </div>
50
86
  </div>
51
87
  `;
52
88
  }
@@ -69,4 +105,13 @@ __decorate([
69
105
  __decorate([
70
106
  property()
71
107
  ], LyraStat.prototype, "caption", void 0);
108
+ __decorate([
109
+ property({ attribute: 'good-direction' })
110
+ ], LyraStat.prototype, "goodDirection", void 0);
111
+ __decorate([
112
+ state()
113
+ ], LyraStat.prototype, "hasIcon", void 0);
114
+ __decorate([
115
+ state()
116
+ ], LyraStat.prototype, "hasCaptionSlot", void 0);
72
117
  defineElement('stat', LyraStat);
@@ -15,7 +15,7 @@ export const styles = css `
15
15
  [part='icon'] {
16
16
  color: var(--lyra-color-text-quiet);
17
17
  }
18
- [part='icon']:empty {
18
+ [part='icon'][hidden] {
19
19
  display: none;
20
20
  }
21
21
  [part='label'] {
@@ -42,18 +42,28 @@ export const styles = css `
42
42
  [part='trend'] {
43
43
  display: inline-flex;
44
44
  align-items: center;
45
- gap: 0.2rem;
45
+ gap: var(--lyra-space-xs);
46
46
  font-size: 0.8125rem;
47
47
  font-weight: 600;
48
48
  border-radius: var(--lyra-radius);
49
+ /* 0.05rem/0.4rem don't cleanly map to any --lyra-space-* step (the
50
+ smallest, --lyra-space-xs, is 0.25rem): rounding the vertical value
51
+ up to xs would 5x this chip's padding and blow out the compact
52
+ trend-chip look, so both stay literal here. */
49
53
  padding: 0.05rem 0.4rem;
50
54
  align-self: flex-start;
51
55
  }
52
- [part='trend'][data-direction='up'] {
56
+ [part='trend'][data-direction='up'] svg {
57
+ transform: rotate(-90deg);
58
+ }
59
+ [part='trend'][data-direction='down'] svg {
60
+ transform: rotate(90deg);
61
+ }
62
+ [part='trend'][data-polarity='good'] {
53
63
  color: var(--lyra-color-success);
54
64
  background: color-mix(in srgb, var(--lyra-color-success) 8%, transparent);
55
65
  }
56
- [part='trend'][data-direction='down'] {
66
+ [part='trend'][data-polarity='bad'] {
57
67
  color: var(--lyra-color-danger);
58
68
  background: color-mix(in srgb, var(--lyra-color-danger) 8%, transparent);
59
69
  }
@@ -70,7 +80,7 @@ export const styles = css `
70
80
  font-size: 0.8125rem;
71
81
  color: var(--lyra-color-text-quiet);
72
82
  }
73
- [part='caption']:empty {
83
+ [part='caption'][hidden] {
74
84
  display: none;
75
85
  }
76
86
  `;
@@ -1,4 +1,4 @@
1
- import { type TemplateResult } from 'lit';
1
+ import { type TemplateResult, type PropertyValues } from 'lit';
2
2
  import { LyraElement } from '../../internal/lyra-element.js';
3
3
  import '../empty/empty.js';
4
4
  export interface TableColumn<T> {
@@ -9,15 +9,19 @@ export interface TableColumn<T> {
9
9
  cell: (row: T) => unknown;
10
10
  }
11
11
  /**
12
- * `<lyra-table>` — a presentational, sort/select-aware data table. The host
13
- * owns actual sorting/filtering/pagination of `rows`; this component renders
14
- * and emits intents (`lyra-sort`, `lyra-row-click`, `lyra-load-more`).
12
+ * `<lyra-table>` — a presentational, sort/select-aware data table.
13
+ *
14
+ * Header/row activation is delegated: one `click` and one `keydown`
15
+ * listener on `<table>` resolve the target via `closest('[data-col-key]'
16
+ * | '[data-row-key]')` and a key→object lookup map, instead of allocating
17
+ * fresh per-column/per-row closures on every render (2026-07-10 audit,
18
+ * "dashboard-atoms" §lyra-table, Medium).
15
19
  *
16
20
  * @customElement lyra-table
17
21
  * @event lyra-sort - A sortable header was activated. `detail: { key }`.
18
22
  * @event lyra-row-click - A row was activated. `detail: { row }`.
19
23
  * @event lyra-load-more - The "load more" control was activated.
20
- * @csspart base, table, head, header-cell, row, cell, more-button
24
+ * @csspart base, table, head, header-cell, row, cell, more-button, sort-icon
21
25
  */
22
26
  export declare class LyraTable<T = unknown> extends LyraElement {
23
27
  static styles: import("lit").CSSResultGroup[];
@@ -31,8 +35,16 @@ export declare class LyraTable<T = unknown> extends LyraElement {
31
35
  moreLabel: string;
32
36
  emptyHeading: string;
33
37
  emptyDescription: string;
38
+ noColumnsHeading: string;
39
+ noColumnsDescription: string;
40
+ private rowsByKey;
41
+ private columnsByKey;
34
42
  private keyOf;
35
- private onHeaderClick;
43
+ protected willUpdate(changed: PropertyValues): void;
44
+ private activateColumn;
45
+ private activateRow;
46
+ private onTableClick;
47
+ private onTableKeyDown;
36
48
  render(): TemplateResult;
37
49
  }
38
50
  declare global {
@@ -10,17 +10,22 @@ import { repeat } from 'lit/directives/repeat.js';
10
10
  import { LyraElement } from '../../internal/lyra-element.js';
11
11
  import { defineElement } from '../../internal/prefix.js';
12
12
  import { styles } from './table.styles.js';
13
+ import { chevronIcon } from '../../internal/icons.js';
13
14
  import '../empty/empty.js';
14
15
  /**
15
- * `<lyra-table>` — a presentational, sort/select-aware data table. The host
16
- * owns actual sorting/filtering/pagination of `rows`; this component renders
17
- * and emits intents (`lyra-sort`, `lyra-row-click`, `lyra-load-more`).
16
+ * `<lyra-table>` — a presentational, sort/select-aware data table.
17
+ *
18
+ * Header/row activation is delegated: one `click` and one `keydown`
19
+ * listener on `<table>` resolve the target via `closest('[data-col-key]'
20
+ * | '[data-row-key]')` and a key→object lookup map, instead of allocating
21
+ * fresh per-column/per-row closures on every render (2026-07-10 audit,
22
+ * "dashboard-atoms" §lyra-table, Medium).
18
23
  *
19
24
  * @customElement lyra-table
20
25
  * @event lyra-sort - A sortable header was activated. `detail: { key }`.
21
26
  * @event lyra-row-click - A row was activated. `detail: { row }`.
22
27
  * @event lyra-load-more - The "load more" control was activated.
23
- * @csspart base, table, head, header-cell, row, cell, more-button
28
+ * @csspart base, table, head, header-cell, row, cell, more-button, sort-icon
24
29
  */
25
30
  export class LyraTable extends LyraElement {
26
31
  constructor() {
@@ -34,17 +39,64 @@ export class LyraTable extends LyraElement {
34
39
  this.moreLabel = 'Load more';
35
40
  this.emptyHeading = 'No data';
36
41
  this.emptyDescription = '';
42
+ this.noColumnsHeading = 'No columns configured';
43
+ this.noColumnsDescription = '';
44
+ this.rowsByKey = new Map();
45
+ this.columnsByKey = new Map();
46
+ this.onTableClick = (e) => {
47
+ const target = e.target;
48
+ const th = target.closest('[data-col-key]');
49
+ if (th)
50
+ return this.activateColumn(th.dataset.colKey);
51
+ const tr = target.closest('[data-row-key]');
52
+ if (tr)
53
+ this.activateRow(tr.dataset.rowKey);
54
+ };
55
+ this.onTableKeyDown = (e) => {
56
+ if (e.key !== 'Enter' && e.key !== ' ')
57
+ return;
58
+ const target = e.target;
59
+ const th = target.closest('[data-col-key]');
60
+ if (th) {
61
+ e.preventDefault();
62
+ return this.activateColumn(th.dataset.colKey);
63
+ }
64
+ const tr = target.closest('[data-row-key]');
65
+ if (tr) {
66
+ e.preventDefault();
67
+ this.activateRow(tr.dataset.rowKey);
68
+ }
69
+ };
37
70
  }
38
71
  static { this.styles = [LyraElement.styles, styles]; }
39
72
  keyOf(row, index) {
40
73
  return this.rowKey ? this.rowKey(row) : index;
41
74
  }
42
- onHeaderClick(col) {
43
- if (!col.sortable)
44
- return;
45
- this.emit('lyra-sort', { key: col.key });
75
+ willUpdate(changed) {
76
+ if (changed.has('rows') || changed.has('rowKey')) {
77
+ this.rowsByKey = new Map(this.rows.map((row, i) => [String(this.keyOf(row, i)), row]));
78
+ }
79
+ if (changed.has('columns')) {
80
+ this.columnsByKey = new Map(this.columns.map((c) => [c.key, c]));
81
+ }
82
+ }
83
+ activateColumn(key) {
84
+ const col = this.columnsByKey.get(key);
85
+ if (col?.sortable)
86
+ this.emit('lyra-sort', { key: col.key });
87
+ }
88
+ activateRow(key) {
89
+ const row = this.rowsByKey.get(key);
90
+ if (row !== undefined)
91
+ this.emit('lyra-row-click', { row });
46
92
  }
47
93
  render() {
94
+ if (this.columns.length === 0) {
95
+ return html `<lyra-empty
96
+ heading=${this.noColumnsHeading}
97
+ description=${this.noColumnsDescription}
98
+ ></lyra-empty>`;
99
+ }
48
100
  if (this.rows.length === 0) {
49
101
  return html `<lyra-empty
50
102
  heading=${this.emptyHeading}
@@ -53,28 +105,28 @@ export class LyraTable extends LyraElement {
53
105
  }
54
106
  return html `
55
107
  <div part="base">
56
- <table part="table" role="grid">
108
+ <table part="table" role="grid" @click=${this.onTableClick} @keydown=${this.onTableKeyDown}>
57
109
  <thead part="head">
58
110
  <tr role="row">
59
111
  ${this.columns.map((col) => {
60
- const ariaSort = this.sortKey === col.key ? (this.sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
112
+ const active = Boolean(col.sortable) && this.sortKey === col.key;
113
+ const ariaSort = active ? (this.sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
61
114
  return html `<th
62
115
  part="header-cell"
63
116
  role="columnheader"
64
117
  scope="col"
118
+ data-col-key=${col.key}
65
119
  data-align=${col.align ?? 'start'}
66
120
  ?data-sortable=${col.sortable}
67
121
  aria-sort=${col.sortable ? ariaSort : nothing}
68
122
  tabindex=${col.sortable ? '0' : '-1'}
69
- @click=${() => this.onHeaderClick(col)}
70
- @keydown=${(e) => {
71
- if (e.key === 'Enter' || e.key === ' ') {
72
- e.preventDefault();
73
- this.onHeaderClick(col);
74
- }
75
- }}
76
123
  >
77
124
  ${col.label}
125
+ ${active
126
+ ? html `<span part="sort-icon" data-dir=${this.sortDir} aria-hidden="true"
127
+ >${chevronIcon()}</span
128
+ >`
129
+ : nothing}
78
130
  </th>`;
79
131
  })}
80
132
  </tr>
@@ -86,15 +138,9 @@ export class LyraTable extends LyraElement {
86
138
  return html `<tr
87
139
  part="row"
88
140
  role="row"
141
+ data-row-key=${String(key)}
89
142
  aria-selected=${selected ? 'true' : 'false'}
90
143
  tabindex="0"
91
- @click=${() => this.emit('lyra-row-click', { row })}
92
- @keydown=${(e) => {
93
- if (e.key === 'Enter' || e.key === ' ') {
94
- e.preventDefault();
95
- this.emit('lyra-row-click', { row });
96
- }
97
- }}
98
144
  >
99
145
  ${this.columns.map((col) => html `<td part="cell" role="gridcell" data-align=${col.align ?? 'start'}>
100
146
  ${col.cell(row)}
@@ -146,4 +192,10 @@ __decorate([
146
192
  __decorate([
147
193
  property({ attribute: 'empty-description' })
148
194
  ], LyraTable.prototype, "emptyDescription", void 0);
195
+ __decorate([
196
+ property({ attribute: 'no-columns-heading' })
197
+ ], LyraTable.prototype, "noColumnsHeading", void 0);
198
+ __decorate([
199
+ property({ attribute: 'no-columns-description' })
200
+ ], LyraTable.prototype, "noColumnsDescription", void 0);
149
201
  defineElement('table', LyraTable);
@@ -33,15 +33,43 @@ export const styles = css `
33
33
  [part='header-cell'][data-sortable]:hover {
34
34
  background: var(--lyra-color-brand-quiet);
35
35
  }
36
+ [part='header-cell'][data-sortable]:focus-visible {
37
+ outline: var(--lyra-focus-ring-width) solid var(--lyra-focus-ring-color);
38
+ outline-offset: var(--lyra-focus-ring-offset);
39
+ }
36
40
  [part='header-cell'][data-align='end'] {
37
41
  text-align: end;
38
42
  }
43
+ [part='sort-icon'] {
44
+ display: inline-block;
45
+ margin-inline-start: var(--lyra-space-xs);
46
+ vertical-align: middle;
47
+ transition: transform var(--lyra-transition-fast);
48
+ }
49
+ [part='sort-icon'] svg {
50
+ display: block;
51
+ }
52
+ /* Rotate the wrapping part element, not the svg — internal/icons.ts's
53
+ documented contract ("callers ... rotate the wrapping part element via
54
+ CSS transform: rotate(...), not the svg"). Design-review finding on
55
+ Task 3 (lyra-table sort indicator): this previously rotated the inner
56
+ <svg> directly. */
57
+ [part='sort-icon'][data-dir='asc'] {
58
+ transform: rotate(-90deg);
59
+ }
60
+ [part='sort-icon'][data-dir='desc'] {
61
+ transform: rotate(90deg);
62
+ }
39
63
  [part='row']:hover {
40
64
  background: var(--lyra-color-brand-quiet);
41
65
  }
42
66
  [part='row'][aria-selected='true'] {
43
67
  background: var(--lyra-color-brand-quiet);
44
68
  }
69
+ [part='row']:focus-visible {
70
+ outline: var(--lyra-focus-ring-width) solid var(--lyra-focus-ring-color);
71
+ outline-offset: var(--lyra-focus-ring-offset);
72
+ }
45
73
  [part='cell'] {
46
74
  padding: var(--lyra-space-s);
47
75
  border-block-end: 1px solid var(--lyra-color-border);
@@ -63,4 +91,8 @@ export const styles = css `
63
91
  [part='more-button']:hover {
64
92
  background: var(--lyra-color-brand-quiet);
65
93
  }
94
+ [part='more-button']:focus-visible {
95
+ outline: var(--lyra-focus-ring-width) solid var(--lyra-focus-ring-color);
96
+ outline-offset: var(--lyra-focus-ring-offset);
97
+ }
66
98
  `;