@streamscloud/kit 0.9.12 → 0.9.14

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 (29) hide show
  1. package/dist/core/data-loaders/cursor-data-loader-with-search.svelte.d.ts +3 -1
  2. package/dist/core/data-loaders/cursor-data-loader-with-search.svelte.js +37 -16
  3. package/dist/core/data-loaders/cursor-data-loader.svelte.d.ts +3 -1
  4. package/dist/core/data-loaders/cursor-data-loader.svelte.js +34 -13
  5. package/dist/core/files/file-types.d.ts +4 -0
  6. package/dist/core/files/file-types.js +24 -9
  7. package/dist/core/files/file-validation-rules.js +2 -30
  8. package/dist/core/utils/number-helper.js +1 -1
  9. package/dist/ui/collection-list/cmp.collection-list.svelte +242 -0
  10. package/dist/ui/collection-list/cmp.collection-list.svelte.d.ts +64 -0
  11. package/dist/ui/collection-list/collection-list-localization.d.ts +4 -0
  12. package/dist/ui/collection-list/collection-list-localization.js +19 -0
  13. package/dist/ui/collection-list/index.d.ts +2 -0
  14. package/dist/ui/collection-list/index.js +2 -0
  15. package/dist/ui/collection-list/types.d.ts +28 -0
  16. package/dist/ui/collection-list/types.js +20 -0
  17. package/dist/ui/duration/cmp.duration.svelte +49 -0
  18. package/dist/ui/duration/cmp.duration.svelte.d.ts +27 -0
  19. package/dist/ui/duration/index.d.ts +1 -0
  20. package/dist/ui/duration/index.js +1 -0
  21. package/dist/ui/select/multiselect-base.svelte +1 -0
  22. package/dist/ui/select/select-core.svelte.d.ts +2 -0
  23. package/dist/ui/select/select-core.svelte.js +6 -2
  24. package/dist/ui/select/select-listbox.svelte +31 -16
  25. package/dist/ui/select/select-listbox.svelte.d.ts +1 -1
  26. package/dist/ui/stepper-dialog-layout/cmp.stepper-dialog-layout.svelte +1 -1
  27. package/dist/ui/tooltip/cmp.tooltip.svelte +7 -0
  28. package/dist/ui/video/cmp.video.svelte +17 -7
  29. package/package.json +13 -1
