@noxlovette/material 0.6.1 → 0.7.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 (84) 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/chips/Chip.mdx +24 -11
  17. package/dist/components/chips/Chip.stories.svelte +10 -0
  18. package/dist/components/chips/Chip.svelte +58 -18
  19. package/dist/components/chips/Chip.svelte.d.ts +3 -1
  20. package/dist/components/chips/ChipGroup.stories.svelte +73 -0
  21. package/dist/components/chips/ChipGroup.stories.svelte.d.ts +4 -0
  22. package/dist/components/chips/ChipGroup.svelte +242 -0
  23. package/dist/components/chips/ChipGroup.svelte.d.ts +33 -0
  24. package/dist/components/chips/index.d.ts +1 -0
  25. package/dist/components/chips/index.js +1 -0
  26. package/dist/components/chips/theme.d.ts +182 -21
  27. package/dist/components/chips/theme.js +116 -25
  28. package/dist/components/chips/types.d.ts +36 -2
  29. package/dist/components/containers/app/theme.d.ts +1 -1
  30. package/dist/components/containers/app/theme.js +1 -1
  31. package/dist/components/containers/bottom-sheet/BottomSheet.stories.svelte +1 -1
  32. package/dist/components/containers/list/List.svelte +8 -2
  33. package/dist/components/containers/list/ListItem.mdx +6 -0
  34. package/dist/components/containers/list/ListItem.svelte +77 -2
  35. package/dist/components/containers/list/context.d.ts +14 -0
  36. package/dist/components/containers/list/theme.d.ts +6 -0
  37. package/dist/components/containers/list/theme.js +10 -2
  38. package/dist/components/containers/pane/DraggablePane.svelte +25 -63
  39. package/dist/components/containers/pane/Pane.svelte +1 -1
  40. package/dist/components/containers/pane/theme.js +32 -6
  41. package/dist/components/containers/popover/theme.d.ts +3 -3
  42. package/dist/components/date/theme.d.ts +6 -6
  43. package/dist/components/forms/checkbox/theme.js +1 -1
  44. package/dist/components/forms/command/theme.d.ts +3 -3
  45. package/dist/components/misc/Avatar.stories.svelte +56 -7
  46. package/dist/components/misc/Avatar.stories.svelte.d.ts +2 -17
  47. package/dist/components/misc/Avatar.svelte +82 -8
  48. package/dist/components/misc/Avatar.svelte.d.ts +5 -0
  49. package/dist/components/misc/theme.d.ts +6 -0
  50. package/dist/components/misc/theme.js +5 -3
  51. package/dist/components/misc/types.d.ts +17 -1
  52. package/dist/components/nav/appbar/theme.js +2 -1
  53. package/dist/components/nav/currentRoute.d.ts +11 -0
  54. package/dist/components/nav/currentRoute.js +28 -0
  55. package/dist/components/nav/navbar/NavbarItem.svelte +3 -6
  56. package/dist/components/nav/navbar/theme.d.ts +3 -3
  57. package/dist/components/nav/rail/Rail.mdx +65 -23
  58. package/dist/components/nav/rail/Rail.stories.svelte +24 -4
  59. package/dist/components/nav/rail/Rail.stories.svelte.d.ts +2 -17
  60. package/dist/components/nav/rail/Rail.svelte +163 -48
  61. package/dist/components/nav/rail/Rail.svelte.d.ts +6 -3
  62. package/dist/components/nav/rail/RailItem.svelte +122 -65
  63. package/dist/components/nav/rail/RailItem.svelte.d.ts +3 -2
  64. package/dist/components/nav/rail/theme.d.ts +69 -84
  65. package/dist/components/nav/rail/theme.js +79 -66
  66. package/dist/components/nav/tabs/Tabs.stories.svelte +2 -1
  67. package/dist/index.css +2 -0
  68. package/dist/styles/components.css +50 -0
  69. package/dist/styles/icon.css +13 -0
  70. package/dist/utils/icon/Icon.stories.svelte +97 -0
  71. package/dist/utils/icon/Icon.stories.svelte.d.ts +4 -0
  72. package/dist/utils/icon/Icon.svelte +101 -12
  73. package/dist/utils/icon/Icon.svelte.d.ts +9 -0
  74. package/dist/utils/icon/MaterialSymbolsProvider.svelte +11 -4
  75. package/dist/utils/icon/base-icons.d.ts +1 -1
  76. package/dist/utils/icon/base-icons.js +1 -1
  77. package/dist/utils/icon/icon-names.d.ts +2 -0
  78. package/dist/utils/icon/icon-names.js +2 -0
  79. package/dist/utils/icon/swap.d.ts +6 -0
  80. package/dist/utils/icon/swap.js +45 -0
  81. package/dist/utils/icon/types.d.ts +59 -7
  82. package/dist/utils/tv.d.ts +3 -4
  83. package/dist/utils/tv.js +14 -6
  84. package/package.json +2 -1
