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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/components/auto-suggest-box/docs/llms.md +76 -15
  3. package/dist/components/card/card-header.svelte +2 -2
  4. package/dist/components/dialog/dialog-actions.svelte +17 -2
  5. package/dist/components/dialog/dialog-content.svelte +2 -2
  6. package/dist/components/dialog/dialog-content.svelte.d.ts +1 -1
  7. package/dist/components/dialog/dialog-surface.svelte +32 -3
  8. package/dist/components/dialog/dialog-title.svelte +23 -6
  9. package/dist/components/dialog/dialog-trigger.svelte +8 -2
  10. package/dist/components/dialog/dialog-trigger.svelte.d.ts +1 -1
  11. package/dist/components/dialog/docs/llms.md +2 -2
  12. package/dist/components/dialog/types.d.ts +6 -2
  13. package/dist/components/dialog/utils.d.ts +2 -0
  14. package/dist/components/dialog/utils.js +1 -0
  15. package/dist/components/dropdown/dropdown.svelte +1 -0
  16. package/dist/components/list-view/docs/llms.md +79 -27
  17. package/dist/components/list-view/list-view-item.svelte +14 -11
  18. package/dist/components/list-view/list-view.svelte +3 -2
  19. package/dist/components/list-view/types.d.ts +5 -1
  20. package/dist/components/menu/docs/llms.md +16 -0
  21. package/dist/components/menu/menu-popover.svelte +7 -12
  22. package/dist/components/menu/menu-popover.svelte.d.ts +2 -10
  23. package/dist/components/menu/types.d.ts +21 -0
  24. package/dist/components/rating/docs/llms.md +110 -0
  25. package/dist/components/rating/rating.svelte +210 -0
  26. package/dist/components/rating/rating.svelte.d.ts +19 -0
  27. package/dist/components/rating/types.d.ts +44 -0
  28. package/dist/components/rating/types.js +1 -0
  29. package/dist/components/tag/docs/llms.md +383 -0
  30. package/dist/components/tag/tag-group.d.ts +2 -0
  31. package/dist/components/tag/tag-group.js +2 -0
  32. package/dist/components/tag/tag-group.svelte +117 -0
  33. package/dist/components/tag/tag-group.svelte.d.ts +20 -0
  34. package/dist/components/tag/tag.svelte +458 -0
  35. package/dist/components/tag/tag.svelte.d.ts +19 -0
  36. package/dist/components/tag/types.d.ts +135 -0
  37. package/dist/components/tag/types.js +1 -0
  38. package/dist/components/tag-picker/docs/llms.md +292 -0
  39. package/dist/components/tag-picker/option-group.svelte +38 -0
  40. package/dist/components/tag-picker/option-group.svelte.d.ts +4 -0
  41. package/dist/components/tag-picker/option.svelte +71 -0
  42. package/dist/components/tag-picker/option.svelte.d.ts +4 -0
  43. package/dist/components/tag-picker/tag-picker.d.ts +2 -0
  44. package/dist/components/tag-picker/tag-picker.js +2 -0
  45. package/dist/components/tag-picker/tag-picker.svelte +644 -0
  46. package/dist/components/tag-picker/tag-picker.svelte.d.ts +21 -0
  47. package/dist/components/tag-picker/types.d.ts +122 -0
  48. package/dist/components/tag-picker/types.js +1 -0
  49. package/dist/components/tree-view/docs/llms.md +166 -1
  50. package/dist/components/tree-view/tree-view-item-content.svelte +41 -8
  51. package/dist/components/tree-view/tree-view-item.svelte +10 -1
  52. package/dist/components/tree-view/tree-view.svelte +17 -2
  53. package/dist/components/tree-view/tree-view.svelte.js +4 -2
  54. package/dist/components/tree-view/types.d.ts +4 -0
  55. package/dist/index.d.ts +7 -0
  56. package/dist/index.js +12 -0
  57. package/dist/internal/constants.d.ts +0 -4
  58. package/dist/internal/constants.js +0 -4002
  59. package/package.json +3 -2
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} />
@@ -2,7 +2,14 @@
2
2
  import { getTabspotAttributes } from 'tabspot';
