@streamscloud/kit 0.9.13 → 0.9.15

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 (38) 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-localization.d.ts +12 -0
  8. package/dist/core/files/file-validation-localization.js +75 -0
  9. package/dist/core/files/file-validation-rule-sets.d.ts +8 -0
  10. package/dist/core/files/file-validation-rule-sets.js +24 -0
  11. package/dist/core/files/file-validation-rules.d.ts +20 -11
  12. package/dist/core/files/file-validation-rules.js +74 -45
  13. package/dist/core/files/file-validation-types.d.ts +9 -2
  14. package/dist/core/files/file-validator.d.ts +3 -3
  15. package/dist/core/files/file-validator.js +16 -2
  16. package/dist/core/files/index.d.ts +2 -1
  17. package/dist/core/files/index.js +1 -0
  18. package/dist/core/utils/number-helper.js +1 -1
  19. package/dist/ui/collection-list/cmp.collection-list.svelte +242 -0
  20. package/dist/ui/collection-list/cmp.collection-list.svelte.d.ts +64 -0
  21. package/dist/ui/collection-list/collection-list-localization.d.ts +4 -0
  22. package/dist/ui/collection-list/collection-list-localization.js +19 -0
  23. package/dist/ui/collection-list/index.d.ts +2 -0
  24. package/dist/ui/collection-list/index.js +2 -0
  25. package/dist/ui/collection-list/types.d.ts +28 -0
  26. package/dist/ui/collection-list/types.js +20 -0
  27. package/dist/ui/file-uploader/cmp.file-uploader.svelte +2 -7
  28. package/dist/ui/file-uploader/cmp.file-uploader.svelte.d.ts +8 -5
  29. package/dist/ui/open-file-button/cmp.open-file-button.svelte +2 -6
  30. package/dist/ui/open-file-button/cmp.open-file-button.svelte.d.ts +7 -4
  31. package/dist/ui/select/multiselect-base.svelte +1 -0
  32. package/dist/ui/select/select-core.svelte.d.ts +2 -0
  33. package/dist/ui/select/select-core.svelte.js +6 -2
  34. package/dist/ui/select/select-listbox.svelte +31 -16
  35. package/dist/ui/select/select-listbox.svelte.d.ts +1 -1
  36. package/dist/ui/tooltip/cmp.tooltip.svelte +7 -0
  37. package/dist/ui/video/cmp.video.svelte +17 -7
  38. package/package.json +9 -1
@@ -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
+ }
@@ -1,13 +1,8 @@
1
- <script lang="ts">import { FileValidator } from '../../core/files';
1
+ <script lang="ts">import { deriveAccept, FileValidator } from '../../core/files';
2
2
  import { Icon } from '../icon';
3
3
  import IconArrowUpload from '@fluentui/svg-icons/icons/arrow_upload_24_regular.svg?raw';
4
4
  const { multiple = false, validationRules, disabled = false, title = '', description = '', on } = $props();
5
- // Auto-derive the native picker `accept` from any Mime-style rule that exposes its accept
6
- // string. Single source of truth — consumer writes the MIME pattern once on the rule.
7
- const accept = $derived(validationRules
8
- ?.map((r) => r.accept)
9
- .filter((a) => !!a)
10
- .join(',') ?? '');
5
+ const accept = $derived(deriveAccept(validationRules ?? []));
11
6
  let inputRef = $state.raw(undefined);
12
7
  let dragOver = $state(false);
13
8
  // Snapshot of MIME types captured at dragenter — DataTransfer.files is empty during drag
@@ -1,13 +1,16 @@
1
- import type { FileValidationResult, FileValidationRule } from '../../core/files';
1
+ import type { FileValidationResult, FileValidationRule, FileValidationRuleSets } from '../../core/files';
2
2
  type Props = {
3
3
  /** Allow multi-file selection. @default false */
4
4
  multiple?: boolean;
5
5
  /**
6
- * Validation rules applied to each picked / dropped file. The native picker filter and
7
- * drag-over visual hint are auto-derived from any `FileRules.Mime(...)` rule's `accept`
8
- * pass it once via the rule, the cmp wires the rest.
6
+ * Validation rules applied to each picked / dropped file a single set
7
+ * (`FileValidationRule[]`) or several sets (`FileValidationRule[][]`). With several sets,
8
+ * each file is validated by the first set whose `accept` matches it (a set with no `accept`
9
+ * rule matches anything; a file matching none is rejected). The native picker filter and
10
+ * drag-over visual hint are auto-derived from every set's `FileRules.Mime(...)` rule's
11
+ * `accept` — pass it once via the rule, the cmp wires the rest.
9
12
  */
10
- validationRules?: FileValidationRule[];
13
+ validationRules?: FileValidationRule[] | FileValidationRuleSets;
11
14
  disabled?: boolean;
12
15
  /** Headline shown inside the drop zone. */
13
16
  title?: string;
@@ -1,11 +1,7 @@
1
- <script lang="ts">import { FileValidator, openFile } from '../../core/files';
1
+ <script lang="ts">import { deriveAccept, FileValidator, openFile } from '../../core/files';
2
2
  import { Button } from '../button';
3
3
  const { size = 'md', variant = 'secondary', disabled = false, multiple = false, capture, validationRules, icon, iconPosition = 'leading', children, 'aria-label': ariaLabel, on } = $props();
4
- // Auto-derive native picker `accept` from any Mime-style rule that exposes its accept string.
5
- const accept = $derived(validationRules
6
- ?.map((r) => r.accept)
7
- .filter((a) => !!a)
8
- .join(',') ?? '');
4
+ const accept = $derived(deriveAccept(validationRules ?? []));
9
5
  const handleClick = async () => {
10
6
  const files = await openFile({ multiple, accept, capture });
11
7
  if (files.length === 0) {
@@ -1,4 +1,4 @@
1
- import type { FileValidationResult, FileValidationRule } from '../../core/files';
1
+ import type { FileValidationResult, FileValidationRule, FileValidationRuleSets } from '../../core/files';
2
2
  import type { ButtonSize, ButtonVariant } from '../button';
3
3
  import type { IconProp } from '../icon';
4
4
  import type { Snippet } from 'svelte';
@@ -12,10 +12,13 @@ type Props = {
12
12
  /** Optional file-picker capture hint (mobile camera / mic). */
13
13
  capture?: 'user' | 'environment';
14
14
  /**
15
- * Rules applied to picked files. The native picker `accept` filter is auto-derived from
16
- * any `FileRules.Mime(...)` rule's `accept` pass the MIME pattern once via the rule.
15
+ * Rules applied to picked files a single set (`FileValidationRule[]`) or several sets
16
+ * (`FileValidationRule[][]`). With several sets, each file is validated by the first set
17
+ * whose `accept` matches it (a set with no `accept` rule matches anything; a file matching
18
+ * none is rejected). The native picker `accept` filter is auto-derived from every set's
19
+ * `FileRules.Mime(...)` rule — pass the MIME pattern once via the rule.
17
20
  */
18
- validationRules?: FileValidationRule[];
21
+ validationRules?: FileValidationRule[] | FileValidationRuleSets;
19
22
  /** Icon — forwarded to the underlying Button. */
20
23
  icon?: IconProp;
21
24
  /** Icon position. @default 'leading' */
@@ -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
  }