@@ -1,72 +1,163 @@
1
1
  import { tv } from '../../utils/tv.js';
2
+ /*
3
+ Values from the M3 chip tokens (md.comp.{assist,filter,input,suggestion}-chip.*):
4
+ https://m3.material.io/components/chips/specs
5
+
6
+ - 32dp tall, 8dp corners, label-large. Padding is 16dp on each side, 8dp on a side that holds an
7
+ icon, and 4dp before an avatar; 8dp between elements. Icons are 18dp, avatars a 24dp circle.
8
+ - The 1dp outline is a border, so it sits inside the 32dp and stays visible (and darkens) while
9
+ the separate focus indicator is showing.
10
+ - Disabled: flat chips lose their fill-less outline to on-surface/12; elevated and selected
11
+ containers turn on-surface/12 with no shadow; label and icons are on-surface/38.
12
+
13
+ Chip doesn't use md-component-button-base: its disabled fill would paint flat chips, which M3
14
+ leaves unfilled.
15
+
16
+ An input chip is a container holding two sibling buttons, the primary `action` and the remove
17
+ button, since one can't nest inside the other. The container keeps the look (and shows the focus
18
+ indicator while the action has focus); the action carries the padding, and its state layer covers the whole chip.
19
+ */
2
20
  export const chip = tv({
3
21
  slots: {
4
- base: 'md-component-button-base group inline-flex h-spacing-400 max-w-max items-center justify-center gap-spacing-100 rounded-sm before:rounded-sm px-spacing-200 md-sys-typescale-label-large',
5
- icon: 'shrink-0',
22
+ // in-data-dragged: M3's dragged state, set by ChipGroup on the chip's slot: 8dp elevation and
23
+ // a 16% state layer.
24
+ base: 'in-data-dragged:shadow-elevation-4 in-data-dragged:before:bg-current/16 state-layer md-sys-state-focus-indicator relative inline-flex h-spacing-400 max-w-max shrink-0 items-center justify-center gap-spacing-100 rounded-sm border border-transparent px-spacing-200 md-sys-typescale-label-large select-none before:rounded-sm transition-[background-color,border-color,box-shadow] md-sys-motion-effects',
25
+ // 18dp is the chip spec's own icon size, between the 16 and 20 Icon presets.
26
+ icon: 'size-[18px] shrink-0 text-[18px]',
27
+ avatar: 'size-spacing-300 shrink-0 overflow-hidden rounded-full',
28
+ // Static, so its Layer (state layer and hit area) spans the whole chip; the remove button is
29
+ // positioned and later in the DOM, so it stays on top.
30
+ action: 'static flex h-full min-w-spacing-0 cursor-pointer items-center gap-spacing-100 self-stretch rounded-sm px-spacing-200 outline-none disabled:cursor-not-allowed',
6
31
  label: '',
7
- trailing: 'relative -mr-spacing-100 ml-spacing-50 inline-flex size-[18px] shrink-0 items-center justify-center rounded-full',
8
- checkIcon: 'size-spacing-200 shrink-0'
32
+ // 18dp glyph with a 48dp touch target around it (::after).
33
+ trailing: "relative inline-flex size-[18px] shrink-0 cursor-pointer items-center justify-center rounded-full after:absolute after:top-1/2 after:left-1/2 after:size-spacing-600 after:-translate-1/2 after:content-['']",
34
+ checkIcon: 'size-[18px] shrink-0'
9
35
  },
10
36
  variants: {
11
37
  variant: {
12
- assist: '',
13
- filter: '',
14
- input: '',
15
- suggestion: ''
38
+ assist: { base: 'text-md-sys-color-on-surface', icon: 'text-md-sys-color-primary' },
39
+ filter: { base: 'text-md-sys-color-on-surface-variant' },
40
+ input: { base: 'text-md-sys-color-on-surface-variant' },
41
+ suggestion: {
42
+ base: 'text-md-sys-color-on-surface-variant',
43
+ icon: 'text-md-sys-color-primary'
44
+ }
16
45
  },
17
46
  elevated: {
18
- true: '',
47
+ true: { base: 'shadow-elevation-1 hover:shadow-elevation-2' },
19
48
  false: ''
20
49
  },
21
50
  selected: {
22
- true: '',
51
+ true: {
52
+ base: 'bg-md-sys-color-secondary-container text-md-sys-color-on-secondary-container'
53
+ },
54
+ false: ''
55
+ },
56
+ leading: {
57
+ none: '',
58
+ icon: { base: 'pl-spacing-100', action: 'pl-spacing-100' },
59
+ avatar: { base: 'pl-spacing-50', action: 'pl-spacing-50' }
60
+ },
61
+ /** A trailing icon: the filter chip's `trailingIconProps`, or the input chip's remove button. */
62
+ trailing: {
63
+ true: { base: 'pr-spacing-100', action: 'pr-spacing-100' },
23
64
  false: ''
24
65
  },
25
66
  disabled: {
26
- true: '',
67
+ true: {
68
+ base: 'text-md-sys-color-on-surface/38 shadow-none hover:shadow-none',
69
+ icon: 'text-md-sys-color-on-surface/38',
70
+ avatar: 'opacity-38',
71
+ trailing: 'text-md-sys-color-on-surface/38',
72
+ checkIcon: 'text-md-sys-color-on-surface/38'
73
+ },
27
74
  false: ''
28
75
  }
29
76
  },
30
77
  compoundVariants: [
78
+ // Flat, unselected: no fill, outline-variant border that darkens on focus.
31
79
  {
32
80
  elevated: false,
33
81
  selected: false,
82
+ // The spec keeps a dragged flat chip unfilled, but one floating over its neighbours needs an
83
+ // opaque container, so it takes the elevated chip's.
34
84
  class: {
35
- base: 'bg-md-sys-color-surface text-md-sys-color-on-surface-variant outline outline-md-sys-color-outline-variant'
85
+ base: 'border-md-sys-color-outline-variant focus-visible:border-md-sys-color-on-surface-variant in-data-dragged:bg-md-sys-color-surface-container-low'
36
86
  }
37
87
  },
38
88
  {
39
- elevated: true,
40
- selected: false,
89
+ variant: 'assist',
90
+ elevated: false,
91
+ class: { base: 'focus-visible:border-md-sys-color-on-surface' }
92
+ },
93
+ { elevated: true, selected: false, class: { base: 'bg-md-sys-color-surface-container-low' } },
94
+ // A flat selected filter chip lifts on hover.
95
+ {
96
+ variant: 'filter',
97
+ elevated: false,
98
+ selected: true,
99
+ class: { base: 'hover:shadow-elevation-1' }
100
+ },
101
+ // Unselected filter icons are primary; selected ones inherit on-secondary-container.
102
+ { variant: 'filter', selected: false, class: { icon: 'text-md-sys-color-primary' } },
103
+ // Input chips are the reverse: on-surface-variant, primary once selected.
104
+ { variant: 'input', selected: true, class: { icon: 'text-md-sys-color-primary' } },
105
+ // The input chip's container has no padding of its own before the action, and needs the
106
+ // focus indicator (and darker border) while the action is focused.
107
+ {
108
+ variant: 'input',
41
109
  class: {
42
- base: 'bg-md-sys-color-surface-container-low text-md-sys-color-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2'
110
+ base: 'pl-spacing-0 has-[[data-chip-action]:focus-visible]:outline-3 has-[[data-chip-action]:focus-visible]:outline-offset-2 has-[[data-chip-action]:focus-visible]:outline-md-sys-color-secondary has-[[data-chip-action]:focus-visible]:outline-solid'
43
111
  }
44
112
  },
113
+ { variant: 'input', trailing: false, class: { base: 'pr-spacing-0' } },
45
114
  {
46
- selected: true,
115
+ variant: 'input',
116
+ elevated: false,
117
+ selected: false,
47
118
  class: {
48
- base: 'bg-md-sys-color-secondary-container text-md-sys-color-on-secondary-container'
119
+ base: 'has-[[data-chip-action]:focus-visible]:border-md-sys-color-on-surface-variant'
49
120
  }
50
121
  },
51
122
  {
52
- variant: 'assist',
123
+ disabled: true,
124
+ elevated: false,
53
125
  selected: false,
54
- class: { icon: 'text-md-sys-color-primary' }
126
+ class: { base: 'border-md-sys-color-on-surface/12' }
55
127
  },
56
128
  {
57
129
  disabled: true,
58
- class: {
59
- base: 'cursor-not-allowed bg-md-sys-color-on-surface/10 text-md-sys-color-on-surface/38 outline-md-sys-color-on-surface/12 hover:shadow-elevation-1',
60
- icon: 'text-md-sys-color-on-surface/38',
61
- trailing: 'text-md-sys-color-on-surface/38 pointer-events-none',
62
- checkIcon: 'text-md-sys-color-on-surface/38'
63
- }
130
+ elevated: true,
131
+ class: { base: 'bg-md-sys-color-on-surface/12' }
132
+ },
133
+ {
134
+ disabled: true,
135
+ selected: true,
136
+ class: { base: 'bg-md-sys-color-on-surface/12 text-md-sys-color-on-surface/38' }
64
137
  }
65
138
  ],
66
139
  defaultVariants: {
67
140
  variant: 'assist',
68
141
  elevated: false,
69
142
  selected: false,
143
+ leading: 'none',
144
+ trailing: false,
70
145
  disabled: false
71
146
  }
72
147
  });