3
3
  import type { DialogActionsProps } from './types.js';
4
4
 
5
- let { ref = $bindable(), position = 'end', fluid, children }: DialogActionsProps = $props();
5
+ let {
6
+ ref = $bindable(),
7
+ position = 'end',
8
+ fluid,
9
+ class: classes,
10
+ children,
11
+ ...attributes
12
+ }: DialogActionsProps = $props();
6
13
 
7
14
  // The bar is one tab stop holding a row of buttons: without the mover the root would
8
15
  // take the tab stop and then offer no way to reach the rest of the buttons.
@@ -12,7 +19,12 @@
12
19
  });
13
20
  </script>
14
21
 
15
- <div class={['dialog-actions', `justify-${position}`, { fluid }]} bind:this={ref} {...tabspotAttributes}>
22
+ <div
23
+ class={['dialog-actions', `justify-${position}`, { fluid }, classes]}
24
+ bind:this={ref}
25
+ {...tabspotAttributes}
26
+ {...attributes}
27
+ >
16
28
  {@render children?.()}
17
29
  </div>
18
30
 
@@ -33,5 +45,8 @@
33
45
  &.justify-end {
34
46
  justify-content: flex-end;
35
47
  }
48
+ &.fluid > :global(*) {
49
+ flex: 1 1 0;
50
+ }
36
51
  }
37
52
  </style>
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
2
  import type { DialogContentProps } from './types.js';
3
3
 
4
- let { children }: DialogContentProps = $props();
4
+ let { ref = $bindable(), class: classes, children, ...attributes }: DialogContentProps = $props();
5
5
  </script>
6
6
 
7
- <div class="dialog-content">
7
+ <div class={['dialog-content', classes]} bind:this={ref} {...attributes}>
8
8
  {@render children?.()}
9
9
  </div>
10
10
 
@@ -1,4 +1,4 @@
1
1
  import type { DialogContentProps } from './types.ts';
2
- declare const DialogContent: import("svelte").Component<DialogContentProps, {}, "">;
2
+ declare const DialogContent: import("svelte").Component<DialogContentProps, {}, "ref">;
3
3
  type DialogContent = ReturnType<typeof DialogContent>;
4
4
  export default DialogContent;
@@ -1,11 +1,12 @@
1
1
  <script lang="ts">
2
2
  import { onClickOutside } from 'runed';
3
+ import { on } from 'svelte/events';
3
4
  import type { DialogSurfaceProps } from './types.js';
4
5
  import { getDialogContext } from './dialog.svelte.js';
5
6
  import { Button } from '../../index.js';
6
7
  import DismissFilled from 'fluentui-icons-svelte/DismissFilled.svelte';
7
8
 
8
- let { ref = $bindable(), children, ...attributes }: DialogSurfaceProps = $props();
9
+ let { ref = $bindable(), class: classes, oncancel, children, ...attributes }: DialogSurfaceProps = $props();
9
10
 
10
11
  const CONTEXT = getDialogContext();
11
12
 
@@ -17,22 +18,50 @@
17
18
 
18
19
  const { closeDialog } = methods;
19
20
 
21
+ // runed reports an outside press 10ms after the pointer goes down. By then the press that opens the
22
+ // dialog has already opened it, and a press while it is closed has nothing to dismiss, so remember
23
+ // whether the dialog was open at the moment the pointer went down.
24
+ let openOnPointerDown = false;
25
+
26
+ $effect(() => {
27
+ const doc = ref?.ownerDocument;
28
+ if (!doc) return;
29
+ return on(doc, 'pointerdown', () => (openOnPointerDown = !!ref?.open), { capture: true });
30
+ });
31
+
20
32
  // Registered unconditionally and guarded from inside, so switching `type` after mount is honored.
