@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
@@ -6,12 +6,14 @@ A single destination within a navigation bar.
6
6
  -->
7
7
  <script lang="ts">
8
8
  import { page } from '$app/state';
9
+ import { base as appBase } from '$app/paths';
9
10
  import type { NavItemProps } from './types.js';
10
11
  import { navitem } from './theme.js';
11
12
  import { Icon, Layer } from '../../../utils/index.js';
12
13
  import Badge from '../../badge/Badge.svelte';
13
14
  import { NavigationMenu } from 'bits-ui';
14
15
  import clsx from 'clsx';
16
+ import { isCurrentRoute } from '../currentRoute.js';
15
17
 
16
18
  let {
17
19
  href = '/',
@@ -23,12 +25,7 @@ A single destination within a navigation bar.
23
25
  ...rest
24
26
  }: NavItemProps = $props();
25
27
 
26
- const isActive = $derived(
27
- selected ||
28
- page.url.pathname === href ||
29
- (href !== '/' && page.url.href.startsWith(href)) ||
30
- page.url.href == href
31
- );
28
+ const isActive = $derived(selected || isCurrentRoute(href, page.url, appBase));
32
29
 
33
30
  const {
34
31
  base,
@@ -1,8 +1,8 @@
1
1
  export declare const navbar: import("tailwind-variants").TVReturnType<{
2
2
  [key: string]: {
3
3
  [key: string]: import("tailwind-variants").ClassValue | {
4
- fab?: import("tailwind-variants").ClassValue;
5
4
  base?: import("tailwind-variants").ClassValue;
5
+ fab?: import("tailwind-variants").ClassValue;
6
6
  items?: import("tailwind-variants").ClassValue;
7
7
  ghost?: import("tailwind-variants").ClassValue;
8
8
  };
@@ -10,8 +10,8 @@ export declare const navbar: import("tailwind-variants").TVReturnType<{
10
10
  } | {
11
11
  [x: string]: {
12
12
  [x: string]: import("tailwind-variants").ClassValue | {
13
- fab?: import("tailwind-variants").ClassValue;
14
13
  base?: import("tailwind-variants").ClassValue;
14
+ fab?: import("tailwind-variants").ClassValue;
15
15
  items?: import("tailwind-variants").ClassValue;
16
16
  ghost?: import("tailwind-variants").ClassValue;
17
17
  };
@@ -24,8 +24,8 @@ export declare const navbar: import("tailwind-variants").TVReturnType<{
24
24
  }, undefined, {
25
25
  [key: string]: {
26
26
  [key: string]: import("tailwind-variants").ClassValue | {
27
- fab?: import("tailwind-variants").ClassValue;
28
27
  base?: import("tailwind-variants").ClassValue;
28
+ fab?: import("tailwind-variants").ClassValue;
29
29
  items?: import("tailwind-variants").ClassValue;
30
30
  ghost?: import("tailwind-variants").ClassValue;
31
31
  };
@@ -18,8 +18,9 @@ import * as RailStories from './Rail.stories.svelte';
18
18
 
19
19
  ## Anatomy
20
20
 
21
- - **`Rail`** is the container: the sidebar, a spacer that reserves its width in the page, and the
22
- scrim shown while it's expanded on medium windows.
21
+ - **`Rail`** is the container: the sidebar, the room it takes in the page (see Layout), and the
22
+ scrim shown while it's expanded on medium windows. An optional header holds the menu toggle and
23
+ a FAB.
23
24
  - **`RailItem`** is one destination. It detects the active route, shows an optional `badge` (a
24
25
  count, or `-1` for a dot), and lays itself out for the collapsed or expanded rail.
25
26
  - **`railStore`** exposes the collapsed state to components outside the rail.
@@ -29,40 +30,79 @@ import * as RailStories from './Rail.stories.svelte';
29
30
  The rail shows from `md` up and is hidden below it. For small windows, mount a `Navbar` with the
30
31
  same destinations; it hides itself from `md` up, so the two never show together.
31
32
 
32
- | Window | Collapsed rail | Expanded rail |
33
- | -------------- | -------------- | --------------------------------------------------- |
34
- | Small (< `md`) | Hidden | Hidden |
35
- | Medium (`md`) | 96dp | Overlays content over a scrim; outside click closes |
36
- | Large (`lg`+) | 96dp | Pushes content aside |
33
+ | Window | Collapsed rail | Expanded rail |
34
+ | -------------- | -------------- | ------------------------------------------------------------------------ |
35
+ | Small (< `md`) | Hidden | Hidden |
36
+ | Medium (`md`) | 96dp | **Modal**: over a scrim; closes on scrim click, Escape, or a destination |
37
+ | Large (`lg`+) | 96dp | **Standard**: pushes content aside, stays open until toggled |
38
+
39
+ ## Spec
40
+
41
+ Values follow the M3 Expressive
42
+ [navigation rail tokens](https://m3.material.io/components/navigation-rail/specs). The container
43
+ uses the spec's optional `surface-container` role rather than the default `surface`: that's the
44
+ window colour `App` paints, so the rail belongs to the window and `surface` panes sit beside it. The off-grid
45
+ ones are `--md-comp-nav-rail-*` properties in `styles/components.css`, so a theme can override
46
+ them.
47
+
48
+ | | Collapsed | Expanded |
49
+ | ---------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
50
+ | Container | 96dp, `surface` | Hugs content, 220–360dp. Modal adds elevation 3 and 16dp corners |
51
+ | Top space | 44dp, then at least 40dp below the header | Same |
52
+ | Destination | 64dp tall, 4dp apart | 56dp tall, touching, 20dp side padding |
53
+ | Active indicator | 56×32dp around the icon, label 4dp below | 56dp pill around icon and label, 16dp padding, 8dp gap |
54
+ | Label | Label medium; active `secondary` | Label large; active `on-secondary-container` |
55
+ | States | `on-secondary-container` layer: 8% hover, 10% focus and pressed, on the indicator | Same |
56
+
57
+ The whole row is the target, however narrow the indicator. The active icon fills; its weight
58
+ stays the same.
59
+
60
+ ## Expanding
61
+
62
+ Expanding is one spatial spring, following Compose's `WideNavigationRail`. The width, the gap
63
+ between destinations (4dp → 0), each destination's height (64 → 56dp), and its indicator
64
+ (56×32 → the full pill) all move together. The indicator stays anchored and the icon keeps its x,
65
+ so nothing moves sideways. The label fades out, switches from under the icon to beside it at the
66
+ halfway point (label medium → label large), then slides in as it fades back. An interrupted
67
+ spring reverses from where it is. Under reduced motion the rail jumps to the end state.
68
+
69
+ Selecting a destination grows its active indicator from the centre out to its edges on
70
+ `fastSpatial`, fading in as it grows, and the one losing the selection shrinks back the same way
71
+ (after Compose's `NavigationRailItem`). The state layer stays on top of it. Under reduced motion
72
+ the indicator just appears.
37
73
 
38
74
  ## Layout
39
75
 
40
- The rail renders a spacer next to itself that takes the rail's width. Put the rail and your
41
- content in a flex row and the content moves aside on its own, with no `margin-left`:
76
+ Mount the rail anywhere inside `App`; the page makes room for it on its own. A
77
+ viewport-anchored rail publishes `--md-rail-inset` on `<html>`: 0 below `md`, the collapsed
78
+ width on medium windows (the expanded rail is modal there and overlays), and the rail's live
79
+ width from `lg`, so the content moves aside in step with the spring. `App`'s shell pads by it and
80
+ `AppBar` starts at it, so there's no `md:ml-24` anywhere. A custom shell or another fixed surface
81
+ uses `ps-(--md-rail-inset)` / `left-(--md-rail-inset)`.
42
82
 
43
83
  ```svelte
44
- <div class="flex min-h-dvh">
84
+ <App>
45
85
  <Rail bind:collapsed>
46
86
  <RailItem label="Home" href="/" iconProps={{ name: 'house' }} />
47
87
  <RailItem label="Messages" href="/messages" iconProps={{ name: 'mail' }} badge={5} />
48
88
  </Rail>
49
- <div class="min-w-spacing-0 flex-1">
50
- {@render children()}
51
- </div>
52
- </div>
53
-
54
- <Navbar>
55
- <NavbarItem label="Home" href="/" iconProps={{ name: 'house' }} />
56
- <NavbarItem label="Messages" href="/messages" iconProps={{ name: 'mail' }} badge={5} />
57
- </Navbar>
89
+ <AppBar title="Home" />
90
+ {@render children()}
91
+ <Navbar>
92
+ <NavbarItem label="Home" href="/" iconProps={{ name: 'house' }} />
93
+ <NavbarItem label="Messages" href="/messages" iconProps={{ name: 'mail' }} badge={5} />
94
+ </Navbar>
95
+ </App>
58
96
  ```
59
97
 
60
98
  - **Under an app bar:** pass the bar's height in px to `railTop` so the rail starts below it.
61
99
  - **Anchoring:** `anchor="viewport"` (the default) fixes the rail to the window's leading edge.
62
100
  `anchor="parent"` positions it inside the nearest positioned ancestor, for embedded layouts
63
- and demos.
64
- - **Extras:** the `fab` snippet places a FAB above the destinations. `expandable={false}` removes
65
- the menu toggle.
101
+ and demos. It doesn't touch `--md-rail-inset`; instead it renders a spacer beside itself, so
102
+ put it and the content in a flex row.
103
+ - **Extras:** the `fab` snippet places a FAB above the destinations, in the same 56dp column as
104
+ the indicators. `expandable={false}` removes the menu toggle; with no FAB either, the header
105
+ and its 40dp gap go too.
66
106
 
67
107
  <Canvas of={RailStories.WithFAB} />
68
108
 
@@ -70,4 +110,6 @@ content in a flex row and the content moves aside on its own, with no `margin-le
70
110
 
71
111
  - **Active item.** The active item gets `aria-current="page"`.
72
112
  - **Disabled items** set `aria-disabled` and leave the tab order but stay visible.
73
- - **Keyboard.** Every item is a link reachable with Tab; the menu toggle is a real button.
113
+ - **Keyboard.** Every item is a link reachable with Tab; the focus ring goes around the
114
+ indicator. The menu toggle is a real button with `aria-expanded`, and Escape closes the modal
115
+ rail.
@@ -25,6 +25,17 @@
25
25
  });
26
26
  </script>
27
27
 
28
+ <script lang="ts">
29
+ // Playground: clicking a destination selects it, to show the indicator growing from its centre.
30
+ const destinations = [
31
+ { label: 'Home', icon: 'house', badge: 0 },
32
+ { label: 'Inbox', icon: 'mail', badge: 4 },
33
+ { label: 'Chat', icon: 'chat', badge: -1 },
34
+ { label: 'Settings', icon: 'settings', badge: 0 }
35
+ ] as const;
36
+ let current = $state<string>('Home');
37
+ </script>
38
+
28
39
  <Story name="Playground">
29
40
  {#snippet template(args)}
30
41
  <div class="bg-md-sys-color-surface-variant relative h-[420px] overflow-hidden rounded-2xl">
@@ -34,10 +45,19 @@
34
45
  expandable={args.expandable}
35
46
  rounded={args.rounded}
36
47
  >
37
- <RailItem label="Home" href="/" iconProps={{ name: 'house' }} selected />
38
- <RailItem label="Inbox" href="#" iconProps={{ name: 'mail' }} badge={4} />
39
- <RailItem label="Chat" href="#" iconProps={{ name: 'chat' }} badge={-1} />
40
- <RailItem label="Settings" href="#" iconProps={{ name: 'settings' }} />
48
+ {#each destinations as d (d.label)}
49
+ <RailItem
50
+ label={d.label}
51
+ href="#{d.label.toLowerCase()}"
52
+ iconProps={{ name: d.icon }}
53
+ badge={d.badge}
54
+ selected={current === d.label}
55
+ onclick={(e) => {
56
+ e.preventDefault();
57
+ current = d.label;
58
+ }}
59
+ />
60
+ {/each}
41
61
  <RailItem label="Disabled" href="#" iconProps={{ name: 'block' }} disabled />
42
62
  </Rail>
43
63
  </div>
@@ -1,19 +1,4 @@
1
1
  import Rail from './Rail.svelte';
2
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
3
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
4
- $$bindings?: Bindings;
5
- } & Exports;
6
- (internal: unknown, props: {
7
- $$events?: Events;
8
- $$slots?: Slots;
9
- }): Exports & {
10
- $set?: any;
11
- $on?: any;
12
- };
13
- z_$$bindings?: Bindings;
14
- }
15
- declare const Rail: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
16
- [evt: string]: CustomEvent<any>;
17
- }, {}, {}, string>;
18
- type Rail = InstanceType<typeof Rail>;
2
+ declare const Rail: import("svelte").Component<Record<string, never>, {}, "">;
3
+ type Rail = ReturnType<typeof Rail>;
19
4
  export default Rail;
@@ -2,17 +2,21 @@
2
2
  @component
3
3
  Material 3 Navigation Rail.
4
4
 
5
- Navigation rails provide access to primary destinations in apps when using tablet and desktop screens.
6
- On mobile (< md) it automatically renders as a bottom navigation bar.
5
+ Navigation rails give access to primary destinations on medium and larger windows. Collapsed it's
6
+ 96dp wide; expanded it shows labels beside the icons. On large windows the expanded rail pushes
7
+ the content aside (standard); on medium windows it opens over it, above a scrim (modal), and
8
+ closes on a scrim click, Escape, or picking a destination. Below `md` it's hidden: mount a
9
+ `Navbar` for small windows.
7
10
 
8
- @see https://m3.material.io/components/navigation-rail/overview
11
+ @see https://m3.material.io/components/navigation-rail/specs
9
12
  -->
10
13
  <script lang="ts">
11
- import { clickOutside } from '../../../attachments/index.js';
12
14
  import clsx from 'clsx';
13
15
  import { setContext, untrack } from 'svelte';
16
+ import { animate, type AnimationPlaybackControls } from 'motion';
14
17
  import type { RailProps } from './types';
15
18
  import ButtonIcon from '../../buttons/ButtonIcon.svelte';
19
+ import { springTokens, springTransition } from '../../../animation/spring.js';
16
20
  import { rail } from './theme';
17
21
  import { NavigationMenu } from 'bits-ui';
18
22
  import { railStore } from './railStore.svelte.js';
@@ -27,13 +31,19 @@ On mobile (< md) it automatically renders as a bottom navigation bar.
27
31
  railTop = 0,
28
32
  expandLabel = 'Expand navigation',
29
33
  collapseLabel = 'Collapse navigation',
30
- class: className
34
+ class: className,
35
+ showHelp: _showHelp,
36
+ withNavbar: _withNavbar,
37
+ ...restProps
31
38
  }: RailProps = $props();
32
39
 
33
40
  const expanded = $derived(!collapsed);
34
41
  const railCtx = {
35
42
  get collapsed() {
36
43
  return collapsed;
44
+ },
45
+ get progress() {
46
+ return progress;
37
47
  }
38
48
  };
39
49
  setContext('rail', railCtx);
@@ -42,64 +52,169 @@ On mobile (< md) it automatically renders as a bottom navigation bar.
42
52
  railStore.collapsed = collapsed;
43
53
  });
44
54
 
45
- const { base, items, ghost, scrim } = $derived(rail({ expanded, anchor, rounded }));
46
- const railBaseClass = $derived(`${base({ class: clsx(className) })} rail-base`);
55
+ const styles = $derived(rail({ expanded, anchor, rounded, header: expandable || !!fab }));
47
56
  const toggleClass = $derived(collapsed ? 'cursor-e-resize' : 'cursor-w-resize');
48
- const cssVars = $derived(`--rail-top: ${railTop}px;`);
57
+
58
+ let railEl = $state<HTMLElement>();
59
+ let ghostEl = $state<HTMLElement>();
60
+ let scrimEl = $state<HTMLElement>();
61
+
62
+ /* The scrim is only displayed while the expanded rail is modal (medium windows). */
63
+ const isModal = () => !!scrimEl && getComputedStyle(scrimEl).display !== 'none';
64
+
65
+ /*
66
+ Making room for the rail. A viewport-anchored rail publishes its drawn width on <html>
67
+ (--md-rail-width, every frame while it springs) plus --md-rail-rest, its collapsed width;
68
+ styles/components.css turns them into --md-rail-inset per window size, which App's shell and
69
+ AppBar use. A parent-anchored rail instead renders a ghost in the layout that holds its place
70
+ the same way: the rail's width from lg, the collapsed width on medium windows, where the
71
+ expanded rail overlays the content.
72
+ */
73
+ $effect(() => {
74
+ const el = railEl;
75
+ if (!el || typeof ResizeObserver === 'undefined') return;
76
+ const target = anchor === 'viewport' ? document.documentElement : ghostEl;
77
+ if (!target) return;
78
+ if (anchor === 'viewport') {
79
+ target.style.setProperty(
80
+ '--md-rail-rest',
81
+ 'var(--md-comp-nav-rail-collapsed-container-width)'
82
+ );
83
+ }
84
+ const property = anchor === 'viewport' ? '--md-rail-width' : '--rail-width';
85
+ const observer = new ResizeObserver(() => {
86
+ target.style.setProperty(property, `${el.offsetWidth}px`);
87
+ });
88
+ observer.observe(el);
89
+ return () => {
90
+ observer.disconnect();
91
+ target.style.removeProperty(property);
92
+ if (anchor === 'viewport') target.style.removeProperty('--md-rail-rest');
93
+ };
94
+ });
95
+
96
+ /*
97
+ Expanding is one spring on `progress`, 0 collapsed to 1 expanded, on spatial: a container
98
+ reshaping, not a navigation transition, so no fastSpatial bounce. It's the choreography of
99
+ Compose's WideNavigationRail: `--rail-p` carries the progress to the destinations (their
100
+ height, indicator and icon interpolate on it; the label fades out, swaps sides at the halfway
101
+ point, and fades back in) and to the gap between them, and the width follows it here.
102
+
103
+ The classes hold each end state, so SSR and reduced motion need no JS. For the width, measure
104
+ where the rail is drawn before the change and where the new classes put it, and map the
105
+ progress onto that span; an interrupted spring picks up from where it's drawn.
106
+ */
107
+ const SPRING = springTransition(springTokens.spatial);
108
+ let progress = $state(untrack(() => (collapsed ? 0 : 1)));
109
+ let widthFrom: number | null = null;
110
+ let progressFrom = 0;
111
+ let spring: AnimationPlaybackControls | undefined;
112
+
113
+ const clearWidth = (el: HTMLElement) =>
114
+ Object.assign(el.style, { width: '', minWidth: '', maxWidth: '' });
115
+
116
+ $effect.pre(() => {
117
+ void expanded;
118
+ if (!railEl) return;
119
+ widthFrom = railEl.getBoundingClientRect().width;
120
+ progressFrom = untrack(() => progress);
121
+ });
122
+
123
+ $effect(() => {
124
+ const target = expanded ? 1 : 0;
125
+ const el = railEl;
126
+ const from = widthFrom;
127
+ widthFrom = null;
128
+ if (!el || from === null) return;
129
+
130
+ spring?.stop();
131
+ clearWidth(el);
132
+ const to = el.getBoundingClientRect().width;
133
+ if (progressFrom === target || matchMedia('(prefers-reduced-motion: reduce)').matches) {
134
+ progress = target;
135
+ return;
136
+ }
137
+
138
+ // Lift the expanded min/max for the ride, or the width would snap to 220dp at the start.
139
+ Object.assign(el.style, { width: `${from}px`, minWidth: '0', maxWidth: 'none' });
140
+ const start = progressFrom;
141
+ const controls = animate(start, target, {
142
+ ...SPRING,
143
+ onUpdate: (p) => {
144
+ progress = p;
145
+ el.style.width = `${from + ((to - from) * (p - start)) / (target - start)}px`;
146
+ },
147
+ onComplete: () => {
148
+ progress = target;
149
+ clearWidth(el);
150
+ }
151
+ });
152
+ spring = controls;
153
+ });
154
+
155
+ function onKeydown(event: KeyboardEvent) {
156
+ if (event.key === 'Escape' && expanded && isModal()) {
157
+ collapsed = true;
158
+ }
159
+ }
160
+
161
+ // Picking a destination closes the modal rail; the standard one stays as it is.
162
+ function onNavClick(event: MouseEvent) {
163
+ if (expanded && isModal() && (event.target as Element).closest('a[href]')) {
164
+ collapsed = true;
165
+ }
166
+ }
49
167
  </script>
50
168
 
51
- <!-- Push ghost: hidden on mobile, becomes flex item on md+ to push content -->
52
- <div class={ghost()}></div>
169
+ <svelte:window onkeydown={onKeydown} />
170
+
171
+ {#if anchor === 'parent'}
172
+ <div
173
+ bind:this={ghostEl}
174
+ class={styles.ghost()}
175
+ style="--rail-width: var(--md-comp-nav-rail-collapsed-container-width)"
176
+ ></div>
177
+ {/if}
53
178
 
54
- <!-- Tablet scrim (md:block lg:hidden) — overlays content on expand -->
55
179
  <div
56
- class={`${scrim()} rail-scrim`}
57
- data-expanded={expanded}
180
+ bind:this={scrimEl}
181
+ class={styles.scrim()}
58
182
  onclick={() => (collapsed = true)}
59
183
  role="presentation"
60
184
  ></div>
61
185
 
62
- <!-- Desktop/tablet sidebar rail -->
63
186
  <div
64
- class={railBaseClass}
65
- style={cssVars}
187
+ bind:this={railEl}
188
+ {...restProps}
189
+ class={styles.base({ class: clsx(className) })}
190
+ style:--rail-top="{railTop}px"
191
+ style:--rail-p={progress}
66
192
  data-expanded={expanded}
67
- {@attach expanded && clickOutside(() => (collapsed = true))}
68
193
  >
69
- {#if expandable}
70
- <ButtonIcon
71
- variant="standard"
72
- type="button"
73
- tooltipContent={collapsed ? expandLabel : collapseLabel}
74
- iconProps={{ name: `${collapsed ? 'menu' : 'menu_open'}` }}
75
- class={toggleClass}
76
- onclick={() => (collapsed = !collapsed)}
77
- />
78
- {/if}
194
+ {#if expandable || fab}
195
+ <div class={styles.header()}>
196
+ {#if expandable}
197
+ <div class={styles.menu()}>
198
+ <ButtonIcon
199
+ variant="standard"
200
+ type="button"
201
+ tooltipContent={collapsed ? expandLabel : collapseLabel}
202
+ iconProps={{ name: collapsed ? 'menu' : 'menu_open', transition: 'rotate' }}
203
+ aria-expanded={expanded}
204
+ class={toggleClass}
205
+ onclick={() => (collapsed = !collapsed)}
206
+ />
207
+ </div>
208
+ {/if}
79
209
 
80
- {@render fab?.()}
210
+ {@render fab?.()}
211
+ </div>
212
+ {/if}
81
213
 
82
- <NavigationMenu.Root orientation="vertical" class="w-full">
83
- <NavigationMenu.List class={`${items()} rail-items`}>
214
+ <!-- The click is delegated from the links inside; Enter on a link fires it too. -->
215
+ <NavigationMenu.Root orientation="vertical" class={styles.nav()} onclick={onNavClick}>
216
+ <NavigationMenu.List class={styles.items()}>
84
217
  {@render children?.()}
85
218
  </NavigationMenu.List>
86
219
  </NavigationMenu.Root>
87
220
  </div>
88
-
89
- <style>
90
- .rail-scrim {
91
- opacity: 0;
92
- pointer-events: none;
93
- background-color: transparent;
94
- z-index: -1;
95
- transition: opacity var(--md-sys-motion-duration-effects-spring)
96
- var(--md-sys-motion-timing-function-effects-spring);
97
- }
98
-
99
- .rail-scrim[data-expanded='true'] {
100
- opacity: 1;
101
- pointer-events: auto;
102
- background-color: rgb(0 0 0 / 0.4);
103
- z-index: var(--z-layer-rail);
104
- }
105
- </style>
@@ -2,10 +2,13 @@ import type { RailProps } from './types';
2
2
  /**
3
3
  * Material 3 Navigation Rail.
4
4
  *
5
- * Navigation rails provide access to primary destinations in apps when using tablet and desktop screens.
6
- * On mobile (< md) it automatically renders as a bottom navigation bar.
5
+ * Navigation rails give access to primary destinations on medium and larger windows. Collapsed it's
6
+ * 96dp wide; expanded it shows labels beside the icons. On large windows the expanded rail pushes
7
+ * the content aside (standard); on medium windows it opens over it, above a scrim (modal), and
8
+ * closes on a scrim click, Escape, or picking a destination. Below `md` it's hidden: mount a
9
+ * `Navbar` for small windows.
7
10
  *
8
- * @see https://m3.material.io/components/navigation-rail/overview
11
+ * @see https://m3.material.io/components/navigation-rail/specs
9
12
  */
10
13
  declare const Rail: import("svelte").Component<RailProps, {}, "collapsed">;
11
14
  type Rail = ReturnType<typeof Rail>;