148
+ /** Layout of a set of chips: 8dp apart, wrapping onto new lines. */
149
+ export const chipGroup = tv({
150
+ slots: {
151
+ base: 'relative flex flex-wrap gap-spacing-100',
152
+ // Its own stacking context only while dragged, so the chip floats over its neighbours.
153
+ item: 'relative flex touch-manipulation data-dragged:z-10 data-dragged:cursor-grabbing',
154
+ hint: 'sr-only',
155
+ live: 'sr-only'
156
+ },
157
+ variants: {
158
+ reorderable: {
159
+ true: { item: 'cursor-grab select-none [-webkit-touch-callout:none]' },
160
+ false: ''
161
+ }
162
+ }
163
+ });
@@ -26,18 +26,52 @@ export type ChipProps = ChipVariants & AnchorButtonAttributes & {
26
26
  disabled?: boolean;
27
27
  /** Configuration for the leading icon. */
28
28
  iconProps?: IconProps;
29
+ /**
30
+ * Filter chips only: a trailing icon, e.g. `arrow_drop_down` on a chip that opens a menu.
31
+ */
32
+ trailingIconProps?: IconProps;
29
33
  /**
30
34
  * Custom leading content (e.g. an avatar image) rendered instead of `iconProps`.
31
35
  * Typically used by 'input' chips to represent a contact or entity.
32
36
  */
33
37
  avatar?: Snippet;
34
38
  /**
35
- * Called when the trailing remove icon is activated.
36
- * Providing this renders the trailing remove affordance on 'input' chips.
39
+ * Called when the trailing remove icon is activated, or on Backspace/Delete while the chip
40
+ * has focus. Providing this renders the trailing remove affordance on 'input' chips. Move
41
+ * focus to a neighbouring chip (or the input) afterwards, or it falls back to the page.
37
42
  */
38
43
  onRemove?: () => void;
44
+ /**
45
+ * Accessible name of the input chip's remove button. Name the item, e.g. `Remove Ada`.
46
+ * @default 'Remove'
47
+ */
48
+ removeLabel?: string;
39
49
  /** The label content of the chip. */
40
50
  children: Snippet;
41
51
  /** The `formaction` attribute for the chip when used in a form. */
42
52
  formaction?: string;
43
53
  };
