@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,10 +1,35 @@
1
1
  import { tv } from '../../../utils/tv.js';
2
+ /*
3
+ M3 Expressive navigation rail (https://m3.material.io/components/navigation-rail/specs):
4
+ - Container: `surface-container`, the spec's optional container role, which is this library's
5
+ window colour (App), so the rail reads as part of the window beside `surface` panes rather
6
+ than as a strip. The modal rail's token is `surface-container` too.
7
+ - Collapsed: 96dp wide, no elevation or shape. Destinations sit 4dp apart.
8
+ - Expanded: hugs its widest destination between 220 and 360dp, destinations touching. Standard
9
+ (lg+, pushes content) keeps the collapsed container; modal (md, over a scrim) adds
10
+ elevation 3 and 16dp trailing corners.
11
+ - 44dp above the first element; at least 40dp between the menu/FAB header and the destinations.
12
+ - Header elements sit in a 56dp column 20dp from the leading edge, the same column as the
13
+ destinations' active indicators, so nothing shifts sideways when the rail expands.
14
+ Expanding is one spatial spring on a progress value, 0 collapsed to 1 expanded, which
15
+ Rail.svelte writes to `--rail-p` (after Compose's WideNavigationRail). The width, the gap
16
+ between destinations, the modal corners and each destination's geometry all follow it, so
17
+ nothing here transitions spatially on its own.
18
+ */
2
19
  export const rail = tv({
3
20
  slots: {
4
- base: 'py-spacing-600 hidden bg-md-sys-color-surface-container z-layer-rail flex-col md:flex gap-spacing-300 transition-[width,padding,background-color] md-sys-motion-spatial',
5
- items: 'flex flex-col gap-spacing-150 pt-spacing-300 scrollbar-none w-full overflow-x-hidden',
6
- ghost: 'hidden md:block shrink-0 transition-[width] md-sys-motion-spatial',
7
- scrim: 'hidden md:block lg:hidden inset-spacing-0'
21
+ // Modal corners grow with the progress (0 → 16dp); the standard rail stays square.
22
+ base: 'rounded-r-[calc(var(--radius-lg)_*_var(--rail-p,0))] lg:rounded-r-none hidden md:flex flex-col z-layer-rail overflow-hidden pt-(--md-comp-nav-rail-collapsed-top-space) pb-spacing-250 bg-md-sys-color-surface-container transition-[box-shadow] md-sys-motion-effects',
23
+ // Always start-aligned: collapsed, the content box is exactly the 56dp column, so this is
24
+ // centred anyway. Centring it would fling the menu and FAB to the middle of the still-wide
25
+ // rail the moment a collapse starts.
26
+ header: 'flex flex-col items-start gap-spacing-300 px-spacing-250',
27
+ menu: 'flex w-spacing-700 justify-center',
28
+ nav: 'flex min-h-spacing-0 w-full flex-1 flex-col',
29
+ // 4dp between destinations collapsed, 0 expanded, following the progress.
30
+ items: 'flex w-full flex-col overflow-x-hidden overflow-y-auto [scrollbar-width:none] gap-[calc(var(--spacing-spacing-50)_*_(1_-_var(--rail-p,0)))]',
31
+ ghost: 'hidden md:block shrink-0 w-(--md-comp-nav-rail-collapsed-container-width) lg:w-(--rail-width)',
32
+ scrim: 'hidden md:block lg:hidden inset-spacing-0 z-layer-rail bg-md-sys-color-scrim/32 transition-opacity md-sys-motion-effects'
8
33
  },
9
34
  variants: {
10
35
  anchor: {
@@ -19,15 +44,19 @@ export const rail = tv({
19
44
  },
20
45
  expanded: {
21
46
  true: {
22
- base: 'pl-spacing-450 w-60 items-start bg-md-sys-color-surface-container-highest lg:bg-md-sys-color-surface-container rounded-r-lg',
23
- ghost: 'w-24 lg:w-60'
47
+ base: 'w-max min-w-(--md-comp-nav-rail-expanded-container-width-minimum) max-w-(--md-comp-nav-rail-expanded-container-width-maximum) pt-(--md-comp-nav-rail-expanded-top-space) shadow-elevation-3 lg:shadow-elevation-0',
48
+ scrim: 'opacity-100'
24
49
  },
25
50
  false: {
26
- base: 'w-24 items-center',
27
- items: 'items-center',
28
- ghost: 'w-24'
51
+ base: 'w-(--md-comp-nav-rail-collapsed-container-width)',
52
+ scrim: 'pointer-events-none opacity-0'
29
53
  }
30
54
  },
55
+ /* Header space: only when there is a header to keep the destinations away from. */
56
+ header: {
57
+ true: { nav: 'mt-spacing-500' },
58
+ false: {}
59
+ },
31
60
  rounded: {
32
61
  true: {
33
62
  base: 'rounded-xl'
@@ -36,81 +65,65 @@ export const rail = tv({
36
65
  }
37
66
  },
38
67
  defaultVariants: {
39
- anchor: 'viewport'
68
+ anchor: 'viewport',
69
+ header: false
40
70
  }
41
71
  });
72
+ /*
73
+ One destination. The link spans the rail's full width (M3: the target area always does); the
74
+ active indicator inside it hugs its contents. Collapsed, the indicator is 56×32 around the icon
75
+ with the label 4dp below; expanded, it's a 56dp pill around icon and label, 16dp padding, 8dp
76
+ between them. State layers are `on-secondary-container` at 8% hover and 10% focus/press, drawn
77
+ on the indicator but triggered anywhere on the link. Selecting a destination grows its fill
78
+ out from the centre (M3's active indicator, after Compose's NavigationRailItem).
79
+
80
+ Geometry isn't here: it interpolates on the rail's expand progress, in RailItem.svelte's
81
+ <style>. `layout` is which side of the halfway point that progress is on, and decides only
82
+ what may switch while the label is invisible: its typescale and colour.
83
+ */
42
84
  export const railElement = tv({
43
85
  slots: {
44
- base: 'group relative z-30 flex w-full transition-all md-sys-motion-spatial',
45
- content: 'flex items-center min-w-spacing-0 rounded-full gap-spacing-25 py-spacing-75 transition-all md-sys-motion-spatial',
46
- iconContainer: 'relative rounded-full items-center justify-center inline-flex transition-all md-sys-motion-spatial',
47
- icon: 'text-[24px] transition-all md-sys-motion-spatial',
48
- label: 'transition-all md-sys-motion-spatial whitespace-nowrap'
86
+ base: 'group rail-item relative flex w-full px-spacing-250 outline-none',
87
+ // As wide as the expanded pill, so an expanded rail (w-max) hugs its widest destination.
88
+ sizer: 'relative h-full shrink-0 pl-spacing-600 pr-spacing-200',
89
+ measure: 'invisible md-sys-typescale-label-large whitespace-nowrap',
90
+ indicator: 'rail-indicator absolute left-spacing-0 isolate rounded-full state-layer before:rounded-full',
91
+ // The selected fill, under the state layer. It grows from the indicator's centre to its
92
+ // edges on select and shrinks back on deselect, on RailItem's --rail-sel spring.
93
+ fill: 'rail-fill absolute -z-10 rounded-full bg-md-sys-color-secondary-container',
94
+ icon: 'rail-icon absolute left-spacing-200 inline-flex',
95
+ label: 'rail-label absolute whitespace-nowrap transition-colors md-sys-motion-fast-effects'
49
96
  },
50
97
  variants: {
51
98
  active: {
52
99
  true: {
53
- content: 'text-md-sys-color-on-secondary-container',
54
- label: 'font-bold',
55
- iconContainer: 'group-hover:text-md-sys-color-secondary bg-md-sys-color-secondary-container'
100
+ icon: 'text-md-sys-color-on-secondary-container'
56
101
  },
57
102
  false: {
58
- content: 'text-md-sys-color-on-surface-variant',
59
- iconContainer: ''
103
+ icon: 'text-md-sys-color-on-surface-variant',
104
+ label: 'text-md-sys-color-on-surface-variant'
60
105
  }
61
106
  },
62
- expanded: {
63
- true: {
64
- base: 'justify-start',
65
- content: 'p-spacing-200 gap-spacing-150',
66
- iconContainer: '',
67
- label: 'md-sys-typescale-label-large'
68
- },
69
- false: {
70
- base: 'text-center justify-center',
71
- content: 'flex-col',
72
- iconContainer: 'py-spacing-75 px-spacing-200',
73
- label: 'md-sys-typescale-label-medium'
74
- }
107
+ layout: {
108
+ top: { label: 'md-sys-typescale-label-medium' },
109
+ start: { label: 'md-sys-typescale-label-large' }
75
110
  },
76
111
  disabled: {
77
112
  true: {
78
113
  base: 'cursor-not-allowed opacity-38'
79
- }
80
- },
81
- mobile: {
82
- true: {
83
- base: 'flex-1 flex-col items-center justify-center h-full min-w-spacing-0 max-w-[168px]',
84
- content: 'py-spacing-0 gap-spacing-50 items-center justify-center w-full',
85
- iconContainer: 'h-spacing-400 w-spacing-800 py-spacing-0 px-spacing-0',
86
- icon: 'size-spacing-300',
87
- label: 'truncate w-full text-center'
88
114
  },
89
- false: {}
115
+ false: {
116
+ // State layer and focus ring on the indicator, triggered by the full-width link.
117
+ indicator: 'group-hover:before:bg-md-sys-color-on-secondary-container/8 group-focus-visible:before:bg-md-sys-color-on-secondary-container/10 group-active:before:bg-md-sys-color-on-secondary-container/10 group-focus-visible:outline-3 group-focus-visible:outline-offset-2 group-focus-visible:outline-md-sys-color-secondary'
118
+ }
90
119
  }
91
120
  },
92
121
  compoundVariants: [
93
- {
94
- expanded: true,
95
- active: true,
96
- class: {
97
- iconContainer: 'bg-transparent',
98
- content: 'bg-md-sys-color-secondary-container'
99
- }
100
- },
101
- {
102
- expanded: true,
103
- active: false,
104
- class: {
105
- content: 'group-hover:bg-md-sys-color-surface-variant'
106
- }
107
- },
108
- {
109
- expanded: false,
110
- active: false,
111
- class: {
112
- iconContainer: 'group-hover:bg-md-sys-color-surface-variant'
113
- }
114
- }
115
- ]
122
+ { active: true, layout: 'top', class: { label: 'text-md-sys-color-secondary' } },
123
+ { active: true, layout: 'start', class: { label: 'text-md-sys-color-on-secondary-container' } }
124
+ ],
125
+ defaultVariants: {
126
+ layout: 'top',
127
+ disabled: false
128
+ }
116
129
  });
@@ -3,6 +3,7 @@
3
3
  import TabHolder from './TabHolder.svelte';
4
4
  import TabContent from './TabContent.svelte';
5
5
  import Body from '../../typography/body/Body.svelte';
6
+ import type { TabProps } from './types.js';
6
7
 
7
8
  const { Story } = defineMeta({
8
9
  title: 'Navigation/Tabs',
@@ -27,7 +28,7 @@
27
28
  { value: 'flights', name: 'Flights', iconProps: { name: 'flight' } },
28
29
  { value: 'trips', name: 'Trips', iconProps: { name: 'luggage' } },
29
30
  { value: 'explore', name: 'Explore', iconProps: { name: 'explore' } }
30
- ];
31
+ ] satisfies TabProps[];
31
32
  </script>
32
33
 
33
34
  <Story name="Playground">
package/dist/index.css CHANGED
@@ -4,5 +4,7 @@
4
4
  @import './styles/rounding.css';
5
5
  @import './styles/elevation.css';
6
6
  @import './styles/layers.css';
7
+ @import './styles/components.css';
8
+ @import './styles/icon.css';
7
9
  @import './styles/prose.css';
8
10
  @import './styles/component.css';
@@ -0,0 +1,50 @@
1
+ /*
2
+ * M3 component dimensions that aren't on the spacing scale (issue #30). Each property is named
3
+ * after its token in the M3 token database, md.comp.<component>.<…> with dots as dashes, and
4
+ * used as `w-(--md-comp-…)`. A dimension that lands on the spacing grid uses the spacing token
5
+ * instead (the rail's 4dp item gap is `gap-spacing-50`, not a property here).
6
+ */
7
+ :root {
8
+ /* Navigation rail: https://m3.material.io/components/navigation-rail/specs */
9
+ --md-comp-nav-rail-collapsed-container-width: 6rem; /* 96dp */
10
+ --md-comp-nav-rail-collapsed-top-space: 2.75rem; /* 44dp */
11
+ --md-comp-nav-rail-expanded-top-space: 2.75rem; /* 44dp */
12
+ --md-comp-nav-rail-expanded-container-width-minimum: 13.75rem; /* 220dp */
13
+ --md-comp-nav-rail-expanded-container-width-maximum: 22.5rem; /* 360dp */
14
+ }
15
+
16
+ /*
17
+ * How far the page's content starts from the leading edge to clear a viewport-anchored Rail.
18
+ * The Rail writes --md-rail-width (its drawn width, every frame while it springs) on <html>;
19
+ * with no Rail mounted it's unset and the inset is 0. On medium windows the expanded rail is
20
+ * modal and overlays the content, so the inset stays at the collapsed width; from lg the
21
+ * standard rail pushes the content, so the inset follows its width. App's shell pads by it;
22
+ * fixed surfaces that span the window (AppBar) start at it. Custom layouts: `ps-(--md-rail-inset)`.
23
+ */
24
+ :root {
25
+ --md-rail-inset: 0px;
26
+ }
27
+ @media (width >= 48rem) {
28
+ :root {
29
+ --md-rail-inset: var(--md-rail-rest, 0px);
30
+ }
31
+ }
32
+ @media (width >= 64rem) {
33
+ :root {
34
+ --md-rail-inset: var(--md-rail-width, 0px);
35
+ }
36
+ }
37
+
38
+ /*
39
+ * A page built on App behaves as an app, not a document: scrolling stops at its edges instead of
40
+ * bouncing (macOS/iOS rubber-banding) or chaining into pull-to-refresh, which showed the blank
41
+ * canvas past App's shell. The canvas takes the window colour anyway, for the browsers that
42
+ * still overscroll and the safe areas around it. Scoped to pages with an App, and in the base
43
+ * layer, so a consumer can restore overscroll with one `html { overscroll-behavior: auto }`.
44
+ */
45
+ @layer base {
46
+ :root:has(.md-app) {
47
+ overscroll-behavior: none;
48
+ background-color: var(--color-md-sys-color-surface-container);
49
+ }
50
+ }
@@ -0,0 +1,13 @@
1
+ /*
2
+ Default grade for Icon's `grad="auto"`. Light icons on a dark background look heavier than dark
3
+ ones on a light background (visual bleed), so M3 drops the grade to -25 on dark UI.
4
+ https://m3.material.io/styles/icons/applying-icons#grade
5
+ */
6
+ :root {
7
+ --md-icon-grade: 0;
8
+ }
9
+
10
+ .dark,
11
+ [data-theme^='dark'] {
12
+ --md-icon-grade: -25;
13
+ }
@@ -0,0 +1,97 @@
1
+ <script module lang="ts">
2
+ import { defineMeta } from '@storybook/addon-svelte-csf';
3
+ import Body from '../../components/typography/body/Body.svelte';
4
+ import ButtonIcon from '../../components/buttons/ButtonIcon.svelte';
5
+ import Icon from './Icon.svelte';
6
+
7
+ const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
8
+ const variants = ['rounded', 'outlined', 'sharp'] as const;
9
+
10
+ const { Story } = defineMeta({
11
+ title: 'Utils/Icon',
12
+ component: Icon,
13
+ tags: ['autodocs'],
14
+ argTypes: {
15
+ size: { control: 'select', options: ['inline', ...sizes] },
16
+ variant: { control: 'inline-radio', options: variants },
17
+ fill: { control: { type: 'range', min: 0, max: 1, step: 0.1 } },
18
+ wght: { control: { type: 'range', min: 100, max: 700, step: 100 } },
19
+ grad: { control: 'select', options: ['auto', -50, -25, 0, 100, 200] },
20
+ opsz: { control: { type: 'range', min: 20, max: 48, step: 1 } },
21
+ transition: { control: 'inline-radio', options: ['none', 'fade', 'rotate'] }
22
+ },
23
+ args: { name: 'favorite', size: 'xl', variant: 'rounded', fill: 0, wght: 400, grad: 'auto' }
24
+ });
25
+ </script>
26
+
27
+ <script lang="ts">
28
+ let menuOpen = $state(false);
29
+ let adding = $state(false);
30
+ let playing = $state(false);
31
+ </script>
32
+
33
+ <Story name="Playground" />
34
+
35
+ <!-- Toggle each button, and toggle again mid-flight: the spring reverses from where it is. -->
36
+ <Story name="Transitions" asChild>
37
+ <div class="gap-spacing-300 flex items-center">
38
+ <ButtonIcon
39
+ variant="standard"
40
+ tooltipContent="rotate: menu ↔ menu_open"
41
+ iconProps={{ name: menuOpen ? 'menu_open' : 'menu', transition: 'rotate' }}
42
+ onclick={() => (menuOpen = !menuOpen)}
43
+ />
44
+ <ButtonIcon
45
+ variant="tonal"
46
+ tooltipContent="rotate: add ↔ close"
47
+ iconProps={{ name: adding ? 'close' : 'add', transition: 'rotate' }}
48
+ onclick={() => (adding = !adding)}
49
+ />
50
+ <ButtonIcon
51
+ variant="filled"
52
+ tooltipContent="fade: play_arrow ↔ pause"
53
+ iconProps={{ name: playing ? 'pause' : 'play_arrow', transition: 'fade' }}
54
+ onclick={() => (playing = !playing)}
55
+ />
56
+ </div>
57
+ </Story>
58
+
59
+ <Story name="Styles" asChild>
60
+ <div class="gap-spacing-300 flex">
61
+ {#each variants as variant (variant)}
62
+ <Icon name="home" size="lg" {variant} />
63
+ {/each}
64
+ </div>
65
+ </Story>
66
+
67
+ <Story name="Sizes" asChild>
68
+ <div class="gap-spacing-300 flex items-end">
69
+ {#each sizes as size (size)}
70
+ <Icon name="settings" {size} />
71
+ {/each}
72
+ </div>
73
+ </Story>
74
+
75
+ <Story name="Fill for state" asChild>
76
+ <div class="gap-spacing-300 flex">
77
+ <Icon name="favorite" size="lg" fill={0} />
78
+ <Icon name="favorite" size="lg" fill={1} />
79
+ </div>
80
+ </Story>
81
+
82
+ <Story name="Weights" asChild>
83
+ <div class="gap-spacing-200 flex">
84
+ {#each [200, 300, 400, 500, 600, 700] as wght (wght)}
85
+ <Icon name="search" size="lg" {wght} />
86
+ {/each}
87
+ </div>
88
+ </Story>
89
+
90
+ <Story name="Inline with text" asChild>
91
+ <Body size="large">
92
+ Saved to favorites <Icon name="favorite" size="inline" fill={1} />, shared <Icon
93
+ name="share"
94
+ size="inline"
95
+ />
96
+ </Body>
97
+ </Story>
@@ -0,0 +1,4 @@
1
+ import Icon from './Icon.svelte';
2
+ declare const Icon: import("svelte").Component<Record<string, never>, {}, "">;
3
+ type Icon = ReturnType<typeof Icon>;
4
+ export default Icon;
@@ -1,9 +1,29 @@
1
+ <!--
2
+ @component
3
+ A Material Symbol, rendered from the variable icon font with its four axes: fill, weight, grade and
4
+ optical size. https://m3.material.io/styles/icons/applying-icons
5
+
6
+ Decorative by default (`aria-hidden`), so a screen reader doesn't read out the ligature name.
7
+ Pass `aria-label` when the icon carries meaning with no visible text next to it.
8
+
9
+ With `transition`, a change of `name` crossfades on a spring instead of swapping the glyph.
10
+ -->
1
11
  <script lang="ts">
2
12
  import clsx from 'clsx';
3
- import { twMerge } from 'tailwind-merge';
4
- import type { IconProps, IconSize } from './types.js';
13
+ import { untrack } from 'svelte';
14
+ import { presence } from '../../animation/presence.svelte.js';
15
+ import { twMerge } from '../tv.js';
16
+ import { atRest, iconSwap, iconSwapReduced } from './swap.js';
17
+ import type { IconProps, IconSize, MaterialSymbolName } from './types.js';
5
18
 
19
+ /*
20
+ The optical size tracks the rendered size, so a 40dp icon keeps its stroke weight instead of
21
+ being a scaled-up 24dp glyph (M3 designs 20, 24, 40 and 48). `inline` takes the size of the
22
+ surrounding text and drops the baseline by ~11.5% of it, as M3 asks for symbols set in a line
23
+ of text; body and label text sit nearest the 20 optical size.
24
+ */
6
25
  const sizeMap: Record<IconSize, { cls: string; opsz: number }> = {
26
+ inline: { cls: 'size-[1em] align-[-0.115em]', opsz: 20 },
7
27
  xs: { cls: 'size-spacing-200 text-[16px]', opsz: 20 },
8
28
  sm: { cls: 'size-spacing-250 text-[20px]', opsz: 20 },
9
29
  md: { cls: 'size-spacing-300 text-[24px]', opsz: 24 },
@@ -17,21 +37,90 @@
17
37
  variant = 'rounded',
18
38
  fill = 0,
19
39
  wght = 400,
20
- grad = 0,
40
+ grad = 'auto',
21
41
  opsz,
22
- class: className
42
+ transition = 'none',
43
+ class: className,
44
+ ...restProps
23
45
  }: IconProps = $props();
24
46
 
25
47
  const resolved = $derived(sizeMap[size]);
26
48
  const resolvedOpsz = $derived(opsz ?? resolved.opsz);
27
- const wrapperClass = 'inline-flex items-center justify-center leading-none';
49
+ // 'auto' reads --md-icon-grade (styles/icon.css): 0 on light schemes, -25 on dark ones.
50
+ const resolvedGrad = $derived(grad === 'auto' ? 'var(--md-icon-grade, 0)' : grad);
51
+ const labelled = $derived(Boolean(restProps['aria-label'] || restProps['aria-labelledby']));
52
+ // With a transition the glyphs stack in one grid cell; without, the original single glyph.
53
+ const wrapperClass = $derived(
54
+ transition === 'none'
55
+ ? 'inline-flex shrink-0 items-center justify-center leading-none'
56
+ : 'inline-grid shrink-0 place-items-center leading-none'
57
+ );
58
+ const glyphClass = $derived(
59
+ `material-symbols-${variant} md-sys-motion-fast-effects transition-[font-variation-settings] select-none`
60
+ );
61
+ const glyphStyle = $derived(
62
+ `font-size: 1em; font-variation-settings: 'FILL' ${fill}, 'wght' ${wght}, 'GRAD' ${resolvedGrad}, 'opsz' ${resolvedOpsz};`
63
+ );
64
+
65
+ /*
66
+ With a transition, the glyphs are a stack: the current one plus any still leaving. A name
67
+ change marks the current glyph leaving and adds the new one; each drops out of the stack when
68
+ its exit spring finishes. Changing back to a name that's still leaving brings that glyph back,
69
+ retargeted mid-flight, instead of stacking a third.
70
+ */
71
+ type Glyph = { id: number; name: MaterialSymbolName; leaving: boolean; initial: boolean };
72
+ let nextId = 1;
73
+ let glyphs = $state<Glyph[]>([
74
+ { id: 0, name: untrack(() => name), leaving: false, initial: true }
75
+ ]);
76
+
77
+ $effect.pre(() => {
78
+ const next = name;
79
+ untrack(() => {
80
+ if (glyphs.find((g) => !g.leaving)?.name === next) return;
81
+ if (transition === 'none') {
82
+ glyphs = [{ id: nextId++, name: next, leaving: false, initial: true }];
83
+ return;
84
+ }
85
+ for (const g of glyphs) g.leaving = g.name !== next;
86
+ if (!glyphs.some((g) => g.name === next)) {
87
+ glyphs.push({ id: nextId++, name: next, leaving: false, initial: false });
88
+ }
89
+ });
90
+ });
91
+
92
+ const reducedMotion = () =>
93
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
94
+
95
+ function swap(glyph: Glyph) {
96
+ const preset =
97
+ transition === 'none' || reducedMotion() ? iconSwapReduced : iconSwap[transition];
98
+ return presence(
99
+ () => !glyph.leaving,
100
+ glyph.initial ? atRest(preset) : preset,
101
+ () => (glyphs = glyphs.filter((g) => g.id !== glyph.id))
102
+ );
103
+ }
28
104
  </script>
29
105
 
30
106
  <!-- twMerge: a caller's size class replaces the preset's instead of racing it in the cascade. -->
31
- <div class={twMerge(resolved.cls, clsx(className), wrapperClass)}>
32
- <span
33
- class="material-symbols-{variant}"
34
- style="font-size: 1em; font-variation-settings: 'FILL' {fill}, 'wght' {wght}, 'GRAD' {grad}, 'opsz' {resolvedOpsz};"
35
- >{name}</span
36
- >
37
- </div>
107
+ <span
108
+ role={labelled ? 'img' : undefined}
109
+ aria-hidden={labelled ? undefined : 'true'}
110
+ {...restProps}
111
+ class={twMerge(resolved.cls, clsx(className), wrapperClass)}
112
+ >
113
+ <!-- translate="no": page translation would rename the ligature and break the glyph. -->
114
+ {#if transition === 'none'}
115
+ <span class={glyphClass} translate="no" style={glyphStyle}>{name}</span>
116
+ {:else}
117
+ {#each glyphs as glyph (glyph.id)}
118
+ <span
119
+ class="{glyphClass} [grid-area:1/1]"
120
+ translate="no"
121
+ style={glyphStyle}
122
+ {@attach swap(glyph)}>{glyph.name}</span
123
+ >
124
+ {/each}
125
+ {/if}
126
+ </span>
@@ -1,4 +1,13 @@
1
1
  import type { IconProps } from './types.js';
2
+ /**
3
+ * A Material Symbol, rendered from the variable icon font with its four axes: fill, weight, grade and
4
+ * optical size. https://m3.material.io/styles/icons/applying-icons
5
+ *
6
+ * Decorative by default (`aria-hidden`), so a screen reader doesn't read out the ligature name.
7
+ * Pass `aria-label` when the icon carries meaning with no visible text next to it.
8
+ *
9
+ * With `transition`, a change of `name` crossfades on a spring instead of swapping the glyph.
10
+ */
2
11
  declare const Icon: import("svelte").Component<IconProps, {}, "">;
3
12
  type Icon = ReturnType<typeof Icon>;
4
13
  export default Icon;
@@ -7,7 +7,12 @@ Use the App.svelte component instead
7
7
  import { baseIcons } from './base-icons.js';
8
8
  import type { MaterialSymbolsProviderProps } from './types.js';
9
9
 
10
- const { extraIcons = [], display = 'swap' }: MaterialSymbolsProviderProps = $props();
10
+ const {
11
+ extraIcons = [],
12
+ display = 'swap',
13
+ subset = true,
14
+ families = ['rounded', 'outlined', 'sharp']
15
+ }: MaterialSymbolsProviderProps = $props();
11
16
 
12
17
  const iconList = $derived.by(() => {
13
18
  const combined = [...baseIcons, ...extraIcons].filter(Boolean);
@@ -15,7 +20,7 @@ Use the App.svelte component instead
15
20
  });
16
21
 
17
22
  const iconNamesParam = $derived.by(() => {
18
- if (!iconList.length) return '';
23
+ if (!subset || !iconList.length) return '';
19
24
  return `&icon_names=${encodeURIComponent(iconList.join(','))}`;
20
25
  });
21
26
 
@@ -25,9 +30,11 @@ Use the App.svelte component instead
25
30
  });
26
31
 
27
32
  const href = $derived.by(() => {
28
- const families = ['Rounded', 'Outlined', 'Sharp'];
29
33
  const familyParams = families
30
- .map((f) => `family=Material+Symbols+${f}:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200`)
34
+ .map(
35
+ (f) =>
36
+ `family=Material+Symbols+${f[0].toUpperCase()}${f.slice(1)}:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200`
37
+ )
31
38
  .join('&');
32
39
  return `https://fonts.googleapis.com/css2?${familyParams}${iconNamesParam}${displayParam}`;
33
40
  });
@@ -1 +1 @@
1
- export declare const baseIcons: readonly ["arrow_drop_down", "check", "chevron_left", "chevron_right", "close", "palette", "file_upload", "home", "info", "search", "settings", "warning", "calendar_month", "schedule", "check_circle", "error", "delete", "right_panel_close", "right_panel_open", "edit", "add", "favorite", "share", "settings", "help", "menu", "menu_open"];
1
+ export declare const baseIcons: readonly ["arrow_drop_down", "check", "chevron_left", "chevron_right", "close", "palette", "upload", "home", "info", "search", "settings", "warning", "calendar_month", "schedule", "check_circle", "error", "delete", "right_panel_close", "right_panel_open", "edit", "add", "favorite", "share", "settings", "help", "menu", "menu_open"];
@@ -5,7 +5,7 @@ export const baseIcons = [
5
5
  'chevron_right',
6
6
  'close',
7
7
  'palette',
8
- 'file_upload',
8
+ 'upload',
9
9
  'home',
10
10
  'info',
11
11
  'search',