@api-client/ui 0.0.11 → 0.0.12

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 (128) hide show
  1. package/.eslintrc +8 -0
  2. package/demo/elements/index.html +3 -0
  3. package/demo/elements/store/file-picker.html +15 -0
  4. package/demo/elements/store/file-picker.ts +134 -0
  5. package/demo/elements/store/index.html +19 -0
  6. package/demo/store/StorePlugin.js +1 -0
  7. package/dist/bindings/base/StoreBindings.d.ts +5 -0
  8. package/dist/bindings/base/StoreBindings.d.ts.map +1 -1
  9. package/dist/bindings/base/StoreBindings.js +15 -1
  10. package/dist/bindings/base/StoreBindings.js.map +1 -1
  11. package/dist/define/store/file-picker.d.ts +9 -0
  12. package/dist/define/store/file-picker.d.ts.map +1 -0
  13. package/dist/define/store/file-picker.js +10 -0
  14. package/dist/define/store/file-picker.js.map +1 -0
  15. package/dist/define/{files → store}/share-file.d.ts +1 -1
  16. package/dist/define/store/share-file.d.ts.map +1 -0
  17. package/dist/define/{files → store}/share-file.js +2 -2
  18. package/dist/define/store/share-file.js.map +1 -0
  19. package/dist/elements/store/FilePicker.element.d.ts +87 -0
  20. package/dist/elements/store/FilePicker.element.d.ts.map +1 -0
  21. package/dist/elements/store/FilePicker.element.js +263 -0
  22. package/dist/elements/store/FilePicker.element.js.map +1 -0
  23. package/dist/elements/store/FilePicker.styles.d.ts +3 -0
  24. package/dist/elements/store/FilePicker.styles.d.ts.map +1 -0
  25. package/dist/elements/store/FilePicker.styles.js +72 -0
  26. package/dist/elements/store/FilePicker.styles.js.map +1 -0
  27. package/dist/elements/store/FilesLib.d.ts +10 -0
  28. package/dist/elements/store/FilesLib.d.ts.map +1 -0
  29. package/dist/elements/store/FilesLib.js +38 -0
  30. package/dist/elements/store/FilesLib.js.map +1 -0
  31. package/dist/elements/{files/ShareFile.d.ts → store/ShareFile.element.d.ts} +1 -1
  32. package/dist/elements/store/ShareFile.element.d.ts.map +1 -0
  33. package/dist/elements/{files/ShareFile.js → store/ShareFile.element.js} +1 -1
  34. package/dist/elements/store/ShareFile.element.js.map +1 -0
  35. package/dist/elements/store/ShareFile.styles.d.ts.map +1 -0
  36. package/dist/elements/{files → store}/ShareFile.styles.js.map +1 -1
  37. package/dist/events/EventTypes.d.ts +1 -0
  38. package/dist/events/EventTypes.d.ts.map +1 -1
  39. package/dist/events/EventTypes.js +1 -0
  40. package/dist/events/EventTypes.js.map +1 -1
  41. package/dist/events/Events.d.ts +1 -0
  42. package/dist/events/Events.d.ts.map +1 -1
  43. package/dist/events/StoreEvents.d.ts +8 -1
  44. package/dist/events/StoreEvents.d.ts.map +1 -1
  45. package/dist/events/StoreEvents.js +19 -0
  46. package/dist/events/StoreEvents.js.map +1 -1
  47. package/dist/pages/ApplicationScreen.d.ts +1 -1
  48. package/dist/pages/ApplicationScreen.d.ts.map +1 -1
  49. package/dist/pages/ApplicationScreen.js +4 -2
  50. package/dist/pages/ApplicationScreen.js.map +1 -1
  51. package/dist/pages/api-client/ApiClient.screen.d.ts +0 -6
  52. package/dist/pages/api-client/ApiClient.screen.d.ts.map +1 -1
  53. package/dist/pages/api-client/ApiClient.screen.js +16 -29
  54. package/dist/pages/api-client/ApiClient.screen.js.map +1 -1
  55. package/dist/pages/api-client/Authenticate.screen.d.ts +1 -1
  56. package/dist/pages/api-client/Authenticate.screen.d.ts.map +1 -1
  57. package/dist/pages/api-client/Authenticate.screen.js +2 -2
  58. package/dist/pages/api-client/Authenticate.screen.js.map +1 -1
  59. package/dist/pages/api-client/pages/Files.page.d.ts +6 -35
  60. package/dist/pages/api-client/pages/Files.page.d.ts.map +1 -1
  61. package/dist/pages/api-client/pages/Files.page.js +32 -141
  62. package/dist/pages/api-client/pages/Files.page.js.map +1 -1
  63. package/dist/pages/api-client/pages/Shared.page.d.ts +1 -5
  64. package/dist/pages/api-client/pages/Shared.page.d.ts.map +1 -1
  65. package/dist/pages/api-client/pages/Shared.page.js +1 -40
  66. package/dist/pages/api-client/pages/Shared.page.js.map +1 -1
  67. package/dist/pages/demo/DemoPage.d.ts +7 -0
  68. package/dist/pages/demo/DemoPage.d.ts.map +1 -1
  69. package/dist/pages/demo/DemoPage.js +14 -0
  70. package/dist/pages/demo/DemoPage.js.map +1 -1
  71. package/dist/store/FileSystem.d.ts +90 -0
  72. package/dist/store/FileSystem.d.ts.map +1 -0
  73. package/dist/store/FileSystem.js +260 -0
  74. package/dist/store/FileSystem.js.map +1 -0
  75. package/dist/styles/global-styles.d.ts.map +1 -1
  76. package/dist/styles/global-styles.js +7 -0
  77. package/dist/styles/global-styles.js.map +1 -1
  78. package/dist/ui/icons/Icons.d.ts +2 -1
  79. package/dist/ui/icons/Icons.d.ts.map +1 -1
  80. package/dist/ui/icons/Icons.js +1 -0
  81. package/dist/ui/icons/Icons.js.map +1 -1
  82. package/dist/ui/list/UiDropdownList.d.ts +9 -1
  83. package/dist/ui/list/UiDropdownList.d.ts.map +1 -1
  84. package/dist/ui/list/UiDropdownList.js +39 -17
  85. package/dist/ui/list/UiDropdownList.js.map +1 -1
  86. package/dist/ui/list/UiList.d.ts +6 -1
  87. package/dist/ui/list/UiList.d.ts.map +1 -1
  88. package/dist/ui/list/UiList.js +24 -9
  89. package/dist/ui/list/UiList.js.map +1 -1
  90. package/dist/ui/table/DataTable.d.ts +4 -0
  91. package/dist/ui/table/DataTable.d.ts.map +1 -1
  92. package/dist/ui/table/DataTable.js +23 -1
  93. package/dist/ui/table/DataTable.js.map +1 -1
  94. package/package.json +1 -1
  95. package/src/bindings/base/StoreBindings.ts +16 -1
  96. package/src/define/store/file-picker.ts +12 -0
  97. package/src/define/{files → store}/share-file.ts +2 -2
  98. package/src/elements/store/FilePicker.element.ts +297 -0
  99. package/src/elements/store/FilePicker.styles.ts +72 -0
  100. package/src/elements/store/FilesLib.ts +32 -0
  101. package/src/events/EventTypes.ts +1 -0
  102. package/src/events/StoreEvents.ts +21 -1
  103. package/src/pages/ApplicationScreen.ts +5 -3
  104. package/src/pages/api-client/ApiClient.screen.ts +16 -31
  105. package/src/pages/api-client/Authenticate.screen.ts +2 -2
  106. package/src/pages/api-client/pages/Files.page.ts +37 -164
  107. package/src/pages/api-client/pages/Shared.page.ts +2 -40
  108. package/src/pages/demo/DemoPage.ts +17 -0
  109. package/src/store/FileSystem.ts +325 -0
  110. package/src/styles/global-styles.ts +7 -0
  111. package/src/ui/icons/Icons.ts +2 -1
  112. package/src/ui/list/UiDropdownList.ts +44 -17
  113. package/src/ui/list/UiList.ts +26 -10
  114. package/src/ui/table/DataTable.ts +29 -3
  115. package/test/elements/store/FilePicker.test.ts +241 -0
  116. package/test/env.js +3 -0
  117. package/test/helpers/StoreHelper.ts +390 -0
  118. package/tsconfig.eslint.json +3 -1
  119. package/web-test-runner.config.mjs +49 -3
  120. package/dist/define/files/share-file.d.ts.map +0 -1
  121. package/dist/define/files/share-file.js.map +0 -1
  122. package/dist/elements/files/ShareFile.d.ts.map +0 -1
  123. package/dist/elements/files/ShareFile.js.map +0 -1
  124. package/dist/elements/files/ShareFile.styles.d.ts.map +0 -1
  125. /package/dist/elements/{files → store}/ShareFile.styles.d.ts +0 -0
  126. /package/dist/elements/{files → store}/ShareFile.styles.js +0 -0
  127. /package/src/elements/{files/ShareFile.ts → store/ShareFile.element.ts} +0 -0
  128. /package/src/elements/{files → store}/ShareFile.styles.ts +0 -0