54
+ export type ChipGroupProps<T> = {
55
+ /** The items, one chip each. With `reorderable`, dragging rewrites their order. Bindable. */
56
+ items: T[];
57
+ /** A stable, unique key per item. */
58
+ key: (item: T) => string;
59
+ /** Renders one item's `Chip`. */
60
+ chip: Snippet<[item: T, index: number]>;
61
+ /**
62
+ * Lets people reorder the chips: drag one (on touch, press and hold first), or focus one and
63
+ * press Alt+Arrow. The dragged chip takes M3's dragged state and its neighbours make room.
64
+ * @default false
65
+ */
66
+ reorderable?: boolean;
67
+ /** Called with the new order after a drag or a keyboard move. */
68
+ onReorder?: (items: T[]) => void;
69
+ /**
70
+ * The item's name in the announcement after a move ("Ada, moved to 2 of 5"). Defaults to its
71
+ * `key`.
72
+ */
73
+ itemLabel?: (item: T) => string;
74
+ /** Accessible name of the group. */
75
+ 'aria-label'?: string;
76
+ class?: string;
77
+ };
@@ -1,3 +1,3 @@
1
1
  import { type VariantProps } from '../../../utils/tv.js';
2
2
  export type AppVariants = VariantProps<typeof app>;
3
- export declare const app: import("tailwind-variants").TVReturnType<{} | {} | {}, undefined, "bg-md-sys-color-surface-container text-md-sys-color-on-background min-h-dvh caret-md-sys-color-primary selection:bg-md-sys-color-primary-container selection:text-md-sys-color-on-primary-container relative min-h-dvh pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] antialiased", {} | {}, undefined, import("tailwind-variants").TVReturnTypeLike<unknown, undefined>>;
3
+ export declare const app: import("tailwind-variants").TVReturnType<{} | {} | {}, undefined, "md-app bg-md-sys-color-surface-container text-md-sys-color-on-background min-h-dvh caret-md-sys-color-primary selection:bg-md-sys-color-primary-container selection:text-md-sys-color-on-primary-container relative min-h-dvh pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[calc(env(safe-area-inset-left)+var(--md-rail-inset,0px))] antialiased", {} | {}, undefined, import("tailwind-variants").TVReturnTypeLike<unknown, undefined>>;
@@ -1,4 +1,4 @@
1
1
  import { tv } from '../../../utils/tv.js';