21
33
  onClickOutside(
22
34
  () => ref,
23
35
  () => {
24
- if (config.type === 'alert') return;
36
+ if (config.type === 'alert' || !openOnPointerDown) return;
25
37
  closeDialog();
26
38
  }
27
39
  );
28
40
 
41
+ // Escape cancels a modal dialog natively. An alert has to be answered with one of its actions, and any
42
+ // other modal closes through `closeDialog` so `onOpenChange` hears about it. A consumer's `oncancel`
43
+ // can still veto it with `preventDefault`.
44
+ function handleCancel(e: Event & { currentTarget: EventTarget & HTMLDialogElement }) {
45
+ oncancel?.(e);
46
+ if (e.defaultPrevented) return;
47
+ e.preventDefault();
48
+ if (config.type !== 'alert') closeDialog();
49
+ }
50
+
29
51
  $effect(() => {
30
52
  if (!ref) return;
31
53
  _state.dialogRef = ref;
32
54
  });
33
55
  </script>
34
56
 
35
- <dialog open={_state.open} class="fs-dialog" aria-labelledby={_state.titleId} bind:this={ref} {...attributes}>
57
+ <dialog
58
+ open={_state.open}
59
+ class={['fs-dialog', classes]}
60
+ aria-labelledby={_state.titleId}
61
+ bind:this={ref}
62
+ oncancel={handleCancel}
63
+ {...attributes}
64
+ >
36
65
  <div class="dialog-wrapper">
