fluentui-svelte 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/components/auto-suggest-box/docs/llms.md +76 -15
- package/dist/components/card/card-header.svelte +2 -2
- package/dist/components/dialog/dialog-actions.svelte +17 -2
- package/dist/components/dialog/dialog-content.svelte +2 -2
- package/dist/components/dialog/dialog-content.svelte.d.ts +1 -1
- package/dist/components/dialog/dialog-surface.svelte +32 -3
- package/dist/components/dialog/dialog-title.svelte +23 -6
- package/dist/components/dialog/dialog-trigger.svelte +8 -2
- package/dist/components/dialog/dialog-trigger.svelte.d.ts +1 -1
- package/dist/components/dialog/docs/llms.md +2 -2
- package/dist/components/dialog/types.d.ts +6 -2
- package/dist/components/dialog/utils.d.ts +2 -0
- package/dist/components/dialog/utils.js +1 -0
- package/dist/components/dropdown/dropdown.svelte +1 -0
- package/dist/components/list-view/docs/llms.md +79 -27
- package/dist/components/list-view/list-view-item.svelte +14 -11
- package/dist/components/list-view/list-view.svelte +3 -2
- package/dist/components/list-view/types.d.ts +5 -1
- package/dist/components/menu/docs/llms.md +16 -0
- package/dist/components/menu/menu-popover.svelte +7 -12
- package/dist/components/menu/menu-popover.svelte.d.ts +2 -10
- package/dist/components/menu/types.d.ts +21 -0
- package/dist/components/rating/docs/llms.md +110 -0
- package/dist/components/rating/rating.svelte +210 -0
- package/dist/components/rating/rating.svelte.d.ts +19 -0
- package/dist/components/rating/types.d.ts +44 -0
- package/dist/components/rating/types.js +1 -0
- package/dist/components/tag/docs/llms.md +383 -0
- package/dist/components/tag/tag-group.d.ts +2 -0
- package/dist/components/tag/tag-group.js +2 -0
- package/dist/components/tag/tag-group.svelte +117 -0
- package/dist/components/tag/tag-group.svelte.d.ts +20 -0
- package/dist/components/tag/tag.svelte +458 -0
- package/dist/components/tag/tag.svelte.d.ts +19 -0
- package/dist/components/tag/types.d.ts +135 -0
- package/dist/components/tag/types.js +1 -0
- package/dist/components/tag-picker/docs/llms.md +292 -0
- package/dist/components/tag-picker/option-group.svelte +38 -0
- package/dist/components/tag-picker/option-group.svelte.d.ts +4 -0
- package/dist/components/tag-picker/option.svelte +71 -0
- package/dist/components/tag-picker/option.svelte.d.ts +4 -0
- package/dist/components/tag-picker/tag-picker.d.ts +2 -0
- package/dist/components/tag-picker/tag-picker.js +2 -0
- package/dist/components/tag-picker/tag-picker.svelte +644 -0
- package/dist/components/tag-picker/tag-picker.svelte.d.ts +21 -0
- package/dist/components/tag-picker/types.d.ts +122 -0
- package/dist/components/tag-picker/types.js +1 -0
- package/dist/components/tree-view/docs/llms.md +166 -1
- package/dist/components/tree-view/tree-view-item-content.svelte +41 -8
- package/dist/components/tree-view/tree-view-item.svelte +10 -1
- package/dist/components/tree-view/tree-view.svelte +17 -2
- package/dist/components/tree-view/tree-view.svelte.js +4 -2
- package/dist/components/tree-view/types.d.ts +4 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +12 -0
- package/dist/internal/constants.d.ts +0 -4
- package/dist/internal/constants.js +0 -4002
- package/package.json +3 -2
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
4
|
+
import { onClickOutside } from 'runed';
|
|
5
|
+
import ChevronDownRegular from 'fluentui-icons-svelte/ChevronDownRegular.svelte';
|
|
6
|
+
import { PREFIX } from '../../internal/constants.js';
|
|
7
|
+
import { RenderSoC } from '../../internal/index.js';
|
|
8
|
+
import { setTagPickerContext } from './tag-picker.js';
|
|
9
|
+
import { Flyout, ListView, ListViewItem, Tag, TagGroup } from '../../index.js';
|
|
10
|
+
import {
|
|
11
|
+
clearTabspotActive,
|
|
12
|
+
setTabspotActive,
|
|
13
|
+
setTabspotAttributes,
|
|
14
|
+
type TabspotNavigationEvent,
|
|
15
|
+
type TabspotNodeOptions
|
|
16
|
+
} from 'tabspot';
|
|
17
|
+
import { getGlobalFSContext } from '../../providers/fluentui-svelte/fluentui-svelte.js';
|
|
18
|
+
import type { TagSize } from '../tag/types.js';
|
|
19
|
+
import type { OptionType, TagPickerContext, TagPickerProps, TagPickerSizes } from './types.js';
|
|
20
|
+
|
|
21
|
+
const FALLBACK_ID = $props.id();
|
|
22
|
+
const ID = `${PREFIX}-tagpicker-${FALLBACK_ID}`;
|
|
23
|
+
const LIST_ID = `${ID}-list`;
|
|
24
|
+
|
|
25
|
+
/** The tag is one step smaller than the control around it. */
|
|
26
|
+
const TAG_SIZES: Record<TagPickerSizes, TagSize> = { medium: 'extra-small', large: 'small', 'extra-large': 'medium' };
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
ref = $bindable(),
|
|
30
|
+
inputRef = $bindable(),
|
|
31
|
+
selectedOptions = $bindable([]),
|
|
32
|
+
value = $bindable(''),
|
|
33
|
+
open = $bindable(false),
|
|
34
|
+
single,
|
|
35
|
+
disabled,
|
|
36
|
+
size = 'medium',
|
|
37
|
+
placeholder,
|
|
38
|
+
notFoundText = 'No options available',
|
|
39
|
+
expandIcon = ChevronDownRegular,
|
|
40
|
+
secondaryAction,
|
|
41
|
+
tag,
|
|
42
|
+
tagGroupProps,
|
|
43
|
+
inputProps,
|
|
44
|
+
onSelectionChange,
|
|
45
|
+
querySubmitted,
|
|
46
|
+
class: classes,
|
|
47
|
+
children,
|
|
48
|
+
...attributes
|
|
49
|
+
}: TagPickerProps = $props();
|
|
50
|
+
|
|
51
|
+
const INPUT_ID = $derived(inputProps?.id ?? `${ID}-input`);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The props the invariants rule over. The markup and the context read them through here, so
|
|
55
|
+
* reading any of them validates the lot during SSR and on every prop update. A picker with no
|
|
56
|
+
* list, or a disabled one, can never be open.
|
|
57
|
+
*/
|
|
58
|
+
const _picker = $derived.by(() => {
|
|
59
|
+
if (single && selectedOptions.length > 1) {
|
|
60
|
+
throw new Error('A single TagPicker cannot hold more than one selected option');
|
|
61
|
+
}
|
|
62
|
+
const hasList = !!children;
|
|
63
|
+
return { single, disabled, hasList, open: open && hasList && !disabled };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** The options on screen, by id. */
|
|
67
|
+
const options = new SvelteMap<string, OptionType>();
|
|
68
|
+
|
|
69
|
+
/** What the tag of each value says. It outlives its option, which leaves the screen when filtered or chosen. */
|
|
70
|
+
const labels = new SvelteMap<string, string>();
|
|
71
|
+
|
|
72
|
+
let listViewRef = $state<HTMLUListElement>();
|
|
73
|
+
|
|
74
|
+
let roundClass = $state('bottom');
|
|
75
|
+
|
|
76
|
+
let activeOption: OptionType | null = $state(null);
|
|
77
|
+
|
|
78
|
+
const tagSize = $derived(TAG_SIZES[size]);
|
|
79
|
+
|
|
80
|
+
const NAVIGATION: TabspotNodeOptions = $derived({
|
|
81
|
+
root: {},
|
|
82
|
+
mover: {
|
|
83
|
+
axis: 'vertical',
|
|
84
|
+
items: '.fs-tagpicker-option',
|
|
85
|
+
skip: "[aria-disabled='true']",
|
|
86
|
+
activation: {
|
|
87
|
+
mode: 'activedescendant',
|
|
88
|
+
controller: `#${CSS.escape(INPUT_ID)}`,
|
|
89
|
+
mark: { attribute: 'data-active' }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const context: TagPickerContext = $state({
|
|
95
|
+
config: null,
|
|
96
|
+
state: {
|
|
97
|
+
get query() {
|
|
98
|
+
return value;
|
|
99
|
+
},
|
|
100
|
+
get selectedOptions() {
|
|
101
|
+
return selectedOptions;
|
|
102
|
+
},
|
|
103
|
+
get open() {
|
|
104
|
+
return _picker.open;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
events: null,
|
|
108
|
+
methods: {
|
|
109
|
+
setLabel: (v, text) => {
|
|
110
|
+
labels.set(v, text);
|
|
111
|
+
},
|
|
112
|
+
setOption: (option) => {
|
|
113
|
+
options.set(option.id, option);
|
|
114
|
+
},
|
|
115
|
+
deleteOption: (id) => {
|
|
116
|
+
options.delete(id);
|
|
117
|
+
},
|
|
118
|
+
chooseOption: async (e, id) => {
|
|
119
|
+
const option = options.get(id);
|
|
120
|
+
|
|
121
|
+
if (!option || option.disabled || selectedOptions.includes(option.value)) return;
|
|
122
|
+
|
|
123
|
+
selectedOptions = _picker.single ? [option.value] : [...selectedOptions, option.value];
|
|
124
|
+
value = '';
|
|
125
|
+
activeOption = null;
|
|
126
|
+
onSelectionChange?.(e, selectedOptions);
|
|
127
|
+
|
|
128
|
+
if (_picker.single) open = false;
|
|
129
|
+
|
|
130
|
+
// The option under the cursor just left the screen.
|
|
131
|
+
await tick();
|
|
132
|
+
|
|
133
|
+
if (listViewRef) clearTabspotActive(listViewRef);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
setTagPickerContext(context);
|
|
139
|
+
|
|
140
|
+
/** Put the cursor on `element`, or send it home when there is none. */
|
|
141
|
+
function pointCursorAt(element: HTMLElement | null) {
|
|
142
|
+
if (!listViewRef) return;
|
|
143
|
+
|
|
144
|
+
// `nearest` walks past an option the cursor cannot land on.
|
|
145
|
+
if (element && setTabspotActive(element, { nearest: true }).ok) return;
|
|
146
|
+
|
|
147
|
+
clearTabspotActive(listViewRef);
|
|
148
|
+
readCursor(null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readCursor(element: HTMLElement | null) {
|
|
152
|
+
const option = element ? (options.get(element.id) ?? null) : null;
|
|
153
|
+
|
|
154
|
+
if (option?.id === activeOption?.id) return;
|
|
155
|
+
|
|
156
|
+
activeOption = option;
|
|
157
|
+
|
|
158
|
+
if (option) element?.scrollIntoView({ block: 'nearest' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `atEdge` means the list ran out of options: send the cursor back to the input. */
|
|
162
|
+
function navigate(event: TabspotNavigationEvent) {
|
|
163
|
+
if (!event.atEdge) {
|
|
164
|
+
readCursor(event.to);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
|
|
170
|
+
if (listViewRef) clearTabspotActive(listViewRef);
|
|
171
|
+
|
|
172
|
+
readCursor(null);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Open, then let Tabspot enter the list. */
|
|
176
|
+
async function enterList(key: 'ArrowUp' | 'ArrowDown') {
|
|
177
|
+
open = true;
|
|
178
|
+
await tick();
|
|
179
|
+
|
|
180
|
+
// The key is replayed rather than turned into a `setTabspotActive` call: a second arrow
|
|
181
|
+
// pressed while this one is still in flight is a second move, and two cursor sets onto
|
|
182
|
+
// the same option would be one. The extra hop lets Tabspot see the list it just got.
|
|
183
|
+
await undefined;
|
|
184
|
+
await tick();
|
|
185
|
+
|
|
186
|
+
inputRef?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
190
|
+
if (e.isComposing) return;
|
|
191
|
+
|
|
192
|
+
switch (e.key) {
|
|
193
|
+
case 'ArrowDown':
|
|
194
|
+
case 'ArrowUp':
|
|
195
|
+
// An arrow inside an open list is Tabspot's. The replay of this one is not trusted.
|
|
196
|
+
if (!e.isTrusted || _picker.open || !_picker.hasList || _picker.disabled) return;
|
|
197
|
+
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
enterList(e.key);
|
|
200
|
+
break;
|
|
201
|
+
case 'Enter':
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
|
|
204
|
+
if (_picker.open && activeOption) context.methods.chooseOption(e, activeOption.id);
|
|
205
|
+
else if (value) querySubmitted?.(e, value);
|
|
206
|
+
else if (_picker.hasList && !_picker.disabled) open = true;
|
|
207
|
+
break;
|
|
208
|
+
case 'Escape':
|
|
209
|
+
if (!_picker.open) return;
|
|
210
|
+
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
open = false;
|
|
213
|
+
break;
|
|
214
|
+
case 'Tab':
|
|
215
|
+
open = false;
|
|
216
|
+
break;
|
|
217
|
+
case 'Backspace':
|
|
218
|
+
if (_picker.disabled || value !== '' || selectedOptions.length === 0) return;
|
|
219
|
+
|
|
220
|
+
selectedOptions = selectedOptions.slice(0, -1);
|
|
221
|
+
onSelectionChange?.(e, selectedOptions);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
|
|
227
|
+
if (!_picker.hasList || _picker.disabled) return;
|
|
228
|
+
|
|
229
|
+
if (e.currentTarget.value && !open) open = true;
|
|
230
|
+
|
|
231
|
+
await tick();
|
|
232
|
+
|
|
233
|
+
// The first match is the one Enter would choose.
|
|
234
|
+
pointCursorAt(value ? (listViewRef?.querySelector<HTMLElement>('.fs-tagpicker-option') ?? null) : null);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function handleDismiss(e: MouseEvent | KeyboardEvent, data: { value: string }) {
|
|
238
|
+
selectedOptions = selectedOptions.filter((v) => v !== data.value);
|
|
239
|
+
onSelectionChange?.(e, selectedOptions);
|
|
240
|
+
inputRef?.focus();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function handleControlClick(e: MouseEvent) {
|
|
244
|
+
// Tags and the aside have a job of their own.
|
|
245
|
+
if (_picker.disabled || (e.target as Element).closest('.fs-tag, .fs-tagpicker-aside')) return;
|
|
246
|
+
|
|
247
|
+
inputRef?.focus();
|
|
248
|
+
|
|
249
|
+
if (_picker.hasList) open = true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function handleChevronClick() {
|
|
253
|
+
if (_picker.disabled) return;
|
|
254
|
+
|
|
255
|
+
inputRef?.focus();
|
|
256
|
+
|
|
257
|
+
if (_picker.hasList) open = !_picker.open;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A click on the secondary action must not strand focus on it: the list is still open, and Escape lives on the input. */
|
|
261
|
+
function handleAsideClick(e: MouseEvent) {
|
|
262
|
+
if (_picker.open && !(e.target as Element).closest('.chevron')) inputRef?.focus();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Focus stays in the input: the chrome around it only forwards the click. */
|
|
266
|
+
function keepFocus(e: MouseEvent) {
|
|
267
|
+
if (!(e.target as Element).closest('input, button')) e.preventDefault();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handlePlacementChange(newPlacement: string) {
|
|
271
|
+
roundClass = newPlacement.includes('-') ? newPlacement.split('-')[0] : newPlacement;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
onClickOutside(
|
|
275
|
+
() => ref,
|
|
276
|
+
() => (open = false)
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const globalContext = getGlobalFSContext();
|
|
280
|
+
|
|
281
|
+
// Closing takes the list down, and leaving `activeOption` behind would point at nothing.
|
|
282
|
+
$effect(() => {
|
|
283
|
+
if (!_picker.open) activeOption = null;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
$effect(() => {
|
|
287
|
+
if (!_picker.open || !listViewRef) return;
|
|
288
|
+
|
|
289
|
+
setTabspotAttributes({ element: listViewRef, config: NAVIGATION });
|
|
290
|
+
|
|
291
|
+
return globalContext?.state.tabspotInstance?.subscribe(listViewRef, navigate);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Tabspot does not notice an option that left the list, so it learns the options that are left.
|
|
295
|
+
$effect(() => {
|
|
296
|
+
void [...options.keys()];
|
|
297
|
+
|
|
298
|
+
if (listViewRef) globalContext?.state.tabspotInstance?.rebuild(listViewRef);
|
|
299
|
+
});
|
|
300
|
+
</script>
|
|
301
|
+
|
|
302
|
+
<!--
|
|
303
|
+
@component
|
|
304
|
+
A text field and a dropdown that give people a way to choose several options from a list, or to enter their own choice. Every choice is shown as a dismissible Tag in the control.
|
|
305
|
+
|
|
306
|
+
- Usage:
|
|
307
|
+
```tsx
|
|
308
|
+
<script>
|
|
309
|
+
import { TagPicker, TagPickerOption } from 'fluentui-svelte';
|
|
310
|
+
|
|
311
|
+
let selectedOptions = $state([]);
|
|
312
|
+
</script>
|
|
313
|
+
|
|
314
|
+
<TagPicker bind:selectedOptions inputProps={{ 'aria-label': 'Fruits' }}>
|
|
315
|
+
<TagPickerOption value="Apple">Apple</TagPickerOption>
|
|
316
|
+
<TagPickerOption value="Banana">Banana</TagPickerOption>
|
|
317
|
+
</TagPicker>
|
|
318
|
+
```
|
|
319
|
+
-->
|
|
320
|
+
<div
|
|
321
|
+
class={['fs-tagpicker', `size-${size}`, { disabled: _picker.disabled, open: _picker.open }, classes]}
|
|
322
|
+
bind:this={ref}
|
|
323
|
+
{...attributes}
|
|
324
|
+
>
|
|
325
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
326
|
+
<div class={['control', _picker.open && `square-${roundClass}`]} onclick={handleControlClick} onmousedown={keepFocus}>
|
|
327
|
+
<div class="content">
|
|
328
|
+
{#if selectedOptions.length > 0}
|
|
329
|
+
<TagGroup
|
|
330
|
+
role="list"
|
|
331
|
+
size={tagSize}
|
|
332
|
+
disabled={_picker.disabled}
|
|
333
|
+
dismissible
|
|
334
|
+
onDismiss={handleDismiss}
|
|
335
|
+
{...tagGroupProps}
|
|
336
|
+
>
|
|
337
|
+
{#each selectedOptions as v (v)}
|
|
338
|
+
{@const text = labels.get(v) ?? v}
|
|
339
|
+
{#if tag}
|
|
340
|
+
{@render tag({ value: v, text })}
|
|
341
|
+
{:else}
|
|
342
|
+
<Tag role="listitem" value={v} title={text}>{text}</Tag>
|
|
343
|
+
{/if}
|
|
344
|
+
{/each}
|
|
345
|
+
</TagGroup>
|
|
346
|
+
{/if}
|
|
347
|
+
<input
|
|
348
|
+
bind:this={inputRef}
|
|
349
|
+
type="text"
|
|
350
|
+
autocomplete="off"
|
|
351
|
+
{placeholder}
|
|
352
|
+
id={INPUT_ID}
|
|
353
|
+
disabled={_picker.disabled}
|
|
354
|
+
role={_picker.hasList ? 'combobox' : undefined}
|
|
355
|
+
aria-expanded={_picker.hasList ? _picker.open : undefined}
|
|
356
|
+
aria-haspopup={_picker.hasList ? 'listbox' : undefined}
|
|
357
|
+
aria-autocomplete={_picker.hasList ? 'list' : undefined}
|
|
358
|
+
aria-controls={_picker.open ? LIST_ID : undefined}
|
|
359
|
+
{...inputProps}
|
|
360
|
+
bind:value
|
|
361
|
+
oninput={handleInput}
|
|
362
|
+
onkeydown={handleKeyDown}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
{#if secondaryAction || (expandIcon && _picker.hasList)}
|
|
366
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
367
|
+
<span class="fs-tagpicker-aside" onclick={handleAsideClick}>
|
|
368
|
+
{@render secondaryAction?.()}
|
|
369
|
+
<!-- Without a list there is nothing to expand. -->
|
|
370
|
+
{#if expandIcon && _picker.hasList}
|
|
371
|
+
<span class="chevron" aria-hidden="true" onclick={handleChevronClick}>
|
|
372
|
+
<RenderSoC SoC={expandIcon} />
|
|
373
|
+
</span>
|
|
374
|
+
{/if}
|
|
375
|
+
</span>
|
|
376
|
+
{/if}
|
|
377
|
+
</div>
|
|
378
|
+
{#if _picker.open && ref}
|
|
379
|
+
<Flyout
|
|
380
|
+
floating
|
|
381
|
+
offset={0}
|
|
382
|
+
reference={ref}
|
|
383
|
+
roundCorners="bottom"
|
|
384
|
+
placementConfig={{ allowedPlacements: ['bottom', 'top'] }}
|
|
385
|
+
onPlacementChange={(place) => handlePlacementChange(place)}
|
|
386
|
+
class={['fs-tagpicker-flyout', roundClass === 'top' && 'top-shadow']}
|
|
387
|
+
>
|
|
388
|
+
<ListView
|
|
389
|
+
role="listbox"
|
|
390
|
+
navigationMode="items"
|
|
391
|
+
disableTabspot
|
|
392
|
+
id={LIST_ID}
|
|
393
|
+
aria-multiselectable={_picker.single ? undefined : 'true'}
|
|
394
|
+
aria-label={inputProps?.['aria-label']}
|
|
395
|
+
aria-labelledby={inputProps?.['aria-labelledby']}
|
|
396
|
+
bind:ref={listViewRef}
|
|
397
|
+
onmousedown={(e: MouseEvent) => e.preventDefault()}
|
|
398
|
+
>
|
|
399
|
+
{#if options.size === 0}
|
|
400
|
+
<ListViewItem role="option" disabled>{notFoundText}</ListViewItem>
|
|
401
|
+
{/if}
|
|
402
|
+
{@render children?.()}
|
|
403
|
+
</ListView>
|
|
404
|
+
</Flyout>
|
|
405
|
+
{:else if children}
|
|
406
|
+
<!-- Nobody sees the options, but each one still tells its tag what to say. -->
|
|
407
|
+
<ListView hidden>
|
|
408
|
+
{@render children()}
|
|
409
|
+
</ListView>
|
|
410
|
+
{/if}
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
<style>
|
|
414
|
+
.fs-tagpicker {
|
|
415
|
+
--tp-min-height: 2rem;
|
|
416
|
+
--tp-group-pad: 0.375rem;
|
|
417
|
+
--tp-gap: 0.25rem;
|
|
418
|
+
--tp-input-pad: 0.375rem;
|
|
419
|
+
--tp-icon: 1rem;
|
|
420
|
+
--tp-icon-gap: 0.125rem;
|
|
421
|
+
|
|
422
|
+
position: relative;
|
|
423
|
+
box-sizing: border-box;
|
|
424
|
+
inline-size: 100%;
|
|
425
|
+
min-inline-size: 15.625rem;
|
|
426
|
+
&.size-large {
|
|
427
|
+
--tp-min-height: 2.5rem;
|
|
428
|
+
--tp-group-pad: 0.5rem;
|
|
429
|
+
--tp-gap: 0.375rem;
|
|
430
|
+
--tp-input-pad: 0.625rem;
|
|
431
|
+
--tp-icon: 1.25rem;
|
|
432
|
+
}
|
|
433
|
+
&.size-extra-large {
|
|
434
|
+
--tp-min-height: 2.75rem;
|
|
435
|
+
--tp-group-pad: 0.5rem;
|
|
436
|
+
--tp-gap: 0.375rem;
|
|
437
|
+
--tp-input-pad: 0.75rem;
|
|
438
|
+
--tp-icon: 1.5rem;
|
|
439
|
+
--tp-icon-gap: 0.375rem;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.control {
|
|
444
|
+
position: relative;
|
|
445
|
+
display: grid;
|
|
446
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
447
|
+
box-sizing: border-box;
|
|
448
|
+
min-block-size: var(--tp-min-height);
|
|
449
|
+
padding-inline: 0.75rem;
|
|
450
|
+
border-radius: var(--fs-control-border-radius);
|
|
451
|
+
background: var(--fs-control-fill-default);
|
|
452
|
+
cursor: text;
|
|
453
|
+
&:hover {
|
|
454
|
+
background: var(--fs-control-fill-secondary);
|
|
455
|
+
}
|
|
456
|
+
&::before {
|
|
457
|
+
content: '';
|
|
458
|
+
pointer-events: none;
|
|
459
|
+
inline-size: calc(100% + 0.125rem);
|
|
460
|
+
block-size: calc(100% + 0.125rem);
|
|
461
|
+
position: absolute;
|
|
462
|
+
inset-block-start: -0.063rem;
|
|
463
|
+
inset-inline-start: -0.063rem;
|
|
464
|
+
padding: 0.063rem;
|
|
465
|
+
box-sizing: border-box;
|
|
466
|
+
border-radius: 0.313rem;
|
|
467
|
+
mask:
|
|
468
|
+
linear-gradient(#fff 0 0) content-box,
|
|
469
|
+
linear-gradient(#fff 0 0);
|
|
470
|
+
mask-composite: exclude;
|
|
471
|
+
background: var(--fs-elevation-text-border);
|
|
472
|
+
z-index: 1;
|
|
473
|
+
}
|
|
474
|
+
/* The bar grows from the middle on focus. Its clip-path is static: animating it would freeze the page. */
|
|
475
|
+
&::after {
|
|
476
|
+
content: '';
|
|
477
|
+
pointer-events: none;
|
|
478
|
+
inline-size: 0;
|
|
479
|
+
block-size: 10px;
|
|
480
|
+
border-radius: 0 0 5px 5px;
|
|
481
|
+
position: absolute;
|
|
482
|
+
inset-block-end: -0.063rem;
|
|
483
|
+
inset-inline-start: calc(50% - 0.063rem);
|
|
484
|
+
clip-path: polygon(0 75%, 100% 75%, 100% 100%, 0% 100%);
|
|
485
|
+
background: transparent;
|
|
486
|
+
transition:
|
|
487
|
+
inline-size var(--fs-normal-duration) var(--fs-point-to-point),
|
|
488
|
+
inset-inline-start var(--fs-normal-duration) var(--fs-point-to-point);
|
|
489
|
+
z-index: 2;
|
|
490
|
+
}
|
|
491
|
+
&.square-top {
|
|
492
|
+
border-start-start-radius: 0;
|
|
493
|
+
border-start-end-radius: 0;
|
|
494
|
+
&::before,
|
|
495
|
+
&::after {
|
|
496
|
+
border-start-start-radius: 0;
|
|
497
|
+
border-start-end-radius: 0;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
&.square-bottom {
|
|
501
|
+
border-end-start-radius: 0;
|
|
502
|
+
border-end-end-radius: 0;
|
|
503
|
+
&::before,
|
|
504
|
+
&::after {
|
|
505
|
+
border-end-start-radius: 0;
|
|
506
|
+
border-end-end-radius: 0;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.fs-tagpicker:focus-within .control,
|
|
512
|
+
.open .control {
|
|
513
|
+
&::after {
|
|
514
|
+
inline-size: calc(100% + 0.125rem);
|
|
515
|
+
inset-inline-start: -0.063rem;
|
|
516
|
+
background-color: var(--fs-accent-fill-default);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
.fs-tagpicker:focus-within .control {
|
|
520
|
+
background: var(--fs-control-fill-input-active);
|
|
521
|
+
& input::placeholder {
|
|
522
|
+
color: var(--fs-text-tertiary);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.content {
|
|
527
|
+
display: flex;
|
|
528
|
+
flex-wrap: wrap;
|
|
529
|
+
align-items: center;
|
|
530
|
+
column-gap: var(--tp-gap);
|
|
531
|
+
min-inline-size: 0;
|
|
532
|
+
/* The tags are flex items of the control, so the input follows the last one instead of starting a row. */
|
|
533
|
+
& :global(.fs-tag-group) {
|
|
534
|
+
display: contents;
|
|
535
|
+
}
|
|
536
|
+
/* A long tag shortens itself instead of overflowing the control. */
|
|
537
|
+
& :global(.fs-tag) {
|
|
538
|
+
max-inline-size: 100%;
|
|
539
|
+
margin-block: var(--tp-group-pad);
|
|
540
|
+
}
|
|
541
|
+
& :global(.fs-tag-primary-text) {
|
|
542
|
+
overflow: hidden;
|
|
543
|
+
text-overflow: ellipsis;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
input {
|
|
548
|
+
flex: 1 1 0;
|
|
549
|
+
min-inline-size: 1.5rem;
|
|
550
|
+
max-inline-size: 100%;
|
|
551
|
+
box-sizing: border-box;
|
|
552
|
+
margin: 0;
|
|
553
|
+
padding: var(--tp-input-pad) 0;
|
|
554
|
+
appearance: none;
|
|
555
|
+
background: transparent;
|
|
556
|
+
border: none;
|
|
557
|
+
outline: none;
|
|
558
|
+
color: var(--fs-text-primary);
|
|
559
|
+
font-family: inherit;
|
|
560
|
+
font-size: var(--fs-body2-font-size);
|
|
561
|
+
&::placeholder {
|
|
562
|
+
color: var(--fs-text-secondary);
|
|
563
|
+
}
|
|
564
|
+
&[readonly] {
|
|
565
|
+
caret-color: transparent;
|
|
566
|
+
cursor: pointer;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
.fs-tagpicker-aside {
|
|
571
|
+
display: flex;
|
|
572
|
+
align-items: center;
|
|
573
|
+
align-self: start;
|
|
574
|
+
block-size: var(--tp-min-height);
|
|
575
|
+
color: var(--fs-text-secondary);
|
|
576
|
+
/* The control sits above the border mask, and the action must stay clickable. */
|
|
577
|
+
z-index: 2;
|
|
578
|
+
/* The action sits inside the control, whatever size its own button has. */
|
|
579
|
+
& :global(.fs-button) {
|
|
580
|
+
block-size: calc(var(--tp-min-height) - 0.25rem);
|
|
581
|
+
padding-block: 0;
|
|
582
|
+
font-size: var(--fs-body2-font-size);
|
|
583
|
+
line-height: var(--fs-body2-line-height);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/* The arrow behaves like a subtle button. Its negative margins keep the icon where it was. */
|
|
587
|
+
.chevron {
|
|
588
|
+
display: flex;
|
|
589
|
+
margin-inline: calc(var(--tp-icon-gap) - 0.25rem) -0.25rem;
|
|
590
|
+
padding: 0.25rem;
|
|
591
|
+
border-radius: var(--fs-control-border-radius);
|
|
592
|
+
cursor: pointer;
|
|
593
|
+
&:hover {
|
|
594
|
+
background: var(--fs-subtle-fill-secondary);
|
|
595
|
+
}
|
|
596
|
+
&:active {
|
|
597
|
+
color: var(--fs-text-tertiary);
|
|
598
|
+
background: var(--fs-subtle-fill-tertiary);
|
|
599
|
+
}
|
|
600
|
+
& :global(svg) {
|
|
601
|
+
display: block;
|
|
602
|
+
inline-size: var(--tp-icon);
|
|
603
|
+
block-size: var(--tp-icon);
|
|
604
|
+
fill: currentColor;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.disabled .control {
|
|
609
|
+
cursor: not-allowed;
|
|
610
|
+
background: var(--fs-control-fill-disabled);
|
|
611
|
+
&::before {
|
|
612
|
+
background: var(--fs-control-stroke-default);
|
|
613
|
+
}
|
|
614
|
+
& input,
|
|
615
|
+
& input::placeholder,
|
|
616
|
+
& .fs-tagpicker-aside {
|
|
617
|
+
color: var(--fs-text-disabled);
|
|
618
|
+
}
|
|
619
|
+
& .chevron {
|
|
620
|
+
cursor: not-allowed;
|
|
621
|
+
&:hover,
|
|
622
|
+
&:active {
|
|
623
|
+
color: inherit;
|
|
624
|
+
background: none;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.fs-tagpicker :global(.fs-flyout) {
|
|
630
|
+
padding: 0;
|
|
631
|
+
left: -1px;
|
|
632
|
+
width: calc(100% + 2px);
|
|
633
|
+
z-index: 10;
|
|
634
|
+
overflow: hidden;
|
|
635
|
+
max-block-size: min(80vh, 20rem);
|
|
636
|
+
&:global(.top-shadow) {
|
|
637
|
+
box-shadow: 0 -0.5rem 0.8rem #00000024;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
.fs-tagpicker :global(.fs-list-view) {
|
|
641
|
+
overflow-y: auto;
|
|
642
|
+
padding: 4px;
|
|
643
|
+
}
|
|
644
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TagPickerProps } from './types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A text field and a dropdown that give people a way to choose several options from a list, or to enter their own choice. Every choice is shown as a dismissible Tag in the control.
|
|
4
|
+
*
|
|
5
|
+
* - Usage:
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <script>
|
|
8
|
+
* import { TagPicker, TagPickerOption } from 'fluentui-svelte';
|
|
9
|
+
*
|
|
10
|
+
* let selectedOptions = $state([]);
|
|
11
|
+
* </script>
|
|
12
|
+
*
|
|
13
|
+
* <TagPicker bind:selectedOptions inputProps={{ 'aria-label': 'Fruits' }}>
|
|
14
|
+
* <TagPickerOption value="Apple">Apple</TagPickerOption>
|
|
15
|
+
* <TagPickerOption value="Banana">Banana</TagPickerOption>
|
|
16
|
+
* </TagPicker>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
declare const TagPicker: import("svelte").Component<TagPickerProps, {}, "ref" | "value" | "open" | "inputRef" | "selectedOptions">;
|
|
20
|
+
type TagPicker = ReturnType<typeof TagPicker>;
|
|
21
|
+
export default TagPicker;
|