2
2
  export const app = tv({
3
- base: 'bg-md-sys-color-surface-container text-md-sys-color-on-background min-h-dvh caret-md-sys-color-primary selection:bg-md-sys-color-primary-container selection:text-md-sys-color-on-primary-container relative min-h-dvh pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] antialiased'
3
+ base: 'md-app bg-md-sys-color-surface-container text-md-sys-color-on-background min-h-dvh caret-md-sys-color-primary selection:bg-md-sys-color-primary-container selection:text-md-sys-color-on-primary-container relative min-h-dvh pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[calc(env(safe-area-inset-left)+var(--md-rail-inset,0px))] antialiased'
4
4
  });
@@ -23,7 +23,7 @@
23
23
  { name: 'download', label: 'Download' },
24
24
  { name: 'edit', label: 'Rename' },
25
25
  { name: 'delete', label: 'Move to trash' }
26
- ];
26
+ ] as const;
27
27
  </script>
28
28
 
29
29
  <script lang="ts">
@@ -6,13 +6,19 @@
6
6
 
7
7
  let { variant = 'standard', class: className, children, ...restProps }: ListProps = $props();
8
8
 
9
+ let element = $state<HTMLElement>();
10
+
9
11
  setListContext({
10
12
  get variant() {
11
13
  return variant;
12
- }
14
+ },
15
+ get element() {
16
+ return element;
17
+ },
18
+ from: null
13
19
  });
14
20
  </script>
15
21
 
16
- <ul class={list({ variant, class: clsx(className) })} {...restProps}>
22
+ <ul bind:this={element} class={list({ variant, class: clsx(className) })} {...restProps}>
17
23
  {@render children?.()}
18
24
  </ul>
@@ -91,6 +91,12 @@ chevron, `aria-expanded` and `aria-controls`. The nested items are `inert` while
91
91
  content to 38%. Hover, focus and press tints come from the shared state layer. The keyboard focus
92
92
  ring is drawn inside the item, so segments don't overlap.
93
93
 
94
+ When the selection moves inside one `List` (a nav list following the route, a single-select
95
+ list), the fill travels: it springs from where the old one was drawn to the newly selected item,
96
+ up or down, on the `spatial` spring. It passes over the items in between, and a selection that
97
+ moves again mid-flight picks up from where the fill is. When several items change at once, or
98
+ under reduced motion, the fill just appears on the selected item.
99
+
94
100
  <Canvas of={ListStories.Disabled} />
95
101
 
96
102
  ## Where this differs from the tokens
@@ -2,7 +2,10 @@
2
2
  import { Icon, Layer } from '../../../utils/index.js';
3
3
  import clsx from 'clsx';
4
4
  import { listItem } from './theme.js';
5
- import { getListContext } from './context.js';
5
+ import { getListContext, type SelectionRect } from './context.js';
6
+ import { untrack } from 'svelte';
7
+ import { animate, type AnimationPlaybackControlsWithThen } from 'motion';
8
+ import { springTokens, springTransition } from '../../../animation/spring.js';
6
9
  import type { ListitemProps } from './types.js';
7
10
  import Badge from '../../badge/Badge.svelte';
8
11
 
@@ -53,6 +56,71 @@
53
56
  })
54
57
  );
55
58
 
