Skip to content

Commit b9cfbe1

Browse files
refactor(nav): use M3eMaterialIcon for rail items; keep Theme.Icon elsewhere
Navbar and NavItem icon slots now render Material-only M3eMaterialIcon (Lit name sync for React 19), not Theme.Icon sprite dispatch. Theme.Icon delegates its Material branch to M3eMaterialIcon for one sync implementation. Made-with: Cursor
1 parent b34a506 commit b9cfbe1

6 files changed

Lines changed: 41 additions & 23 deletions

File tree

src/features/navigation/components/navbar/NavbarItems.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useNavigate } from '@tanstack/react-router'
22
import { Fragment, type ReactNode } from 'react'
3-
import { Theme } from '@/features/theme/components'
3+
import { M3eMaterialIcon } from '@/features/theme/components/icon/M3eMaterialIcon'
44
import { Nav } from '@/features/theme/components/nav/Nav'
55
import type { NavItemProps } from '@/features/theme/components/nav/NavItem'
66
import type { NavItemModel } from '../../models/NavItemModel'
@@ -24,7 +24,7 @@ const Item = ({ model, selected, ...props }: ItemProps) => {
2424
onClick={(event) => model.onClick(event.nativeEvent, { navigate, onActivate })}
2525
{...model.toAnchorAttrs()}
2626
>
27-
<Theme.Icon slot="icon" name={model.icon} aria-label={model.id} />
27+
<M3eMaterialIcon slot="icon" name={model.icon} aria-label={model.id} />
2828
</Nav.Item>
2929
)
3030
}

src/features/theme/components/icon/AGENTS.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,20 @@ The renderer choice (Material vs sprite) is an implementation detail and must st
3030
## Internal dispatch pattern
3131

3232
- `Icon.tsx` receives `name`.
33-
- `registry.ts` decides whether the name is a sprite icon.
33+
- `registry.ts` decides whether the name is a sprite icon; ids must match `public/icons.svg` (see `__tests__/registry.test.ts`).
3434
- If sprite: render `<svg><use href="...#id" /></svg>`.
35-
- Otherwise: render `<M3eIcon name={...} />` (with a `useLayoutEffect` sync for React 19 / Lit).
35+
- Otherwise: render `M3eMaterialIcon` (wraps `M3eIcon` with Lit `name` sync for React 19).
3636

37-
- `registry.ts` lists sprite symbol ids (`SPRITE_ICON_IDS`) that must match `<symbol id="…">` in `public/icons.svg` (see `__tests__/registry.test.ts`).
37+
## M3E navigation (nav rail / nav bar)
38+
39+
- **`NavbarItems`** and **`NavItem`** (`icon` prop) use **`M3eMaterialIcon`** only — Material Symbols, no sprite registry. Consumers of the navbar should keep `model.icon` / `icon` as Material icon names.
3840

3941
---
4042

4143
## Rules
4244

43-
- Keep `Theme.Icon` as the only semantic API.
44-
- Never branch icon renderer in consumer components.
45+
- Keep **`Theme.Icon`** as the only **public** icon API for pages, cards, and buttons.
46+
- Never branch icon renderer in those consumers.
4547
- Never reintroduce `Icon.Svg` as public API.
4648
- When you add a symbol to `public/icons.svg`, append its id to `SPRITE_ICON_IDS` in `registry.ts`.
49+
- Do not use `Theme.Icon` inside **`NavbarItems`** / nav item icon slots until we add explicit sprite support there.

src/features/theme/components/icon/Icon.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { M3eIcon } from '@m3e/react/icon'
1+
import type { M3eIcon } from '@m3e/react/icon'
22
import type { ComponentProps } from 'react'
3-
import { useLayoutEffect, useRef } from 'react'
3+
import { M3eMaterialIcon } from './M3eMaterialIcon'
44
import { isSpriteIcon } from './registry'
55

66
export type ThemeIconProps = Omit<ComponentProps<typeof M3eIcon>, 'name'> & {
@@ -15,16 +15,6 @@ export const ThemeIcon = ({
1515
color,
1616
...props
1717
}: ThemeIconProps) => {
18-
const materialRef = useRef<HTMLElement | null>(null)
19-
20-
useLayoutEffect(() => {
21-
const el = materialRef.current
22-
if (!el) return
23-
// `m3e-icon` paints from the Lit `name` property inside its shadow tree, not from light-DOM children.
24-
// With React 19, @lit/react can leave `name` unset on the element; sync after mount/updates.
25-
Object.assign(el, { name })
26-
}, [name])
27-
2818
if (isSpriteIcon(name)) {
2919
const href = `${spriteHref}#${name}`
3020
return (
@@ -42,5 +32,5 @@ export const ThemeIcon = ({
4232
)
4333
}
4434

45-
return <M3eIcon ref={materialRef} {...props} name={name} />
35+
return <M3eMaterialIcon {...props} name={name} />
4636
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { M3eIcon } from '@m3e/react/icon'
2+
import type { ComponentProps } from 'react'
3+
import { useLayoutEffect, useRef } from 'react'
4+
5+
/**
6+
* Material-only icon for M3E nav slots. Bypasses `Theme.Icon` sprite dispatch.
7+
*
8+
* `m3e-icon` paints from the Lit `name` property in shadow DOM; @lit/react can
9+
* leave it unset with React 19 — sync after mount/update.
10+
*/
11+
export type M3eMaterialIconProps = Omit<ComponentProps<typeof M3eIcon>, 'name'> & {
12+
name: string
13+
}
14+
15+
export const M3eMaterialIcon = ({ name, ...props }: M3eMaterialIconProps) => {
16+
const ref = useRef<HTMLElement | null>(null)
17+
18+
useLayoutEffect(() => {
19+
const el = ref.current
20+
if (!el) return
21+
Object.assign(el, { name })
22+
}, [name])
23+
24+
return <M3eIcon ref={ref} {...props} name={name} />
25+
}

src/features/theme/components/nav/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export const NavItem = ({ children, className, ...props }: Props) => {
188188

189189
return (
190190
<M3eNavItem {...props} selected={isSelected} ref={m3eNavItemRef} onChange={onChangeHandler} className={resolvedClassName}>
191-
{props.icon && <Theme.Icon slot="icon" name={props.icon} />}
191+
{props.icon && <M3eMaterialIcon slot="icon" name={props.icon} />}
192192
{children}
193193
</M3eNavItem>
194194
)

src/features/theme/components/nav/NavItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { M3eNavItem } from '@m3e/react/nav-bar'
22
import type { ComponentProps, PropsWithChildren } from 'react'
33
import { useNavItemController } from '../../hooks/useNavItemController'
4-
import { ThemeIcon } from '../icon/Icon'
4+
import { M3eMaterialIcon } from '../icon/M3eMaterialIcon'
55
import styles from './nav.module.css'
66

77
export type NavItemProps = PropsWithChildren &
@@ -22,7 +22,7 @@ export const NavItem = ({ children, className, ...props }: NavItemProps) => {
2222
onChange={onChangeHandler}
2323
className={resolvedClassName}
2424
>
25-
{props.icon && <ThemeIcon slot="icon" name={props.icon} />}
25+
{props.icon && <M3eMaterialIcon slot="icon" name={props.icon} />}
2626
{children}
2727
</M3eNavItem>
2828
)

0 commit comments

Comments
 (0)