fluentui-svelte 0.4.0 → 0.5.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 CHANGED
@@ -169,7 +169,7 @@ after changing a prop's JSDoc rather than editing the generated tables by hand.
169
169
  >
170
170
  > This app demonstrates all of the Windows UI 3 library controls and styles available to make a WinUI 3 app with the Windows App SDK.
171
171
 
172
- > ### Documentation: [Microsoft's Fluent UI](https://learn.microsoft.com/en-us/windows/apps/design/)
172
+ > ### Documentation: [Microsoft's Fluent UI](https://developer.microsoft.com/en-us/fluentui/)
173
173
  >
174
174
  > Design guidelines and UI code examples for creating Windows app experiences.
175
175
 
@@ -18,44 +18,105 @@ The AutoSuggestBox component provides an input field with dynamic suggestions as
18
18
 
19
19
  ## Examples
20
20
 
21
- ### Virtualized Suggestions
21
+ ### Open on focus
22
22
 
23
- Long lists can be windowed. Pass a `virtualizer` describing the _whole_ list — `size` is the
24
- total number of suggestions, not the number rendered — and the box drives your list component
25
- when the cursor walks past what is on screen. Options keep their real `index`, which is what
26
- ties a rendered row back to the data.
23
+ Use `openOnFocus` to show suggestions as soon as the text box receives focus, before anything is typed.
24
+
25
+ ```svelte
26
+ <AutoSuggestBox openOnFocus placeholder="Choose a fruit...">
27
+ {#each fruits as fruit, index (fruit.id)}
28
+ <AutoSuggestBoxOption {index} id={fruit.id} value={fruit.name}>
29
+ {fruit.name}
30
+ </AutoSuggestBoxOption>
31
+ {/each}
32
+ </AutoSuggestBox>
33
+ ```
34
+
35
+ ### Virtualization
36
+
37
+ Window large suggestion sets by connecting AutoSuggestBox to a virtual list through the `virtualizer` prop. Its `size` is the total number of suggestions, while each option keeps its real `index`.
38
+
39
+ ```svelte
40
+ <script>
41
+ import SvelteVirtualList from '@humanspeak/svelte-virtual-list';
42
+
43
+ let listRef;
44
+ </script>
45
+
46
+ <AutoSuggestBox
47
+ placeholder="Type a name..."
48
+ virtualizer={{
49
+ size: people.length,
50
+ scrollToTop: () => listRef.scroll({ index: 0, align: 'top', smoothScroll: false }),
51
+ scrollToBottom: () => listRef.scroll({ index: people.length - 1, align: 'bottom', smoothScroll: false }),
52
+ scrollToIndex: (index) => listRef.scroll({ index, align: 'auto', smoothScroll: false })
53
+ }}
54
+ >
55
+ <SvelteVirtualList items={people} bind:this={listRef} defaultEstimatedItemHeight={30}>
56
+ {#snippet renderItem(person, index)}
57
+ <AutoSuggestBoxOption {index} id={person.id} value={person.name}>
58
+ {person.name}
59
+ </AutoSuggestBoxOption>
60
+ {/snippet}
61
+ </SvelteVirtualList>
62
+ </AutoSuggestBox>
63
+ ```
64
+
65
+ ### Multiselect
66
+
67
+ Allow people to choose several suggestions and optionally show the selected values in the text box.
68
+
69
+ ```svelte
70
+ <script>
71
+ let selectedOptions = $state([]);
72
+ </script>
73
+
74
+ <AutoSuggestBox multiselect showTextualMultiselect bind:selectedOptions placeholder="Choose fruits...">
75
+ {#each fruits as fruit, index (fruit.id)}
76
+ <AutoSuggestBoxOption {index} id={fruit.id} value={fruit.name}>
77
+ {fruit.name}
78
+ </AutoSuggestBoxOption>
79
+ {/each}
80
+ </AutoSuggestBox>
81
+ ```
82
+
83
+ ### Virtualization with filtering
84
+
85
+ A virtualized list must filter its source data itself and report the filtered length through `virtualizer.size`.
27
86
 
28
87
  ```svelte
29
88
  <script>
30
89
  import SvelteVirtualList from '@humanspeak/svelte-virtual-list';
31
90
 
32
- let items = $state(fruits);
91
+ let items = $state(people);
33
92
  let listRef;
34
93
  </script>
35
94
 
36
95
  <AutoSuggestBox
37
- placeholder="Type a fruit..."
96
+ placeholder="Type a name..."
38
97
  virtualizer={{
39
98
  size: items.length,
40
99
  scrollToTop: () => listRef.scroll({ index: 0, align: 'top', smoothScroll: false }),
41
- scrollToBottom: () => listRef.scroll({ index: items.length - 1, align: 'bottom', smoothScroll: false }),
100
+ scrollToBottom: () => {
101
+ if (items.length) listRef.scroll({ index: items.length - 1, align: 'bottom', smoothScroll: false });
102
+ },
42
103
  scrollToIndex: (index) => listRef.scroll({ index, align: 'auto', smoothScroll: false })
43
104
  }}
44
- textChanged={(e, val) => (items = fruits.filter((item) => item.name.toLowerCase().includes(val.toLowerCase())))}
105
+ textChanged={(_, value) => {
106
+ const query = value.trim().toLowerCase();
107
+ items = people.filter(({ name }) => name.toLowerCase().includes(query));
108
+ }}
45
109
  >
46
110
  <SvelteVirtualList {items} bind:this={listRef} defaultEstimatedItemHeight={30}>
47
- {#snippet renderItem(item, index)}
48
- <AutoSuggestBoxOption {index} id={item.id} value={item.name} text={item.name}>
49
- {item.name}
111
+ {#snippet renderItem(person, index)}
112
+ <AutoSuggestBoxOption {index} id={person.id} value={person.name}>
113
+ {person.name}
50
114
  </AutoSuggestBoxOption>
51
115
  {/snippet}
52
116
  </SvelteVirtualList>
53
117
  </AutoSuggestBox>
54
118
  ```
