Skip to content

Commit 42a84e8

Browse files
committed
feat: migrate components in menu to runes
1 parent 2b77cf8 commit 42a84e8

File tree

9 files changed

+158
-78
lines changed

9 files changed

+158
-78
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Svelte 5 Runes mode is being migrated to slowly. This is the todo list of compon
187187
- [ ] Layout Grid
188188
- [x] List
189189
- [x] Menu Surface
190-
- [ ] Menu
190+
- [x] Menu
191191
- [x] Paper
192192
- Progress Indicators
193193
- [ ] Circular Progress

packages/common/src/smui.types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Component } from 'svelte';
1+
import type { SvelteComponent, Component } from 'svelte';
22
import type {
33
HTMLAttributes,
44
HTMLAnchorAttributes,
@@ -60,6 +60,15 @@ export type SmuiComponent<
6060
Bindings extends keyof Props | '' = string,
6161
> = Component<Props, Exports & { getElement(): Element }, Bindings>;
6262

63+
export type ComponentBindings<
64+
Comp extends SvelteComponent | Component<any, any>,
65+
> =
66+
Comp extends SvelteComponent<infer Props>
67+
? keyof Props
68+
: Comp extends Component<any, any, infer Bindings>
69+
? Bindings
70+
: never;
71+
6372
export type SmuiEveryElement = keyof SmuiElementMap;
6473
export type SmuiHTMLElement = keyof Omit<SmuiElementMap, 'svg'>;
6574

packages/menu/src/Menu.svelte

Lines changed: 105 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<svelte:options runes={false} />
1+
<svelte:options runes={true} />
22

33
<MenuSurface
44
bind:this={element}
@@ -8,31 +8,33 @@
88
'mdc-menu': true,
99
})}
1010
bind:open
11-
{...$$restProps}
11+
bind:anchorElement
12+
{...restProps}
1213
onkeydown={(e) => {
1314
handleKeydown(e);
14-
$$restProps.onkeydown?.(e);
15+
restProps.onkeydown?.(e);
1516
}}
1617
onSMUIMenuSurfaceOpened={(e) => {
1718
if (instance) {
1819
instance.handleMenuSurfaceOpened();
1920
}
20-
$$restProps.onSMUIMenuSurfaceOpened?.(e);
21+
restProps.onSMUIMenuSurfaceOpened?.(e);
2122
}}
2223
onSMUIListAction={(e) => {
23-
if (instance) {
24+
if (instance && listAccessor) {
2425
instance.handleItemAction(
2526
listAccessor.getOrderedList()[e.detail.index].element,
2627
);
2728
}
28-
$$restProps.onSMUIListAction?.(e);
29-
}}><slot /></MenuSurface
29+
restProps.onSMUIListAction?.(e);
30+
}}
31+
>{#if children}{@render children()}{/if}</MenuSurface
3032
>
3133

3234
<script lang="ts">
3335
import { MDCMenuFoundation, cssClasses } from '@material/menu';
3436
import { ponyfill } from '@material/dom';
35-
import type { ComponentProps } from 'svelte';
37+
import type { ComponentProps, Snippet } from 'svelte';
3638
import { onMount, getContext, setContext } from 'svelte';
3739
import type { ActionArray } from '@smui/common/internal';
3840
import { classMap, dispatch } from '@smui/common/internal';
@@ -45,22 +47,35 @@
4547
const { closest } = ponyfill;
4648
4749
type OwnProps = {
50+
/**
51+
* An array of Action or [Action, ActionProps] to be applied to the element.
52+
*/
4853
use?: ActionArray;
54+
/**
55+
* A space separated list of CSS classes.
56+
*/
4957
class?: string;
58+
/**
59+
* Whether the menu is open.
60+
*/
5061
open?: boolean;
51-
};
52-
type $$Props = OwnProps &
53-
Omit<ComponentProps<typeof MenuSurface>, keyof OwnProps>;
5462
55-
export let use: ActionArray = [];
56-
let className = '';
57-
export { className as class };
58-
export let open = false;
63+
children?: Snippet;
64+
};
65+
let {
66+
use = [],
67+
class: className = '',
68+
open = $bindable(false),
69+
anchorElement = $bindable(),
70+
children,
71+
...restProps
72+
}: OwnProps &
73+
Omit<ComponentProps<typeof MenuSurface>, keyof OwnProps> = $props();
5974
6075
let element: MenuSurface;
61-
let instance: MDCMenuFoundation;
62-
let menuSurfaceAccessor: SMUIMenuSurfaceAccessor;
63-
let listAccessor: SMUIListAccessor;
76+
let instance: MDCMenuFoundation | undefined = $state();
77+
let menuSurfaceAccessor: SMUIMenuSurfaceAccessor | undefined = $state();
78+
let listAccessor: SMUIListAccessor | undefined = $state();
6479
6580
setContext('SMUI:menu-surface:mount', (accessor: SMUIMenuSurfaceAccessor) => {
6681
if (!menuSurfaceAccessor) {
@@ -87,46 +102,92 @@
87102
onMount(() => {
88103
instance = new MDCMenuFoundation({
89104
addClassToElementAtIndex: (index, className) => {
105+
if (listAccessor == null) {
106+
throw new Error('List accessor is undefined.');
107+
}
90108
listAccessor.addClassForElementIndex(index, className);
91109
},
92110
removeClassFromElementAtIndex: (index, className) => {
111+
if (listAccessor == null) {
112+
throw new Error('List accessor is undefined.');
113+
}
93114
listAccessor.removeClassForElementIndex(index, className);
94115
},
95116
addAttributeToElementAtIndex: (index, attr, value) => {
117+
if (listAccessor == null) {
118+
throw new Error('List accessor is undefined.');
119+
}
96120
listAccessor.setAttributeForElementIndex(index, attr, value);
97121
},
98122
removeAttributeFromElementAtIndex: (index, attr) => {
123+
if (listAccessor == null) {
124+
throw new Error('List accessor is undefined.');
125+
}
99126
listAccessor.removeAttributeForElementIndex(index, attr);
100127
},
101-
getAttributeFromElementAtIndex: (index, attr) =>
102-
listAccessor.getAttributeFromElementIndex(index, attr),
128+
getAttributeFromElementAtIndex: (index, attr) => {
129+
if (listAccessor == null) {
130+
throw new Error('List accessor is undefined.');
131+
}
132+
return listAccessor.getAttributeFromElementIndex(index, attr);
133+
},
103134
elementContainsClass: (element, className) =>
104135
element.classList.contains(className),
105136
closeSurface: (skipRestoreFocus) => {
106-
menuSurfaceAccessor.closeProgrammatic(skipRestoreFocus);
137+
menuSurfaceAccessor?.closeProgrammatic(skipRestoreFocus);
107138
dispatch(getElement(), 'SMUIMenuClosedProgrammatically');
108139
},
109-
getElementIndex: (element) =>
110-
listAccessor
140+
getElementIndex: (element) => {
141+
if (listAccessor == null) {
142+
throw new Error('List accessor is undefined.');
143+
}
144+
return listAccessor
111145
.getOrderedList()
112146
.map((accessor) => accessor.element)
113-
.indexOf(element),
114-
notifySelected: (evtData) =>
147+
.indexOf(element);
148+
},
149+
notifySelected: (evtData) => {
150+
if (listAccessor == null) {
151+
throw new Error('List accessor is undefined.');
152+
}
115153
dispatch(getElement(), 'SMUIMenuSelected', {
116154
index: evtData.index,
117155
item: listAccessor.getOrderedList()[evtData.index].element,
118-
}),
119-
getMenuItemCount: () => listAccessor.items.length,
120-
focusItemAtIndex: (index) => listAccessor.focusItemAtIndex(index),
121-
focusListRoot: () =>
122-
'focus' in listAccessor.element &&
123-
(listAccessor.element as HTMLInputElement).focus(),
124-
isSelectableItemAtIndex: (index) =>
125-
!!closest(
156+
});
157+
},
158+
getMenuItemCount: () => {
159+
if (listAccessor == null) {
160+
throw new Error('List accessor is undefined.');
161+
}
162+
return listAccessor.items.length;
163+
},
164+
focusItemAtIndex: (index) => {
165+
if (listAccessor == null) {
166+
throw new Error('List accessor is undefined.');
167+
}
168+
listAccessor.focusItemAtIndex(index);
169+
},
170+
focusListRoot: () => {
171+
if (listAccessor == null) {
172+
throw new Error('List accessor is undefined.');
173+
}
174+
if ('focus' in listAccessor.element) {
175+
(listAccessor.element as HTMLInputElement).focus();
176+
}
177+
},
178+
isSelectableItemAtIndex: (index) => {
179+
if (listAccessor == null) {
180+
throw new Error('List accessor is undefined.');
181+
}
182+
return !!closest(
126183
listAccessor.getOrderedList()[index].element,
127184
`.${cssClasses.MENU_SELECTION_GROUP}`,
128-
),
185+
);
186+
},
129187
getSelectedSiblingOfItemAtIndex: (index) => {
188+
if (listAccessor == null) {
189+
throw new Error('List accessor is undefined.');
190+
}
130191
const orderedList = listAccessor.getOrderedList();
131192
const selectionGroupEl = closest(
132193
orderedList[index].element,
@@ -146,9 +207,11 @@
146207
instance.init();
147208
148209
return () => {
149-
SMUIMenuUnmount && SMUIMenuUnmount(instance);
210+
if (SMUIMenuUnmount && instance) {
211+
SMUIMenuUnmount(instance);
212+
}
150213
151-
instance.destroy();
214+
instance?.destroy();
152215
};
153216
});
154217
@@ -165,10 +228,16 @@
165228
}
166229
167230
export function setDefaultFocusState(focusState: DefaultFocusState) {
231+
if (instance == null) {
232+
throw new Error('Instance is undefined.');
233+
}
168234
instance.setDefaultFocusState(focusState);
169235
}
170236
171237
export function getSelectedIndex() {
238+
if (instance == null) {
239+
throw new Error('Instance is undefined.');
240+
}
172241
return instance.getSelectedIndex();
173242
}
174243

packages/menu/src/SelectionGroup.svelte

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,20 @@
1-
<svelte:options runes={false} />
1+
<svelte:options runes={true} />
22

3-
<li
4-
bind:this={element}
5-
use:useActions={use}
6-
{...exclude($$restProps, ['list$'])}
7-
>
3+
<li bind:this={element} use:useActions={use} {...exclude(restProps, ['list$'])}>
84
<ul
95
use:useActions={list$use}
106
class={classMap({
117
[list$class]: true,
128
'mdc-menu__selection-group': true,
139
})}
14-
{...prefixFilter($$restProps, 'list$')}
10+
{...prefixFilter(restProps, 'list$')}
1511
>
16-
<slot />
12+
{#if children}{@render children()}{/if}
1713
</ul>
1814
</li>
1915

2016
<script lang="ts">
17+
import type { Snippet } from 'svelte';
2118
import { setContext } from 'svelte';
2219
import type { SmuiAttrs, SmuiElementPropMap } from '@smui/common';
2320
import type { ActionArray } from '@smui/common/internal';
@@ -29,19 +26,31 @@
2926
} from '@smui/common/internal';
3027
3128
type OwnProps = {
29+
/**
30+
* An array of Action or [Action, ActionProps] to be applied to the element.
31+
*/
3232
use?: ActionArray;
33+
/**
34+
* An array of Action or [Action, ActionProps] to be applied to the element.
35+
*/
3336
list$use?: ActionArray;
37+
/**
38+
* A space separated list of CSS classes.
39+
*/
3440
list$class?: string;
41+
42+
children?: Snippet;
3543
};
36-
type $$Props = OwnProps &
44+
let {
45+
use = [],
46+
list$use = [],
47+
list$class = '',
48+
children,
49+
...restProps
50+
}: OwnProps &
3751
SmuiAttrs<'li', keyof OwnProps> & {
3852
[k in keyof SmuiElementPropMap['ul'] as `list\$${k}`]?: SmuiElementPropMap['ul'][k];
39-
};
40-
41-
// Remember to update $$Props if you add/remove/rename props.
42-
export let use: ActionArray = [];
43-
export let list$use: ActionArray = [];
44-
export let list$class = '';
53+
} = $props();
4554
4655
let element: HTMLLIElement;
4756

packages/site/src/routes/demo/menu/_Anchored.svelte

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
<svelte:options runes={false} />
2-
31
<div style="min-width: 100px;">
42
<Button onclick={() => menu.setOpen(true)}>
53
<Label>Open Menu</Label>
@@ -31,5 +29,5 @@
3129
import Button, { Label } from '@smui/button';
3230
3331
let menu: Menu;
34-
let clicked = 'nothing yet';
32+
let clicked = $state('nothing yet');
3533
</script>

packages/site/src/routes/demo/menu/_Portal.svelte

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
<svelte:options runes={false} />
2-
31
<!--
42
Note: This is a very hacky way of creating a sub-menu that has a lot of
53
downsides. Don't use this in production, it's meant to show off using a
@@ -64,7 +62,7 @@
6462
<Menu
6563
bind:this={subMenu}
6664
anchor={false}
67-
bind:anchorElement
65+
{anchorElement}
6866
anchorCorner="TOP_END"
6967
>
7068
<List>
@@ -104,9 +102,9 @@
104102
let menu: Menu;
105103
let subMenu: Menu;
106104
let anchor: Item;
107-
let anchorElement: HTMLElement;
108-
let anchorClasses: { [k: string]: boolean } = {};
109-
let clicked = 'nothing yet';
105+
let anchorElement: HTMLElement | undefined = $state();
106+
let anchorClasses: { [k: string]: boolean } = $state({});
107+
let clicked = $state('nothing yet');
110108
111109
function addClass(className: string) {
112110
if (!anchorClasses[className]) {
@@ -183,7 +181,10 @@
183181
}
184182
});
185183
subMenuElement.addEventListener('mouseleave', (event) => {
186-
if (!contains(anchorElement, event.relatedTarget as HTMLElement)) {
184+
if (
185+
anchorElement &&
186+
!contains(anchorElement, event.relatedTarget as HTMLElement)
187+
) {
187188
subMenu.setOpen(false);
188189
}
189190
});

0 commit comments

Comments
 (0)