@@ -8,6 +8,7 @@ import CheckboxElement from '../../ui/input/CheckboxElement.js';
8
8
  import typography from "../../styles/m3/typography.module.js";
9
9
  import surface from "../../styles/m3/surface.module.js";
10
10
  import '../../define/ui/ui-icon.js';
11
+ import '../../define/ui/ui-button.js';
11
12
 
12
13
  /**
13
14
  * A base class for demo pages in the API Client ecosystem.
@@ -33,6 +34,11 @@ export abstract class DemoPage extends RouteMixin(RenderableMixin(EventTarget))
33
34
  */
34
35
  @reactive() darkThemeActive = false;
35
36
 
37
+ /**
38
+ * For some demo pages, whether the user is authenticated in the store.
39
+ */
40
+ authenticated = false;
41
+
36
42
  constructor() {
37
43
  super();
38
44
  this.handleMediaQuery = this.handleMediaQuery.bind(this);
@@ -151,4 +157,15 @@ export abstract class DemoPage extends RouteMixin(RenderableMixin(EventTarget))
151
157
  * ```
152
158
  */
153
159
  abstract contentTemplate(): TemplateResult;
160
+
161
+ handleAuthenticate(): void {
162
+ // ...
163
+ }
164
+
165
+ authenticateTemplate(): TemplateResult {
166
+ return html`
167
+ <p>Store authorization required.</p>
168
+ <ui-button @click="${this.handleAuthenticate}">Authenticate</ui-button>
169
+ `;
170
+ }
154
171
  }
@@ -0,0 +1,325 @@
1
+ import {
2
+ BroadcastEvent,
3
+ BroadcastFileData,
4
+ ContextListResult,
5
+ ContextSpaceListOptions,
6
+ DeletedBroadcastEvent,
7
+ FileAccessBroadcastEvent,
8
+ FileBreadcrumb,
9
+ FileMetaCreatedBroadcastEvent,
10
+ FilePatchBroadcastEvent,
11
+ IFile,
12
+ ListFileKind,
13
+ } from "@api-client/core/build/browser.js";
14
+ import { Patch } from "@api-client/json";
15
+ import { filesSortFunction } from "../elements/store/FilesLib.js";
16
+ import { Events } from "../events/Events.js";
17
+ import { StoreBroadcast } from "../http-client/store/StoreBroadcast.js";
18
+
19
+ type FilesSource = 'own' | 'shared';
20
+
21
+ /**
22
+ * Contains a logic to read files from the store.
23
+ *
24
+ * It uses app events to query for the data.
25
+ */
26
+ export class FileSystem extends EventTarget {
27
+ source: FilesSource = 'own';
28
+
29
+ /**
30
+ * The list of files to render.
31
+ * Do not overwrite this property or when you must then update the DataTable's `items` as well.
32
+ */
33
+ files: IFile[] = [];
34
+
35
+ /**
36
+ * The pagination cursor for files.
37
+ */
38
+ cursor?: string;
39
+
40
+ /**
41
+ * This is set when the last files query did not yeld results.
42
+ */
43
+ queryEnded?: boolean;
44
+
45
+ /**
46
+ * The currently rendered breadcrumbs.
47
+ */
48
+ breadcrumbs?: FileBreadcrumb[];
49
+
50
+ /**
51
+ * Whether the UI is reading files
52
+ */
53
+ reading?: boolean;
54
+
55
+ /**
56
+ * The list of file kinds to list.
57
+ * Folders are always included.
58
+ */
59
+ kinds?: ListFileKind[];
60
+
61
+ /**
62
+ * The timeout for the query debouncer.
63
+ * When any property change this is the time the element will wait
64
+ * until the actual query is made.
65
+ */
66
+ debounceTimeout = 100;
67
+
68
+ debouncerValue?: number;
69
+
70
+ /**
71
+ * The variable parent folder key.
72
+ * This is set when interacting with the UI or when the `folder` attribute change.
73
+ */
74
+ parent?: string;
75
+
76
+ /**
77
+ * The page limit. Defaults to the store defaults.
78
+ * @attribute
79
+ */
80
+ limit?: number;
81
+
82
+ filesChannel = new BroadcastChannel(StoreBroadcast.files);
83
+
84
+ eventsTarget: EventTarget = document.body;
85
+
86
+ constructor() {
87
+ super();
88
+ this.fileMetaMessageHandler = this.fileMetaMessageHandler.bind(this);
89
+ }
90
+
91
+ observe(): void {
92
+ this.filesChannel.addEventListener('message', this.fileMetaMessageHandler);
93
+ }
94
+
95
+ unobserve(): void {
96
+ this.filesChannel.removeEventListener('message', this.fileMetaMessageHandler);
97
+ }
98
+
99
+ fileMetaMessageHandler(e: MessageEvent): void {
100
+ const event = e.data as BroadcastEvent & BroadcastFileData;
101
+ if (event.parent !== this.parent) {
102
+ return;
103
+ }
104
+ if (event.alt !== 'meta') {
105
+ return;
106
+ }
107
+ switch (event.operation) {
108
+ case 'created': this.handleFileCreated((event as FileMetaCreatedBroadcastEvent).data); break;
109
+ case 'deleted': this.handleFileDeleted(event as DeletedBroadcastEvent); break;
110
+ case 'patch': this.handleFilePatch(event as FilePatchBroadcastEvent); break;
111
+ case 'access-granted': this.handleFileAccessGranted(event as FileAccessBroadcastEvent); break;
112
+ case 'access-removed': this.handleFileAccessRemoved(event as FileAccessBroadcastEvent); break;
113
+ default:
114
+ }
115
+ }
116
+
117
+ handleFileCreated(file: IFile): void {
118
+ this.files.push(file);
119
+ // We don't sort here so files that appear at the end which makes sense because the list is scrolling.
120
+ // this.files.sort(filesSortFunction);
121
+ this.notifyUpdate();
122
+ }
123
+
124
+ handleFileDeleted(event: DeletedBroadcastEvent): void {
125
+ this.removeFileFromList(event.key);
126
+ }
127
+
128
+ async handleFileAccessGranted(event: FileAccessBroadcastEvent): Promise<void> {
129
+ if (this.source !== 'shared') {
130
+ return;
131
+ }
132
+ const { key } = event;
133
+ try {
134
+ const file = await Events.Store.File.read(key, false, this.eventsTarget);
135
+ const index = this.files.findIndex(i => i.key === key);
136
+ if (index >= 0) {
137
+ this.files.splice(index, 1);
138
+ }
139
+ this.handleFileCreated(file);
140
+ } catch (e) {
141
+ // eslint-disable-next-line no-console
142
+ console.warn(e);
143
+ }
144
+ }
145
+
146
+ handleFileAccessRemoved(event: FileAccessBroadcastEvent): void {
147
+ if (this.source !== 'shared') {
148
+ return;
149
+ }
150
+ const { key } = event;
151
+ this.removeFileFromList(key);
152
+ }
153
+
154
+ removeFileFromList(key: string): void {
155
+ const index = this.files.findIndex(i => i.key === key);
156
+ if (index === -1) {
157
+ return;
158
+ }
159
+ this.files.splice(index, 1);
160
+ this.notifyUpdate();
161
+ this.dispatchEvent(new CustomEvent('delete', { detail: key }));
162
+ }
163
+
164
+ handleFilePatch(event: FilePatchBroadcastEvent): void {
165
+ const { data, key } = event;
166
+ const index = this.files.findIndex(i => i.key === key);
167
+ if (index === -1) {
168
+ return;
169
+ }
170
+ const file = this.files[index];
171
+ const result = Patch.apply(file, data.patch);
172
+ this.files[index] = result.doc as IFile;
173
+ this.notifyUpdate();
174
+ }
175
+
176
+ debounceQuery(): void {
177
+ if (this.debouncerValue) {
178
+ clearTimeout(this.debouncerValue);
179
+ }
180
+ this.debouncerValue = window.setTimeout(() => {
181
+ this.queryPage();
182
+ this.queryBreadcrumbs();
183
+ }, this.debounceTimeout);
184
+ }
185
+
186
+ resetList(): void {
187
+ this.files = [];
188
+ this.cursor = undefined;
189
+ this.breadcrumbs = undefined;
190
+ this.queryEnded = false;
191
+ }
192
+
193
+ async queryPage(): Promise<void> {
194
+ if (this.reading || this.queryEnded) {
195
+ return;
196
+ }
197
+ this.reading = true;
198
+ this.notifyUpdate();
199
+ try {
200
+ const result = await this.listFiles();
201
+ if (!result) {
202
+ throw new Error(`Files event not handled.`);
203
+ }
204
+ const { items, cursor } = result;
205
+ if (!items.length) {
206
+ this.queryEnded = true;
207
+ } else {
208
+ if (!this.files.length) {
209
+ // we only sort the first page as later the user scrolls and sorting would change the order.
210
+ items.sort(filesSortFunction);
211
+ }
212
+ this.files = this.files.concat(items);
213
+ }
214
+ if (cursor) {
215
+ this.cursor = cursor;
216
+ }
217
+ } catch(e) {
218
+ const err = e as Error;
219
+ this.notifyError(`Unable to load files list. ${err.message}`);
220
+ // eslint-disable-next-line no-console
221
+ console.error(e);
222
+ } finally {
223
+ this.reading = false;
224
+ }
225
+ this.dispatchEvent(new Event('querycomplete'));
226
+ this.notifyUpdate();
227
+ }
228
+
229
+ async queryBreadcrumbs(): Promise<void> {
230
+ if (this.parent) {
231
+ const br = await this.listBreadcrumbs(this.parent);
232
+ this.breadcrumbs = br.items;
233
+ } else {
234
+ this.breadcrumbs = undefined;
235
+ }
236
+ }
237
+
238
+ getPageQueryOptions(): ContextSpaceListOptions {
239
+ const { cursor: filesCursor, parent, limit } = this;
240
+ const opts: ContextSpaceListOptions = {
241
+ space: '', // this is filled by the bindings.
242
+ descending: true,
243
+ };
244
+ if (filesCursor) {
245
+ opts.cursor = filesCursor;
246
+ }
247
+ if (parent) {
248
+ opts.parent = parent;
249
+ }
250
+ if (typeof limit === 'number' && limit) {
251
+ opts.limit = limit;
252
+ }
253
+ return opts;
254
+ }
255
+
256
+ async listFiles(): Promise<ContextListResult<IFile>> {
257
+ const { kinds } = this;
258
+ const opts = this.getPageQueryOptions();
259
+ if (this.source === 'shared') {
260
+ return Events.Store.File.listShared(kinds, opts, this.eventsTarget);
261
+ }
262
+ return Events.Store.File.list(kinds, opts, this.eventsTarget);
263
+ }
264
+
265
+ async listBreadcrumbs(folder: string): Promise<ContextListResult<FileBreadcrumb>> {
266
+ return Events.Store.File.breadcrumbs(folder, this.eventsTarget);
267
+ }
268
+
269
+ notifyError(error: string): void {
270
+ this.dispatchEvent(new CustomEvent<string>('error', {
271
+ detail: error,
272
+ }));
273
+ }
274
+
275
+ notifyUpdate(): void {
276
+ this.dispatchEvent(new Event('change'));
277
+ }
278
+
279
+ /**
280
+ * Sets a parent and makes the query.
281
+ * @param key The key of the parent to query. Empty for root folder.
282
+ */
283
+ selectFolder(key?: string): void {
284
+ this.parent = key;
285
+ this.resetList();
286
+ this.debounceQuery();
287
+ // this.queryPage();
288
+ this.queryBreadcrumbs();
289
+ }
290
+
291
+ /**
292
+ * Moves to a parent folder, if any.
293
+ */
294
+ async parentUp(): Promise<void> {
295
+ const { breadcrumbs = [] } = this;
296
+ const [, parent] = breadcrumbs;
297
+ if (!parent) {
298
+ if (!this.parent) {
299
+ return;
300
+ }
301
+ this.parent = undefined;
302
+ } else {
303
+ if (this.parent === parent.key) {
304
+ return;
305
+ }
306
+ this.parent = parent.key;
307
+ }
308
+ this.resetList();
309
+ await Promise.all([
310
+ this.queryPage(),
311
+ this.queryBreadcrumbs(),
312
+ ]);
313
+ this.notifyUpdate();
314
+ }
315
+
316
+ /**
317
+ * @param target The HTML element that is a scrollable list if files.
318
+ * @returns True when the scroll position indicates that the user scrolled near the end of the list.
319
+ */
320
+ isListEnd(target: HTMLElement): boolean {
321
+ const { scrollTop, offsetHeight, scrollHeight } = target;
322
+ // 20 is the offset which qualifies as the end. An arbitrary number.
323
+ return scrollTop + offsetHeight >= scrollHeight - 20;
324
+ }
325
+ }
@@ -65,6 +65,13 @@ body {
65
65
  justify-content: center;
66
66
  position: absolute;
67
67
  inset: 0;
68
+ background-color: var(--md-sys-color-surface);
69
+ }
70
+
71
+ .message,
72
+ .sub-message,
73
+ .auth-required-screen {
74
+ color: var(--md-sys-color-on-surface);
68
75
  }
69
76
 
70
77
  .full-error {
@@ -8,7 +8,7 @@ import { svg, SVGTemplateResult } from 'lit';
8
8
  export const iconWrapper = (tpl: SVGTemplateResult, width = 24, height = 24, top = 0, left = 0): SVGTemplateResult => svg`<svg viewBox="${top} ${left} ${width} ${height}" preserveAspectRatio="xMidYMid meet" focusable="false" style="pointer-events: none; display: block; width: 100%; height: 100%;">${tpl}</svg>`;
9
9
 
10
10
  export type IconType =
11
- 'add' | 'api' | 'apps' | 'arrowBack' | 'arrowDropDown' | 'cancel' | 'cancelFilled' | 'check' | 'checkBox' | 'checkBoxBlank' | 'checkIndeterminate' | 'chevronLeft' | 'chevronRight' | 'close' | 'cloud' | 'cloudFilled' | 'collectionsBookmark' | 'deleteFile' | 'deleteOutline' | 'edit' | 'environment' | 'expandMore' |
11
+ 'add' | 'api' | 'apps' | 'arrowBack' | 'arrowDropDown' | 'cancel' | 'cancelFilled' | 'certificate' | 'check' | 'checkBox' | 'checkBoxBlank' | 'checkIndeterminate' | 'chevronLeft' | 'chevronRight' | 'close' | 'cloud' | 'cloudFilled' | 'collectionsBookmark' | 'deleteFile' | 'deleteOutline' | 'edit' | 'environment' | 'expandMore' |
12
12
  'fileDownload' | 'folder' | 'folderFilled' | 'folderShared' | 'help' | 'history' | 'info' | 'key' | 'menu' | 'leaderBoard' |
13
13
  'moreVert' | 'openInNew' | 'personAdd' | 'playArrow' | 'project' | 'remove' | 'rename' | 'request' | 'restoreFromTrash' | 'save' |
14
14
  'schema' | 'schemaEntity' | 'schemaModel' | 'schemaNamespace' | 'search' | 'send' | 'settings' | 'space' | 'taskAlt' | 'timeline' | 'tune' | 'viewGrid' | 'viewList' | 'visibility' | 'visibilityOff' | 'warning';
@@ -20,6 +20,7 @@ export const arrowBack = iconWrapper(svg`<path d="M20 11H7.83l5.59-5.59L12 4l-8
20
20
  export const arrowDropDown = iconWrapper(svg`<path d="M7 10l5 5 5-5z"/>`);
21
21
  export const cancel = iconWrapper(svg`<path d="M0 0h24v24H0V0z" fill="none" opacity=".87"/><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.59-13L12 10.59 8.41 7 7 8.41 10.59 12 7 15.59 8.41 17 12 13.41 15.59 17 17 15.59 13.41 12 17 8.41z"/>`);
22
22
  export const cancelFilled = iconWrapper(svg`<path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/>`);
23
+ export const certificate = iconWrapper(svg`<path d="M4 3c-1.11 0-2 .89-2 2v10a2 2 0 0 0 2 2h8v5l3-3l3 3v-5h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4m8 2l3 2l3-2v3.5l3 1.5l-3 1.5V15l-3-2l-3 2v-3.5L9 10l3-1.5V5M4 5h5v2H4V5m0 4h3v2H4V9m0 4h5v2H4v-2Z"/>`);
23
24
  export const check = iconWrapper(svg`<path d="m9.55 18-5.7-5.7 1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4Z"/>`);
24
25
  export const checkBox = iconWrapper(svg`<path d="m10.6 16.2 7.05-7.05-1.4-1.4-5.65 5.65-2.85-2.85-1.4 1.4ZM5 21q-.825 0-1.413-.587Q3 19.825 3 19V5q0-.825.587-1.413Q4.175 3 5 3h14q.825 0 1.413.587Q21 4.175 21 5v14q0 .825-.587 1.413Q19.825 21 19 21Zm0-2h14V5H5v14ZM5 5v14V5Z"/>`);
25
26
  export const checkBoxBlank = iconWrapper(svg`<path d="M5 21q-.825 0-1.413-.587Q3 19.825 3 19V5q0-.825.587-1.413Q4.175 3 5 3h14q.825 0 1.413.587Q21 4.175 21 5v14q0 .825-.587 1.413Q19.825 21 19 21Zm0-2h14V5H5v14Z"/>`);
@@ -27,7 +27,7 @@ export interface UiDropdownListSelection {
27
27
  *
28
28
  * @slot - The default slot for the dropdown trigger (button)
29
29
  * @slot dropdown - The slot for the list.
30
- * @fires select - Custom event with the selected item on the `detail.item` when the user selected an item.
30
+ * @fires select - Custom event with the selected item on the `detail.item` when the user selected an item. When the event is cancelled then there's no side effects (closing the dropdown)
31
31
  * @fires dropdownopen - An event informing other dropdowns that this one was opened and other should close.
32
32
  * @fires open - An event dispatched when the open state change through a user interaction
33
33
  */
@@ -91,6 +91,13 @@ export default class UiDropdownList extends LitElement {
91
91
  */
92
92
  @property({ type: Boolean }) matchTriggerWidth?: boolean;
93
93
 
94
+ /**
95
+ * When set it closes the drop-down when `tab` button is pressed.
96
+ * This is not a default behavior since the drop-down content can have its own logic
97
+ * related to tab index.
98
+ */
99
+ @property({ type: Boolean }) closeOnTab?: boolean;
100
+
94
101
  /**
95
102
  * The first element located in the default slot.
96
103
  */
@@ -145,9 +152,7 @@ export default class UiDropdownList extends LitElement {
145
152
  if (e.composedPath()[0] === this) {
146
153
  return;
147
154
  }
148
- this.open = false;
149
- this.updateExpanded();
150
- this.notifyOpen();
155
+ this.close();
151
156
  }
152
157
 
153
158
  protected updateExpanded(): void {
@@ -212,10 +217,15 @@ export default class UiDropdownList extends LitElement {
212
217
  }
213
218
 
214
219
  protected contentKeyDownHandler(e: KeyboardEvent): void {
215
- if (e.code === 'Escape' || e.code === 'Tab') {
216
- this.open = false;
217
- this.updateExpanded();
218
- this.notifyOpen();
220
+ if (e.defaultPrevented) {
221
+ return;
222
+ }
223
+ if (e.code === 'Escape') {
224
+ this.close();
225
+ } else if (e.code === 'Tab') {
226
+ if (this.closeOnTab) {
227
+ this.close();
228
+ }
219
229
  } else if (['Enter', 'Space'].includes(e.code)) {
220
230
  this.activate(e);
221
231
  }
@@ -225,6 +235,17 @@ export default class UiDropdownList extends LitElement {
225
235
  this.activate(e);
226
236
  }
227
237
 
238
+ close(): void {
239
+ this.open = false;
240
+ this.updateExpanded();
241
+ this.notifyOpen();
242
+ }
243
+
244
+ protected contentCloseHandler(e: Event): void {
245
+ e.stopPropagation();
246
+ this.close();
247
+ }
248
+
228
249
  protected override willUpdate(cp: PropertyValues<this>): void {
229
250
  super.willUpdate(cp);
230
251
  if ((cp.has('noOverlap') || cp.has('verticalAlign') || cp.has('horizontalAlign') || cp.has('open')) && this.open) {
@@ -272,9 +293,7 @@ export default class UiDropdownList extends LitElement {
272
293
  if (inside) {
273
294
  return;
274
295
  }
275
- this.open = false;
276
- this.updateExpanded();
277
- this.notifyOpen();
296
+ this.close();
278
297
  }
279
298
 
280
299
  protected toggleOpened(): void {
@@ -311,6 +330,9 @@ export default class UiDropdownList extends LitElement {
311
330
  }
312
331
 
313
332
  protected activate(e: Event): void {
333
+ if (e.defaultPrevented) {
334
+ return;
335
+ }
314
336
  const path = e.composedPath();
315
337
  let item: HTMLElement | undefined;
316
338
  while (!item) {
@@ -328,14 +350,18 @@ export default class UiDropdownList extends LitElement {
328
350
  if (!item) {
329
351
  return;
330
352
  }
331
- this.dispatchEvent(new CustomEvent<UiDropdownListSelection>('select', {
353
+ const event = new CustomEvent<UiDropdownListSelection>('select', {
354
+ cancelable: true,
355
+ composed: true,
332
356
  detail: {
333
357
  item,
334
- }
335
- }));
336
- this.open = false;
337
- this.updateExpanded();
338
- this.notifyOpen();
358
+ },
359
+ })
360
+ this.dispatchEvent(event);
361
+ if (event.defaultPrevented) {
362
+ return;
363
+ }
364
+ this.close();
339
365
  }
340
366
 
341
367
  protected notifyOpen(): void {
@@ -385,6 +411,7 @@ export default class UiDropdownList extends LitElement {
385
411
  @slotchange="${this.dropdownChanged}"
386
412
  @keydown="${this.contentKeyDownHandler}"
387
413
  @click="${this.contentClickHandler}"
414
+ @close="${this.contentCloseHandler}"
388
415
  ></slot>
389
416
  </div>
390
417
  `;
@@ -58,6 +58,15 @@ export default class UiList extends LitElement {
58
58
  }
59
59
  }
60
60
 
61
+ override focus(options?: FocusOptions): void {
62
+ const { activeListItem } = this;
63
+ if (activeListItem) {
64
+ activeListItem.focus(options);
65
+ return;
66
+ }
67
+ this.activateFirstItem();
68
+ }
69
+
61
70
  override firstUpdated(changedProperties: PropertyValues): void {
62
71
  super.firstUpdated(changedProperties);
63
72
 
@@ -66,7 +75,7 @@ export default class UiList extends LitElement {
66
75
 
67
76
  activateFirstItem(): void {
68
77
  this.activeListItem = this.getFirstItem();
69
- this.activeListItem.activate();
78
+ this.activeListItem?.activate();
70
79
  }
71
80
 
72
81
  activateLastItem(): void {
@@ -318,30 +327,37 @@ export default class UiList extends LitElement {
318
327
  this.manageSelection(item);
319
328
  item.activate();
320
329
  this.activeListItem = item;
321
- this.notifySelect(item);
330
+ if (this.notifySelect(item)) {
331
+ e.preventDefault();
332
+ }
322
333
  }
323
334
 
324
- notifySelect(item: UiListItem): void {
335
+ /**
336
+ * @param item The UiListItem that is selected.
337
+ * @returns True when the event was canceled.
338
+ */
339
+ notifySelect(item: UiListItem): boolean {
325
340
  const index = this.items.indexOf(item);
326
341
  if (index === -1) {
327
- return;
342
+ return false;
328
343
  }
329
- this.dispatchEvent(new CustomEvent<UiListSelection>('select', {
344
+ const event = new CustomEvent<UiListSelection>('select', {
345
+ cancelable: true,
330
346
  detail: {
331
347
  item,
332
348
  index,
333
349
  }
334
- }));
350
+ });
351
+ this.dispatchEvent(event);
352
+ return event.defaultPrevented;
335
353
  }
336
354
 
337
355
  protected manageSelection(item: UiListItem): void {
338
356
  if (!this.selectActive) {
339
357
  return;
340
358
  }
341
- const { activeListItem } = this;
342
- if (activeListItem) {
343
- activeListItem.classList.remove('select');
344
- }
359
+ const { items } = this;
360
+ items.forEach((current) => current.classList.remove('select'));
345
361
  item.classList.add('select');
346
362
  }
347
363
 
@@ -27,14 +27,15 @@ export interface IDataTableOptions {
27
27
 
28
28
  export type RenderContent = string | TemplateResult | TemplateResult[];
29
29
 
30
- type DataEvents = 'header' | 'item' | 'empty' | 'activate' | 'select' | 'render';
30
+ type DataEvents = 'header' | 'item' | 'empty' | 'activate' | 'select' | 'render' | 'scrollend';
31
31
 
32
32
  type CellCallback<T> = (item: T) => TemplateResult[];
33
33
  type ActivateCallback<T> = (item: T) => void;
34
34
  type SelectCallback = (item: string | string[]) => void;
35
+ type Callback = () => void;
35
36
 
36
37
  type DataEventsMap<T> = {
37
- [key in DataEvents]?: SelectCallback | CellCallback<T> | CellCallback<T[]> | ActivateCallback<T> | ActivateCallback<T[]> | {(): TemplateResult | TemplateResult[]};
38
+ [key in DataEvents]?: Callback | SelectCallback | CellCallback<T> | CellCallback<T[]> | ActivateCallback<T> | ActivateCallback<T[]> | {(): TemplateResult | TemplateResult[]};
38
39
  };
39
40
 
40
41
  export interface ICellOptions {
@@ -227,6 +228,7 @@ export class DataTable<T> {
227
228
  this.handleClick = this.handleClick.bind(this);
228
229
  this.handleKeyDown = this.handleKeyDown.bind(this);
229
230
  this.handleFocus = this.handleFocus.bind(this);
231
+ this.handleListScroll = this.handleListScroll.bind(this);
230
232
  }
231
233
 
232
234
  addEventListener(type: 'header', listener: () => TemplateResult[]): void;
@@ -241,6 +243,8 @@ export class DataTable<T> {
241
243
 
242
244
  addEventListener(type: 'select', listener: SelectCallback): void;
243
245
 
246
+ addEventListener(type: 'scrollend', listener: Callback): void;
247
+
244
248
  addEventListener(type: DataEvents, listener: SelectCallback | CellCallback<T> | CellCallback<T[]> | ActivateCallback<T> | ActivateCallback<T[]> | {(): TemplateResult | TemplateResult[]}): void {
245
249
  this.#listeners[type] = listener;
246
250
  }
@@ -585,6 +589,15 @@ export class DataTable<T> {
585
589
  }
586
590
  }
587
591
 
592
+ protected handleListScroll(e: Event): void {
593
+ const list = e.target as HTMLElement;
594
+ const { scrollTop, offsetHeight, scrollHeight } = list;
595
+ const bottom = scrollTop + offsetHeight >= scrollHeight - 20; // 20 is the offset which qualifies as the end. An arbitrary number.
596
+ if (bottom) {
597
+ this.dispatchScrollEnd();
598
+ }
599
+ }
600
+
588
601
  protected renderHeader(): TemplateResult {
589
602
  const cells = this.dispatchHeader();
590
603
  return html`
@@ -602,7 +615,7 @@ export class DataTable<T> {
602
615
  const contents = !!items && !!items.length ? items.map(item => this.renderItem(item)) : this.renderEmpty();
603
616
  const dbListener = active ? this.dblclickHandler : undefined;
604
617
  return html`
605
- <div class="data-table-body" role="rowgroup" @dblclick="${dbListener}">
618
+ <div class="data-table-body" role="rowgroup" @dblclick="${dbListener}" @scroll="${{ handleEvent: this.handleListScroll, passive: true }}">
606
619
  ${contents}
607
620
  </div>`;
608
621
  }
@@ -715,4 +728,17 @@ export class DataTable<T> {
715
728
  console.warn(e);
716
729
  }
717
730
  }
731
+
732
+ protected dispatchScrollEnd(): void {
733
+ const callback = this.#listeners.scrollend as Callback;
734
+ if (!callback) {
735
+ return;
736
+ }
737
+ try {
738
+ callback();
739
+ } catch (e) {
740
+ // eslint-disable-next-line no-console
741
+ console.warn(e);
742
+ }
743
+ }
718
744
  }