37
66
  {#if config.type === 'non-modal'}
38
67
  <Button class="close-icon" appearance="subtle" onclick={() => methods.closeDialog()} aria-label="Close dialog">
@@ -1,19 +1,36 @@
1
1
  <script lang="ts" generics="T extends DialogTitleTag">
2
2
  import { PREFIX } from '../../internal/constants.js';
3
3
  import { getDialogContext } from './dialog.svelte.js';
4
+ import { TITLE_TAG } from './utils.js';
4
5
  import type { DialogTitleProps, DialogTitleTag } from './types.js';
5
6
 
6
- let { as = 'h3' as T, ref = $bindable(), children }: DialogTitleProps<T> = $props();
7
-
8
- const FALLBACK_ID = $props.id();
9
- const ID = `${PREFIX}dialog-title-${FALLBACK_ID}`;
7
+ const _ID = $props.id();
8
+ const FALLBACK_ID = `${PREFIX}dialog-title-${_ID}`;
9
+
10
+ let {
11
+ as = 'h3' as T,
12
+ ref = $bindable(),
13
+ id = FALLBACK_ID,
14
+ class: classes,
15
+ children,
16
+ ...attributes
17
+ }: DialogTitleProps<T> = $props();
18
+
19
+ /**
20
+ * The tag the invariant rules over. The markup renders it through here, so it is checked
21
+ * during SSR and on every prop update rather than only once on mount.
22
+ */
23
+ const _as = $derived.by(() => {
24
+ if (!TITLE_TAG.includes(as)) throw new Error(`Invalid tag: ${as}. Must be one of ${TITLE_TAG.join(', ')}`);
25
+ return as;
26
+ });
10
27
 
11
28
  const context = getDialogContext();
12
29
 
13
30
  $effect.pre(() => {
14
31
  if (!context) return;
15
32
 
16
- context.state.titleId = ID;
33
+ context.state.titleId = id ?? undefined;
17
34
 
18
35
  return () => {
19
36
  context.state.titleId = undefined;
@@ -21,7 +38,7 @@
21
38
  });
22
39
  </script>
23
40
 
24
- <svelte:element this={as} id={ID} class="dialog-title" bind:this={ref}>
41
+ <svelte:element this={_as} {id} class={['dialog-title', classes]} bind:this={ref} {...attributes}>
25
42
  {@render children?.()}
26
43
  </svelte:element>
27
44
 
@@ -1,9 +1,10 @@
1
1
  <script lang="ts">
2
2
  import { Button } from '../../index.js';
3
+ import { invokeHandlers } from '../../internal/index.js';
3
4
  import { getDialogContext } from './dialog.svelte.js';
4
5
  import type { DialogTriggerProps } from './types.js';
5
6
 
6
- let { children }: DialogTriggerProps = $props();
7
+ let { ref = $bindable(), class: classes, onclick, children, ...attributes }: DialogTriggerProps = $props();
7
8
 
8
9
  const CONTEXT = getDialogContext();
9
10
 
@@ -14,7 +15,12 @@
14
15
  const { methods } = CONTEXT;
15
16
  </script>
16
17
 
17
- <Button class="dialog-trigger" onclick={methods.openDialog}>
18
+ <Button
19
+ bind:ref
20
+ class={['dialog-trigger', classes]}
21
+ onclick={(e: MouseEvent) => invokeHandlers(e, [], [onclick, methods.openDialog])}
22
+ {...attributes}
23
+ >
18
24
  {#if children}
19
25
  {@render children()}
20
26
  {:else}
@@ -1,4 +1,4 @@
1
1
  import type { DialogTriggerProps } from './types.ts';
2
- declare const DialogTrigger: import("svelte").Component<DialogTriggerProps, {}, "">;
2
+ declare const DialogTrigger: import("svelte").Component<DialogTriggerProps, {}, "ref">;
3
3
  type DialogTrigger = ReturnType<typeof DialogTrigger>;
4
4
  export default DialogTrigger;
@@ -99,7 +99,7 @@ Add a `DialogActions` region to render action buttons in the dialog footer. Use
99
99
 
100
100
  | Name | Type | Default | Description |
101
101
  | ----------------------- | ------------------- | ------- | ----------------------------------------- |
102
- | `ref` | `HTMLButtonElement` | | The DOM reference of the trigger element. |
102
+ | `ref` _bindable_ | `HTMLButtonElement` | | The DOM reference of the trigger element. |
103
103
  | `ButtonProps<'button'>` | | | |
104
104
 
105
105
  <!-- /props:DialogTriggerProps -->
@@ -136,7 +136,7 @@ Add a `DialogActions` region to render action buttons in the dialog footer. Use
136
136
 
137
137
  | Name | Type | Default | Description |
138
138
  | -------------------------- | ---------------- | ------- | ----------------------------------------- |
139
- | `ref` | `HTMLDivElement` | | The DOM reference of the content element. |
139
+ | `ref` _bindable_ | `HTMLDivElement` | | The DOM reference of the content element. |
140
140
  | Element Attributes (`div`) | | | |
141
141
 
142
142
  <!-- /props:DialogContentProps -->
@@ -35,12 +35,16 @@ export type DialogSurfaceProps = {
35
35
  } & HTMLAttributes<HTMLDialogElement>;
36
36
  /** @propsmith DialogTriggerProps */
37
37
  export type DialogTriggerProps = {
38
- /** The DOM reference of the trigger element. */
38
+ /** The DOM reference of the trigger element.
39
+ * @bindable
40
+ */
39
41
  ref?: HTMLButtonElement;
40
42
  } & ButtonProps<'button'>;
41
43
  /** @propsmith DialogContentProps */
42
44
  export type DialogContentProps = {
43
- /** The DOM reference of the content element. */
45
+ /** The DOM reference of the content element.
46
+ * @bindable
47
+ */
44
48
  ref?: HTMLDivElement;
45
49
  } & HTMLAttributes<HTMLDivElement>;
46
50
  /** @propsmith DialogActionsProps */
@@ -0,0 +1,2 @@
1
+ import type { DialogTitleTag } from './types.ts';
2
+ export declare const TITLE_TAG: DialogTitleTag[];
@@ -0,0 +1 @@
1
+ export const TITLE_TAG = ['div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
@@ -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;