@noxlovette/material 0.6.1 → 0.7.1

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 (90) hide show
  1. package/claude-skill/material-design/SKILL.md +5 -2
  2. package/claude-skill/material-design/references/accessibility-checklist.md +5 -0
  3. package/claude-skill/material-design/references/component-patterns.md +24 -3
  4. package/claude-skill/material-design/references/motion-guide.md +53 -11
  5. package/claude-skill/material-design/references/tokens-and-styles.md +4 -0
  6. package/dist/animation/TransitionPatterns.stories.svelte +50 -0
  7. package/dist/animation/containerTransform.js +10 -4
  8. package/dist/animation/reducedMotion.d.ts +2 -0
  9. package/dist/animation/reducedMotion.js +2 -0
  10. package/dist/animation/sharedAxisTransition.d.ts +20 -3
  11. package/dist/animation/sharedAxisTransition.js +67 -8
  12. package/dist/attachments/drag.d.ts +52 -0
  13. package/dist/attachments/drag.js +217 -0
  14. package/dist/attachments/index.d.ts +1 -0
  15. package/dist/attachments/index.js +1 -0
  16. package/dist/components/buttons/ButtonIcon.svelte +6 -2
  17. package/dist/components/buttons/ButtonIcon.svelte.d.ts +1 -1
  18. package/dist/components/buttons/context.d.ts +10 -0
  19. package/dist/components/buttons/context.js +9 -0
  20. package/dist/components/chips/Chip.mdx +24 -11
  21. package/dist/components/chips/Chip.stories.svelte +10 -0
  22. package/dist/components/chips/Chip.svelte +58 -18
  23. package/dist/components/chips/Chip.svelte.d.ts +3 -1
  24. package/dist/components/chips/ChipGroup.stories.svelte +73 -0
  25. package/dist/components/chips/ChipGroup.stories.svelte.d.ts +4 -0
  26. package/dist/components/chips/ChipGroup.svelte +242 -0
  27. package/dist/components/chips/ChipGroup.svelte.d.ts +33 -0
  28. package/dist/components/chips/index.d.ts +1 -0
  29. package/dist/components/chips/index.js +1 -0
  30. package/dist/components/chips/theme.d.ts +182 -21
  31. package/dist/components/chips/theme.js +116 -25
  32. package/dist/components/chips/types.d.ts +36 -2
  33. package/dist/components/containers/app/theme.d.ts +1 -1
  34. package/dist/components/containers/app/theme.js +1 -1
  35. package/dist/components/containers/bottom-sheet/BottomSheet.stories.svelte +1 -1
  36. package/dist/components/containers/list/List.svelte +8 -2
  37. package/dist/components/containers/list/ListItem.mdx +6 -0
  38. package/dist/components/containers/list/ListItem.svelte +77 -2
  39. package/dist/components/containers/list/context.d.ts +14 -0
  40. package/dist/components/containers/list/theme.d.ts +6 -0
  41. package/dist/components/containers/list/theme.js +10 -2
  42. package/dist/components/containers/pane/DraggablePane.svelte +25 -63
  43. package/dist/components/containers/pane/Pane.svelte +1 -1
  44. package/dist/components/containers/pane/theme.js +32 -6
  45. package/dist/components/containers/popover/theme.d.ts +3 -3
  46. package/dist/components/date/theme.d.ts +6 -6
  47. package/dist/components/forms/checkbox/theme.js +1 -1
  48. package/dist/components/forms/command/theme.d.ts +3 -3
  49. package/dist/components/misc/Avatar.stories.svelte +56 -7
  50. package/dist/components/misc/Avatar.stories.svelte.d.ts +2 -17
  51. package/dist/components/misc/Avatar.svelte +82 -8
  52. package/dist/components/misc/Avatar.svelte.d.ts +5 -0
  53. package/dist/components/misc/theme.d.ts +6 -0
  54. package/dist/components/misc/theme.js +5 -3
  55. package/dist/components/misc/types.d.ts +17 -1
  56. package/dist/components/nav/appbar/AppBar.svelte +8 -0
  57. package/dist/components/nav/appbar/AppBar.svelte.d.ts +3 -0
  58. package/dist/components/nav/appbar/theme.js +14 -2
  59. package/dist/components/nav/currentRoute.d.ts +11 -0
  60. package/dist/components/nav/currentRoute.js +28 -0
  61. package/dist/components/nav/navbar/NavbarItem.svelte +3 -6
  62. package/dist/components/nav/navbar/theme.d.ts +3 -3
  63. package/dist/components/nav/rail/Rail.mdx +65 -23
  64. package/dist/components/nav/rail/Rail.stories.svelte +24 -4
  65. package/dist/components/nav/rail/Rail.stories.svelte.d.ts +2 -17
  66. package/dist/components/nav/rail/Rail.svelte +163 -48
  67. package/dist/components/nav/rail/Rail.svelte.d.ts +6 -3
  68. package/dist/components/nav/rail/RailItem.svelte +122 -65
  69. package/dist/components/nav/rail/RailItem.svelte.d.ts +3 -2
  70. package/dist/components/nav/rail/theme.d.ts +69 -84
  71. package/dist/components/nav/rail/theme.js +79 -66
  72. package/dist/components/nav/tabs/Tabs.stories.svelte +2 -1
  73. package/dist/index.css +2 -0
  74. package/dist/styles/components.css +50 -0
  75. package/dist/styles/icon.css +13 -0
  76. package/dist/utils/icon/Icon.stories.svelte +97 -0
  77. package/dist/utils/icon/Icon.stories.svelte.d.ts +4 -0
  78. package/dist/utils/icon/Icon.svelte +101 -12
  79. package/dist/utils/icon/Icon.svelte.d.ts +9 -0
  80. package/dist/utils/icon/MaterialSymbolsProvider.svelte +11 -4
  81. package/dist/utils/icon/base-icons.d.ts +1 -1
  82. package/dist/utils/icon/base-icons.js +1 -1
  83. package/dist/utils/icon/icon-names.d.ts +2 -0
  84. package/dist/utils/icon/icon-names.js +2 -0
  85. package/dist/utils/icon/swap.d.ts +6 -0
  86. package/dist/utils/icon/swap.js +45 -0
  87. package/dist/utils/icon/types.d.ts +59 -7
  88. package/dist/utils/tv.d.ts +3 -4
  89. package/dist/utils/tv.js +14 -6
  90. package/package.json +2 -1
