@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.
Files changed (148) hide show
  1. package/README.md +8 -9
  2. package/custom-elements.json +12479 -6535
  3. package/dist/components/chart/chart.d.ts +46 -1
  4. package/dist/components/chart/chart.js +148 -10
  5. package/dist/components/chart/chart.stories.d.ts +32 -0
  6. package/dist/components/chart/chart.stories.js +141 -0
  7. package/dist/components/chart/chart.styles.js +16 -0
  8. package/dist/components/chart/histogram.d.ts +2 -0
  9. package/dist/components/chart/histogram.js +18 -2
  10. package/dist/components/chart/line-chart.stories.d.ts +17 -0
  11. package/dist/components/chart/line-chart.stories.js +75 -0
  12. package/dist/components/chart/lite-chart.d.ts +74 -0
  13. package/dist/components/chart/lite-chart.js +384 -0
  14. package/dist/components/chart/lite-chart.stories.d.ts +12 -0
  15. package/dist/components/chart/lite-chart.stories.js +98 -0
  16. package/dist/components/chart/lite-chart.styles.d.ts +1 -0
  17. package/dist/components/chart/lite-chart.styles.js +83 -0
  18. package/dist/components/combobox/combobox.d.ts +9 -0
  19. package/dist/components/combobox/combobox.js +75 -12
  20. package/dist/components/combobox/combobox.stories.d.ts +18 -0
  21. package/dist/components/combobox/combobox.stories.js +75 -0
  22. package/dist/components/date-picker/calendar-core.js +13 -2
  23. package/dist/components/date-picker/date-input.d.ts +9 -1
  24. package/dist/components/date-picker/date-input.js +55 -7
  25. package/dist/components/date-picker/date-input.stories.d.ts +5 -0
  26. package/dist/components/date-picker/date-input.stories.js +36 -0
  27. package/dist/components/date-picker/date-picker.d.ts +11 -3
  28. package/dist/components/date-picker/date-picker.js +87 -37
  29. package/dist/components/date-picker/date-picker.stories.d.ts +4 -0
  30. package/dist/components/date-picker/date-picker.stories.js +12 -0
  31. package/dist/components/date-picker/date-picker.styles.js +1 -1
  32. package/dist/components/empty/empty.d.ts +12 -1
  33. package/dist/components/empty/empty.js +20 -3
  34. package/dist/components/empty/empty.stories.d.ts +2 -0
  35. package/dist/components/empty/empty.stories.js +20 -0
  36. package/dist/components/empty/empty.styles.js +11 -0
  37. package/dist/components/export-button/csv.js +6 -2
  38. package/dist/components/export-button/export-button.d.ts +15 -1
  39. package/dist/components/export-button/export-button.js +89 -12
  40. package/dist/components/export-button/export-button.stories.d.ts +1 -0
  41. package/dist/components/export-button/export-button.stories.js +5 -0
  42. package/dist/components/export-button/export-button.styles.js +11 -3
  43. package/dist/components/file-input/accept.d.ts +8 -2
  44. package/dist/components/file-input/accept.js +9 -3
  45. package/dist/components/file-input/file-input.d.ts +5 -2
  46. package/dist/components/file-input/file-input.js +23 -8
  47. package/dist/components/file-input/file-input.stories.d.ts +2 -0
  48. package/dist/components/file-input/file-input.stories.js +9 -0
  49. package/dist/components/flag/flag.d.ts +48 -3
  50. package/dist/components/flag/flag.js +65 -29
  51. package/dist/components/flag/flag.styles.js +1 -1
  52. package/dist/components/flag/language-map.d.ts +11 -0
  53. package/dist/components/flag/language-map.js +12 -1
  54. package/dist/components/gauge/gauge.d.ts +2 -0
  55. package/dist/components/gauge/gauge.js +19 -5
  56. package/dist/components/gauge/gauge.stories.d.ts +2 -0
  57. package/dist/components/gauge/gauge.stories.js +6 -0
  58. package/dist/components/graph/graph-loader.d.ts +28 -0
  59. package/dist/components/graph/graph-loader.js +44 -0
  60. package/dist/components/graph/graph.d.ts +41 -15
  61. package/dist/components/graph/graph.js +146 -71
  62. package/dist/components/graph/graph.stories.d.ts +3 -0
  63. package/dist/components/graph/graph.stories.js +38 -0
  64. package/dist/components/graph/graph.styles.js +6 -1
  65. package/dist/components/heatmap/calendar-grid.d.ts +7 -0
  66. package/dist/components/heatmap/calendar-grid.js +27 -10
  67. package/dist/components/heatmap/heatmap.d.ts +137 -12
  68. package/dist/components/heatmap/heatmap.js +427 -27
  69. package/dist/components/heatmap/heatmap.stories.d.ts +17 -0
  70. package/dist/components/heatmap/heatmap.stories.js +130 -0
  71. package/dist/components/heatmap/heatmap.styles.js +60 -2
  72. package/dist/components/map/map.d.ts +17 -1
  73. package/dist/components/map/map.js +68 -21
  74. package/dist/components/map/map.stories.d.ts +10 -0
  75. package/dist/components/map/map.stories.js +107 -12
  76. package/dist/components/map/map.styles.js +2 -2
  77. package/dist/components/playback/playback.d.ts +6 -2
  78. package/dist/components/playback/playback.js +36 -12
  79. package/dist/components/playback/playback.stories.d.ts +2 -0
  80. package/dist/components/playback/playback.stories.js +6 -0
  81. package/dist/components/playback/playback.styles.js +10 -2
  82. package/dist/components/select/select.d.ts +122 -0
  83. package/dist/components/select/select.js +516 -0
  84. package/dist/components/select/select.stories.d.ts +19 -0
  85. package/dist/components/select/select.stories.js +75 -0
  86. package/dist/components/select/select.styles.d.ts +1 -0
  87. package/dist/components/select/select.styles.js +188 -0
  88. package/dist/components/skeleton/skeleton.js +4 -7
  89. package/dist/components/sparkline/sparkline.d.ts +1 -1
  90. package/dist/components/sparkline/sparkline.js +40 -13
  91. package/dist/components/sparkline/sparkline.stories.d.ts +4 -0
  92. package/dist/components/sparkline/sparkline.stories.js +22 -0
  93. package/dist/components/split/split.d.ts +24 -3
  94. package/dist/components/split/split.js +155 -31
  95. package/dist/components/split/split.stories.d.ts +2 -0
  96. package/dist/components/split/split.stories.js +20 -0
  97. package/dist/components/stat/stat.d.ts +27 -1
  98. package/dist/components/stat/stat.js +56 -3
  99. package/dist/components/stat/stat.stories.d.ts +3 -0
  100. package/dist/components/stat/stat.stories.js +49 -1
  101. package/dist/components/stat/stat.styles.js +44 -0
  102. package/dist/components/table/table.d.ts +64 -2
  103. package/dist/components/table/table.js +213 -17
  104. package/dist/components/table/table.stories.d.ts +6 -0
  105. package/dist/components/table/table.stories.js +45 -0
  106. package/dist/components/table/table.styles.js +51 -4
  107. package/dist/components/time-range/time-range.d.ts +34 -5
  108. package/dist/components/time-range/time-range.js +217 -36
  109. package/dist/components/time-range/time-range.stories.d.ts +2 -0
  110. package/dist/components/time-range/time-range.stories.js +13 -0
  111. package/dist/components/time-range/time-range.styles.js +44 -2
  112. package/dist/components/toast/toast-item.d.ts +14 -2
  113. package/dist/components/toast/toast-item.js +134 -17
  114. package/dist/components/toast/toast-item.styles.js +28 -2
  115. package/dist/components/toast/toast.stories.d.ts +3 -0
  116. package/dist/components/toast/toast.stories.js +41 -1
  117. package/dist/components/toast/toast.styles.js +11 -11
  118. package/dist/components/toast/toaster.d.ts +1 -1
  119. package/dist/components/toast/toaster.js +13 -6
  120. package/dist/components/tree/tree-node.d.ts +13 -7
  121. package/dist/components/tree/tree-node.js +24 -10
  122. package/dist/components/tree/tree.d.ts +64 -15
  123. package/dist/components/tree/tree.js +113 -22
  124. package/dist/components/tree/tree.stories.d.ts +4 -0
  125. package/dist/components/tree/tree.stories.js +31 -1
  126. package/dist/components/tree/update-cascade.d.ts +13 -0
  127. package/dist/components/tree/update-cascade.js +13 -0
  128. package/dist/components/widget/widget.d.ts +15 -1
  129. package/dist/components/widget/widget.js +140 -5
  130. package/dist/components/widget/widget.stories.d.ts +1 -0
  131. package/dist/components/widget/widget.stories.js +10 -0
  132. package/dist/components/widget/widget.styles.js +11 -1
  133. package/dist/components/word-cloud/word-cloud-layout.d.ts +63 -0
  134. package/dist/components/word-cloud/word-cloud-layout.js +145 -0
  135. package/dist/components/word-cloud/word-cloud.d.ts +65 -0
  136. package/dist/components/word-cloud/word-cloud.js +255 -0
  137. package/dist/components/word-cloud/word-cloud.stories.d.ts +10 -0
  138. package/dist/components/word-cloud/word-cloud.stories.js +55 -0
  139. package/dist/components/word-cloud/word-cloud.styles.d.ts +1 -0
  140. package/dist/components/word-cloud/word-cloud.styles.js +54 -0
  141. package/dist/internal/a11y.js +2 -1
  142. package/dist/internal/rtl.d.ts +7 -0
  143. package/dist/internal/rtl.js +9 -0
  144. package/dist/lyra.d.ts +9 -1
  145. package/dist/lyra.js +6 -0
  146. package/llms-full.txt +892 -276
  147. package/llms.txt +12 -9
  148. package/package.json +6 -4