59
+ /*
60
+ The selected fill travels. Before an update, the item losing the selection leaves where its
61
+ fill is drawn (mid-flight too) in the List's context; after it, the item gaining the
62
+ selection springs its new fill over from there, on spatial: a sizeable move, and part of a
63
+ navigation, so no fastSpatial bounce. Without a List, or with several items selected or
64
+ deselected at once, the fill just appears where it belongs. Reduced motion skips the ride.
65
+ */
66
+ const showFill = $derived(!!selected && !disabled);
67
+ let fill = $state<HTMLElement>();
68
+ let travelling = $state(false);
69
+ let wasShown = untrack(() => showFill);
70
+ let ride: AnimationPlaybackControlsWithThen | undefined;
71
+
72
+ const measure = (el: HTMLElement): SelectionRect | null => {
73
+ const box = ctx?.element?.getBoundingClientRect();
74
+ if (!box) return null;
75
+ const r = el.getBoundingClientRect();
76
+ return {
77
+ top: r.top - box.top,
78
+ bottom: r.bottom - box.top,
79
+ left: r.left - box.left,
80
+ right: r.right - box.left
81
+ };
82
+ };
83
+
84
+ $effect.pre(() => {
85
+ const shown = showFill;
86
+ untrack(() => {
87
+ if (!wasShown || shown || !ctx || !fill) return;
88
+ ctx.from = measure(fill);
89
+ ride?.stop();
90
+ travelling = false;
91
+ // Only for this update: a deselect with no select after it leaves nothing behind.
92
+ queueMicrotask(() => (ctx.from = null));
93
+ });
94
+ });
95
+
96
+ $effect(() => {
97
+ const shown = showFill;
98
+ untrack(() => {
99
+ const gained = shown && !wasShown;
100
+ wasShown = shown;
101
+ const from = ctx?.from;
102
+ if (!gained || !from || !fill) return;
103
+ ctx!.from = null;
104
+ const to = measure(fill);
105
+ if (!to || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
106
+ // Above the items it passes over, which are the item's siblings.
107
+ travelling = true;
108
+ ride = animate(
109
+ fill,
110
+ {
111
+ top: [`${from.top - to.top}px`, '0px'],
112
+ bottom: [`${to.bottom - from.bottom}px`, '0px'],
113
+ left: [`${from.left - to.left}px`, '0px'],
114
+ right: [`${to.right - from.right}px`, '0px']
115
+ },
116
+ springTransition(springTokens.spatial)
117
+ );
118
+ ride.then(() => (travelling = false));
119
+ });
120
+ });
121
+
122
+ $effect(() => () => ride?.stop());
123
+
56
124
  const attrs = $derived.by(() => {
57
125
  const { label: _, href, onclick, ...extra } = rest;
58
126
  const a: Record<string, unknown> = { ...extra };
@@ -81,6 +149,9 @@
81
149
  </script>
82
150
 
83
151
  {#snippet content()}
152
+ {#if showFill}
153
+ <span bind:this={fill} class={cls.selection()} aria-hidden="true"></span>
154
+ {/if}
84
155
  {#if interactive}
85
156
  <Layer />
86
157
  {/if}
@@ -121,7 +192,11 @@
121
192
  {/snippet}
122
193
 
123
194
  {#snippet item()}
124
- <svelte:element this={tag} class={cls.base({ class: clsx(className) })} {...attrs}>
195
+ <svelte:element
196
+ this={tag}
197
+ class={cls.base({ class: clsx(className, travelling && 'z-10') })}
198
+ {...attrs}
199
+ >
125
200
  {@render content()}
126
201
  </svelte:element>
127
202
  {#if children}
@@ -1,5 +1,19 @@
1
+ /** Where the outgoing selection was drawn, relative to the list's box. */
2
+ export type SelectionRect = {
3
+ top: number;
4
+ bottom: number;
5
+ left: number;
6
+ right: number;
7
+ };
1
8
  type ListContext = {
2
9
  readonly variant: 'standard' | 'segmented';
10
+ /** The list's own element, which selection rects are measured against. */
11
+ readonly element: HTMLElement | undefined;
12
+ /**
13
+ * Handed from the item losing the selection to the one gaining it within one update, so the
14
+ * selected fill travels between them. Cleared once that update settles.
15
+ */
16
+ from: SelectionRect | null;
3
17
  };
4
18
  export declare const setListContext: (ctx: ListContext) => ListContext;
5
19
  export declare const getListContext: () => ListContext | undefined;
@@ -85,6 +85,7 @@ export declare const listItem: import("tailwind-variants").TVReturnType<{
85
85
  };
86
86
  }, {
87
87
  base: string[];
88
+ selection: string;
88
89
  leading: string;
89
90
  body: string;
90
91
  overline: string;
@@ -165,6 +166,7 @@ export declare const listItem: import("tailwind-variants").TVReturnType<{
165
166
  };
166
167
  }, {
167
168
  base: string[];
169
+ selection: string;
168
170
  leading: string;
169
171
  body: string;
170
172
  overline: string;
@@ -245,6 +247,7 @@ export declare const listItem: import("tailwind-variants").TVReturnType<{
245
247
  };
246
248
  }, {
247
249
  base: string[];
250
+ selection: string;
248
251
  leading: string;
249
252
  body: string;
250
253
  overline: string;
@@ -327,6 +330,7 @@ export declare const listiem: import("tailwind-variants").TVReturnType<{
327
330
  };
328
331
  }, {
329
332
  base: string[];
333
+ selection: string;
330
334
  leading: string;
331
335
  body: string;
332
336
  overline: string;
@@ -407,6 +411,7 @@ export declare const listiem: import("tailwind-variants").TVReturnType<{
407
411
  };
408
412
  }, {
409
413
  base: string[];
414
+ selection: string;
410
415
  leading: string;
411
416
  body: string;
412
417
  overline: string;
@@ -487,6 +492,7 @@ export declare const listiem: import("tailwind-variants").TVReturnType<{
487
492
  };
488
493
  }, {
489
494
  base: string[];
495
+ selection: string;
490
496
  leading: string;
491
497
  body: string;
492
498
  overline: string;
@@ -7,6 +7,11 @@ import { tv } from '../../../utils/tv.js';
7
7
  `List` sets `--li-top`/`--li-bottom` to 16dp on its first/last child, so the list's outer
8
8
  corners stay 16dp while the inner ones morph. Custom properties inherit, so this reaches an item
9
9
  through its `<li>`; each `<ul>` resets both so nested lists start clean.
10
+
11
+ Selection: the selected fill is its own element rather than the item's background. When one
12
+ item loses the selection and another gains it in the same update (a nav list following the
13
+ route, a single-select list), the fill springs from where the old one was drawn to the new
14
+ item, like the tab indicator, instead of blinking out and in.
10
15
  */
11
16
  export const list = tv({
12
17
  base: 'flex w-full flex-col [--li-bottom:initial] [--li-top:initial]',
@@ -22,11 +27,14 @@ export const list = tv({
22
27
  export const listItem = tv({
23
28
  slots: {
24
29
  base: [
25
- 'relative flex w-full min-w-spacing-0 gap-spacing-150 ps-spacing-200 pe-spacing-200 pt-spacing-125 pb-spacing-125 text-start',
30
+ 'relative isolate flex w-full min-w-spacing-0 gap-spacing-150 ps-spacing-200 pe-spacing-200 pt-spacing-125 pb-spacing-125 text-start',
26
31
  '[--li-shape:0.25rem] rounded-t-[var(--li-top,var(--li-shape))] rounded-b-[var(--li-bottom,var(--li-shape))]',
27
32
  'transition-[border-radius,background-color,color] md-sys-motion-fast-spatial',
28
33
  'text-md-sys-color-on-surface-variant'
29
34
  ],
35
+ /* The selected fill, under the item's content and state layer, in the item's shape. When the
36
+ selection moves within a List it springs over from the previous item (ListItem.svelte). */
37
+ selection: 'pointer-events-none absolute inset-spacing-0 -z-10 rounded-[inherit] bg-md-sys-color-secondary-container',
30
38
  leading: 'flex shrink-0 items-center text-md-sys-color-on-surface-variant',
31
39
  body: 'flex min-w-spacing-0 flex-1 flex-col justify-center',
32
40
  overline: 'md-sys-typescale-label-small line-clamp-1 text-md-sys-color-on-surface-variant',
@@ -71,7 +79,7 @@ export const listItem = tv({
71
79
  },
72
80
  selected: {
73
81
  true: {
74
- base: 'bg-md-sys-color-secondary-container text-md-sys-color-on-secondary-container [--li-shape:1rem] hover:[--li-shape:1rem]',
82
+ base: 'text-md-sys-color-on-secondary-container [--li-shape:1rem] hover:[--li-shape:1rem]',
75
83
  leading: 'text-md-sys-color-on-secondary-container',
76
84
  overline: 'text-md-sys-color-on-secondary-container',
77
85
  headline: 'text-md-sys-color-on-secondary-container',
@@ -31,7 +31,7 @@ the pane itself stays `position: fixed` and doesn't move with either.
31
31
  import { springTokens, springTransition } from '../../../animation/spring.js';
32
32
  import { Icon, Layer } from '../../../utils/index.js';
33
33
  import { ButtonIcon } from '../../buttons/index.js';
34
- import { clickOutside } from '../../../attachments/index.js';
34
+ import { clickOutside, drag, resist } from '../../../attachments/index.js';
35
35
  import { draggablePane, type ResizeEdge } from './theme.js';
36
36
  import { dragPositions } from './dragStore.svelte.js';
37
37
  import type { DraggablePaneProps } from './types.js';
@@ -313,11 +313,6 @@ the pane itself stays `position: fixed` and doesn't move with either.
313
313
  the same spring. Grabbing the pane mid-spring stops it where it is.
314
314
  */
315
315
  const SETTLE_SPRING = springTransition(springTokens.fastSpatial);
316
- /* Share of the overshoot still applied at the edge, shrinking the further it's pulled. The
317
- UIScrollView rubber-band constant — M3 has no token for this; tune by eye. */
318
- const RUBBER_BAND = 0.55;
319
- /* Pointer samples older than this don't count towards the release velocity. */
320
- const VELOCITY_WINDOW_MS = 100;
321
316
 
322
317
  let settleX: AnimationPlaybackControls | undefined;
323
318
  let settleY: AnimationPlaybackControls | undefined;
@@ -352,61 +347,31 @@ the pane itself stays `position: fixed` and doesn't move with either.
352
347
  if (persistKey) dragPositions.set(persistKey, { x: next.x, y: next.y, width, height });
353
348
  }
354
349
 
355
- /** Resisted overshoot: the further past the edge, the less the pane follows. */
356
- function rubberBand(overshoot: number, limit: number) {
357
- return (1 - 1 / ((overshoot * RUBBER_BAND) / limit + 1)) * limit;
358
- }
359
-
360
- function resist(value: number, min: number, max: number, size: number) {
361
- if (value < min) return min - rubberBand(min - value, size);
362
- if (value > max) return max + rubberBand(value - max, size);
363
- return value;
364
- }
365
-
366
- let dragStartX = 0;
367
- let dragStartY = 0;
368
350
  let originX = 0;
369
351
  let originY = 0;
370
- let samples: { x: number; y: number; t: number }[] = [];
371
-
372
- function startDrag(event: PointerEvent) {
373
- if (disableDrag) return;
374
- stopSettle();
375
- dragging = true;
376
- dragStartX = event.clientX;
377
- dragStartY = event.clientY;
378
- originX = x ?? initialX;
379
- originY = y ?? initialY;
380
- samples = [{ x: event.clientX, y: event.clientY, t: event.timeStamp }];
381
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
382
- }
383
352
 
384
- function moveDrag(event: PointerEvent) {
385
- if (!dragging) return;
386
- const rawX = originX + (event.clientX - dragStartX);
387
- const rawY = originY + (event.clientY - dragStartY);
388
- const rect = boundsRect();
389
- const { x: minX, y: minY } = clamp(-Infinity, -Infinity);
390
- const { x: maxX, y: maxY } = clamp(Infinity, Infinity);
391
- x = resist(rawX, minX, maxX, rect.right - rect.left);
392
- y = resist(rawY, minY, maxY, rect.bottom - rect.top);
393
- samples.push({ x: event.clientX, y: event.clientY, t: event.timeStamp });
394
- samples = samples.filter((sample) => event.timeStamp - sample.t <= VELOCITY_WINDOW_MS);
395
- }
396
-
397
- function releaseVelocity() {
398
- const first = samples[0];
399
- const last = samples[samples.length - 1];
400
- const seconds = first && last ? (last.t - first.t) / 1000 : 0;
401
- if (!seconds) return { x: 0, y: 0 };
402
- return { x: (last.x - first.x) / seconds, y: (last.y - first.y) / seconds };
403
- }
404
-
405
- function endDrag() {
406
- if (!dragging) return;
407
- dragging = false;
408
- settleTo(x ?? initialX, y ?? initialY, releaseVelocity());
409
- }
353
+ // Pointer plumbing (capture, release velocity) and the rubber-band come from the shared drag
354
+ // attachment; this only positions the pane.
355
+ const dragPane = drag(() => ({
356
+ disabled: disableDrag,
357
+ onStart: () => {
358
+ stopSettle();
359
+ dragging = true;
360
+ originX = x ?? initialX;
361
+ originY = y ?? initialY;
362
+ },
363
+ onMove: (offset) => {
364
+ const rect = boundsRect();
365
+ const { x: minX, y: minY } = clamp(-Infinity, -Infinity);
366
+ const { x: maxX, y: maxY } = clamp(Infinity, Infinity);
367
+ x = resist(originX + offset.x, minX, maxX, rect.right - rect.left);
368
+ y = resist(originY + offset.y, minY, maxY, rect.bottom - rect.top);
369
+ },
370
+ onEnd: (velocity) => {
371
+ dragging = false;
372
+ settleTo(x ?? initialX, y ?? initialY, velocity);
373
+ }
374
+ }));
410
375
 
411
376
  const NUDGE = 8;
412
377
 
@@ -453,7 +418,7 @@ the pane itself stays `position: fixed` and doesn't move with either.
453
418
  // every move rather than accumulated via `event.movementX`/`movementY` —
454
419
  // the latter isn't reliably populated across every input path (some
455
420
  // synthetic/automated drag sequences leave it 0), which would silently
456
- // drop the resize. Matches `moveDrag`'s baseline-delta approach.
421
+ // drop the resize. The drag attachment works from a fixed baseline the same way.
457
422
  function moveResize(event: PointerEvent) {
458
423
  if (!resizeDir) return;
459
424
  applyResize(
@@ -534,10 +499,7 @@ the pane itself stays `position: fixed` and doesn't move with either.
534
499
  tabindex={disableDrag ? undefined : 0}
535
500
  aria-label={disableDrag ? undefined : title ? `Drag to move ${title}` : 'Drag to move'}
536
501
  aria-keyshortcuts={disableDrag ? undefined : 'ArrowUp ArrowDown ArrowLeft ArrowRight'}
537
- onpointerdown={disableDrag ? undefined : startDrag}
538
- onpointermove={disableDrag ? undefined : moveDrag}
539
- onpointerup={disableDrag ? undefined : endDrag}
540
- onpointercancel={disableDrag ? undefined : endDrag}
502
+ {@attach dragPane}
541
503
  onkeydown={disableDrag ? undefined : onKeydown}
542
504
  >
543
505
  {#if !disableDrag}
@@ -76,7 +76,7 @@ the document root yourself to get the same behavior.
76
76
 
77
77
  const style = $derived(
78
78
  clsx(
79
- !flexible && `width: ${shown}px; flex-basis: ${shown}px;`,
79
+ !flexible && `width: ${shown}px; --pane-basis: ${shown}px;`,
80
80
  sticky && `--pane-sticky-top: ${stickyTop}px;`
81
81
  )
82
82
  );