@@ -0,0 +1,217 @@
1
+ import { on } from 'svelte/events';
2
+ /* Pointer samples older than this don't count towards the release velocity. */
3
+ const VELOCITY_WINDOW_MS = 100;
4
+ /* How far a resting finger may wander during `touchDelay` before it counts as a scroll. */
5
+ const TOUCH_SLOP = 8;
6
+ /**
7
+ * Pointer dragging for an element: capture, a start threshold, a long-press delay on touch, and
8
+ * the release velocity. It moves nothing itself: `onMove` gets the pointer's offset and the
9
+ * caller positions things, typically with `resist` at the edges and a Motion spring on release.
10
+ *
11
+ * Options are read through a getter at event time, so changing them never tears down a drag in
12
+ * progress:
13
+ *
14
+ * ```svelte
15
+ * <div {@attach drag(() => ({ threshold: 4, onMove: (o) => (offset = o) }))}>…</div>
16
+ * ```
17
+ *
18
+ * After a drag the click that would follow the release is swallowed, so dragging a button
19
+ * doesn't also press it.
20
+ */
21
+ export function drag(getOptions) {
22
+ return (node) => {
23
+ let phase = 'idle';
24
+ let pointerId = -1;
25
+ let origin = { x: 0, y: 0 };
26
+ let samples = [];
27
+ let touchTimer;
28
+ let suppressClick = false;
29
+ let unwatch;
30
+ const offsetOf = (event) => ({
31
+ x: event.clientX - origin.x,
32
+ y: event.clientY - origin.y
33
+ });
34
+ const record = (event) => {
35
+ samples.push({ x: event.clientX, y: event.clientY, t: event.timeStamp });
36
+ samples = samples.filter((sample) => event.timeStamp - sample.t <= VELOCITY_WINDOW_MS);
37
+ };
38
+ const velocity = () => {
39
+ const first = samples[0];
40
+ const last = samples[samples.length - 1];
41
+ const seconds = first && last ? (last.t - first.t) / 1000 : 0;
42
+ if (!seconds)
43
+ return { x: 0, y: 0 };
44
+ return { x: (last.x - first.x) / seconds, y: (last.y - first.y) / seconds };
45
+ };
46
+ const reset = () => {
47
+ clearTimeout(touchTimer);
48
+ unwatch?.();
49
+ unwatch = undefined;
50
+ phase = 'idle';
51
+ pointerId = -1;
52
+ };
53
+ const capture = () => {
54
+ try {
55
+ node.setPointerCapture(pointerId);
56
+ }
57
+ catch {
58
+ // Not an active pointer (a synthetic event, or one already gone): drag uncaptured.
59
+ }
60
+ };
61
+ const begin = (event) => {
62
+ clearTimeout(touchTimer);
63
+ touchTimer = undefined;
64
+ phase = 'dragging';
65
+ // Capturing only now, not on pointerdown: a capture from the start would retarget the
66
+ // click to this node, and a press that never became a drag would lose its click.
67
+ capture();
68
+ getOptions().onStart?.(event);
69
+ };
70
+ const finish = (v) => {
71
+ const wasDragging = phase === 'dragging';
72
+ if (node.hasPointerCapture?.(pointerId))
73
+ node.releasePointerCapture(pointerId);
74
+ reset();
75
+ if (!wasDragging)
76
+ return;
77
+ suppressClick = true;
78
+ // The click, if any, fires synchronously after pointerup; anything later is a new press.
79
+ setTimeout(() => (suppressClick = false));
80
+ getOptions().onEnd?.(v);
81
+ };
82
+ /*
83
+ The whole gesture, pending or dragging, is followed on the window rather than the node.
84
+ Capture alone isn't enough: moving the captured node in the DOM (a list reordering under the
85
+ drag) silently releases it, and then the release would land on some other element and never
86
+ end the drag. Plain listeners, not svelte/events' `on`: that defers pointer listeners to a
87
+ microtask, and these are added mid-gesture, so they must be live for the very next event.
88
+ */
89
+ const watch = () => {
90
+ const onMove = (event) => {
91
+ if (event.pointerId !== pointerId)
92
+ return;
93
+ record(event);
94
+ if (phase === 'dragging') {
95
+ // The button came up somewhere we didn't hear about (outside the window, uncaptured).
96
+ if (event.pointerType === 'mouse' && event.buttons === 0)
97
+ return finish(velocity());
98
+ getOptions().onMove?.(offsetOf(event), event);
99
+ return;
100
+ }
101
+ const { threshold = 0 } = getOptions();
102
+ const moved = Math.hypot(event.clientX - origin.x, event.clientY - origin.y);
103
+ if (event.pointerType === 'touch' && touchTimer !== undefined) {
104
+ // Moved before the long press: it's a scroll, not a drag.
105
+ if (moved > TOUCH_SLOP)
106
+ reset();
107
+ return;
108
+ }
109
+ if (moved >= threshold) {
110
+ begin(event);
111
+ getOptions().onMove?.(offsetOf(event), event);
112
+ }
113
+ };
114
+ const onUp = (event) => {
115
+ if (event.pointerId !== pointerId)
116
+ return;
117
+ if (phase === 'dragging')
118
+ finish(velocity());
119
+ else
120
+ reset();
121
+ };
122
+ const onCancel = (event) => {
123
+ if (event.pointerId !== pointerId)
124
+ return;
125
+ if (phase === 'dragging')
126
+ finish({ x: 0, y: 0 });
127
+ else
128
+ reset();
129
+ };
130
+ // Capture lost mid-drag (the node moved): take it back, so a release outside the browser
131
+ // window still reaches us.
132
+ const onLostCapture = (event) => {
133
+ if (phase === 'dragging' && event.pointerId === pointerId && node.isConnected)
134
+ capture();
135
+ };
136
+ window.addEventListener('pointermove', onMove);
137
+ window.addEventListener('pointerup', onUp);
138
+ window.addEventListener('pointercancel', onCancel);
139
+ node.addEventListener('lostpointercapture', onLostCapture);
140
+ return () => {
141
+ window.removeEventListener('pointermove', onMove);
142
+ window.removeEventListener('pointerup', onUp);
143
+ window.removeEventListener('pointercancel', onCancel);
144
+ node.removeEventListener('lostpointercapture', onLostCapture);
145
+ };
146
+ };
147
+ const offDown = on(node, 'pointerdown', (event) => {
148
+ if (phase !== 'idle' || !event.isPrimary || event.button !== 0)
149
+ return;
150
+ const { disabled, threshold = 0, touchDelay = 0 } = getOptions();
151
+ if (disabled)
152
+ return;
153
+ pointerId = event.pointerId;
154
+ origin = { x: event.clientX, y: event.clientY };
155
+ samples = [{ x: event.clientX, y: event.clientY, t: event.timeStamp }];
156
+ unwatch = watch();
157
+ if (event.pointerType === 'touch' && touchDelay > 0) {
158
+ phase = 'pending';
159
+ touchTimer = setTimeout(() => {
160
+ touchTimer = undefined;
161
+ begin(event);
162
+ }, touchDelay);
163
+ return;
164
+ }
165
+ if (threshold > 0) {
166
+ phase = 'pending';
167
+ return;
168
+ }
169
+ begin(event);
170
+ });
171
+ // A touch drag started by a long press: stop the page from scrolling under it. Needs a
172
+ // non-passive listener; touch-action can't be switched once the gesture has begun.
173
+ const offTouchMove = on(node, 'touchmove', (event) => {
174
+ if (phase === 'dragging')
175
+ event.preventDefault();
176
+ }, { passive: false });
177
+ // The long press would otherwise open the context menu or the text callout.
178
+ const offContextMenu = on(node, 'contextmenu', (event) => {
179
+ if (phase !== 'idle')
180
+ event.preventDefault();
181
+ });
182
+ const offClick = on(node, 'click', (event) => {
183
+ if (!suppressClick)
184
+ return;
185
+ event.preventDefault();
186
+ event.stopPropagation();
187
+ }, { capture: true });
188
+ return () => {
189
+ reset();
190
+ offDown();
191
+ offTouchMove();
192
+ offContextMenu();
193
+ offClick();
194
+ };
195
+ };
196
+ }
197
+ /*
198
+ Share of the overshoot still applied at the edge, shrinking the further it's pulled. The
199
+ UIScrollView rubber-band constant; M3 has no token for this, tune by eye.
200
+ */
201
+ const RUBBER_BAND = 0.55;
202
+ /** Resisted overshoot: the further past the edge, the less it follows. */
203
+ function rubberBand(overshoot, limit) {
204
+ return (1 - 1 / ((overshoot * RUBBER_BAND) / limit + 1)) * limit;
205
+ }
206
+ /**
207
+ * `value` clamped to `[min, max]` with rubber-banding instead of a wall: past an edge it keeps
208
+ * following the pointer, less and less. `size` is the scale of the resistance, usually the
209
+ * extent of the bounds on that axis.
210
+ */
211
+ export function resist(value, min, max, size) {
212
+ if (value < min)
213
+ return min - rubberBand(min - value, size);
214
+ if (value > max)
215
+ return max + rubberBand(value - max, size);
216
+ return value;
217
+ }
@@ -1 +1,2 @@
1
1
  export * from './clickOutside.js';