55
119
 
56
- Filtering is yours to do in `textChanged`: a windowed list renders from your data, so the
57
- built-in filter that a plain option list applies is skipped.
58
-
59
120
  ## Component Props
60
121
 
61
122
  <!-- DO NOT EDIT THIS SECTION (propsmith generated) -->
@@ -42,7 +42,7 @@
42
42
  {#if typeof title === 'string'}
43
43
  <h4 class="body" id={`${id}-title`}>{title}</h4>
44
44
  {:else if title}
45
- <RenderSoC SoC={title} id={`${id}-title`} />
45
+ <RenderSoC SoC={title} args={{ id: `${id}-title` }} />
46
46
  {/if}
47
47
  {#if typeof description === 'string'}
48
48
  <p class="caption">{description}</p>
@@ -53,7 +53,7 @@
53
53
  {:else if typeof title === 'string'}
54
54
  <h4 class="body" id={`${id}-title`}>{title}</h4>
55
55
  {:else if title}
56
- <RenderSoC SoC={title} id={`${id}-title`} />
56
+ <RenderSoC SoC={title} args={{ id: `${id}-title` }} />
57
57
  {/if}
58
58
  {#if _action}
59
59
  <RenderSoC SoC={_action} />
@@ -195,6 +195,7 @@
195
195
  }
196
196
  }
197
197
  selectionMode={multiple ? 'multiselect' : 'single'}
198
+ toggleOnClick={multiple}
198
199
  onSelectionChange={() => {
199
200
  if (!multiple) closeDropdown();
200
201
  }}
@@ -56,27 +56,78 @@ Use `selectionMode` (`single`, `multiselect`, or `extended`) to control how item
56
56
  </ListView>
57
57
  ```
58
58
 
59
+ ### Toggle on click
60
+
61
+ In a multiselect list, a plain click selects only that item and Ctrl/Cmd + click toggles it. Set `toggleOnClick` to make a plain click toggle the item directly.
62
+
63
+ ```svelte
64
+ <ListView selectionMode="multiselect" toggleOnClick>
65
+ {#each ['Apple', 'Banana', 'Cherry'] as item (item)}
66
+ <ListViewItem value={item}>{item}</ListViewItem>
67
+ {/each}
68
+ </ListView>
69
+ ```
70
+
59
71
  ### Checkmark
60
72
 
61
- Add and configure a checkbox for the element. You can use the same props for the Checkbox component. Note: every actionable element inside this ListViewItem must call e.stopPropagation().
73
+ Add and configure a checkbox for the element. You can use the same props for the Checkbox component. The checkbox toggles the selection of its item without triggering `onAction`. Bind `selectedItems` to read or control the selection.
62
74
 
63
75
  ```svelte
64
- <ListViewItem
65
- value="card"
66
- role="row"
67
- checkmark={{ style: 'position: absolute; top: 10px; left: 10px; z-index: 10;' }}
68
- onAction={(e, d) => alert('Triggered Primary Action')}
69
- >
70
- <div style="...">
71
- Card Content Inside ListItem
72
- <Button
73
- onclick={(e) => {
74
- e.stopPropagation();
75
- alert('Installing');
76
- }}>Install</Button
76
+ <script>
77
+ let selected = $state(['Apple']);
78
+ </script>
79
+
80
+ <ListView selectionMode="multiselect" bind:selectedItems={selected}>
81
+ {#each ['Apple', 'Banana', 'Cherry'] as item (item)}
82
+ <ListViewItem value={item} checkmark={{}}>{item}</ListViewItem>
83
+ {/each}
84
+ </ListView>
85
+
86
+ <p>Selected: {selected.join(', ')}</p>
87
+ ```
88
+
89
+ ### Cards with a primary action
90
+
91
+ With `navigationMode` set to `composite` and a `selectionMode`, every item is a row. Clicking it runs its primary action (`onAction`), the checkbox toggles its selection, and any button inside is a secondary action. Use `checkmark.wrapperAttributes.style` to place the checkbox where you need it, like the top-right corner of the card. Note: every actionable element inside this ListViewItem, except the checkbox, must call e.stopPropagation().
92
+
93
+ ```svelte
94
+ <script>
95
+ const cards = [
96
+ { name: 'Fluent', description: 'Design system' },
97
+ { name: 'Svelte', description: 'UI framework' },
98
+ { name: 'Vite', description: 'Build tool' }
99
+ ];
100
+
101
+ let selected = $state([]);
102
+ let lastAction = $state('');
103
+ </script>
104
+
105
+ <ListView as="div" navigationMode="composite" selectionMode="multiselect" bind:selectedItems={selected}>
106
+ {#each cards as card (card.name)}
107
+ {#snippet install()}
108
+ <Button
109
+ onclick={(e) => {
110
+ e.stopPropagation();
111
+ lastAction = 'Installed ' + card.name;
112
+ }}>Install</Button
113
+ >
114
+ {/snippet}
115
+ <ListViewItem
116
+ value={card.name}
117
+ checkmark={{ wrapperAttributes: { style: 'position: absolute; top: 0.5rem; right: 0.5rem; z-index: 2;' } }}
118
+ style="padding: 0"
119
+ onAction={() => (lastAction = 'Opened ' + card.name)}
77
120
  >
78
- </div>
79
- </ListViewItem>
121
+ <Card orientation="vertical" style="width: 100%;">
122
+ <CardHeader title={card.name} description={card.description} />
123
+ <CardFooter action={install} />
124
+ </Card>
125
+ </ListViewItem>
126
+ {/each}
127
+ </ListView>
128
+
129
+ <p>Selected: {selected.join(', ')}</p>
130
+ <p>Last action: {lastAction}</p>
80
131
  ```
81
132
 
82
133
  ## ListView Props
@@ -84,17 +135,18 @@ Add and configure a checkbox for the element. You can use the same props for the
84
135
  <!-- DO NOT EDIT THIS SECTION (propsmith generated) -->
85
136
  <!-- props:ListViewProps -->
86
137
 
87
- | Name | Type | Default | Description |
88
- | -------------------------- | --------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------- |
89
- | `selectionMode` | `'none'` &#124; `'single'` &#124; `'multiselect'` &#124; `'extended'` | `'none'` | How many items can be selected, and how the selection is made. |
90
- | `navigationMode` | `'items'` &#124; `'composite'` | `'items'` | Whether the list itself takes a single tab stop, or every item takes its own. |
91
- | `selectedItems` _bindable_ | `string[]` | | The values of the selected items. |
92
- | `shape` | `'circular'` &#124; `'rounded'` &#124; `'square'` | `'rounded'` | The items can have a circular, rounded or square shape. |
93
- | `onSelectionChange` | `(e: Event, selectedItems: string[]` &#124; `[]) => void` | | Called whenever the selection changes. |
94
- | `as` | `'ul'` &#124; `'ol'` &#124; `'div'` | `'ul'` | The HTML element to render the list as. |
95
- | `ref` _bindable_ | `HTMLUListElement` &#124; `HTMLOListElement` &#124; `HTMLDivElement` | | The DOM reference of the list element. |
96
- | `disableTabspot` | `boolean` | `false` | Opts the list out of the tabspot focus management, to wire your own. |
97
- | HTML Attributes | | | |
138
+ | Name | Type | Default | Description |
139
+ | -------------------------- | --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
140
+ | `selectionMode` | `'none'` &#124; `'single'` &#124; `'multiselect'` &#124; `'extended'` | `'none'` | How many items can be selected, and how the selection is made. |
141
+ | `navigationMode` | `'items'` &#124; `'composite'` | `'items'` | Whether the list itself takes a single tab stop, or every item takes its own. |
142
+ | `selectedItems` _bindable_ | `string[]` | | The values of the selected items. |
143
+ | `shape` | `'circular'` &#124; `'rounded'` &#124; `'square'` | `'rounded'` | The items can have a circular, rounded or square shape. |
144
+ | `onSelectionChange` | `(e: Event, selectedItems: string[]` &#124; `[]) => void` | | Called whenever the selection changes. |
145
+ | `toggleOnClick` | `boolean` | `false` | When `selectionMode` is `'multiselect'`, clicking an item toggles its selection directly instead of requiring Ctrl/Cmd + click. |
146
+ | `as` | `'ul'` &#124; `'ol'` &#124; `'div'` | `'ul'` | The HTML element to render the list as. |
147
+ | `ref` _bindable_ | `HTMLUListElement` &#124; `HTMLOListElement` &#124; `HTMLDivElement` | | The DOM reference of the list element. |
148
+ | `disableTabspot` | `boolean` | `false` | Opts the list out of the tabspot focus management, to wire your own. |
149
+ | HTML Attributes | | | |
98
150
 
99
151
  <!-- /props:ListViewProps -->
100
152
 
@@ -43,6 +43,7 @@
43
43
  const role = $derived(_role || getChildrenRole(as));
44
44
 
45
45
  let active = $derived(_state.selectedItems.includes(value));
46
+ let checkmarkRef = $state<HTMLDivElement>();
46
47
 
47
48
  // A row is a level of its own: right enters the cells, left comes back out. Anything
48
49
  // that is not a row navigates as a plain item of the list above it.
@@ -82,21 +83,23 @@
82
83
  {...tabspotAttrs}
83
84
  onfocus={(e: FocusEvent) => invokeHandlers(e, disabled, [(event: FocusEvent) => onfocus?.(event, value || '')])}
84
85
  onclick={(e: MouseEvent) => {
85
- invokeHandlers(
86
- e,
87
- disabled,
88
- role !== 'row'
89
- ? [
90
- (event: MouseEvent) => handleSelection?.(event, value),
91
- (event: MouseEvent) => onAction?.(event, value || '')
92
- ]
93
- : [(event: MouseEvent) => onAction?.(event, value || '')]
94
- );
86
+ // The checkmark only toggles the selection, even on a row. The primary action is for the rest of the item.
87
+ const fromCheckmark = !!checkmarkRef?.contains(e.target as Node);
88
+ invokeHandlers(e, disabled, [
89
+ (role !== 'row' || fromCheckmark) && ((event: MouseEvent) => handleSelection?.(event, value, fromCheckmark)),
90
+ !fromCheckmark && ((event: MouseEvent) => onAction?.(event, value || ''))
91
+ ]);
95
92
  }}
96
93
  {...attributes}
97
94
  >
98
95
  {#if checkmark}
99
- <Checkbox {disabled} bind:checked={active} tabindex={-1} {...!isObjectEmpty(checkmark) ? checkmark : {}} />
96
+ <Checkbox
97
+ {disabled}
98
+ bind:checked={active}
99
+ bind:wrapperRef={checkmarkRef}
100
+ tabindex={-1}
101
+ {...!isObjectEmpty(checkmark) ? checkmark : {}}
102
+ />
100
103
  {/if}
101
104
  {@render children?.()}
102
105
  </svelte:element>
@@ -12,6 +12,7 @@
12
12
  selectedItems = $bindable([]),
13
13
  onSelectionChange,
14
14
  disableTabspot = false,
15
+ toggleOnClick = false,
15
16
  children,
16
17
  ...attributes
17
18
  }: ListViewProps<Tag> = $props();
@@ -72,7 +73,7 @@
72
73
  },
73
74
  events: null,
74
75
  methods: {
75
- handleSelection: (e: MouseEvent, value: string) => {
76
+ handleSelection: (e: MouseEvent, value: string, toggle = false) => {
76
77
  if (selectionMode === 'none') return;
77
78
 
78
79
  const itemIndex = items.findIndex((item) => item.value === value);
@@ -89,7 +90,7 @@
89
90
  .filter((i) => !i.disabled)
90
91
  .map((i) => i.value);
91
92
  selectedItems = Array.from(new Set([...selectedItems, ...rangeValues]));
92
- } else if (MULTISELECTABLE && (e.ctrlKey || e.metaKey)) {
93
+ } else if (MULTISELECTABLE && (toggle || e.ctrlKey || e.metaKey || toggleOnClick)) {
93
94
  selectedItems = selectedItems.includes(value)
94
95
  ? selectedItems.filter((v) => v !== value)
95
96
  : [...selectedItems, value];
@@ -23,6 +23,10 @@ export type ListViewProps<T extends 'ul' | 'ol' | 'div'> = {
23
23
  shape?: Shapes;
24
24
  /** Called whenever the selection changes. */
25
25
  onSelectionChange?: (e: Event, selectedItems: string[] | []) => void;
26
+ /** When `selectionMode` is `'multiselect'`, clicking an item toggles its selection directly instead of requiring Ctrl/Cmd + click.
27
+ * @default false
28
+ */
29
+ toggleOnClick?: boolean;
26
30
  /** The HTML element to render the list as.
27
31
  * @type 'ul' | 'ol' | 'div'
28
32
  * @default 'ul'
@@ -87,7 +91,7 @@ export type ListViewContextState = {
87
91
  anchorIndex: number | null;
88
92
  };
89
93
  export type ListViewContextMethods = {
90
- handleSelection: (e: MouseEvent, value: string) => void;
94
+ handleSelection: (e: MouseEvent, value: string, toggle?: boolean) => void;
91
95
  getChildrenRole: (tag: 'li' | 'a' | 'div') => 'listitem' | 'option' | 'row' | undefined;
92
96
  registerItem: (id: string, value: string, disabled: boolean) => void;
93
97
  unregisterItem: (id: string) => void;
@@ -140,6 +140,22 @@ This variant of the `MenuTrigger` component allows you to create context menus t
140
140
 
141
141
  <!-- /props:MenuProps -->
142
142
 
143
+ ## Component Props (MenuPopover)
144
+
145
+ <!-- DO NOT EDIT THIS SECTION (propsmith generated) -->
146
+ <!-- props:MenuPopoverProps -->
147
+
148
+ | Name | Type | Default | Description |
149
+ | ----------------- | ------------------------------------ | ------- | -------------------------------------------------------- |
150
+ | `ref` | `HTMLElement` | | The element used to position the popover. |
151
+ | `open` _bindable_ | `boolean` | | Controls the open state of the popover. |
152
+ | `keepMounted` | `boolean` | `false` | Keeps the popover mounted in the DOM while it is closed. |
153
+ | `placement` | `ComputePositionConfig['placement']` | | Where the popover is positioned relative to its trigger. |
154
+ | `positionConfig` | `ComputePositionConfig` | | The position configuration handed over to floating-ui. |
155
+ | `children` | `Snippet` | | The content of the popover. |
156
+
157
+ <!-- /props:MenuPopoverProps -->
158
+
143
159
  ## Component Props (MenuTrigger)
144
160
 
145
161
  <!-- DO NOT EDIT THIS SECTION (propsmith generated) -->
@@ -6,12 +6,12 @@
6
6
  import { useDebounce } from 'runed';
7
7
  import { floating, flyToOffset, getCSSDuration, reactiveBoundingRect } from '../../internal/index.js';
8
8
  import { getReducedMotion } from '../../providers/fluentui-svelte/fluentui-svelte.js';
9
- import type { Snippet } from 'svelte';
10
- import type { ComputePositionConfig } from '@floating-ui/dom';
9
+ import type { MenuPopoverProps } from './types.js';
11
10
 
12
11
  let {
13
12
  ref: _ref,
14
13
  open: _open = $bindable(),
14
+ keepMounted = false,
15
15
  placement,
16
16
  positionConfig: floatingPosition = {
17
17
  placement,
@@ -19,13 +19,7 @@
19
19
  strategy: 'fixed'
20
20
  },
21
21
  children
22
- }: {
23
- ref?: HTMLElement;
24
- open?: boolean;
25
- placement?: ComputePositionConfig['placement'];
26
- positionConfig?: Partial<ComputePositionConfig>;
27
- children: Snippet;
28
- } = $props();
22
+ }: MenuPopoverProps = $props();
29
23
 
30
24
  const context = getMenuContext();
31
25
 
@@ -90,16 +84,17 @@
90
84
  });
91
85
  </script>
92
86
 
93
- {#if opened && ref}
87
+ {#if keepMounted || (opened && ref)}
94
88
  {@const isRefAnElement = ref instanceof HTMLElement}
95
89
  <!-- svelte-ignore a11y_no_static_element_interactions -->
96
90
  <div
97
91
  bind:this={element}
98
92
  class="fs-menu-popover"
93
+ hidden={!opened}
99
94
  onmouseleave={openOnHover ? (e: MouseEvent) => debounced(e) : undefined}
100
- {@attach ref ? floating(ref, config) : undefined}
95
+ {@attach opened && ref ? floating(ref, config) : undefined}
101
96
  style={`--position=${isSubMenu ? 'absolute' : 'fixed'} --min-width=${isRefAnElement && !isSubMenu ? `${boundingElement.rect.width}px` : 'max-content'}`}
102
- in:flyToOffset={isRefAnElement
97
+ in:flyToOffset={opened && isRefAnElement
103
98
  ? {
104
99
  ...getIntroTransition({ config, anchor: boundingElement.rect, element, ref, isSubMenu }),
105
100
  duration: duration,
@@ -1,12 +1,4 @@
1
- import type { Snippet } from 'svelte';
2
- import type { ComputePositionConfig } from '@floating-ui/dom';
3
- type $$ComponentProps = {
4
- ref?: HTMLElement;
5
- open?: boolean;
6
- placement?: ComputePositionConfig['placement'];
7
- positionConfig?: Partial<ComputePositionConfig>;
8
- children: Snippet;
9
- };
10
- declare const MenuPopover: import("svelte").Component<$$ComponentProps, {}, "open">;
1
+ import type { MenuPopoverProps } from './types.ts';
2
+ declare const MenuPopover: import("svelte").Component<MenuPopoverProps, {}, "open">;
11
3
  type MenuPopover = ReturnType<typeof MenuPopover>;
12
4
  export default MenuPopover;
@@ -59,6 +59,27 @@ export type MenuProps = {
59
59
  /** The parts of the menu: a trigger and a popover. */
60
60
  children?: Snippet;
61
61
  };
62
+ /** @propsmith MenuPopoverProps */
63
+ export type MenuPopoverProps = {
64
+ /** The element used to position the popover. */
65
+ ref?: HTMLElement;
66
+ /** Controls the open state of the popover.
67
+ * @bindable
68
+ */
69
+ open?: boolean;
70
+ /** Keeps the popover mounted in the DOM while it is closed.
71
+ * @default false
72
+ */
73
+ keepMounted?: boolean;
74
+ /** Where the popover is positioned relative to its trigger. */
75
+ placement?: ComputePositionConfig['placement'];
76
+ /** The position configuration handed over to floating-ui.
77
+ * @type ComputePositionConfig
78
+ */
79
+ positionConfig?: Partial<ComputePositionConfig>;
80
+ /** The content of the popover. */
81
+ children: Snippet;
82
+ };
62
83
  /** @propsmith MenuTriggerProps */
63
84
  export type MenuTriggerProps = {
64
85
  /** Disables the user interaction. */
@@ -35,7 +35,7 @@ Nesting a TreeView inside a TreeViewItem of type='branch' creates a subtree. Use
35
35
 
36
36
  ### Checkable Items
37
37
 
38
- Set `selectionMode="multiple"` to render checkboxes on selectable items.
38
+ Set `selectionMode` to `"multiple" | "single"` to render checkboxes on selectable items.
39
39
 
40
40
  ```svelte
41
41
  <TreeView selectionMode="multiple">
@@ -50,6 +50,132 @@ Set `selectionMode="multiple"` to render checkboxes on selectable items.
50
50
  </TreeView>
51
51
  ```
52
52
 
53
+ ### Single selection
54
+
55
+ Set `selectionMode` to `single` so checking an item unchecks the previous one, and branches no longer cascade to their children.
56
+
57
+ ```svelte
58
+ <script>
59
+ let checked = $state([]);
60
+ </script>
61
+
62
+ <TreeView selectionMode="single" bind:checkedItems={checked}>
63
+ <TreeViewItem id="fruits" type="branch" open>
64
+ <TreeViewItemContent>Fruits</TreeViewItemContent>
65
+ <TreeView>
66
+ <TreeViewItem id="apple">
67
+ <TreeViewItemContent>Apple</TreeViewItemContent>
68
+ </TreeViewItem>
69
+ <TreeViewItem id="banana">
70
+ <TreeViewItemContent>Banana</TreeViewItemContent>
71
+ </TreeViewItem>
72
+ </TreeView>
73
+ </TreeViewItem>
74
+ <TreeViewItem id="vegetables">
75
+ <TreeViewItemContent>Vegetables</TreeViewItemContent>
76
+ </TreeViewItem>
77
+ </TreeView>
78
+
79
+ <p>Checked: {checked.join(', ')}</p>
80
+ ```
81
+
82
+ ### Icons
83
+
84
+ Use `iconBefore` and `iconAfter` to render icons around the label. Every item reserves the space of the expand chevron, so checkboxes and icons stay aligned between branches and leaves.
85
+
86
+ ```svelte
87
+ <script>
88
+ import { TreeView, TreeViewItem, TreeViewItemContent } from 'fluentui-svelte';
89
+ import FolderRegular from 'fluentui-icons-svelte/FolderRegular.svelte';
90
+ import DocumentRegular from 'fluentui-icons-svelte/DocumentRegular.svelte';
91
+ import ImageRegular from 'fluentui-icons-svelte/ImageRegular.svelte';
92
+ import StarRegular from 'fluentui-icons-svelte/StarRegular.svelte';
93
+ import LockClosedRegular from 'fluentui-icons-svelte/LockClosedRegular.svelte';
94
+ </script>
95
+
96
+ {#snippet folder()}<FolderRegular width="1.25rem" />{/snippet}
97
+ {#snippet document()}<DocumentRegular width="1.25rem" />{/snippet}
98
+ {#snippet image()}<ImageRegular width="1.25rem" />{/snippet}
99
+ {#snippet star()}<StarRegular width="1rem" />{/snippet}
100
+ {#snippet lock()}<LockClosedRegular width="1rem" />{/snippet}
101
+
102
+ <TreeView selectionMode="multiple">
103
+ <TreeViewItem id="documents" type="branch" open>
104
+ <TreeViewItemContent iconBefore={folder}>Documents</TreeViewItemContent>
105
+ <TreeView>
106
+ <TreeViewItem id="report">
107
+ <TreeViewItemContent iconBefore={document} iconAfter={star}>Report</TreeViewItemContent>
108
+ </TreeViewItem>
109
+ <TreeViewItem id="photo">
110
+ <TreeViewItemContent iconBefore={image}>Photo</TreeViewItemContent>
111
+ </TreeViewItem>
112
+ </TreeView>
113
+ </TreeViewItem>
114
+ <TreeViewItem id="notes">
115
+ <TreeViewItemContent iconBefore={document} iconAfter={lock}>Notes</TreeViewItemContent>
116
+ </TreeViewItem>
117
+ </TreeView>
118
+ ```
119
+
120
+ ### Aside and actions
121
+
122
+ `aside` is pinned to the end of the row. `actions` comes after it and is revealed when the row is hovered or focused.
123
+
124
+ ```svelte
125
+ <script>
126
+ import { TreeView, TreeViewItem, TreeViewItemContent, Button } from 'fluentui-svelte';
127
+ import DocumentRegular from 'fluentui-icons-svelte/DocumentRegular.svelte';
128
+ import MoreHorizontalRegular from 'fluentui-icons-svelte/MoreHorizontalRegular.svelte';
129
+ </script>
130
+
131
+ {#snippet document()}<DocumentRegular width="1.25rem" />{/snippet}
132
+ {#snippet fileSize()}<span>2 KB</span>{/snippet}
133
+ {#snippet more()}
134
+ <Button appearance="subtle" onclick={(e) => e.stopPropagation()} aria-label="More actions"
135
+ ><MoreHorizontalRegular width="1rem" /></Button
136
+ >
137
+ {/snippet}
138
+
139
+ <TreeView>
140
+ <TreeViewItem id="report">
141
+ <TreeViewItemContent iconBefore={document} aside={fileSize} actions={more}>Report</TreeViewItemContent>
142
+ </TreeViewItem>
143
+ <TreeViewItem id="annual-summary">
144
+ <TreeViewItemContent iconBefore={document} aside={fileSize} actions={more}>Annual summary</TreeViewItemContent>
145
+ </TreeViewItem>
146
+ </TreeView>
147
+ ```
148
+
149
+ ### Sizes
150
+
151
+ Set `size` to `small`, `medium` or `large` on the root `TreeView`.
152
+
153
+ ```svelte
154
+ <TreeView size="small">...</TreeView>
155
+ <TreeView size="medium">...</TreeView>
156
+ <TreeView size="large">...</TreeView>
157
+ ```
158
+
159
+ ### Disabled items
160
+
161
+ A disabled item can't be opened or checked, and it is skipped when its parent checks its children, which leaves the parent in its mixed state.
162
+
163
+ ```svelte
164
+ <TreeView selectionMode="multiple">
165
+ <TreeViewItem id="fruits" type="branch" open>
166
+ <TreeViewItemContent>Fruits</TreeViewItemContent>
167
+ <TreeView>
168
+ <TreeViewItem id="apple">
169
+ <TreeViewItemContent>Apple</TreeViewItemContent>
170
+ </TreeViewItem>
171
+ <TreeViewItem id="banana" disabled>
172
+ <TreeViewItemContent>Banana</TreeViewItemContent>
173
+ </TreeViewItem>
174
+ </TreeView>
175
+ </TreeViewItem>
176
+ </TreeView>
177
+ ```
178
+
53
179
  ### Controlling Open and Checked State
54
180
 
55
181
  Use the bindable `openItems` and `checkedItems` props on the root `TreeView` to control state programmatically.
@@ -70,6 +196,45 @@ Use the bindable `openItems` and `checkedItems` props on the root `TreeView` to
70
196
  </TreeView>
71
197
  </TreeViewItem>
72
198
  </TreeView>
199
+
200
+ <p>Open: {openItems.join(', ')}</p>
201
+ <p>Checked: {checkedItems.join(', ')}</p>
202
+ ```
203
+
204
+ ### Events
205
+
206
+ `onCheckedChange` and `onOpenChange` on the TreeView receive the resulting ids. The same props on a TreeViewItem receive the item and its new state, including the children a branch cascades to.
207
+
208
+ ```svelte
209
+ <script>
210
+ let treeEvent = $state('');
211
+ let itemEvent = $state('');
212
+ </script>
213
+
214
+ <TreeView
215
+ selectionMode="multiple"
216
+ onCheckedChange={(e, checkedItems) => (treeEvent = 'Checked: ' + checkedItems.join(', '))}
217
+ onOpenChange={(e, openItems) => (treeEvent = 'Open: ' + openItems.join(', '))}
218
+ >
219
+ <TreeViewItem
220
+ id="fruits"
221
+ type="branch"
222
+ onOpenChange={(e, { id, open }) => (itemEvent = id + (open ? ' opened' : ' closed'))}
223
+ >
224
+ <TreeViewItemContent>Fruits</TreeViewItemContent>
225
+ <TreeView>
226
+ <TreeViewItem
227
+ id="apple"
228
+ onCheckedChange={(e, { id, checked }) => (itemEvent = id + (checked ? ' checked' : ' unchecked'))}
229
+ >
230
+ <TreeViewItemContent>Apple</TreeViewItemContent>
231
+ </TreeViewItem>
232
+ </TreeView>
233
+ </TreeViewItem>
234
+ </TreeView>
235
+
236
+ <p>Tree: {treeEvent}</p>
237
+ <p>Item: {itemEvent}</p>
73
238
  ```
74
239
 
75
240
  ## Component Props (TreeView)
@@ -47,10 +47,8 @@
47
47
  wrapperAttributes={{
48
48
  onclick: (e) => {
49
49
  e.stopPropagation();
50
- if (ITEM_CONTEXT.disabled) {
51
- e.preventDefault();
52
- return;
53
- }
50
+ e.preventDefault();
51
+ if (ITEM_CONTEXT.disabled) return;
54
52
  handleCheck(e, ITEM_CONTEXT.id, !ITEM_CONTEXT.checked);
55
53
  }
56
54
  }}
@@ -87,9 +85,12 @@
87
85
 
88
86
  <style>
89
87
  .fs-tree-view-item-content {
88
+ --chevron-width: 1.2rem;
89
+ --gap: 0.75rem;
90
+ box-sizing: border-box;
90
91
  display: flex;
91
92
  align-items: center;
92
- gap: 0.75rem;
93
+ gap: var(--gap);
93
94
  position: relative;
94
95
  border-radius: var(--fs-control-border-radius);
95
96
  font-size: var(--fs-body-font-size);
@@ -100,20 +101,26 @@
100
101
  width: 100%;
101
102
  text-align: left;
102
103
  user-select: none;
104
+ &:not(:has(:global(.branch-indicator))) {
105
+ --leaf-offset: calc(var(--chevron-width) + var(--gap) + 0rem);
106
+ }
103
107
  &.size-small {
104
108
  font-size: var(--fs-body2-font-size);
105
109
  padding: 0.125rem 0.5rem;
106
- padding-left: calc(0.5rem + 1.5rem * var(--depth, 0));
110
+ padding-left: calc(0.5rem + 2rem * var(--depth, 0) + var(--leaf-offset, 0rem));
111
+ min-height: 1.75rem;
107
112
  }
108
113
  &.size-medium {
109
114
  font-size: var(--fs-body-font-size);
110
115
  padding: 0.25rem 0.625rem;
111
- padding-left: calc(0.625rem + 1.5rem * var(--depth, 0));
116
+ padding-left: calc(0.625rem + 2rem * var(--depth, 0) + var(--leaf-offset, 0rem));
117
+ min-height: 2rem;
112
118
  }
113
119
  &.size-large {
114
120
  font-size: var(--fs-subtitle2-font-size);
115
121
  padding: 0.375rem 0.75rem;
116
- padding-left: calc(0.75rem + 1.5rem * var(--depth, 0));
122
+ padding-left: calc(0.75rem + 2rem * var(--depth, 0) + var(--leaf-offset, 0rem));
123
+ min-height: 2.25rem;
117
124
  }
118
125
 
119
126
  &::after {
@@ -155,4 +162,30 @@
155
162
  fill: currentColor;
156
163
  }
157
164
  }
165
+ .fs-tree-view-item-icon-before,
166
+ .fs-tree-view-item-icon-after,
167
+ .fs-tree-view-item-aside,
168
+ .fs-tree-view-item-actions {
169
+ display: inline-flex;
170
+ align-items: center;
171
+ flex-shrink: 0;
172
+ }
173
+ .fs-tree-view-item-aside,
174
+ .fs-tree-view-item-actions {
175
+ margin-left: auto;
176
+ }
177
+ .fs-tree-view-item-aside + .fs-tree-view-item-actions {
178
+ margin-left: 0;
179
+ }
180
+ .fs-tree-view-item-actions {
181
+ opacity: 0;
182
+ @media (hover: none) {
183
+ opacity: 1;
184
+ }
185
+ }
186
+ .fs-tree-view-item-content:hover .fs-tree-view-item-actions,
187
+ .fs-tree-view-item-content:focus-within .fs-tree-view-item-actions,
188
+ :global(.fs-tree-view-item:focus-visible) > .fs-tree-view-item-content .fs-tree-view-item-actions {
189
+ opacity: 1;
190
+ }
158
191
  </style>
@@ -32,6 +32,8 @@
32
32
  type = 'item',
33
33
  open = (openItems instanceof Set ? openItems.has(id) : openItems.includes(id)) || undefined,
34
34
  checked = (checkedItems instanceof Set ? checkedItems.has(id) : checkedItems.includes(id)) || undefined,
35
+ onCheckedChange,
36
+ onOpenChange,
35
37
  children,
36
38
  ...attributes
37
39
  }: TreeViewItemProps = $props();
@@ -61,6 +63,12 @@
61
63
  get parentId() {
62
64
  return itemContext?.id;
63
65
  },
66
+ get onCheckedChange() {
67
+ return onCheckedChange;
68
+ },
69
+ get onOpenChange() {
70
+ return onOpenChange;
71
+ },
64
72
  get open() {
65
73
  return open;
66
74
  },
@@ -190,7 +198,8 @@
190
198
  outline: 2px solid var(--fs-focus-stroke-outer);
191
199
  outline-offset: 2px;
192
200
  }
193
- &.open :global(.branch-indicator) {
201
+ /* Only the chevron of this item's own row, not the ones of the branches nested inside it. */
202
+ &.open > :global(.fs-tree-view-item-content .branch-indicator) {
194
203
  rotate: 90deg;
195
204
  }
196
205
  }
@@ -89,12 +89,21 @@
89
89
  await tick();
90
90
  }
91
91
 
92
+ const wasChecked = new Map(state.TREE_NODES.entries().map(([nodeId, node]) => [nodeId, !!node.checked]));
93
+
92
94
  if (selectionMode === 'single') {
93
95
  traversalSyncSingleSelection(id, state.TREE_NODES, checkedItems);
94
- return;
96
+ } else {
97
+ traversalSync(id, state.TREE_NODES, checkedItems, checked);
95
98
  }
96
99
 
97
- traversalSync(id, state.TREE_NODES, checkedItems, checked);
100
+ // Every item the click reached reports its own change, the tree reports the resulting set.
101
+ for (const node of state.TREE_NODES.values()) {
102
+ if (!!node.checked !== wasChecked.get(node.id)) {
103
+ node.onCheckedChange?.(e as Event, { id: node.id, checked: !!node.checked });
104
+ }
105
+ }
106
+ treeViewContext.events.onCheckedChange(e as Event, Array.from(checkedItems));
98
107
  },
99
108
  openItem: (e, id) => {
100
109
  if (openItems instanceof SvelteSet) {
@@ -105,6 +114,9 @@
105
114
  const node = treeViewContext.state.TREE_NODES.get(id)!;
106
115
 
107
116
  node.open = true;
117
+
118
+ node.onOpenChange?.(e as Event, { id, open: true });
119
+ treeViewContext.events.onOpenChange(e as Event, Array.from(openItems));
108
120
  },
109
121
  closeItem: (e, id) => {
110
122
  const { state } = treeViewContext;
@@ -122,6 +134,9 @@
122
134
  if (virtualizer && state.forceVirtualRender) {
123
135
  state.forceVirtualRender = false;
124
136
  }
137
+
138
+ node.onOpenChange?.(e as Event, { id, open: false });
139
+ treeViewContext.events.onOpenChange(e as Event, Array.from(openItems));
125
140
  },
126
141
  registerItem: (node) => {
127
142
  treeViewContext.state.TREE_NODES.set(node.id, node);
@@ -102,10 +102,12 @@ export function traversalSyncSingleSelection(itemId, state, iterable) {
102
102
  node.indeterminate = false;
103
103
  if (iterable instanceof Set) {
104
104
  iterable.clear();
105
- iterable.add(node.id);
105
+ if (node.checked)
106
+ iterable.add(node.id);
106
107
  }
107
108
  else if (Array.isArray(iterable)) {
108
109
  iterable.length = 0;
109
- iterable.push(node.id);
110
+ if (node.checked)
111
+ iterable.push(node.id);
110
112
  }
111
113
  }
@@ -14,6 +14,8 @@ export type TreeViewNode = {
14
14
  checked?: boolean;
15
15
  indeterminate?: boolean;
16
16
  open?: boolean;
17
+ onCheckedChange?: TreeViewItemProps['onCheckedChange'];
18
+ onOpenChange?: TreeViewItemProps['onOpenChange'];
17
19
  };
18
20
  export type TreeViewEvent<T extends Record<string, unknown> | Iterable<unknown>> = (e: Event, data: T) => void;
19
21
  /**
@@ -179,6 +181,8 @@ export type TreeViewItemContext = {
179
181
  readonly id: string;
180
182
  readonly type: TreeViewItemType;
181
183
  readonly parentId?: string;
184
+ readonly onCheckedChange?: TreeViewItemProps['onCheckedChange'];
185
+ readonly onOpenChange?: TreeViewItemProps['onOpenChange'];
182
186
  open?: boolean;
183
187
  checked?: boolean;
184
188
  indeterminate?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluentui-svelte",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Modern Fluent UI 2 Components for Svelte 5",
5
5
  "author": {
6
6
  "name": "Jorge Acosta",
@@ -83,6 +83,7 @@
83
83
  "@humanspeak/svelte-virtual-list": "^0.5.14",
84
84
  "@inlang/paraglide-js": "^2.24.1",
85
85
  "@inlang/paraglide-js-svelte": "^1.0.4",
86
+ "@jlacostaec/paraglide-markup-svelte": "0.1.0",
86
87
  "@jlacostaec/propsmith": "^0.2.0",
87
88
  "@playwright/test": "^1.62.1",
88
89
  "@sveltejs/adapter-auto": "^7.0.1",