@@ -5,7 +5,8 @@ export declare class CursorDataLoaderWithSearch<T> implements IDataLoader<T> {
5
5
  private continuationToken;
6
6
  private _searchString;
7
7
  private loadPage;
8
- private deferred;
8
+ private pending;
9
+ private generation;
9
10
  private searchStringMinLength;
10
11
  constructor(init: {
11
12
  loadPage: (continuationToken: ContinuationToken, searchString: string) => Promise<CursorResult<T> | null>;
@@ -15,5 +16,6 @@ export declare class CursorDataLoaderWithSearch<T> implements IDataLoader<T> {
15
16
  loadMore: () => Promise<T[]>;
16
17
  reset(): Promise<void>;
17
18
  updateSearchString: (searchString: string | null) => void;
19
+ private runLoad;
18
20
  private isSearchStringEffective;
19
21
  }
@@ -1,11 +1,12 @@
1
- import { ContinuationToken, Deferred } from '..';
1
+ import { ContinuationToken } from '..';
2
2
  import { Utils } from '../utils';
3
3
  export class CursorDataLoaderWithSearch {
4
4
  items = $state.raw([]);
5
5
  continuationToken = $state.raw(ContinuationToken.init());
6
6
  _searchString = $state.raw('');
7
7
  loadPage;
8
- deferred = null;
8
+ pending = null;
9
+ generation = 0;
9
10
  searchStringMinLength = 1;
10
11
  constructor(init) {
11
12
  this.loadPage = init.loadPage;
@@ -18,25 +19,27 @@ export class CursorDataLoaderWithSearch {
18
19
  return this._searchString;
19
20
  }
20
21
  loadMore = async () => {
21
- if (this.deferred) {
22
- return this.deferred.promise;
22
+ if (this.pending) {
23
+ return this.pending;
23
24
  }
24
25
  if (!this.continuationToken.canLoadMore) {
25
26
  return [];
26
27
  }
27
- this.deferred = new Deferred();
28
- const search = this.isSearchStringEffective(this._searchString) ? this._searchString : '';
29
- let result = await this.loadPage(this.continuationToken, search);
30
- if (!result) {
31
- result = { items: [], continuationToken: ContinuationToken.preventLoading() };
32
- }
33
- this.continuationToken = result.continuationToken;
34
- this.items = [...this.items, ...result.items];
35
- this.deferred.resolve(result.items);
36
- this.deferred = null;
37
- return result.items;
28
+ const pending = this.runLoad();
29
+ this.pending = pending;
30
+ try {
31
+ return await pending;
32
+ }
33
+ finally {
34
+ // a concurrent reset() may have installed a newer load — leave that one alone
35
+ if (this.pending === pending) {
36
+ this.pending = null;
37
+ }
38
+ }
38
39
  };
39
40
  async reset() {
41
+ this.generation++;
42
+ this.pending = null;
40
43
  this.items = [];
41
44
  this.continuationToken = ContinuationToken.init();
42
45
  await this.loadMore();
@@ -50,7 +53,25 @@ export class CursorDataLoaderWithSearch {
50
53
  const isEffective = this.isSearchStringEffective(newValue);
51
54
  this._searchString = newValue;
52
55
  if (wasEffective || isEffective) {
53
- this.reset();
56
+ void this.reset();
57
+ }
58
+ };
59
+ runLoad = async () => {
60
+ const generation = this.generation;
61
+ const search = this.isSearchStringEffective(this._searchString) ? this._searchString : '';
62
+ try {
63
+ const result = (await this.loadPage(this.continuationToken, search)) ?? { items: [], continuationToken: ContinuationToken.preventLoading() };
64
+ // a reset() mid-load bumps the generation — drop the stale page instead of appending it to the cleared list
65
+ if (generation !== this.generation) {
66
+ return [];
67
+ }
68
+ this.continuationToken = result.continuationToken;
69
+ this.items = [...this.items, ...result.items];
70
+ return result.items;
71
+ }
72
+ catch (error) {
73
+ console.error('CursorDataLoaderWithSearch: failed to load page', error);
74
+ return [];
54
75
  }
55
76
  };
56
77
  isSearchStringEffective = (searchString) => searchString && searchString.length >= this.searchStringMinLength;
@@ -4,10 +4,12 @@ export declare class CursorDataLoader<T> implements IDataLoader<T> {
4
4
  items: T[];
5
5
  continuationToken: ContinuationToken;
6
6
  private loadPage;
7
- private deferred;
7
+ private pending;
8
+ private generation;
8
9
  constructor(init: {
9
10
  loadPage: (continuationToken: ContinuationToken) => Promise<CursorResult<T> | null>;
10
11
  });
11
12
  loadMore: () => Promise<T[]>;
12
13
  reset(): Promise<void>;
14
+ private runLoad;
13
15
  }
@@ -1,33 +1,54 @@
1
- import { ContinuationToken, Deferred } from '..';
1
+ import { ContinuationToken } from '..';
2
2
  export class CursorDataLoader {
3
3
  items = $state.raw([]);
4
4
  continuationToken = $state.raw(ContinuationToken.init());
5
5
  loadPage;
6
- deferred = null;
6
+ pending = null;
7
+ generation = 0;
7
8
  constructor(init) {
8
9
  this.loadPage = init.loadPage;
9
10
  }
10
11
  loadMore = async () => {
11
- if (this.deferred) {
12
- return this.deferred.promise;
12
+ if (this.pending) {
13
+ return this.pending;
13
14
  }
14
15
  if (!this.continuationToken.canLoadMore) {
15
16
  return [];
16
17
  }
17
- this.deferred = new Deferred();
18
- let result = await this.loadPage(this.continuationToken);
19
- if (!result) {
20
- result = { items: [], continuationToken: ContinuationToken.preventLoading() };
18
+ const pending = this.runLoad();
19
+ this.pending = pending;
20
+ try {
21
+ return await pending;
22
+ }
23
+ finally {
24
+ // a concurrent reset() may have installed a newer load — leave that one alone
25
+ if (this.pending === pending) {
26
+ this.pending = null;
27
+ }
21
28
  }
22
- this.continuationToken = result.continuationToken;
23
- this.items = [...this.items, ...result.items];
24
- this.deferred.resolve(result.items);
25
- this.deferred = null;
26
- return result.items;
27
29
  };
28
30
  async reset() {
31
+ this.generation++;
32
+ this.pending = null;
29
33
  this.items = [];
30
34
  this.continuationToken = ContinuationToken.init();
31
35
  await this.loadMore();
32
36
  }
37
+ runLoad = async () => {
38
+ const generation = this.generation;
39
+ try {
40
+ const result = (await this.loadPage(this.continuationToken)) ?? { items: [], continuationToken: ContinuationToken.preventLoading() };
41
+ // a reset() mid-load bumps the generation — drop the stale page instead of appending it to the cleared list
42
+ if (generation !== this.generation) {
43
+ return [];
44
+ }
45
+ this.continuationToken = result.continuationToken;
46
+ this.items = [...this.items, ...result.items];
47
+ return result.items;
48
+ }
49
+ catch (error) {
50
+ console.error('CursorDataLoader: failed to load page', error);
51
+ return [];
52
+ }
53
+ };
33
54
  }
@@ -13,4 +13,8 @@ export declare class AcceptFileType {
13
13
  static readonly webAssetImageOrVideo: string;
14
14
  static readonly webAssetIcon = "image/png";
15
15
  }
16
+ /**
17
+ * `accept`-style matcher. Tokens may be MIMEs (`image/*`, `*\/*`, `application/pdf`) or
18
+ * extensions (`.pdf`), case-insensitive. Empty accept = match anything.
19
+ */
16
20
  export declare const matchesAcceptedFileTypes: (file: File, acceptedTypesAndExtensions: string) => boolean;
@@ -1,4 +1,3 @@
1
- import { FileHelper } from './file-helper';
2
1
  export class AcceptFileType {
3
2
  static any = '*/*';
4
3
  static image = 'image/png,image/jpeg,image/webp';
@@ -15,14 +14,30 @@ export class AcceptFileType {
15
14
  static webAssetImageOrVideo = [this.webAssetImage, this.webAssetVideo].join(',');
16
15
  static webAssetIcon = 'image/png';
17
16
  }
17
+ /**
18
+ * `accept`-style matcher. Tokens may be MIMEs (`image/*`, `*\/*`, `application/pdf`) or
19
+ * extensions (`.pdf`), case-insensitive. Empty accept = match anything.
20
+ */
18
21
  export const matchesAcceptedFileTypes = (file, acceptedTypesAndExtensions) => {
19
- const isValid = AcceptFileType.anySupported.split(',').includes(file.type);
20
- if (acceptedTypesAndExtensions) {
21
- const acceptanceArray = acceptedTypesAndExtensions.split(',');
22
- const extensions = acceptanceArray.filter((x) => x.startsWith('.'));
23
- const fileExt = '.' + FileHelper.getFileExtension(file.name);
24
- const fileTypes = acceptanceArray.filter((x) => !x.startsWith('.'));
25
- return (extensions.includes(fileExt) || fileTypes.includes(file.type)) && isValid;
22
+ const tokens = acceptedTypesAndExtensions
23
+ .split(',')
24
+ .map((s) => s.trim().toLowerCase())
25
+ .filter(Boolean);
26
+ if (tokens.length === 0) {
27
+ return true;
26
28
  }
27
- return isValid;
29
+ const fileType = (file.type || '').toLowerCase();
30
+ const fileName = file.name.toLowerCase();
31
+ return tokens.some((token) => {
32
+ if (token.startsWith('.')) {
33
+ return fileName.endsWith(token);
34
+ }
35
+ if (token === '*/*') {
36
+ return true;
37
+ }
38
+ if (token.endsWith('/*')) {
39
+ return fileType.startsWith(token.slice(0, -1));
40
+ }
41
+ return fileType === token;
42
+ });
28
43
  };
@@ -1,32 +1,4 @@
1
- /**
2
- * Permissive `accept`-style matcher. Tokens may be MIMEs (`image/*`, `application/pdf`) or
3
- * extensions (`.pdf`). Empty accept = match anything. Unlike `matchesAcceptedFileTypes` from
4
- * `./file-types`, this does NOT cross-check against an internal whitelist — only the
5
- * consumer's accept string drives the decision.
6
- */
7
- const matchesAccept = (file, accept) => {
8
- if (!accept) {
9
- return true;
10
- }
11
- const tokens = accept
12
- .split(',')
13
- .map((s) => s.trim().toLowerCase())
14
- .filter(Boolean);
15
- if (tokens.length === 0) {
16
- return true;
17
- }
18
- const fileType = (file.type || '').toLowerCase();
19
- const fileName = file.name.toLowerCase();
20
- return tokens.some((t) => {
21
- if (t.startsWith('.')) {
22
- return fileName.endsWith(t);
23
- }
24
- if (t.endsWith('/*')) {
25
- return fileType.startsWith(t.slice(0, -1));
26
- }
27
- return fileType === t;
28
- });
29
- };
1
+ import { matchesAcceptedFileTypes } from './file-types';
30
2
  const readImageDimensions = (file) => new Promise((resolve, reject) => {
31
3
  const url = URL.createObjectURL(file);
32
4
  const img = new Image();
@@ -72,7 +44,7 @@ const readMediaDuration = (file, kind) => new Promise((resolve, reject) => {
72
44
  export const FileRules = {
73
45
  MaxSize: (bytes, message) => (file) => file.size > bytes ? message : null,
74
46
  Mime: (accept, message) => {
75
- const rule = (file) => (matchesAccept(file, accept) ? null : message);
47
+ const rule = (file) => (matchesAcceptedFileTypes(file, accept) ? null : message);
76
48
  rule.accept = accept;
77
49
  return rule;
78
50
  },
@@ -9,7 +9,7 @@ export class NumberHelper {
9
9
  const isNegative = n < 0;
10
10
  let modulo = isNegative ? 0 - n : n;
11
11
  const output = [];
12
- for (; modulo >= 1000; modulo = Math.floor(n / 1000)) {
12
+ for (; modulo >= 1000; modulo = Math.floor(modulo / 1000)) {
13
13
  output.unshift(String(modulo % 1000).padStart(3, '0'));
14
14
  }
15
15
  output.unshift(modulo);
@@ -0,0 +1,242 @@
1
+ <script lang="ts" generics="T">import { Icon, IconSlot } from '../icon';
2
+ import { CollectionListLocalization } from './collection-list-localization';
3
+ import IconReorderDotsVertical from '@fluentui/svg-icons/icons/re_order_dots_vertical_20_regular.svg?raw';
4
+ import { flip } from 'svelte/animate';
5
+ import { dndzone } from 'svelte-dnd-action';
6
+ let { items, actions = [], orderable = true, showImage = true, actionsAlwaysVisible = false, view, on } = $props();
7
+ const localization = new CollectionListLocalization();
8
+ // unique per instance so two lists on one screen can't drag items between each other
9
+ const dndType = `collection-list-${crypto.randomUUID()}`;
10
+ const flipDurationMs = 200;
11
+ let dndItems = $state.raw([]);
12
+ $effect(() => {
13
+ dndItems = items ?? [];
14
+ });
15
+ const dragDisabled = $derived(!orderable || dndItems.length < 2);
16
+ const handleConsider = (e) => {
17
+ dndItems = e.detail.items;
18
+ };
19
+ const handleFinalize = (e) => {
20
+ const reordered = e.detail.items;
21
+ dndItems = reordered;
22
+ const newIndex = reordered.findIndex((i) => i.id === e.detail.info.id);
23
+ on?.reorder?.(reordered.map((i) => i.originalItem), newIndex);
24
+ };
25
+ // strip the library's default yellow outline on the cloned drag element
26
+ const cleanDraggedElement = (element) => {
27
+ if (element) {
28
+ element.style.outline = 'none';
29
+ }
30
+ };
31
+ const visibleActions = (item, index) => actions.filter((a) => !a.hidden || !a.hidden(item.originalItem, index));
32
+ </script>
33
+
34
+ {#snippet builtinBody(item: CollectionItem<T>)}
35
+ {#if showImage}
36
+ <span class="collection-list__thumbnail">
37
+ {#if item.image}
38
+ <img class="collection-list__thumbnail-image" src={item.image} alt="" draggable="false" />
39
+ {/if}
40
+ </span>
41
+ {/if}
42
+ {#if item.icon}
43
+ <span class="collection-list__icon"><IconSlot icon={item.icon} /></span>
44
+ {/if}
45
+ {#if item.description}
46
+ <span class="collection-list__description" title={item.description}>{item.description}</span>
47
+ {/if}
48
+ {/snippet}
49
+
50
+ <div
51
+ class="collection-list"
52
+ use:dndzone={{
53
+ items: dndItems,
54
+ type: dndType,
55
+ flipDurationMs,
56
+ dropTargetStyle: {},
57
+ morphDisabled: true,
58
+ dragDisabled,
59
+ transformDraggedElement: cleanDraggedElement
60
+ }}
61
+ onconsider={handleConsider}
62
+ onfinalize={handleFinalize}>
63
+ {#each dndItems as item, index (item.id)}
64
+ {@const rowActions = visibleActions(item, index)}
65
+ <div class="collection-list__row" class:collection-list__row--actions-always={actionsAlwaysVisible} animate:flip={{ duration: flipDurationMs }}>
66
+ {#if !dragDisabled}
67
+ <span class="collection-list__handle" role="img" aria-label={localization.reorderHandle}>
68
+ <Icon src={IconReorderDotsVertical} />
69
+ </span>
70
+ {/if}
71
+
72
+ {#if view}
73
+ <div class="collection-list__body">{@render view({ item: item.originalItem, index })}</div>
74
+ {:else if item.imageClickCallback}
75
+ <button type="button" class="collection-list__body collection-list__body--clickable" onclick={() => item.imageClickCallback?.(item.originalItem)}>
76
+ {@render builtinBody(item)}
77
+ </button>
78
+ {:else}
79
+ <div class="collection-list__body">{@render builtinBody(item)}</div>
80
+ {/if}
81
+
82
+ {#if rowActions.length > 0}
83
+ <div class="collection-list__actions">
84
+ {#each rowActions as action (action)}
85
+ <button
86
+ type="button"
87
+ class="collection-list__action"
88
+ aria-label={action.label ?? localization.action}
89
+ onclick={() => action.callback(item.originalItem, index)}>
90
+ <IconSlot icon={action.icon} />
91
+ </button>
92
+ {/each}
93
+ </div>
94
+ {/if}
95
+ </div>
96
+ {/each}
97
+ </div>
98
+
99
+ <!--
100
+ @component
101
+ A reorderable vertical list of items: each row is an optional drag handle, an optional square
102
+ thumbnail + description (or a fully custom `view`), and hover-revealed icon actions. Generic over
103
+ the item payload `T`; the consumer maps its data into `CollectionItem<T>` and reads `on.reorder`.
104
+
105
+ ### CSS Custom Properties
106
+ | Property | Description | Default |
107
+ |---|---|---|
108
+ | `--sc-kit--collection-list--gap` | Gap between rows | `var(--sc-kit--space--2)` |
109
+ | `--sc-kit--collection-list--row--gap` | Gap between a row's handle / body / actions | `var(--sc-kit--space--3)` |
110
+ | `--sc-kit--collection-list--thumbnail--size` | Thumbnail inline size | `3.125rem` |
111
+ | `--sc-kit--collection-list--thumbnail--aspect-ratio` | Thumbnail aspect ratio | `1` |
112
+ | `--sc-kit--collection-list--thumbnail--radius` | Thumbnail corner radius | `var(--sc-kit--radius--sm)` |
113
+ | `--sc-kit--collection-list--thumbnail--background` | Thumbnail placeholder background | `var(--sc-kit--color--bg--element)` |
114
+ | `--sc-kit--collection-list--description--color` | Description / leading-icon color | `var(--sc-kit--color--text--primary)` |
115
+ | `--sc-kit--collection-list--handle--color` | Drag handle icon color | `var(--sc-kit--color--text--secondary)` |
116
+ | `--sc-kit--collection-list--action--color` | Action icon color | `var(--sc-kit--color--text--secondary)` |
117
+ | `--sc-kit--collection-list--action--background` | Action button background | `transparent` |
118
+ | `--sc-kit--collection-list--action--background--hover` | Action button hover background | `var(--sc-kit--color--bg--hover)` |
119
+ -->
120
+
121
+ <style>.collection-list {
122
+ --_cl--gap: var(--sc-kit--collection-list--gap, var(--sc-kit--space--2));
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: var(--_cl--gap);
126
+ }
127
+ .collection-list__row {
128
+ --_cl--row-gap: var(--sc-kit--collection-list--row--gap, var(--sc-kit--space--3));
129
+ --_cl--thumbnail-size: var(--sc-kit--collection-list--thumbnail--size, 3.125rem);
130
+ --_cl--thumbnail-aspect-ratio: var(--sc-kit--collection-list--thumbnail--aspect-ratio, 1);
131
+ --_cl--thumbnail-radius: var(--sc-kit--collection-list--thumbnail--radius, var(--sc-kit--radius--sm));
132
+ --_cl--thumbnail-background: var(--sc-kit--collection-list--thumbnail--background, var(--sc-kit--color--bg--element));
133
+ --_cl--description-color: var(--sc-kit--collection-list--description--color, var(--sc-kit--color--text--primary));
134
+ --_cl--handle-color: var(--sc-kit--collection-list--handle--color, var(--sc-kit--color--text--secondary));
135
+ --_cl--action-color: var(--sc-kit--collection-list--action--color, var(--sc-kit--color--text--secondary));
136
+ --_cl--action-background: var(--sc-kit--collection-list--action--background, transparent);
137
+ --_cl--action-background-hover: var(--sc-kit--collection-list--action--background--hover, var(--sc-kit--color--bg--hover));
138
+ --_cl--actions-opacity: 0;
139
+ --_cl--actions-pointer-events: none;
140
+ display: flex;
141
+ align-items: center;
142
+ gap: var(--_cl--row-gap);
143
+ min-width: 0;
144
+ }
145
+ .collection-list__row:hover, .collection-list__row:focus-within {
146
+ --_cl--actions-opacity: 1;
147
+ --_cl--actions-pointer-events: auto;
148
+ }
149
+ .collection-list__row--actions-always {
150
+ --_cl--actions-opacity: 1;
151
+ --_cl--actions-pointer-events: auto;
152
+ }
153
+ .collection-list__handle {
154
+ --sc-kit--icon--size: 1.25rem;
155
+ --sc-kit--icon--color: var(--_cl--handle-color);
156
+ flex: none;
157
+ display: inline-flex;
158
+ align-items: center;
159
+ cursor: grab;
160
+ }
161
+ .collection-list__handle:active {
162
+ cursor: grabbing;
163
+ }
164
+ .collection-list__body {
165
+ flex: 1;
166
+ min-width: 0;
167
+ display: flex;
168
+ align-items: center;
169
+ gap: var(--_cl--row-gap);
170
+ }
171
+ .collection-list__body--clickable {
172
+ appearance: none;
173
+ border: none;
174
+ background: none;
175
+ padding: 0;
176
+ font: inherit;
177
+ color: inherit;
178
+ text-align: start;
179
+ cursor: pointer;
180
+ }
181
+ .collection-list__thumbnail {
182
+ flex: none;
183
+ position: relative;
184
+ inline-size: var(--_cl--thumbnail-size);
185
+ aspect-ratio: var(--_cl--thumbnail-aspect-ratio);
186
+ border-radius: var(--_cl--thumbnail-radius);
187
+ background: var(--_cl--thumbnail-background);
188
+ overflow: hidden;
189
+ }
190
+ .collection-list__thumbnail-image {
191
+ position: absolute;
192
+ inset: 0;
193
+ inline-size: 100%;
194
+ block-size: 100%;
195
+ object-fit: cover;
196
+ }
197
+ .collection-list__icon {
198
+ --sc-kit--icon--size: 1rem;
199
+ --sc-kit--icon--color: var(--_cl--description-color);
200
+ flex: none;
201
+ display: inline-flex;
202
+ align-items: center;
203
+ }
204
+ .collection-list__description {
205
+ flex: 1;
206
+ min-width: 0;
207
+ color: var(--_cl--description-color);
208
+ font-size: var(--sc-kit--font-size--md);
209
+ line-height: var(--sc-kit--line-height--md);
210
+ text-overflow: ellipsis;
211
+ max-width: 100%;
212
+ white-space: nowrap;
213
+ overflow: hidden;
214
+ }
215
+ .collection-list__actions {
216
+ flex: none;
217
+ display: flex;
218
+ align-items: center;
219
+ gap: var(--sc-kit--space--1);
220
+ margin-inline-start: auto;
221
+ opacity: var(--_cl--actions-opacity);
222
+ pointer-events: var(--_cl--actions-pointer-events);
223
+ transition: opacity var(--sc-kit--duration--base) var(--sc-kit--ease--default);
224
+ }
225
+ .collection-list__action {
226
+ --sc-kit--icon--size: 1.25rem;
227
+ --sc-kit--icon--color: var(--_cl--action-color);
228
+ flex: none;
229
+ display: inline-flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ inline-size: 1.75rem;
233
+ block-size: 1.75rem;
234
+ border: none;
235
+ border-radius: var(--sc-kit--radius--sm);
236
+ background: var(--_cl--action-background);
237
+ cursor: pointer;
238
+ transition: background-color var(--sc-kit--duration--base) var(--sc-kit--ease--default);
239
+ }
240
+ .collection-list__action:hover {
241
+ background: var(--_cl--action-background-hover);
242
+ }</style>
@@ -0,0 +1,64 @@
1
+ import type { CollectionAction, CollectionItem } from './types';
2
+ import type { Snippet } from 'svelte';
3
+ declare function $$render<T>(): {
4
+ props: {
5
+ items: CollectionItem<T>[] | null;
6
+ /** Per-row icon buttons, revealed on row hover (or pinned via `actionsAlwaysVisible`). */
7
+ actions?: CollectionAction<T>[];
8
+ /** Enables drag reorder. Auto-disabled when fewer than 2 items. @default true */
9
+ orderable?: boolean;
10
+ /** Render the leading square thumbnail in the built-in row. Ignored when `view` is set. @default true */
11
+ showImage?: boolean;
12
+ /** Pin row actions visible instead of revealing them on hover/focus. @default false */
13
+ actionsAlwaysVisible?: boolean;
14
+ /** Replaces the built-in row body (thumbnail + description); the drag handle and actions still render around it. */
15
+ view?: Snippet<[{
16
+ item: T;
17
+ index: number;
18
+ }]>;
19
+ on?: {
20
+ reorder?: (items: T[], newIndex: number) => void;
21
+ };
22
+ };
23
+ exports: {};
24
+ bindings: "";
25
+ slots: {};
26
+ events: {};
27
+ };
28
+ declare class __sveltets_Render<T> {
29
+ props(): ReturnType<typeof $$render<T>>['props'];
30
+ events(): ReturnType<typeof $$render<T>>['events'];
31
+ slots(): ReturnType<typeof $$render<T>>['slots'];
32
+ bindings(): "";
33
+ exports(): {};
34
+ }
35
+ interface $$IsomorphicComponent {
36
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
37
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
38
+ } & ReturnType<__sveltets_Render<T>['exports']>;
39
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
40
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
41
+ }
42
+ /**
43
+ * A reorderable vertical list of items: each row is an optional drag handle, an optional square
44
+ * thumbnail + description (or a fully custom `view`), and hover-revealed icon actions. Generic over
45
+ * the item payload `T`; the consumer maps its data into `CollectionItem<T>` and reads `on.reorder`.
46
+ *
47
+ * ### CSS Custom Properties
48
+ * | Property | Description | Default |
49
+ * |---|---|---|
50
+ * | `--sc-kit--collection-list--gap` | Gap between rows | `var(--sc-kit--space--2)` |
51
+ * | `--sc-kit--collection-list--row--gap` | Gap between a row's handle / body / actions | `var(--sc-kit--space--3)` |
52
+ * | `--sc-kit--collection-list--thumbnail--size` | Thumbnail inline size | `3.125rem` |
53
+ * | `--sc-kit--collection-list--thumbnail--aspect-ratio` | Thumbnail aspect ratio | `1` |
54
+ * | `--sc-kit--collection-list--thumbnail--radius` | Thumbnail corner radius | `var(--sc-kit--radius--sm)` |
55
+ * | `--sc-kit--collection-list--thumbnail--background` | Thumbnail placeholder background | `var(--sc-kit--color--bg--element)` |
56
+ * | `--sc-kit--collection-list--description--color` | Description / leading-icon color | `var(--sc-kit--color--text--primary)` |
57
+ * | `--sc-kit--collection-list--handle--color` | Drag handle icon color | `var(--sc-kit--color--text--secondary)` |
58
+ * | `--sc-kit--collection-list--action--color` | Action icon color | `var(--sc-kit--color--text--secondary)` |
59
+ * | `--sc-kit--collection-list--action--background` | Action button background | `transparent` |
60
+ * | `--sc-kit--collection-list--action--background--hover` | Action button hover background | `var(--sc-kit--color--bg--hover)` |
61
+ */
62
+ declare const Cmp: $$IsomorphicComponent;
63
+ type Cmp<T> = InstanceType<typeof Cmp<T>>;
64
+ export default Cmp;
@@ -0,0 +1,4 @@
1
+ export declare class CollectionListLocalization {
2
+ get reorderHandle(): string;
3
+ get action(): string;
4
+ }
@@ -0,0 +1,19 @@
1
+ import { AppLocale } from '../../core/locale';
2
+ const loc = {
3
+ reorderHandle: {
4
+ en: 'Drag to reorder',
5
+ no: 'Dra for å endre rekkefølge'
6
+ },
7
+ action: {
8
+ en: 'Action',
9
+ no: 'Handling'
10
+ }
11
+ };
12
+ export class CollectionListLocalization {
13
+ get reorderHandle() {
14
+ return loc.reorderHandle[AppLocale.current];
15
+ }
16
+ get action() {
17
+ return loc.action[AppLocale.current];
18
+ }
19
+ }
@@ -0,0 +1,2 @@
1
+ export { default as CollectionList } from './cmp.collection-list.svelte';
2
+ export { CollectionItem, type CollectionAction } from './types';
@@ -0,0 +1,2 @@
1
+ export { default as CollectionList } from './cmp.collection-list.svelte';
2
+ export { CollectionItem } from './types';
@@ -0,0 +1,28 @@
1
+ import type { IconProp } from '../icon';
2
+ /**
3
+ * A row in a {@link CollectionList}. Wraps the consumer's payload (`originalItem`) with the
4
+ * presentation fields the built-in row renders. Construct one per item; `id` auto-fills when omitted.
5
+ */
6
+ export declare class CollectionItem<T> {
7
+ originalItem: T;
8
+ id: string;
9
+ image: string | null;
10
+ icon: IconProp | null;
11
+ description: string | null;
12
+ imageClickCallback: ((item: T) => void) | null;
13
+ constructor(init: {
14
+ originalItem: T;
15
+ id?: string;
16
+ image?: string | null;
17
+ icon?: IconProp | null;
18
+ description?: string | null;
19
+ imageClickCallback?: (item: T) => void;
20
+ });
21
+ }
22
+ export type CollectionAction<T> = {
23
+ icon: IconProp;
24
+ /** Accessible name for the icon-only button. Falls back to a generic localized label. */
25
+ label?: string;
26
+ callback: (item: T, index: number) => void;
27
+ hidden?: (item: T, index: number) => boolean;
28
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A row in a {@link CollectionList}. Wraps the consumer's payload (`originalItem`) with the
3
+ * presentation fields the built-in row renders. Construct one per item; `id` auto-fills when omitted.
4
+ */
5
+ export class CollectionItem {
6
+ originalItem;
7
+ id;
8
+ image;
9
+ icon;
10
+ description;
11
+ imageClickCallback;
12
+ constructor(init) {
13
+ this.originalItem = init.originalItem;
14
+ this.id = init.id ?? crypto.randomUUID();
15
+ this.image = init.image ?? null;
16
+ this.icon = init.icon ?? null;
17
+ this.description = init.description ?? null;
18
+ this.imageClickCallback = init.imageClickCallback ?? null;
19
+ }
20
+ }
@@ -0,0 +1,49 @@
1
+ <script lang="ts">const { seconds, variant = 'raw', showZero = false } = $props();
2
+ const format = (secs) => {
3
+ const total = Math.max(0, Math.round(secs));
4
+ const hours = Math.floor(total / 3600);
5
+ const minutes = Math.floor((total % 3600) / 60);
6
+ const ss = (total % 60).toString().padStart(2, '0');
7
+ return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${ss}` : `${minutes}:${ss}`;
8
+ };
9
+ const value = $derived(seconds !== null && seconds > 0 ? format(seconds) : showZero ? format(0) : null);
10
+ </script>
11
+
12
+ {#if value !== null}
13
+ <span class="duration" class:duration--badge={variant === 'badge'}>{value}</span>
14
+ {/if}
15
+
16
+ <!--
17
+ @component
18
+ Renders a media/time duration given in seconds as a human-readable clock string — `m:ss` under an
19
+ hour, `h:mm:ss` at or above (seconds are rounded, so a 60-second carry rolls up). `null` or ≤ 0
20
+ renders nothing; pass `showZero` to force `0:00`. `variant='raw'` (default) is bare text that
21
+ inherits the parent's typography; `variant='badge'` is a dark overlay pill for the bottom corner of
22
+ a video thumbnail. Placement (e.g. absolute over media) is the consumer's job via the cascade.
23
+
24
+ ### CSS Custom Properties
25
+ | Property | Description | Default |
26
+ |---|---|---|
27
+ | `--sc-kit--duration--badge--background` | Badge background (badge variant) | `rgb(0 0 0 / 55%)` (dark translucent) |
28
+ | `--sc-kit--duration--badge--color` | Badge text color (badge variant) | `var(--sc-kit--color--text--on-accent)` |
29
+ | `--sc-kit--duration--badge--padding` | Badge padding (badge variant) | `var(--sc-kit--space--1) var(--sc-kit--space--2)` |
30
+ | `--sc-kit--duration--badge--border-radius` | Badge corner radius (badge variant) | `var(--sc-kit--radius--sm)` |
31
+ | `--sc-kit--duration--badge--font-size` | Badge font size (badge variant) | `var(--sc-kit--font-size--xs)` |
32
+ -->
33
+ <style>.duration {
34
+ font-variant-numeric: tabular-nums;
35
+ }
36
+ .duration--badge {
37
+ --_duration--badge-background: var(--sc-kit--duration--badge--background, rgb(0 0 0 / 55%));
38
+ --_duration--badge-color: var(--sc-kit--duration--badge--color, var(--sc-kit--color--text--on-accent));
39
+ --_duration--badge-padding: var(--sc-kit--duration--badge--padding, var(--sc-kit--space--1) var(--sc-kit--space--2));
40
+ --_duration--badge-border-radius: var(--sc-kit--duration--badge--border-radius, var(--sc-kit--radius--sm));
41
+ --_duration--badge-font-size: var(--sc-kit--duration--badge--font-size, var(--sc-kit--font-size--xs));
42
+ display: inline-block;
43
+ padding: var(--_duration--badge-padding);
44
+ background: var(--_duration--badge-background);
45
+ border-radius: var(--_duration--badge-border-radius);
46
+ color: var(--_duration--badge-color);
47
+ font-size: var(--_duration--badge-font-size);
48
+ line-height: var(--sc-kit--leading--normal);
49
+ }</style>
@@ -0,0 +1,27 @@
1
+ type Props = {
2
+ /** Duration in seconds. Zero, negative, or `null` renders nothing unless `showZero`. */
3
+ seconds: number | null;
4
+ /** Visual style — `raw` is bare text; `badge` is a dark overlay pill for media thumbnails. @default 'raw' */
5
+ variant?: 'raw' | 'badge';
6
+ /** Render `0:00` for a null / zero / negative duration instead of nothing. @default false */
7
+ showZero?: boolean;
8
+ };
9
+ /**
10
+ * Renders a media/time duration given in seconds as a human-readable clock string — `m:ss` under an
11
+ * hour, `h:mm:ss` at or above (seconds are rounded, so a 60-second carry rolls up). `null` or ≤ 0
12
+ * renders nothing; pass `showZero` to force `0:00`. `variant='raw'` (default) is bare text that
13
+ * inherits the parent's typography; `variant='badge'` is a dark overlay pill for the bottom corner of
14
+ * a video thumbnail. Placement (e.g. absolute over media) is the consumer's job via the cascade.
15
+ *
16
+ * ### CSS Custom Properties
17
+ * | Property | Description | Default |
18
+ * |---|---|---|
19
+ * | `--sc-kit--duration--badge--background` | Badge background (badge variant) | `rgb(0 0 0 / 55%)` (dark translucent) |
20
+ * | `--sc-kit--duration--badge--color` | Badge text color (badge variant) | `var(--sc-kit--color--text--on-accent)` |
21
+ * | `--sc-kit--duration--badge--padding` | Badge padding (badge variant) | `var(--sc-kit--space--1) var(--sc-kit--space--2)` |
22
+ * | `--sc-kit--duration--badge--border-radius` | Badge corner radius (badge variant) | `var(--sc-kit--radius--sm)` |
23
+ * | `--sc-kit--duration--badge--font-size` | Badge font size (badge variant) | `var(--sc-kit--font-size--xs)` |
24
+ */
25
+ declare const Cmp: import("svelte").Component<Props, {}, "">;
26
+ type Cmp = ReturnType<typeof Cmp>;
27
+ export default Cmp;
@@ -0,0 +1 @@
1
+ export { default as Duration } from './cmp.duration.svelte';
@@ -0,0 +1 @@
1
+ export { default as Duration } from './cmp.duration.svelte';
@@ -35,6 +35,7 @@ const core = createSelectCore({
35
35
  getDebounceMs: () => debounceMs,
36
36
  getCompare: () => compare,
37
37
  getCanCreate: () => canCreate,
38
+ getHideCreateRow: () => showCreateSection,
38
39
  getGroupHeader: () => groupHeader,
39
40
  getSelectionMode: () => selectionMode,
40
41
  isPicked: (option) => isPicked(option.value),
@@ -13,6 +13,8 @@ export type SelectCoreConfig<T> = {
13
13
  getCompare: () => (a: T, b: T) => boolean;
14
14
  /** Optional `canCreate` validator — presence enables creatable mode. */
15
15
  getCanCreate: () => ((q: string) => boolean) | undefined;
16
+ /** When true, no `kind:'create'` row is emitted (the host renders its own create UI) — keyboard navigation must not reach a row the listbox doesn't show. @default false */
17
+ getHideCreateRow?: () => boolean;
16
18
  /** Group-header behavior. `toggle-all` only makes sense for multi. */
17
19
  getGroupHeader: () => 'static' | 'value' | 'toggle-all';
18
20
  /**
@@ -83,7 +83,7 @@ export function createSelectCore(config) {
83
83
  const groupMap = $derived(buildGroupMap(items));
84
84
  const rows = $derived.by(() => {
85
85
  const out = [];
86
- if (showCreateRow) {
86
+ if (showCreateRow && !config.getHideCreateRow?.()) {
87
87
  out.push({ kind: 'create', query });
88
88
  }
89
89
  const compare = config.getCompare();
@@ -187,9 +187,13 @@ export function createSelectCore(config) {
187
187
  cancelInFlightLoad();
188
188
  };
189
189
  const resetFilterKeepFocus = () => {
190
+ // keep highlight on the picked row unless a filter was narrowing the list — else it flashes to the first row after a click
191
+ const wasFiltering = isFiltering || query !== '';
190
192
  query = '';
191
193
  isFiltering = false;
192
- highlight = navigableIndices[0] ?? 0;
194
+ if (wasFiltering) {
195
+ highlight = navigableIndices[0] ?? 0;
196
+ }
193
197
  config.getInputEl()?.focus();
194
198
  void runLoad('');
195
199
  };
@@ -8,9 +8,12 @@ import IconCheckboxUnchecked from '@fluentui/svg-icons/icons/checkbox_unchecked_
8
8
  import IconCheckmark from '@fluentui/svg-icons/icons/checkmark_16_regular.svg?raw';
9
9
  let { triggerEl, isOpen, rows, highlight, loading = false, listboxId, matchTriggerWidth = true, selectedDisplay = 'checkmark', optionSnippet, headerSnippet, bodySnippet, hideCreateRows = false, suppressEmpty = false, on } = $props();
10
10
  const localization = new SelectListboxLocalization();
11
- const visibleRows = $derived(hideCreateRows ? rows.filter((r) => r.kind !== 'create') : rows);
11
+ // rows keep their index into the full `rows` array `highlight` and row ids are core-side indices,
12
+ // so filtering without preserving them shifts every row by one and Enter acts on the wrong row
13
+ const visibleRows = $derived(rows.map((row, index) => ({ row, index })).filter(({ row }) => !hideCreateRows || row.kind !== 'create'));
12
14
  const hasRows = $derived(visibleRows.length > 0 || !!headerSnippet);
13
15
  const showEmpty = $derived(!hasRows && !loading && !suppressEmpty);
16
+ const isHighlightMode = $derived(selectedDisplay === 'highlight');
14
17
  let panelEl = $state.raw(undefined);
15
18
  let triggerWidth = $state(undefined);
16
19
  // Floating UI: position when open, autoUpdate on scroll/resize, cleanup on close.
@@ -129,14 +132,15 @@ $effect(() => {
129
132
  {#if visibleRows.length === 0 && !headerSnippet}
130
133
  <div class="select-listbox__empty">{localization.noMatches}</div>
131
134
  {:else if bodySnippet}
132
- {@render bodySnippet({ rows: visibleRows, highlight, triggerWidth, on: { pickRow: on.pickRow, hoverRow: on.hoverRow } })}
135
+ {@render bodySnippet({ rows: visibleRows.map(({ row }) => row), highlight, triggerWidth, on: { pickRow: on.pickRow, hoverRow: on.hoverRow } })}
133
136
  {:else}
134
- {#each visibleRows as row, i (i)}
135
- {@const rowId = `${listboxId}-row-${i}`}
137
+ {#each visibleRows as { row, index } (index)}
138
+ {@const rowId = `${listboxId}-row-${index}`}
136
139
  {#if row.kind === 'create'}
137
140
  <div
138
141
  class="select-listbox__row select-listbox__row--create"
139
- class:select-listbox__row--active={i === highlight}
142
+ class:select-listbox__row--active={index === highlight}
143
+ class:select-listbox__row--ring={isHighlightMode && index === highlight}
140
144
  role="option"
141
145
  aria-selected="false"
142
146
  tabindex="-1"
@@ -144,7 +148,7 @@ $effect(() => {
144
148
  data-row-id={rowId}
145
149
  onclick={() => on.pickRow(row)}
146
150
  onkeydown={() => undefined}
147
- onmouseenter={() => on.hoverRow(i)}>
151
+ onmouseenter={() => on.hoverRow(index)}>
148
152
  <span class="select-listbox__row-icon"><Icon src={IconAdd} /></span>
149
153
  <span class="select-listbox__row-label">{localization.createLabel(row.query)}</span>
150
154
  </div>
@@ -152,8 +156,10 @@ $effect(() => {
152
156
  {#if row.selectable}
153
157
  <div
154
158
  class="select-listbox__row select-listbox__row--group-header select-listbox__row--selectable"
155
- class:select-listbox__row--active={i === highlight}
159
+ class:select-listbox__row--active={index === highlight}
156
160
  class:select-listbox__row--selected={row.selected}
161
+ class:select-listbox__row--ring={isHighlightMode && index === highlight}
162
+ class:select-listbox__row--fill={isHighlightMode && row.selected}
157
163
  role="option"
158
164
  aria-selected={row.state === 'checked'}
159
165
  aria-checked={row.state === 'indeterminate' ? 'mixed' : row.state === 'checked'}
@@ -162,7 +168,7 @@ $effect(() => {
162
168
  data-row-id={rowId}
163
169
  onclick={() => on.pickRow(row)}
164
170
  onkeydown={() => undefined}
165
- onmouseenter={() => on.hoverRow(i)}>
171
+ onmouseenter={() => on.hoverRow(index)}>
166
172
  {#if selectedDisplay === 'checkbox'}
167
173
  {@render checkboxGlyph(row.state)}
168
174
  {/if}
@@ -179,8 +185,10 @@ $effect(() => {
179
185
  {:else}
180
186
  <div
181
187
  class="select-listbox__row select-listbox__row--option"
182
- class:select-listbox__row--active={i === highlight}
188
+ class:select-listbox__row--active={index === highlight}
183
189
  class:select-listbox__row--selected={row.selected}
190
+ class:select-listbox__row--ring={isHighlightMode && index === highlight}
191
+ class:select-listbox__row--fill={isHighlightMode && row.selected}
184
192
  class:select-listbox__row--disabled={row.option.disabled}
185
193
  class:select-listbox__row--indent={row.indent}
186
194
  role="option"
@@ -191,7 +199,7 @@ $effect(() => {
191
199
  data-row-id={rowId}
192
200
  onclick={() => on.pickRow(row)}
193
201
  onkeydown={() => undefined}
194
- onmouseenter={() => on.hoverRow(i)}>
202
+ onmouseenter={() => on.hoverRow(index)}>
195
203
  {#if selectedDisplay === 'checkbox'}
196
204
  {@render checkboxGlyph(row.selected ? 'checked' : 'unchecked')}
197
205
  {/if}
@@ -228,7 +236,7 @@ dismiss events upward. Floating UI handles placement with autoUpdate on scroll/r
228
236
  | `--sc-kit--select--panel--padding` | Panel inner padding | `var(--sc-kit--space--1)` |
229
237
  | `--sc-kit--select--option--padding-block` | Option vertical padding | `var(--sc-kit--space--2)` |
230
238
  | `--sc-kit--select--option--padding-inline` | Option horizontal padding | `var(--sc-kit--space--3)` |
231
- | `--sc-kit--select--option--background--active` | Active row background | `var(--sc-kit--color--bg--hover)` |
239
+ | `--sc-kit--select--option--background--active` | Active / focused row background | `var(--sc-kit--color--accent--softer)` |
232
240
  | `--sc-kit--select--option--color` | Row text color | `var(--sc-kit--color--text--primary)` |
233
241
  | `--sc-kit--select--option--color--selected` | Selected row text color | `var(--sc-kit--color--accent)` |
234
242
  | `--sc-kit--select--group-header--color` | Group header text color | `var(--sc-kit--color--text--muted)` |
@@ -245,12 +253,15 @@ dismiss events upward. Floating UI handles placement with autoUpdate on scroll/r
245
253
  --_lb--padding: var(--sc-kit--select--panel--padding, var(--sc-kit--space--1));
246
254
  --_lb--row-padding-block: var(--sc-kit--select--option--padding-block, var(--sc-kit--space--2));
247
255
  --_lb--row-padding-inline: var(--sc-kit--select--option--padding-inline, var(--sc-kit--space--3));
248
- --_lb--row-background-active: var(--sc-kit--select--option--background--active, var(--sc-kit--color--bg--hover));
256
+ --_lb--row-background-active: var(--sc-kit--select--option--background--active, var(--sc-kit--color--accent--softer));
249
257
  --_lb--row-color: var(--sc-kit--select--option--color, var(--sc-kit--color--text--primary));
250
258
  --_lb--row-color-selected: var(--sc-kit--select--option--color--selected, var(--sc-kit--color--accent));
251
259
  --_lb--group-header-color: var(--sc-kit--select--group-header--color, var(--sc-kit--color--text--muted));
252
260
  --_lb--group-header-font-size: var(--sc-kit--select--group-header--font-size, var(--sc-kit--font-size--xs));
253
261
  --_lb--empty-color: var(--sc-kit--select--empty--color, var(--sc-kit--color--text--muted));
262
+ display: flex;
263
+ flex-direction: column;
264
+ gap: 0.125rem;
254
265
  position: absolute;
255
266
  z-index: var(--sc-kit--z-index--popover);
256
267
  background: var(--_lb--background);
@@ -298,6 +309,14 @@ dismiss events upward. Floating UI handles placement with autoUpdate on scroll/r
298
309
  .select-listbox__row--selected {
299
310
  color: var(--_lb--row-color-selected);
300
311
  }
312
+ .select-listbox__row--ring {
313
+ background: transparent;
314
+ box-shadow: inset 0 0 0 2px var(--sc-kit--color--accent--soft);
315
+ }
316
+ .select-listbox__row--fill {
317
+ background: var(--sc-kit--color--accent--softer);
318
+ color: var(--_lb--row-color);
319
+ }
301
320
  .select-listbox__row--disabled {
302
321
  color: var(--sc-kit--color--text--muted);
303
322
  cursor: default;
@@ -355,10 +374,6 @@ dismiss events upward. Floating UI handles placement with autoUpdate on scroll/r
355
374
  .select-listbox__row-checkbox--checked, .select-listbox__row-checkbox--indeterminate {
356
375
  --sc-kit--icon--color: var(--_lb--row-color-selected);
357
376
  }
358
- .select-listbox--display-highlight .select-listbox__row--selected {
359
- background: var(--_lb--row-background-active);
360
- color: var(--_lb--row-color);
361
- }
362
377
  .select-listbox__header {
363
378
  margin-block-end: var(--sc-kit--space--1);
364
379
  }
@@ -81,7 +81,7 @@ interface $$IsomorphicComponent {
81
81
  * | `--sc-kit--select--panel--padding` | Panel inner padding | `var(--sc-kit--space--1)` |
82
82
  * | `--sc-kit--select--option--padding-block` | Option vertical padding | `var(--sc-kit--space--2)` |
83
83
  * | `--sc-kit--select--option--padding-inline` | Option horizontal padding | `var(--sc-kit--space--3)` |
84
- * | `--sc-kit--select--option--background--active` | Active row background | `var(--sc-kit--color--bg--hover)` |
84
+ * | `--sc-kit--select--option--background--active` | Active / focused row background | `var(--sc-kit--color--accent--softer)` |
85
85
  * | `--sc-kit--select--option--color` | Row text color | `var(--sc-kit--color--text--primary)` |
86
86
  * | `--sc-kit--select--option--color--selected` | Selected row text color | `var(--sc-kit--color--accent)` |
87
87
  * | `--sc-kit--select--group-header--color` | Group header text color | `var(--sc-kit--color--text--muted)` |
@@ -113,7 +113,7 @@ Completed sidebar steps are clickable to go back; `canAdvance(step)` gates Next
113
113
  -->
114
114
 
115
115
  <style>.stepper-dialog-layout {
116
- --_sdl--sidebar-width: var(--sc-kit--stepper-dialog-layout--sidebar--width, functions.to-rem(320));
116
+ --_sdl--sidebar-width: var(--sc-kit--stepper-dialog-layout--sidebar--width, 20rem);
117
117
  --_sdl--sidebar-background: var(--sc-kit--stepper-dialog-layout--sidebar--background, var(--sc-kit--color--bg--field-alt));
118
118
  --sc-kit--dialog--height: var(--sc-kit--stepper-dialog-layout--height, auto);
119
119
  --sc-kit--dialog--body--overflow-y: auto;
@@ -53,6 +53,13 @@ const hide = () => {
53
53
  stopAutoUpdate = undefined;
54
54
  visible = false;
55
55
  };
56
+ // unmount while visible/pending must release the show timer and autoUpdate's scroll/resize observers
57
+ $effect(() => () => {
58
+ if (showTimer) {
59
+ clearTimeout(showTimer);
60
+ }
61
+ stopAutoUpdate?.();
62
+ });
56
63
  </script>
57
64
 
58
65
  <span
@@ -129,20 +129,30 @@ const play = async () => {
129
129
  if (!video) {
130
130
  return;
131
131
  }
132
+ let played = false;
132
133
  try {
133
134
  await video.play();
135
+ played = true;
134
136
  }
135
- catch {
136
- // can't play video without interaction with document, just ignoring the error
137
- }
138
- finally {
139
- if (video.paused) {
137
+ catch (error) {
138
+ // muted retry only on autoplay rejection an AbortError from a user pause must stay paused
139
+ if (error instanceof DOMException && error.name === 'NotAllowedError' && video.paused) {
140
140
  video.muted = true;
141
- void video.play();
141
+ try {
142
+ await video.play();
143
+ played = true;
144
+ }
145
+ catch {
146
+ // still blocked without user interaction
147
+ }
142
148
  }
149
+ }
150
+ finally {
143
151
  everActivated = true;
144
152
  }
145
- on?.started?.({ id, src });
153
+ if (played) {
154
+ on?.started?.({ id, src });
155
+ }
146
156
  };
147
157
  const pause = () => {
148
158
  video?.pause();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",
@@ -95,6 +95,10 @@
95
95
  "types": "./dist/ui/ai-panel/index.d.ts",
96
96
  "svelte": "./dist/ui/ai-panel/index.js"
97
97
  },
98
+ "./ui/announcement-banner": {
99
+ "types": "./dist/ui/announcement-banner/index.d.ts",
100
+ "svelte": "./dist/ui/announcement-banner/index.js"
101
+ },
98
102
  "./ui/avatar": {
99
103
  "types": "./dist/ui/avatar/index.d.ts",
100
104
  "svelte": "./dist/ui/avatar/index.js"
@@ -131,6 +135,10 @@
131
135
  "types": "./dist/ui/chip-group/index.d.ts",
132
136
  "svelte": "./dist/ui/chip-group/index.js"
133
137
  },
138
+ "./ui/collection-list": {
139
+ "types": "./dist/ui/collection-list/index.d.ts",
140
+ "svelte": "./dist/ui/collection-list/index.js"
141
+ },
134
142
  "./ui/color-picker": {
135
143
  "types": "./dist/ui/color-picker/index.d.ts",
136
144
  "svelte": "./dist/ui/color-picker/index.js"
@@ -163,6 +171,10 @@
163
171
  "types": "./dist/ui/drawer/index.d.ts",
164
172
  "svelte": "./dist/ui/drawer/index.js"
165
173
  },
174
+ "./ui/duration": {
175
+ "types": "./dist/ui/duration/index.d.ts",
176
+ "svelte": "./dist/ui/duration/index.js"
177
+ },
166
178
  "./ui/dynamic-component": {
167
179
  "types": "./dist/ui/dynamic-component/index.d.ts",
168
180
  "svelte": "./dist/ui/dynamic-component/index.js"