2
+ export * from './drag.js';
@@ -1 +1,2 @@
1
1
  export * from './clickOutside.js';
2
+ export * from './drag.js';
@@ -2,7 +2,7 @@
2
2
  @component
3
3
  Icon buttons help people take minor actions with one tap.
4
4
 
5
- - Filled: high emphasis (default)
5
+ - Filled: high emphasis (default, except inside an `AppBar`, where it's standard)
6
6
  - Tonal: medium-high emphasis
7
7
  - Outlined: medium emphasis
8
8
  - Standard: low emphasis, no container at rest; for app bars, fields and dense layouts
@@ -17,12 +17,16 @@ Icon buttons help people take minor actions with one tap.
17
17
  import { buttonColor, buttonIcon } from './theme.js';
18
18
  import clsx from 'clsx';
19
19
  import { buttonCorners, shapeMorph } from './shapeMorph.js';
20
+ import { getButtonIconVariant } from './context.js';
20
21
  import Tooltip from '../forms/tooltip/Tooltip.svelte';
21
22
  import { Button, Toggle, type ButtonRootProps, type ToggleRootProps } from 'bits-ui';
22
23
 
24
+ // A container like AppBar can lower the default emphasis; an explicit `variant` still wins.
25
+ const inheritedVariant = getButtonIconVariant();
26
+
23
27
  let {
24
28
  iconProps,
25
- variant = 'filled',
29
+ variant = inheritedVariant ?? 'filled',
26
30
  size = 'sm',
27
31
  shape = 'round',
28
32
  width = 'default',
@@ -2,7 +2,7 @@ import type { ButtonIconProps } from './types.js';
2
2
  /**
3
3
  * Icon buttons help people take minor actions with one tap.
4
4
  *
5
- * - Filled: high emphasis (default)
5
+ * - Filled: high emphasis (default, except inside an `AppBar`, where it's standard)
6
6
  * - Tonal: medium-high emphasis
7
7
  * - Outlined: medium emphasis
8
8
  * - Standard: low emphasis, no container at rest; for app bars, fields and dense layouts
@@ -0,0 +1,10 @@
1
+ import type { ButtonIconProps } from './types.js';
2
+ type ButtonIconVariant = NonNullable<ButtonIconProps['variant']>;
3
+ /**
4
+ * Sets the variant a `ButtonIcon` below this component takes when it's given none, e.g. an
5
+ * app bar's `standard` (M3: icon buttons in app bars have no container). An explicit
6
+ * `variant` on the button always wins.
7
+ */
8
+ export declare const setButtonIconVariant: (variant: ButtonIconVariant) => ButtonIconVariant;
9
+ export declare const getButtonIconVariant: () => ButtonIconVariant | undefined;
10
+ export {};
@@ -0,0 +1,9 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const KEY = Symbol('buttonIconVariant');
3
+ /**
4
+ * Sets the variant a `ButtonIcon` below this component takes when it's given none, e.g. an
5
+ * app bar's `standard` (M3: icon buttons in app bars have no container). An explicit
6
+ * `variant` on the button always wins.
7
+ */
8
+ export const setButtonIconVariant = (variant) => setContext(KEY, variant);
9
+ export const getButtonIconVariant = () => getContext(KEY);
@@ -20,12 +20,12 @@ import * as ChipStories from './Chip.stories.svelte';
20
20
 
21
21
  `variant` sets both the look and the element the chip renders:
22
22
 
23
- | Variant | Use for | Renders |
24
- | ------------ | --------------------------------------------------------------- | ------------------------------------------------------ |
25
- | `assist` | A smart or automated action, like adding an event to a calendar | `<button>` (or `<a>` with `href`); icon tinted primary |
26
- | `filter` | Filtering content by tags or descriptive words | A toggle; a checkmark replaces the icon when selected |
27
- | `input` | A piece of information the user entered (a contact, a value) | A plain container; `onRemove` adds a remove button |
28
- | `suggestion` | A generated suggestion that narrows intent | Like `assist`, without the tinted icon |
23
+ | Variant | Use for | Renders |
24
+ | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------ |
25
+ | `assist` | A smart or automated action, like adding an event to a calendar | `<button>` (or `<a>` with `href`); icon tinted primary |
26
+ | `filter` | Filtering content by tags or descriptive words | A toggle; a checkmark replaces the icon when selected |
27
+ | `input` | A piece of information the user entered (a contact, a value) | A container with a primary button; `onRemove` adds a remove button |
28
+ | `suggestion` | A generated suggestion that narrows intent | Like `assist`; its label is on-surface-variant |
29
29
 
30
30
  `elevated` swaps the flat, outlined container for a shadowed one on every variant except `input`,
31
31
  which the spec keeps flat. For a static label with no interaction (a status or a count), use
@@ -41,6 +41,9 @@ which the spec keeps flat. For a static label with no interaction (a status or a
41
41
 
42
42
  <Canvas of={ChipStories.Input} />
43
43
 
44
+ An `avatar` snippet replaces the leading icon and is clipped to a 24dp circle; it wins over
45
+ `iconProps` when both are given.
46
+
44
47
  Render one input chip per entry, and remove the entry from your own array in `onRemove`:
45
48
 
46
49
  ```svelte
@@ -48,6 +51,7 @@ Render one input chip per entry, and remove the entry from your own array in `on
48
51
  <Chip
49
52
  variant="input"
50
53
  iconProps={{ name: 'person' }}
54
+ removeLabel="Remove {name}"
51
55
  onRemove={() => (recipients = recipients.filter((r) => r !== name))}
52
56
  >
53
57
  {name}
@@ -57,12 +61,21 @@ Render one input chip per entry, and remove the entry from your own array in `on
57
61
 
58
62
  <Canvas of={ChipStories.Suggestion} />
59
63
 
64
+ ## Sets of chips
65
+
66
+ Lay several chips out with `ChipGroup` (8dp apart, wrapping). With `reorderable`, people can drag
67
+ them into a new order, or move the focused one with Alt+Arrow; see Chips → Chip Group.
68
+
60
69
  ## Accessibility
61
70
 
62
71
  - **Roles.** Assist and suggestion chips are real buttons or links. Filter chips are toggles
63
- whose `aria-pressed` reflects `selected`. An input chip's remove button is its own focusable
64
- `<button>`, never nested inside another interactive element.
65
- - **Disabled.** Assist, filter and suggestion chips use the native `disabled` attribute. An input
66
- chip's container is a `<div>`, so it gets `aria-disabled`, and its remove button a real
67
- `disabled`.
72
+ whose `aria-pressed` reflects `selected`; `trailingIconProps` adds a decorative trailing icon,
73
+ e.g. `arrow_drop_down` for a chip that opens a menu. An input chip holds two sibling buttons:
74
+ its primary action (leading content and label, taking `onclick` and the rest props) and the
75
+ remove button, so neither nests inside the other. Backspace or Delete on the primary action
76
+ calls `onRemove`; give the remove button a `removeLabel` that names the item, and move focus
77
+ to a neighbour after removing.
78
+ - **Disabled.** Every chip uses the native `disabled` attribute; an input chip sets it on both
79
+ of its buttons.
80
+ - **Target size.** The remove icon is 18dp with a 48dp touch target around it.
68
81
  - **Contrast.** Every state pairs a container with its `on-*` role, so text keeps AA contrast.
@@ -39,6 +39,7 @@
39
39
  <div class="gap-spacing-200 p-spacing-300 flex flex-wrap items-center">
40
40
  <Chip variant="filter" bind:selected={filterSelected}>Filter chip</Chip>
41
41
  <Chip variant="filter" iconProps={{ name: 'star' }}>Starred</Chip>
42
+ <Chip variant="filter" trailingIconProps={{ name: 'arrow_drop_down' }}>Sort by</Chip>
42
43
  <Chip variant="filter" elevated selected>Elevated selected</Chip>
43
44
  <Chip variant="filter" disabled>Disabled</Chip>
44
45
  </div>
@@ -48,6 +49,15 @@
48
49
  <div class="gap-spacing-200 p-spacing-300 flex flex-wrap items-center">
49
50
  <Chip variant="input" iconProps={{ name: 'person' }} onRemove={() => {}}>Ada Lovelace</Chip>
50
51
  <Chip variant="input" onRemove={() => {}}>No avatar</Chip>
52
+ <Chip variant="input" onRemove={() => {}}>
53
+ {#snippet avatar()}
54
+ <span
55
+ class="bg-md-sys-color-primary text-md-sys-color-on-primary md-sys-typescale-label-small flex size-full items-center justify-center"
56
+ >GH</span
57
+ >
58
+ {/snippet}
59
+ Grace Hopper
60
+ </Chip>
51
61
  <Chip variant="input" selected iconProps={{ name: 'person' }} onRemove={() => {}}>Selected</Chip
52
62
  >
53
63
  <Chip variant="input" disabled iconProps={{ name: 'person' }} onRemove={() => {}}>Disabled</Chip
@@ -4,7 +4,9 @@ Chips help people enter information, make selections, filter content, or trigger
4
4
 
5
5
  - Assist: Represents a smart or automated action, such as adding an event to a calendar
6
6
  - Filter: Uses tags or descriptive words to filter content; supports a selected state
7
- - Input: Represents a discrete piece of information entered by a user, and can be removed
7
+ - Input: Represents a discrete piece of information entered by a user, and can be removed. Its
8
+ primary action is a button of its own (focusable, pressable, `onclick`); Backspace or Delete on
9
+ it calls `onRemove`
8
10
  - Suggestion: Helps narrow a user's intent by presenting dynamically generated suggestions
9
11
 
10
12
  @see https://m3.material.io/components/chips/overview
@@ -15,7 +17,6 @@ Chips help people enter information, make selections, filter content, or trigger
15
17
  import { chip } from './theme.js';
16
18
  import clsx from 'clsx';
17
19
  import { Button, Toggle, type ButtonRootProps, type ToggleRootProps } from 'bits-ui';
18
- import type { HTMLAttributes } from 'svelte/elements';
19
20
 
20
21
  let {
21
22
  children,
@@ -25,8 +26,10 @@ Chips help people enter information, make selections, filter content, or trigger
25
26
  onPressedChange,
26
27
  disabled = false,
27
28
  iconProps,
29
+ trailingIconProps,
28
30
  avatar,
29
31
  onRemove,
32
+ removeLabel = 'Remove',
30
33
  formaction,
31
34
  class: className,
32
35
  ...restProps
@@ -37,10 +40,37 @@ Chips help people enter information, make selections, filter content, or trigger
37
40
  const isSelected = $derived(variant === 'filter' || variant === 'input' ? selected : false);
38
41
  const showCheck = $derived(isFilter && selected);
39
42
 
40
- const { base, icon, label, trailing, checkIcon } = $derived(
41
- chip({ variant, elevated: isElevated, selected: isSelected, disabled })
43
+ const leadingKind = $derived(
44
+ showCheck ? 'icon' : avatar ? 'avatar' : iconProps ? 'icon' : 'none'
45
+ );
46
+ const removable = $derived(variant === 'input' && Boolean(onRemove));
47
+ const hasTrailingIcon = $derived(isFilter && Boolean(trailingIconProps));
48
+
49
+ const {
50
+ base,
51
+ icon,
52
+ avatar: avatarCls,
53
+ action,
54
+ label,
55
+ trailing,
56
+ checkIcon
57
+ } = $derived(
58
+ chip({
59
+ variant,
60
+ elevated: isElevated,
61
+ selected: isSelected,
62
+ leading: leadingKind,
63
+ trailing: removable || hasTrailingIcon,
64
+ disabled
65
+ })
42
66
  );
43
67
  const chipCls = $derived(base({ class: clsx(className) }));
68
+
69
+ function removeOnKey(e: KeyboardEvent) {
70
+ if (!onRemove || disabled || (e.key !== 'Backspace' && e.key !== 'Delete')) return;
71
+ e.preventDefault();
72
+ onRemove();
73
+ }
44
74
  </script>
45
75
 
46
76
  {#snippet leading()}
@@ -49,14 +79,15 @@ Chips help people enter information, make selections, filter content, or trigger
49
79
  viewBox="0 0 24 24"
50
80
  xmlns="http://www.w3.org/2000/svg"
51
81
  class={checkIcon()}
82
+ aria-hidden="true"
52
83
  fill="currentColor"
53
84
  >
54
85
  <path d="M9.55 18 3.85 12.3 5.275 10.875 9.55 15.15 18.725 5.975 20.15 7.4Z" />
55
86
  </svg>
56
87
  {:else if avatar}
57
- {@render avatar()}
88
+ <span class={avatarCls()}>{@render avatar()}</span>
58
89
  {:else if iconProps}
59
- <Icon size="xs" class={icon()} {...iconProps} />
90
+ <Icon size="sm" {...iconProps} class={icon({ class: clsx(iconProps.class) })} />
60
91
  {/if}
61
92
  {/snippet}
62
93
 
@@ -71,18 +102,28 @@ Chips help people enter information, make selections, filter content, or trigger
71
102
  >
72
103
  {@render leading()}
73
104
  <span class={label()}>{@render children()}</span>
105
+ {#if trailingIconProps}
106
+ <Icon
107
+ size="sm"
108
+ {...trailingIconProps}
109
+ class={icon({ class: ['text-current', clsx(trailingIconProps.class)] })}
110
+ />
111
+ {/if}
74
112
  <Layer />
75
113
  </Toggle.Root>
76
114
  {:else if variant === 'input'}
77
- <div
78
- class={chipCls}
79
- data-cy="m3-chip"
80
- aria-disabled={disabled}
81
- data-disabled={disabled ? true : undefined}
82
- {...restProps as HTMLAttributes<HTMLDivElement>}
83
- >
84
- {@render leading()}
85
- <span class={label()}>{@render children()}</span>
115
+ <div class={chipCls} data-cy="m3-chip" data-disabled={disabled ? true : undefined}>
116
+ <Button.Root
117
+ {disabled}
118
+ class={action()}
119
+ data-chip-action
120
+ onkeydown={removeOnKey}
121
+ {...restProps as ButtonRootProps}
122
+ >
123
+ {@render leading()}
124
+ <span class={label()}>{@render children()}</span>
125
+ <Layer />
126
+ </Button.Root>
86
127
  {#if onRemove}
87
128
  <button
88
129
  type="button"
@@ -92,13 +133,12 @@ Chips help people enter information, make selections, filter content, or trigger
92
133
  e.stopPropagation();
93
134
  onRemove?.();
94
135
  }}
95
- aria-label="Remove"
136
+ aria-label={removeLabel}
96
137
  >
97
- <Icon size="xs" name="close" />
138
+ <Icon size="sm" name="close" class="size-[18px] text-[18px]" />
98
139
  <Layer />
99
140
  </button>
100
141
  {/if}
101
- <Layer />
102
142
  </div>
103
143
  {:else}
104
144
  <Button.Root
@@ -4,7 +4,9 @@ import type { ChipProps } from './types.js';
4
4
  *
5
5
  * - Assist: Represents a smart or automated action, such as adding an event to a calendar
6
6
  * - Filter: Uses tags or descriptive words to filter content; supports a selected state
7
- * - Input: Represents a discrete piece of information entered by a user, and can be removed
7
+ * - Input: Represents a discrete piece of information entered by a user, and can be removed. Its
8
+ * primary action is a button of its own (focusable, pressable, `onclick`); Backspace or Delete on
9
+ * it calls `onRemove`
8
10
  * - Suggestion: Helps narrow a user's intent by presenting dynamically generated suggestions
9
11
  *
10
12
  * @see https://m3.material.io/components/chips/overview
@@ -0,0 +1,73 @@
1
+ <script module lang="ts">
2
+ import { defineMeta } from '@storybook/addon-svelte-csf';
3
+ import Chip from './Chip.svelte';
4
+ import ChipGroup from './ChipGroup.svelte';
5
+
6
+ const { Story } = defineMeta({
7
+ title: 'Chips/Chip Group',
8
+ component: ChipGroup,
9
+ tags: ['autodocs']
10
+ });
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ let people = $state([
15
+ 'Ada Lovelace',
16
+ 'Grace Hopper',
17
+ 'Alan Turing',
18
+ 'Katherine Johnson',
19
+ 'Edsger Dijkstra'
20
+ ]);
21
+ let filters = $state([
22
+ { id: 'wifi', label: 'Wifi', selected: true },
23
+ { id: 'parking', label: 'Parking', selected: false },
24
+ { id: 'pool', label: 'Pool', selected: false },
25
+ { id: 'gym', label: 'Gym', selected: true },
26
+ { id: 'pets', label: 'Pets allowed', selected: false },
27
+ { id: 'breakfast', label: 'Breakfast', selected: false }
28
+ ]);
29
+ let tags = $state(['Design', 'Svelte', 'Motion', 'Accessibility']);
30
+ </script>
31
+
32
+ <Story name="Reorderable input chips" asChild>
33
+ <div class="p-spacing-300 max-w-md">
34
+ <ChipGroup bind:items={people} key={(name) => name} reorderable aria-label="Recipients">
35
+ {#snippet chip(name)}
36
+ <Chip
37
+ variant="input"
38
+ iconProps={{ name: 'person' }}
39
+ removeLabel="Remove {name}"
40
+ onRemove={() => (people = people.filter((p) => p !== name))}
41
+ >
42
+ {name}
43
+ </Chip>
44
+ {/snippet}
45
+ </ChipGroup>
46
+ </div>
47
+ </Story>
48
+
49
+ <Story name="Reorderable filter chips" asChild>
50
+ <div class="p-spacing-300 max-w-md">
51
+ <ChipGroup
52
+ bind:items={filters}
53
+ key={(f) => f.id}
54
+ itemLabel={(f) => f.label}
55
+ reorderable
56
+ aria-label="Amenities"
57
+ >
58
+ {#snippet chip(f)}
59
+ <Chip variant="filter" bind:selected={f.selected}>{f.label}</Chip>
60
+ {/snippet}
61
+ </ChipGroup>
62
+ </div>
63
+ </Story>
64
+
65
+ <Story name="Static set" asChild>
66
+ <div class="p-spacing-300 max-w-md">
67
+ <ChipGroup items={tags} key={(t) => t} aria-label="Topics">
68
+ {#snippet chip(tag)}
69
+ <Chip variant="assist">{tag}</Chip>
70
+ {/snippet}
71
+ </ChipGroup>
72
+ </div>
73
+ </Story>
@@ -0,0 +1,4 @@
1
+ import ChipGroup from './ChipGroup.svelte';
2
+ declare const ChipGroup: import("svelte").Component<Record<string, never>, {}, "">;
3
+ type ChipGroup = ReturnType<typeof ChipGroup>;
4
+ export default ChipGroup;