@@ -5,10 +5,11 @@ 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, query } from 'lit/decorators.js';
9
9
  import { LyraElement } from '../../internal/lyra-element.js';
10
10
  import { defineElement } from '../../internal/prefix.js';
11
11
  import { place } from '../../internal/positioner.js';
12
+ import { nextId } from '../../internal/a11y.js';
12
13
  import { buildCsv, downloadBlob } from './csv.js';
13
14
  import { styles } from './export-button.styles.js';
14
15
  /**
@@ -20,7 +21,9 @@ import { styles } from './export-button.styles.js';
20
21
  * @event lyra-export - `detail: { format }`, cancelable — call `preventDefault()`
21
22
  * to substitute the built-in client-side download with a server-generated one.
22
23
  * @event lyra-export-complete - Fired after a non-cancelled download completes.
23
- * @csspart trigger, menu, menu-item
24
+ * @csspart trigger - The button that triggers the export (or opens the format menu).
25
+ * @csspart menu - The format-choice menu, shown when more than one format is configured.
26
+ * @csspart menu-item - A single format option inside the menu.
24
27
  */
25
28
  export class LyraExportButton extends LyraElement {
26
29
  constructor() {
@@ -32,15 +35,47 @@ export class LyraExportButton extends LyraElement {
32
35
  this.disabled = false;
33
36
  this.label = 'Export';
34
37
  this.open = false;
38
+ this.menuId = nextId('export-menu');
39
+ /** Which menu item to focus the next time `open` flips true; reset after use. */
40
+ this.pendingMenuFocusIndex = 0;
35
41
  this.onDocPointer = (e) => {
36
42
  if (!e.composedPath().includes(this))
37
43
  this.closeMenu();
38
44
  };
39
45
  this.onKeyDown = (e) => {
40
- if (e.key === 'Escape' && this.open) {
41
- e.preventDefault();
42
- this.closeMenu();
43
- this.renderRoot.querySelector('[part="trigger"]')?.focus();
46
+ if (e.key === 'Escape') {
47
+ if (this.open) {
48
+ e.preventDefault();
49
+ this.closeMenu();
50
+ this.triggerEl?.focus();
51
+ }
52
+ return;
53
+ }
54
+ if (this.formats.length <= 1 || this.disabled)
55
+ return;
56
+ const items = this.menuItemEls();
57
+ const currentIndex = items.indexOf(e.target);
58
+ switch (e.key) {
59
+ case 'ArrowDown':
60
+ e.preventDefault();
61
+ this.focusMenuItemOnOpen(currentIndex === -1 ? 0 : currentIndex + 1);
62
+ break;
63
+ case 'ArrowUp':
64
+ e.preventDefault();
65
+ this.focusMenuItemOnOpen(currentIndex === -1 ? items.length - 1 : currentIndex - 1);
66
+ break;
67
+ case 'Home':
68
+ if (this.open) {
69
+ e.preventDefault();
70
+ this.focusMenuItem(0);
71
+ }
72
+ break;
73
+ case 'End':
74
+ if (this.open) {
75
+ e.preventDefault();
76
+ this.focusMenuItem(items.length - 1);
77
+ }
78
+ break;
44
79
  }
45
80
  };
46
81
  }
@@ -60,9 +95,29 @@ export class LyraExportButton extends LyraElement {
60
95
  return;
61
96
  this.open = false;
62
97
  }
98
+ menuItemEls() {
99
+ return Array.from(this.renderRoot.querySelectorAll('[part="menu-item"]'));
100
+ }
101
+ /** Focuses the menu item at `index` (clamped), if the menu is already open and rendered. */
102
+ focusMenuItem(index) {
103
+ const items = this.menuItemEls();
104
+ if (items.length === 0)
105
+ return;
106
+ items[Math.max(0, Math.min(items.length - 1, index))]?.focus();
107
+ }
108
+ /** Opens the menu (if closed) and focuses `index`, or moves focus there directly if already open. */
109
+ focusMenuItemOnOpen(index) {
110
+ if (this.open) {
111
+ this.focusMenuItem(index);
112
+ }
113
+ else {
114
+ this.pendingMenuFocusIndex = index;
115
+ this.openMenu();
116
+ }
117
+ }
63
118
  firstUpdated() {
64
- // Single delegated listener catches Escape from the trigger button or
65
- // any menu-item inside this shadow root.
119
+ // Single delegated listener catches Escape/Arrow/Home/End from the
120
+ // trigger button or any menu-item inside this shadow root.
66
121
  this.renderRoot.addEventListener('keydown', this.onKeyDown);
67
122
  }
68
123
  updated(changed) {
@@ -76,16 +131,31 @@ export class LyraExportButton extends LyraElement {
76
131
  // property), which bypasses openMenu() entirely.
77
132
  document.removeEventListener('pointerdown', this.onDocPointer);
78
133
  if (this.open) {
79
- const anchor = this.renderRoot.querySelector('[part="trigger"]');
80
- const menu = this.renderRoot.querySelector('[part="menu"]');
134
+ const anchor = this.triggerEl;
135
+ const menu = this.menuEl;
81
136
  if (anchor && menu)
82
137
  this.cleanup = place(anchor, menu);
83
138
  document.addEventListener('pointerdown', this.onDocPointer);
139
+ this.focusMenuItem(this.pendingMenuFocusIndex);
140
+ this.pendingMenuFocusIndex = 0;
84
141
  }
85
142
  }
86
143
  }
144
+ /** Applies the same `columns` allow-list CSV exports use, so JSON can't leak fields CSV hides. */
145
+ rowsForExport() {
146
+ if (this.columns.length === 0)
147
+ return this.rows;
148
+ const keys = this.columns.map((c) => c.key);
149
+ return this.rows.map((row) => {
150
+ const picked = {};
151
+ for (const key of keys)
152
+ picked[key] = row[key];
153
+ return picked;
154
+ });
155
+ }
87
156
  doExport(format) {
88
157
  this.closeMenu();
158
+ this.triggerEl?.focus();
89
159
  const ev = this.emit('lyra-export', { format });
90
160
  if (ev.defaultPrevented)
91
161
  return;
@@ -93,7 +163,7 @@ export class LyraExportButton extends LyraElement {
93
163
  downloadBlob(buildCsv(this.rows, this.columns), `${this.filename}.csv`, 'text/csv;charset=utf-8;');
94
164
  }
95
165
  else {
96
- downloadBlob(JSON.stringify(this.rows, null, 2), `${this.filename}.json`, 'application/json');
166
+ downloadBlob(JSON.stringify(this.rowsForExport(), null, 2), `${this.filename}.json`, 'application/json');
97
167
  }
98
168
  this.emit('lyra-export-complete', { format });
99
169
  }
@@ -113,12 +183,13 @@ export class LyraExportButton extends LyraElement {
113
183
  ?disabled=${this.disabled}
114
184
  aria-haspopup=${this.formats.length > 1 ? 'menu' : nothing}
115
185
  aria-expanded=${this.formats.length > 1 ? (this.open ? 'true' : 'false') : nothing}
186
+ aria-controls=${this.formats.length > 1 ? this.menuId : nothing}
116
187
  @click=${() => this.onTriggerClick()}
117
188
  >
118
189
  ${this.label}
119
190
  </button>
120
191
  ${this.formats.length > 1
121
- ? html `<div part="menu" role="menu">
192
+ ? html `<div id=${this.menuId} part="menu" role="menu">
122
193
  ${this.formats.map((f) => html `<button
123
194
  part="menu-item"
124
195
  role="menuitem"
@@ -153,4 +224,10 @@ __decorate([
153
224
  __decorate([
154
225
  property({ type: Boolean, reflect: true })
155
226
  ], LyraExportButton.prototype, "open", void 0);
227
+ __decorate([
228
+ query('[part="trigger"]')
229
+ ], LyraExportButton.prototype, "triggerEl", void 0);
230
+ __decorate([
231
+ query('[part="menu"]')
232
+ ], LyraExportButton.prototype, "menuEl", void 0);
156
233
  defineElement('export-button', LyraExportButton);
@@ -4,3 +4,4 @@ export default meta;
4
4
  type Story = StoryObj;
5
5
  export declare const SingleFormat: Story;
6
6
  export declare const MultiFormatMenu: Story;
7
+ export declare const Disabled: Story;
@@ -30,3 +30,8 @@ export const MultiFormatMenu = {
30
30
  ></lyra-export-button>
31
31
  `,
32
32
  };
33
+ export const Disabled = {
34
+ render: () => html `
35
+ <lyra-export-button filename="demo" .rows=${rows} .columns=${columns} disabled></lyra-export-button>
36
+ `,
37
+ };
@@ -30,7 +30,16 @@ export const styles = css `
30
30
  [part='menu'] {
31
31
  /* Closed state: invisible + slightly raised. visibility (not
32
32
  display:none) so opacity/transform can actually transition; hit-testing
33
- and a11y exposure stay off since this part is already position:fixed. */
33
+ and a11y exposure stay off since this part is already position:fixed.
34
+ visibility deliberately isn't in the transition list below: a
35
+ transitioned property's computed value only settles to its new
36
+ target after the UA has run an actual style-change/rendering pass,
37
+ which lags behind a same-tick attribute write (e.g. updated()
38
+ synchronously focusing the first menu item right after flipping
39
+ open) -- that item would still resolve as visibility: hidden (and
40
+ so silently fail to focus) at the moment .focus() is called. Leaving
41
+ visibility untransitioned makes it apply immediately, in the same
42
+ synchronous style pass as the open attribute write. */
34
43
  visibility: hidden;
35
44
  position: fixed;
36
45
  z-index: 900;
@@ -44,8 +53,7 @@ export const styles = css `
44
53
  transform: translateY(-4px);
45
54
  transition:
46
55
  opacity var(--lyra-transition-fast),
47
- transform var(--lyra-transition-fast),
48
- visibility var(--lyra-transition-fast);
56
+ transform var(--lyra-transition-fast);
49
57
  }
50
58
  :host([open]) [part='menu'] {
51
59
  visibility: visible;
@@ -8,6 +8,12 @@
8
8
  * `file` may be a `DataTransferItem` cast as `File` (the dragenter-preview
9
9
  * call site, before the drop payload has real `File` objects) - unlike
10
10
  * `File`, `DataTransferItem` has no `.name`, only `.type`, so extension
11
- * patterns (`.csv`) can't be evaluated pre-drop and never match.
11
+ * patterns (`.csv`) can't be evaluated pre-drop. By default an unresolved
12
+ * extension pattern counts as no match, since that's the correct behavior
13
+ * once real `File` objects are available at drop time. Pass
14
+ * `assumeExtensionMatch: true` to instead treat an unresolved extension
15
+ * pattern as a possible match — this is what dragenter preview uses so it
16
+ * doesn't render a false "reject" state for accept lists that are
17
+ * extension-only, when the file may well be accepted once dropped.
12
18
  */
13
- export declare function matchesAccept(file: File, accept: string): boolean;
19
+ export declare function matchesAccept(file: File, accept: string, assumeExtensionMatch?: boolean): boolean;
@@ -8,9 +8,15 @@
8
8
  * `file` may be a `DataTransferItem` cast as `File` (the dragenter-preview
9
9
  * call site, before the drop payload has real `File` objects) - unlike
10
10
  * `File`, `DataTransferItem` has no `.name`, only `.type`, so extension
11
- * patterns (`.csv`) can't be evaluated pre-drop and never match.
11
+ * patterns (`.csv`) can't be evaluated pre-drop. By default an unresolved
12
+ * extension pattern counts as no match, since that's the correct behavior
13
+ * once real `File` objects are available at drop time. Pass
14
+ * `assumeExtensionMatch: true` to instead treat an unresolved extension
15
+ * pattern as a possible match — this is what dragenter preview uses so it
16
+ * doesn't render a false "reject" state for accept lists that are
17
+ * extension-only, when the file may well be accepted once dropped.
12
18
  */
13
- export function matchesAccept(file, accept) {
19
+ export function matchesAccept(file, accept, assumeExtensionMatch = false) {
14
20
  const parts = accept
15
21
  .split(',')
16
22
  .map((s) => s.trim().toLowerCase())
@@ -21,7 +27,7 @@ export function matchesAccept(file, accept) {
21
27
  const type = (file.type || '').toLowerCase();
22
28
  return parts.some((p) => {
23
29
  if (p.startsWith('.'))
24
- return name !== undefined && name.endsWith(p);
30
+ return name === undefined ? assumeExtensionMatch : name.endsWith(p);
25
31
  if (p.endsWith('/*'))
26
32
  return type.startsWith(p.slice(0, -1));
27
33
  return type === p;
@@ -10,9 +10,11 @@ export interface RejectedFile {
10
10
  * where files ultimately get uploaded and processed anyway.
11
11
  *
12
12
  * @customElement lyra-file-input
13
- * @slot - Custom drop-zone content, overrides the `label` attribute.
13
+ * @slot - Custom drop-zone content, overrides the `label` attribute. The
14
+ * accessible name always comes from `label`, so icon-only slot content
15
+ * remains announced correctly.
14
16
  * @event lyra-files - `detail: { files, rejected }`, fired on drop and manual selection.
15
- * @csspart base, input
17
+ * @csspart base, input, status
16
18
  */
17
19
  export declare class LyraFileInput extends LyraElement {
18
20
  static styles: import("lit").CSSResultGroup[];
@@ -38,6 +40,7 @@ export declare class LyraFileInput extends LyraElement {
38
40
  private onDrop;
39
41
  private onInputChange;
40
42
  private onKeyDown;
43
+ private statusText;
41
44
  render(): TemplateResult;
42
45
  }
43
46
  declare global {
@@ -8,6 +8,7 @@ import { html, nothing } from 'lit';
8
8
  import { property, state, query } from 'lit/decorators.js';
9
9
  import { LyraElement } from '../../internal/lyra-element.js';
10
10
  import { defineElement } from '../../internal/prefix.js';
11
+ import { srOnly } from '../../internal/a11y.js';
11
12
  import { styles } from './file-input.styles.js';
12
13
  import { matchesAccept } from './accept.js';
13
14
  /**
@@ -16,9 +17,11 @@ import { matchesAccept } from './accept.js';
16
17
  * where files ultimately get uploaded and processed anyway.
17
18
  *
18
19
  * @customElement lyra-file-input
19
- * @slot - Custom drop-zone content, overrides the `label` attribute.
20
+ * @slot - Custom drop-zone content, overrides the `label` attribute. The
21
+ * accessible name always comes from `label`, so icon-only slot content
22
+ * remains announced correctly.
20
23
  * @event lyra-files - `detail: { files, rejected }`, fired on drop and manual selection.
21
- * @csspart base, input
24
+ * @csspart base, input, status
22
25
  */
23
26
  export class LyraFileInput extends LyraElement {
24
27
  constructor() {
@@ -80,13 +83,16 @@ export class LyraFileInput extends LyraElement {
80
83
  }
81
84
  };
82
85
  }
83
- static { this.styles = [LyraElement.styles, styles]; }
84
- isAllowed(file) {
86
+ static { this.styles = [LyraElement.styles, styles, srOnly]; }
87
+ isAllowed(file, isPreview = false) {
85
88
  if (this.forbiddenMimeTypes.includes(file.type))
86
89
  return 'type';
87
90
  if (this.allowedMimeTypes.length > 0 && !this.allowedMimeTypes.includes(file.type))
88
91
  return 'type';
89
- if (this.accept && !matchesAccept(file, this.accept))
92
+ // During dragenter preview, `accept` extension patterns can't be evaluated (no
93
+ // `.name` yet) — treat them as a possible match rather than a guaranteed reject,
94
+ // so the preview doesn't flag a file that will in fact be accepted on drop.
95
+ if (this.accept && !matchesAccept(file, this.accept, isPreview))
90
96
  return 'type';
91
97
  // `file.size` is `undefined` on the synthetic `DataTransferItem`-cast objects
92
98
  // used during dragenter preview (real sizes aren't available until drop),
@@ -95,14 +101,14 @@ export class LyraFileInput extends LyraElement {
95
101
  return 'size';
96
102
  return 'ok';
97
103
  }
98
- classify(fileList) {
104
+ classify(fileList, isPreview = false) {
99
105
  if (!this.multiple && fileList.length > 1) {
100
106
  return { files: [], rejected: fileList.map((file) => ({ file, reason: 'count' })) };
101
107
  }
102
108
  const files = [];
103
109
  const rejected = [];
104
110
  for (const f of fileList) {
105
- const reason = this.isAllowed(f);
111
+ const reason = this.isAllowed(f, isPreview);
106
112
  if (reason === 'ok')
107
113
  files.push(f);
108
114
  else
@@ -119,9 +125,16 @@ export class LyraFileInput extends LyraElement {
119
125
  this.inputEl?.click();
120
126
  }
121
127
  previewState(fileList) {
122
- const { rejected } = this.classify(fileList);
128
+ const { rejected } = this.classify(fileList, true);
123
129
  return rejected.length > 0 ? 'reject' : 'accept';
124
130
  }
131
+ statusText() {
132
+ if (this.dragState === 'accept')
133
+ return 'Release to add the file.';
134
+ if (this.dragState === 'reject')
135
+ return 'This file type is not accepted.';
136
+ return '';
137
+ }
125
138
  render() {
126
139
  return html `
127
140
  <div
@@ -129,6 +142,7 @@ export class LyraFileInput extends LyraElement {
129
142
  role="button"
130
143
  tabindex=${this.disabled ? '-1' : '0'}
131
144
  aria-disabled=${this.disabled ? 'true' : nothing}
145
+ aria-label=${this.label}
132
146
  data-drag-state=${this.dragState}
133
147
  @dragenter=${this.onDragEnter}
134
148
  @dragover=${this.onDragOver}
@@ -139,6 +153,7 @@ export class LyraFileInput extends LyraElement {
139
153
  >
140
154
  <slot>${this.label}</slot>
141
155
  </div>
156
+ <div part="status" class="sr-only" role="status" aria-live="polite">${this.statusText()}</div>
142
157
  <input
143
158
  part="input"
144
159
  type="file"
@@ -3,3 +3,5 @@ declare const meta: Meta;
3
3
  export default meta;
4
4
  type Story = StoryObj;
5
5
  export declare const Default: Story;
6
+ export declare const CustomSlotContent: Story;
7
+ export declare const Disabled: Story;
@@ -8,3 +8,12 @@ export default meta;
8
8
  export const Default = {
9
9
  render: () => html `<lyra-file-input multiple accept=".csv,.xlsx"></lyra-file-input>`,
10
10
  };
11
+ export const CustomSlotContent = {
12
+ render: () => html `<lyra-file-input multiple accept=".csv,.xlsx" label="Upload spreadsheets">
13
+ <strong>Drag spreadsheets here</strong>
14
+ <span>or click to browse (.csv, .xlsx)</span>
15
+ </lyra-file-input>`,
16
+ };
17
+ export const Disabled = {
18
+ render: () => html `<lyra-file-input disabled accept=".csv,.xlsx"></lyra-file-input>`,
19
+ };
@@ -1,17 +1,40 @@
1
1
  import { type TemplateResult, type PropertyValues } from 'lit';
2
2
  import { LyraElement } from '../../internal/lyra-element.js';
3
3
  import '../skeleton/skeleton.js';
4
+ type FlagUrlResolver = (code: string) => Promise<string | undefined>;
5
+ /**
6
+ * Resolves the optional peer dependency `@aceshooting/lyra-flags`'s `flagUrl`
7
+ * via the given importer (a real dynamic import by default). Uncached and
8
+ * dependency-injectable — unlike `loadFlagUrlResolver()` below — so the
9
+ * caught-error warning path is directly testable without needing to
10
+ * actually uninstall the package.
11
+ */
12
+ export declare function loadFlagUrl(importFlags?: () => Promise<{
13
+ flagUrl: FlagUrlResolver;
14
+ }>): Promise<FlagUrlResolver | null>;
4
15
  /**
5
16
  * `<lyra-flag>` — a country/language flag.
6
17
  *
7
18
  * Flag images are shipped by the optional peer package `@aceshooting/lyra-flags`,
8
19
  * not bundled into lyra-ui itself, so importing the core library pulls zero flag
9
20
  * weight. Give it a `country` (ISO 3166-1 alpha-2) or a `language` tag (mapped to
10
- * a representative country).
21
+ * a representative country). While that peer package's `flagUrl()` resolves (or
22
+ * if it isn't installed), the host carries `aria-busy="true"` and a skeleton
23
+ * placeholder renders in its place.
24
+ *
25
+ * **Bundle-size note:** `country`/`language` resolve through the peer package's
26
+ * `flagUrl(code)`, which is itself genuinely code-split per flag (see that
27
+ * package's docs) — using `<lyra-flag country="fr">` anywhere in an app ships
28
+ * only the flags actually requested at runtime, not all 249. If you already
29
+ * have a flag's URL at build time (e.g. from your own literal
30
+ * `import frUrl from '@aceshooting/lyra-flags/flags/fr.svg?url'`), pass it as
31
+ * `src` instead to skip the peer-package round trip (and its loading-skeleton
32
+ * flash) entirely.
11
33
  *
12
34
  * @customElement lyra-flag
13
35
  * @example <lyra-flag country="fr"></lyra-flag>
14
36
  * @example <lyra-flag language="en" label="English"></lyra-flag>
37
+ * @example <lyra-flag src=${frUrl} label="French"></lyra-flag>
15
38
  * @csspart image - The underlying <img>.
16
39
  */
17
40
  export declare class LyraFlag extends LyraElement {
@@ -20,13 +43,34 @@ export declare class LyraFlag extends LyraElement {
20
43
  country?: string;
21
44
  /** BCP-47-ish language tag (e.g. `en`, `en-US`) resolved to a country flag. */
22
45
  language?: string;
23
- /** Accessible label / `alt` text. Defaults to the uppercase code. */
46
+ /**
47
+ * A pre-resolved flag image URL — takes precedence over `country`/`language`
48
+ * and skips the `@aceshooting/lyra-flags` peer-package lookup (and its
49
+ * loading-skeleton round trip) entirely. See the class doc: mainly useful to
50
+ * avoid even the small per-flag async hop when you already have the URL at
51
+ * build time. `label` is effectively required alongside `src` — there's no
52
+ * `country`/`language` to derive a fallback `alt` from.
53
+ */
54
+ src?: string;
55
+ /**
56
+ * Accessible label / `alt` text. Defaults to the uppercase *resolved country
57
+ * code* — for a `language`-only element (e.g. `language="en"`) that's the
58
+ * mapped country (`"GB"`), not the language tag itself (`"EN"`). Has no
59
+ * default when only `src` is given (no country/language to derive one from).
60
+ */
24
61
  label?: string;
25
62
  /** Render as a circular flag. */
26
63
  round: boolean;
27
- private src?;
64
+ private resolvedSrc?;
28
65
  /** True while the lazy-loaded `@aceshooting/lyra-flags` peer resolver is in flight. */
29
66
  private loading;
67
+ /**
68
+ * Bumped on every `willUpdate` pass; captured by each in-flight resolver
69
+ * `.then()` so a resolution for a `country`/`language` that's since changed
70
+ * (or been cleared) can recognize itself as stale and no-op instead of
71
+ * overwriting newer state.
72
+ */
73
+ private resolveToken;
30
74
  private get code();
31
75
  protected willUpdate(changed: PropertyValues<this>): void;
32
76
  protected updated(): void;
@@ -37,3 +81,4 @@ declare global {
37
81
  'lyra-flag': LyraFlag;
38
82
  }
39
83
  }
84
+ export {};
@@ -9,31 +9,34 @@ 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 { styles } from './flag.styles.js';
12
- import { languageToCountry } from './language-map.js';
12
+ import { ALPHA2_RE, languageToCountry } from './language-map.js';
13
13
  import '../skeleton/skeleton.js';
14
14
  /**
15
- * ISO 3166-1 alpha-2 country code: exactly two letters, case-insensitive.
16
- * The peer package `@aceshooting/lyra-flags` naively interpolates its `code`
17
- * argument into a `new URL('./flags/${code}.svg', ...)` with no validation
18
- * of its own an unvalidated `country` containing `../` segments (e.g.
19
- * `../../etc`) can escape the intended flags/ directory. Anything that
20
- * doesn't match this shape is treated the same as an unknown/missing flag.
15
+ * Resolves the optional peer dependency `@aceshooting/lyra-flags`'s `flagUrl`
16
+ * via the given importer (a real dynamic import by default). Uncached and
17
+ * dependency-injectable unlike `loadFlagUrlResolver()` below so the
18
+ * caught-error warning path is directly testable without needing to
19
+ * actually uninstall the package.
21
20
  */
22
- const COUNTRY_CODE_RE = /^[a-z]{2}$/i;
21
+ export async function loadFlagUrl(importFlags = () => import('@aceshooting/lyra-flags')) {
22
+ try {
23
+ return (await importFlags()).flagUrl;
24
+ }
25
+ catch {
26
+ console.warn("<lyra-flag> needs the optional peer dependency '@aceshooting/lyra-flags' to render " +
27
+ 'flag images — install it with `pnpm add @aceshooting/lyra-flags`.');
28
+ return null;
29
+ }
30
+ }
23
31
  let flagUrlResolver;
24
32
  /**
25
33
  * Lazily loads the optional peer dependency '@aceshooting/lyra-flags' once per
26
- * page. Resolves to `null` (with a one-time warning) if it isn't installed.
34
+ * page. Resolves to `null` (with a one-time warning, see `loadFlagUrl()`) if
35
+ * it isn't installed.
27
36
  */
28
37
  function loadFlagUrlResolver() {
29
38
  if (!flagUrlResolver) {
30
- flagUrlResolver = import('@aceshooting/lyra-flags')
31
- .then((mod) => mod.flagUrl)
32
- .catch(() => {
33
- console.warn("<lyra-flag> needs the optional peer dependency '@aceshooting/lyra-flags' to render " +
34
- 'flag images — install it with `pnpm add @aceshooting/lyra-flags`.');
35
- return null;
36
- });
39
+ flagUrlResolver = loadFlagUrl();
37
40
  }
38
41
  return flagUrlResolver;
39
42
  }
@@ -43,11 +46,23 @@ function loadFlagUrlResolver() {
43
46
  * Flag images are shipped by the optional peer package `@aceshooting/lyra-flags`,
44
47
  * not bundled into lyra-ui itself, so importing the core library pulls zero flag
45
48
  * weight. Give it a `country` (ISO 3166-1 alpha-2) or a `language` tag (mapped to
46
- * a representative country).
49
+ * a representative country). While that peer package's `flagUrl()` resolves (or
50
+ * if it isn't installed), the host carries `aria-busy="true"` and a skeleton
51
+ * placeholder renders in its place.
52
+ *
53
+ * **Bundle-size note:** `country`/`language` resolve through the peer package's
54
+ * `flagUrl(code)`, which is itself genuinely code-split per flag (see that
55
+ * package's docs) — using `<lyra-flag country="fr">` anywhere in an app ships
56
+ * only the flags actually requested at runtime, not all 249. If you already
57
+ * have a flag's URL at build time (e.g. from your own literal
58
+ * `import frUrl from '@aceshooting/lyra-flags/flags/fr.svg?url'`), pass it as
59
+ * `src` instead to skip the peer-package round trip (and its loading-skeleton
60
+ * flash) entirely.
47
61
  *
48
62
  * @customElement lyra-flag
49
63
  * @example <lyra-flag country="fr"></lyra-flag>
50
64
  * @example <lyra-flag language="en" label="English"></lyra-flag>
65
+ * @example <lyra-flag src=${frUrl} label="French"></lyra-flag>
51
66
  * @csspart image - The underlying <img>.
52
67
  */
53
68
  export class LyraFlag extends LyraElement {
@@ -57,11 +72,18 @@ export class LyraFlag extends LyraElement {
57
72
  this.round = false;
58
73
  /** True while the lazy-loaded `@aceshooting/lyra-flags` peer resolver is in flight. */
59
74
  this.loading = true;
75
+ /**
76
+ * Bumped on every `willUpdate` pass; captured by each in-flight resolver
77
+ * `.then()` so a resolution for a `country`/`language` that's since changed
78
+ * (or been cleared) can recognize itself as stale and no-op instead of
79
+ * overwriting newer state.
80
+ */
81
+ this.resolveToken = 0;
60
82
  }
61
83
  static { this.styles = [LyraElement.styles, styles]; }
62
84
  get code() {
63
85
  if (this.country) {
64
- return COUNTRY_CODE_RE.test(this.country) ? this.country.toLowerCase() : undefined;
86
+ return ALPHA2_RE.test(this.country) ? this.country.toLowerCase() : undefined;
65
87
  }
66
88
  if (this.language)
67
89
  return languageToCountry(this.language);
@@ -69,22 +91,32 @@ export class LyraFlag extends LyraElement {
69
91
  }
70
92
  willUpdate(changed) {
71
93
  // `this.hasUpdated` (not just `changed`) forces this body to run once on
72
- // the very first update even when neither `country` nor `language` is
94
+ // the very first update even when none of `country`/`language`/`src` is
73
95
  // ever set: an unset optional property's first "assignment" is
74
- // undefined -> undefined (no-op to Lit), so `changed.has('country')`
75
- // would otherwise never become true for a `<lyra-flag>` that never
76
- // receives a code, leaving `loading` stuck at its initial `true` forever.
77
- if (this.hasUpdated && !changed.has('country') && !changed.has('language'))
96
+ // undefined -> undefined (no-op to Lit), so none of `changed.has(...)`
97
+ // would otherwise ever become true for a `<lyra-flag>` that never
98
+ // receives one, leaving `loading` stuck at its initial `true` forever.
99
+ if (this.hasUpdated && !changed.has('country') && !changed.has('language') && !changed.has('src'))
100
+ return;
101
+ const token = ++this.resolveToken; // invalidates any in-flight peer resolution either way
102
+ if (this.src) {
103
+ this.resolvedSrc = undefined;
104
+ this.loading = false;
78
105
  return;
106
+ }
79
107
  const code = this.code;
80
108
  if (!code) {
81
- this.src = undefined;
109
+ this.resolvedSrc = undefined;
82
110
  this.loading = false;
83
111
  return;
84
112
  }
85
113
  this.loading = true;
86
- void loadFlagUrlResolver().then((resolve) => {
87
- this.src = resolve?.(code);
114
+ void loadFlagUrlResolver()
115
+ .then((resolve) => resolve?.(code))
116
+ .then((url) => {
117
+ if (token !== this.resolveToken)
118
+ return; // superseded by a later country/language/src change
119
+ this.resolvedSrc = url;
88
120
  this.loading = false;
89
121
  });
90
122
  }
@@ -97,10 +129,11 @@ export class LyraFlag extends LyraElement {
97
129
  render() {
98
130
  if (this.loading)
99
131
  return html `<lyra-skeleton variant="rect"></lyra-skeleton>`;
100
- if (!this.src)
132
+ const url = this.src ?? this.resolvedSrc;
133
+ if (!url)
101
134
  return html ``;
102
135
  const alt = this.label ?? (this.code ?? '').toUpperCase();
103
- return html `<img part="image" src=${this.src} alt=${alt} loading="lazy" decoding="async" />`;
136
+ return html `<img part="image" src=${url} alt=${alt} loading="lazy" decoding="async" />`;
104
137
  }
105
138
  }
106
139
  __decorate([
@@ -109,6 +142,9 @@ __decorate([
109
142
  __decorate([
110
143
  property()
111
144
  ], LyraFlag.prototype, "language", void 0);
145
+ __decorate([
146
+ property()
147
+ ], LyraFlag.prototype, "src", void 0);
112
148
  __decorate([
113
149
  property()
114
150
  ], LyraFlag.prototype, "label", void 0);
@@ -117,7 +153,7 @@ __decorate([
117
153
  ], LyraFlag.prototype, "round", void 0);
118
154
  __decorate([
119
155
  state()
120
- ], LyraFlag.prototype, "src", void 0);
156
+ ], LyraFlag.prototype, "resolvedSrc", void 0);
121
157
  __decorate([
122
158
  state()
123
159
  ], LyraFlag.prototype, "loading", void 0);
@@ -12,7 +12,7 @@ export const styles = css `
12
12
  block-size: 100%;
13
13
  inline-size: 100%;
14
14
  border-radius: var(--lyra-flag-radius, 2px);
15
- box-shadow: 0 0 0 1px rgb(0 0 0 / 0.08) inset;
15
+ box-shadow: 0 0 0 1px var(--lyra-color-border) inset;
16
16
  object-fit: cover;
17
17
  }
18
18
  :host([round]) {