@hanzo/ui 8.0.5 → 8.0.7

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 (39) hide show
  1. package/package.json +5 -1
  2. package/src/product/AppHeader.tsx +34 -26
  3. package/src/product/ComboBox.tsx +49 -69
  4. package/src/product/SelectMenu.tsx +23 -37
  5. package/src/product/ThemeToggle.tsx +121 -11
  6. package/src/product/ThemeToggleNext.tsx +27 -0
  7. package/src/product/index.ts +8 -0
  8. package/src/product/menu/ContextMenu.tsx +114 -0
  9. package/src/product/menu/DropdownMenu.tsx +76 -0
  10. package/src/product/menu/index.ts +15 -0
  11. package/src/product/menu/items.tsx +230 -0
  12. package/src/product/menu/portal-theme.tsx +33 -0
  13. package/src/product/menu/roving.ts +34 -0
  14. package/src/product/surfaces.data.ts +42 -0
  15. package/types/product/AppHeader.d.ts +5 -11
  16. package/types/product/AppHeader.d.ts.map +1 -1
  17. package/types/product/ComboBox.d.ts.map +1 -1
  18. package/types/product/SelectMenu.d.ts +8 -5
  19. package/types/product/SelectMenu.d.ts.map +1 -1
  20. package/types/product/ThemeToggle.d.ts +21 -1
  21. package/types/product/ThemeToggle.d.ts.map +1 -1
  22. package/types/product/ThemeToggleNext.d.ts +5 -0
  23. package/types/product/ThemeToggleNext.d.ts.map +1 -0
  24. package/types/product/index.d.ts +3 -0
  25. package/types/product/index.d.ts.map +1 -1
  26. package/types/product/menu/ContextMenu.d.ts +24 -0
  27. package/types/product/menu/ContextMenu.d.ts.map +1 -0
  28. package/types/product/menu/DropdownMenu.d.ts +34 -0
  29. package/types/product/menu/DropdownMenu.d.ts.map +1 -0
  30. package/types/product/menu/index.d.ts +5 -0
  31. package/types/product/menu/index.d.ts.map +1 -0
  32. package/types/product/menu/items.d.ts +69 -0
  33. package/types/product/menu/items.d.ts.map +1 -0
  34. package/types/product/menu/portal-theme.d.ts +29 -0
  35. package/types/product/menu/portal-theme.d.ts.map +1 -0
  36. package/types/product/menu/roving.d.ts +9 -0
  37. package/types/product/menu/roving.d.ts.map +1 -0
  38. package/types/product/surfaces.data.d.ts +17 -0
  39. package/types/product/surfaces.data.d.ts.map +1 -0
@@ -0,0 +1,114 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * ContextMenu — a right-click menu that renders the SAME shared item spec as
5
+ * DropdownMenu, so every menu in the fleet is identical. Built on the @hanzo/gui
6
+ * Portal with the SAME portal-theme fix as the dropdown: the menu is portaled to the
7
+ * root, escaping any nested `<Theme>`, so it re-applies the captured theme via
8
+ * <PortalTheme>. Web/desktop right-click (`onContextMenu`); on native — which has no
9
+ * right-click — the wrapped child renders untouched.
10
+ *
11
+ * <ContextMenu items={[{ key:'copy', label:'Copy', icon:<Copy size={16}/>, onSelect:copy }]}>
12
+ * <YStack>right-click me</YStack>
13
+ * </ContextMenu>
14
+ */
15
+ import type { ReactElement, MouseEvent } from 'react'
16
+ import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
17
+ import { Portal } from '@hanzo/gui'
18
+ import { MenuPanel, renderMenuItems, type MenuItemSpec } from './items'
19
+ import { PortalTheme, useThemeName } from './portal-theme'
20
+ import { menuKeyDown } from './roving'
21
+
22
+ const EDGE = 8 // keep this far from the viewport edge
23
+
24
+ export type ContextMenuProps = {
25
+ /** The right-clickable target. Its `onContextMenu` is composed, not replaced. */
26
+ children: ReactElement
27
+ items: MenuItemSpec[]
28
+ disabled?: boolean
29
+ minWidth?: number
30
+ maxHeight?: number
31
+ }
32
+
33
+ export function ContextMenu({ children, items, disabled, minWidth = 200, maxHeight }: ContextMenuProps) {
34
+ const themeName = useThemeName()
35
+ const [state, setState] = useState<{ open: boolean; x: number; y: number }>({ open: false, x: 0, y: 0 })
36
+ const panelRef = useRef<HTMLElement | null>(null)
37
+
38
+ const close = useCallback(() => setState((s) => (s.open ? { ...s, open: false } : s)), [])
39
+
40
+ const openAt = (e: MouseEvent) => {
41
+ if (disabled) return
42
+ e.preventDefault()
43
+ e.stopPropagation()
44
+ setState({ open: true, x: e.clientX, y: e.clientY })
45
+ }
46
+
47
+ // Edge-flip before paint: clamp so the panel stays fully in the viewport.
48
+ useLayoutEffect(() => {
49
+ if (!state.open || typeof window === 'undefined') return
50
+ const node = panelRef.current
51
+ if (!node) return
52
+ const r = node.getBoundingClientRect()
53
+ let x = state.x
54
+ let y = state.y
55
+ if (x + r.width > window.innerWidth - EDGE) x = Math.max(EDGE, window.innerWidth - r.width - EDGE)
56
+ if (y + r.height > window.innerHeight - EDGE) y = Math.max(EDGE, window.innerHeight - r.height - EDGE)
57
+ if (x !== state.x || y !== state.y) setState((s) => ({ ...s, x, y }))
58
+ // Focus the panel so keyboard nav works immediately.
59
+ node.focus?.()
60
+ }, [state.open, state.x, state.y])
61
+
62
+ // Dismiss on outside pointer, Escape, scroll, resize, blur.
63
+ useEffect(() => {
64
+ if (!state.open || typeof document === 'undefined') return
65
+ const onPointerDown = (e: PointerEvent) => {
66
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) close()
67
+ }
68
+ const onKey = (e: KeyboardEvent) => {
69
+ if (e.key === 'Escape') close()
70
+ }
71
+ document.addEventListener('pointerdown', onPointerDown, true)
72
+ document.addEventListener('keydown', onKey, true)
73
+ window.addEventListener('scroll', close, true)
74
+ window.addEventListener('resize', close)
75
+ window.addEventListener('blur', close)
76
+ return () => {
77
+ document.removeEventListener('pointerdown', onPointerDown, true)
78
+ document.removeEventListener('keydown', onKey, true)
79
+ window.removeEventListener('scroll', close, true)
80
+ window.removeEventListener('resize', close)
81
+ window.removeEventListener('blur', close)
82
+ }
83
+ }, [state.open, close])
84
+
85
+ const childOnContextMenu = (children.props as { onContextMenu?: (e: MouseEvent) => void }).onContextMenu
86
+ const target = cloneElement(children, {
87
+ onContextMenu: (e: MouseEvent) => {
88
+ childOnContextMenu?.(e)
89
+ openAt(e)
90
+ },
91
+ } as Partial<typeof children.props>)
92
+
93
+ return (
94
+ <>
95
+ {target}
96
+ {state.open ? (
97
+ <Portal>
98
+ <PortalTheme name={themeName}>
99
+ <MenuPanel
100
+ panelRef={(n) => (panelRef.current = n)}
101
+ minWidth={minWidth}
102
+ maxHeight={maxHeight}
103
+ tabIndex={-1}
104
+ onKeyDown={(e) => menuKeyDown(e, close)}
105
+ style={{ position: 'fixed', left: state.x, top: state.y, zIndex: 100000 }}
106
+ >
107
+ {renderMenuItems(items, close)}
108
+ </MenuPanel>
109
+ </PortalTheme>
110
+ </Portal>
111
+ ) : null}
112
+ </>
113
+ )
114
+ }
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * DropdownMenu — a portal-theme-safe dropdown on the @hanzo/gui Popover (the proven
5
+ * console idiom, same shell as SelectMenu/ComboBox). Declarative `items`; renders the
6
+ * ONE shared menu-item spec so it is pixel-identical to every other menu. Opens under
7
+ * a nested `<Theme>` correctly because the portaled content re-applies the captured
8
+ * theme (see PortalTheme).
9
+ *
10
+ * <DropdownMenu
11
+ * trigger={<Button>Actions</Button>}
12
+ * items={[
13
+ * { key: 'rename', label: 'Rename', icon: <Pencil size={16} />, onSelect: rename },
14
+ * { type: 'separator' },
15
+ * { key: 'del', label: 'Delete', destructive: true, onSelect: del },
16
+ * ]}
17
+ * />
18
+ */
19
+ import type { ReactElement } from 'react'
20
+ import { Popover, useControllableState } from '@hanzo/gui'
21
+ import { MenuPanel, renderMenuItems, type MenuItemSpec } from './items'
22
+ import { PortalTheme, useThemeName } from './portal-theme'
23
+ import { menuKeyDown } from './roving'
24
+
25
+ export type DropdownMenuProps = {
26
+ /** The clickable element. Cloned as the Popover trigger (`asChild`). */
27
+ trigger: ReactElement
28
+ items: MenuItemSpec[]
29
+ /** Controlled open state. Omit for uncontrolled. */
30
+ open?: boolean
31
+ defaultOpen?: boolean
32
+ onOpenChange?: (open: boolean) => void
33
+ /** Popover placement (default `bottom-start`). */
34
+ placement?: React.ComponentProps<typeof Popover>['placement']
35
+ minWidth?: number
36
+ maxHeight?: number
37
+ }
38
+
39
+ export function DropdownMenu({
40
+ trigger,
41
+ items,
42
+ open,
43
+ defaultOpen = false,
44
+ onOpenChange,
45
+ placement = 'bottom-start',
46
+ minWidth = 200,
47
+ maxHeight,
48
+ }: DropdownMenuProps) {
49
+ const [isOpen, setOpen] = useControllableState({
50
+ prop: open,
51
+ defaultProp: defaultOpen,
52
+ onChange: onOpenChange,
53
+ })
54
+ // Capture the resolved theme HERE, where theme context is still available; the
55
+ // Popover.Content portals out of it and re-applies it via <PortalTheme>.
56
+ const themeName = useThemeName()
57
+
58
+ return (
59
+ <Popover open={isOpen} onOpenChange={setOpen} placement={placement} allowFlip>
60
+ <Popover.Trigger asChild>{trigger}</Popover.Trigger>
61
+ <Popover.Content
62
+ // The visible surface is our MenuPanel — keep Content a transparent shell.
63
+ bg="transparent"
64
+ borderWidth={0}
65
+ p={0}
66
+ elevation={0}
67
+ >
68
+ <PortalTheme name={themeName}>
69
+ <MenuPanel minWidth={minWidth} maxHeight={maxHeight} onKeyDown={(e) => menuKeyDown(e, () => setOpen(false))}>
70
+ {renderMenuItems(items, () => setOpen(false))}
71
+ </MenuPanel>
72
+ </PortalTheme>
73
+ </Popover.Content>
74
+ </Popover>
75
+ )
76
+ }
@@ -0,0 +1,15 @@
1
+ // @hanzo/ui/product menu — ONE menu system. Portal-theme-safe DropdownMenu (click)
2
+ // and ContextMenu (right-click) both render the SAME item spec, so every menu across
3
+ // the fleet is pixel-identical. See items.tsx for the shared spec.
4
+
5
+ export { DropdownMenu, type DropdownMenuProps } from './DropdownMenu'
6
+ export { ContextMenu, type ContextMenuProps } from './ContextMenu'
7
+ export {
8
+ MenuPanel,
9
+ MenuItemView,
10
+ MenuSeparatorView,
11
+ MenuLabelView,
12
+ renderMenuItems,
13
+ type MenuItemSpec,
14
+ } from './items'
15
+ export { PortalTheme } from './portal-theme'
@@ -0,0 +1,230 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * The ONE menu-item spec — every menu across the fleet renders through these
5
+ * presentational primitives, so DropdownMenu, ContextMenu, SelectMenu and ComboBox
6
+ * are pixel-identical. Geometry is literal px on a strict 8-grid (never random);
7
+ * colour is theme-adaptive tokens ($color2/$color11/$color12/$borderColor) so light
8
+ * and dark both work, with the single purple accent supplied by the brand CSS vars.
9
+ *
10
+ * Style props use the @hanzo/gui config shorthands (bg/items/justify/px/py/p/mx/my/
11
+ * minW/maxH/rounded/select/shrink) — the config omits the longhand aliases.
12
+ *
13
+ * Panel — bg $color2 (#111 dark), hairline border, radius 12, inner pad 4, subtle
14
+ * border+ambient elevation.
15
+ * Item — height 30 (28–32 band), px 8, gap 8, radius 7; icon in a fixed 16px slot
16
+ * on the left; label 13px $color12; right affordance (shortcut / check /
17
+ * chevron) right-aligned. States: hover/active/focus → accent-soft, focus
18
+ * visible; selected → check + purple; disabled → muted, no hover.
19
+ * Separator — 1px hairline, 4px vertical margin.
20
+ * Label — 11px uppercase muted section header, px 8.
21
+ */
22
+ import type { ReactNode, KeyboardEvent } from 'react'
23
+ import { Text, XStack, YStack } from '@hanzo/gui'
24
+ import { Check, ChevronRight } from '@hanzogui/lucide-icons-2'
25
+
26
+ // ── Geometry — literal px, 8-grid ──────────────────────────────────────────────
27
+ const ITEM_MIN_HEIGHT = 30
28
+ const ITEM_RADIUS = 7
29
+ const ITEM_PX = 8
30
+ const ITEM_GAP = 8
31
+ const ICON_SLOT = 16
32
+ export const PANEL_RADIUS = 12
33
+ export const PANEL_PAD = 4
34
+ const PANEL_GAP = 2
35
+ const SEP_MARGIN = 4
36
+ const FONT_LABEL = 13
37
+ const FONT_MUTED = 11
38
+ const FONT_SHORTCUT = 12
39
+
40
+ // ── Colour — brand purple accent via CSS vars (fallbacks keep it correct even when
41
+ // @hanzo/brand is not loaded, e.g. a bare Vite/Tauri host); everything else uses
42
+ // theme-adaptive Tamagui tokens. ──────────────────────────────────────────────
43
+ const ACCENT = 'var(--hanzo-accent, #8b5cf6)'
44
+ const ACCENT_SOFT = 'var(--hanzo-accent-soft, rgba(139,92,246,0.16))'
45
+ const DANGER = 'var(--hanzo-danger, #ef4444)'
46
+
47
+ // ── Declarative item model ──────────────────────────────────────────────────────
48
+ export type MenuItemSpec =
49
+ | {
50
+ type?: 'item'
51
+ /** Stable key. */
52
+ key: string
53
+ label: string
54
+ /** Leading Lucide icon (16px, unfilled) in the fixed left slot. */
55
+ icon?: ReactNode
56
+ /** Second muted line under the label (e.g. an id or hint). */
57
+ description?: string
58
+ /** Right-aligned shortcut / hint text (e.g. "⌘K"). */
59
+ shortcut?: string
60
+ /** Renders a right check + purple tint. */
61
+ selected?: boolean
62
+ disabled?: boolean
63
+ /** Danger styling (red label/icon). */
64
+ destructive?: boolean
65
+ /** Renders a right chevron (submenu / drill-in affordance). */
66
+ hasSubmenu?: boolean
67
+ onSelect: () => void
68
+ /** Keep the menu open after selecting (default: close). */
69
+ closeOnSelect?: boolean
70
+ }
71
+ | { type: 'separator'; key?: string }
72
+ | { type: 'label'; key?: string; label: string }
73
+
74
+ // ── Surface ───────────────────────────────────────────────────────────────────
75
+ export function MenuPanel({
76
+ children,
77
+ minWidth = 200,
78
+ maxHeight = 360,
79
+ onKeyDown,
80
+ panelRef,
81
+ ...rest
82
+ }: {
83
+ children: ReactNode
84
+ minWidth?: number
85
+ maxHeight?: number
86
+ onKeyDown?: (e: KeyboardEvent) => void
87
+ /** Web DOM node of the panel (for measuring / edge-flip). */
88
+ panelRef?: (node: HTMLElement | null) => void
89
+ [key: string]: unknown
90
+ }) {
91
+ return (
92
+ <YStack
93
+ // Web DOM ref; Gui forwards it to the underlying node. Cast (not @ts-expect-error)
94
+ // so it is env-agnostic — the ref type is strict under the pkg build, loose here.
95
+ ref={panelRef as never}
96
+ role="menu"
97
+ bg="$color2"
98
+ borderColor="$borderColor"
99
+ borderWidth={1}
100
+ rounded={PANEL_RADIUS}
101
+ p={PANEL_PAD}
102
+ gap={PANEL_GAP}
103
+ minW={minWidth}
104
+ maxH={maxHeight}
105
+ overflow="scroll"
106
+ // Subtle border + ambient elevation, minimal shadow.
107
+ shadowColor="rgba(0,0,0,0.45)"
108
+ shadowRadius={20}
109
+ shadowOffset={{ width: 0, height: 10 }}
110
+ onKeyDown={onKeyDown as never}
111
+ {...rest}
112
+ >
113
+ {children}
114
+ </YStack>
115
+ )
116
+ }
117
+
118
+ // ── Item — the ONE row ──────────────────────────────────────────────────────────
119
+ export function MenuItemView({
120
+ icon,
121
+ label,
122
+ description,
123
+ shortcut,
124
+ selected = false,
125
+ disabled = false,
126
+ destructive = false,
127
+ hasSubmenu = false,
128
+ onSelect,
129
+ }: Omit<Extract<MenuItemSpec, { type?: 'item' }>, 'key' | 'type' | 'closeOnSelect'>) {
130
+ const press = () => {
131
+ if (!disabled) onSelect()
132
+ }
133
+ const onKeyDown = (e: KeyboardEvent) => {
134
+ if (disabled) return
135
+ if (e.key === 'Enter' || e.key === ' ') {
136
+ e.preventDefault()
137
+ onSelect()
138
+ }
139
+ }
140
+ const labelColor = destructive ? DANGER : '$color12'
141
+ return (
142
+ <XStack
143
+ role="menuitem"
144
+ tabIndex={disabled ? -1 : 0}
145
+ aria-disabled={disabled || undefined}
146
+ items="center"
147
+ gap={ITEM_GAP}
148
+ px={ITEM_PX}
149
+ minH={ITEM_MIN_HEIGHT}
150
+ rounded={ITEM_RADIUS}
151
+ cursor={disabled ? 'default' : 'pointer'}
152
+ opacity={disabled ? 0.4 : 1}
153
+ select="none"
154
+ style={{ outline: 'none' }}
155
+ hoverStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
156
+ pressStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
157
+ focusStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
158
+ onPress={press}
159
+ onKeyDown={onKeyDown as never}
160
+ >
161
+ <XStack width={ICON_SLOT} height={ICON_SLOT} items="center" justify="center" shrink={0}>
162
+ {icon ? (
163
+ <XStack items="center" justify="center" opacity={destructive ? 1 : 0.9} style={destructive ? { color: DANGER } : undefined}>
164
+ {icon}
165
+ </XStack>
166
+ ) : null}
167
+ </XStack>
168
+
169
+ <YStack flex={1} minW={0}>
170
+ <Text fontSize={FONT_LABEL} lineHeight={18} color={labelColor as never} numberOfLines={1}>
171
+ {label}
172
+ </Text>
173
+ {description ? (
174
+ <Text fontSize={FONT_MUTED} lineHeight={14} color="$color11" numberOfLines={1}>
175
+ {description}
176
+ </Text>
177
+ ) : null}
178
+ </YStack>
179
+
180
+ {shortcut ? (
181
+ <Text fontSize={FONT_SHORTCUT} color="$color11" shrink={0}>
182
+ {shortcut}
183
+ </Text>
184
+ ) : null}
185
+ {selected ? <Check size={14} color={ACCENT} /> : null}
186
+ {hasSubmenu ? <ChevronRight size={14} color="var(--hanzo-muted, currentColor)" opacity={0.6} /> : null}
187
+ </XStack>
188
+ )
189
+ }
190
+
191
+ // ── Separator ───────────────────────────────────────────────────────────────────
192
+ export function MenuSeparatorView() {
193
+ return <YStack height={1} bg="$borderColor" my={SEP_MARGIN} mx={SEP_MARGIN} role="separator" />
194
+ }
195
+
196
+ // ── Section label ────────────────────────────────────────────────────────────────
197
+ export function MenuLabelView({ children }: { children: ReactNode }) {
198
+ return (
199
+ <Text
200
+ fontSize={FONT_MUTED}
201
+ color="$color11"
202
+ px={ITEM_PX}
203
+ py={SEP_MARGIN}
204
+ textTransform="uppercase"
205
+ letterSpacing={0.4}
206
+ select="none"
207
+ >
208
+ {children}
209
+ </Text>
210
+ )
211
+ }
212
+
213
+ // ── The single render path shared by every menu ──────────────────────────────────
214
+ export function renderMenuItems(items: MenuItemSpec[], close?: () => void): ReactNode {
215
+ return items.map((it, i) => {
216
+ if (it.type === 'separator') return <MenuSeparatorView key={it.key ?? `sep-${i}`} />
217
+ if (it.type === 'label') return <MenuLabelView key={it.key ?? `lbl-${i}`}>{it.label}</MenuLabelView>
218
+ const { key, onSelect, closeOnSelect = true, ...rest } = it
219
+ return (
220
+ <MenuItemView
221
+ key={key}
222
+ {...rest}
223
+ onSelect={() => {
224
+ onSelect()
225
+ if (closeOnSelect) close?.()
226
+ }}
227
+ />
228
+ )
229
+ })
230
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * PortalTheme — the ONE fix for "themed content escapes its theme through a portal".
5
+ *
6
+ * @hanzo/gui's `Popover.Content` and `Portal` render their children in a SEPARATE
7
+ * React subtree (a Gorhom-style portal host mounted at the app root), NOT via
8
+ * `ReactDOM.createPortal`. React context therefore does NOT flow from the trigger's
9
+ * location to the host. Under a nested `<Theme name="dark">` the portaled content
10
+ * lands OUTSIDE that theme context and Tamagui throws "Missing theme" (or renders
11
+ * unthemed).
12
+ *
13
+ * The fix is two-part and must stay two-part:
14
+ * 1. At the TRIGGER site (theme context still available) capture the resolved
15
+ * theme name with `useThemeName()`.
16
+ * 2. INSIDE the portaled content re-apply it: `<PortalTheme name={captured}>`.
17
+ *
18
+ * Every portal-backed menu in this package (DropdownMenu, ContextMenu, SelectMenu,
19
+ * ComboBox) uses this so a menu renders identically whether it is opened under the
20
+ * root theme or under any nested `<Theme>`, light or dark.
21
+ */
22
+ import type { ReactNode } from 'react'
23
+ import { Theme, useThemeName } from '@hanzo/gui'
24
+
25
+ /** Capture the current resolved theme name at the trigger site. Re-exported so the
26
+ * capture + re-apply pair reads from one module. */
27
+ export { useThemeName }
28
+
29
+ export function PortalTheme({ name, children }: { name: string; children: ReactNode }) {
30
+ // `name` is the resolved theme name (e.g. "dark", or a compound "dark_purple").
31
+ // Tamagui's `Theme` accepts the resolved name and reconstructs the context.
32
+ return <Theme name={name as never}>{children}</Theme>
33
+ }
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Roving keyboard focus for a menu panel — ArrowUp/Down move focus between enabled
5
+ * `[role="menuitem"]` children, Home/End jump to ends, Escape closes. Web/desktop
6
+ * only (guards on `document`); native menus have no pointer-keyboard nav. Shared by
7
+ * DropdownMenu and ContextMenu so navigation is identical.
8
+ */
9
+ import type { KeyboardEvent } from 'react'
10
+
11
+ export function menuKeyDown(e: KeyboardEvent, onClose?: () => void): void {
12
+ if (e.key === 'Escape') {
13
+ onClose?.()
14
+ return
15
+ }
16
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return
17
+ if (typeof document === 'undefined') return
18
+
19
+ const panel = e.currentTarget as HTMLElement
20
+ const items = Array.from(
21
+ panel.querySelectorAll<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])'),
22
+ )
23
+ if (items.length === 0) return
24
+ e.preventDefault()
25
+
26
+ const active = document.activeElement as HTMLElement | null
27
+ const current = active ? items.indexOf(active) : -1
28
+ let next: number
29
+ if (e.key === 'Home') next = 0
30
+ else if (e.key === 'End') next = items.length - 1
31
+ else if (e.key === 'ArrowDown') next = current < 0 ? 0 : (current + 1) % items.length
32
+ else next = current <= 0 ? items.length - 1 : current - 1
33
+ items[next]?.focus()
34
+ }
@@ -0,0 +1,42 @@
1
+ // The ONE canonical list of Hanzo product surfaces the cross-surface app switcher
2
+ // offers — consumed by EVERY surface's launcher so the set never diverges.
3
+ //
4
+ // Framework-agnostic (plain TS, zero imports): React consumers (the @hanzo/ui
5
+ // `AppHeader`, the console `AppLauncher`) import it directly; a surface that
6
+ // cannot resolve THIS package — the Svelte Huly-fork `team` (whose own `@hanzo/ui`
7
+ // is the workbench UI framework, a name clash) and the shadcn-aliased `app` — keeps
8
+ // a byte-identical MIRROR that points back here. Add or reorder a surface HERE and
9
+ // every launcher moves together (mirrors are updated in the same change).
10
+ //
11
+ // `id` is the stable key AND the icon key: each framework maps it to its own glyph
12
+ // (React → lucide, Svelte → SurfaceIcon), so the DATA itself stays glyph-free.
13
+
14
+ export type SurfaceId = 'ai' | 'console' | 'app' | 'chat' | 'bot' | 'team' | 'billing'
15
+
16
+ /** One Hanzo surface the app switcher offers. */
17
+ export type Surface = {
18
+ /** Stable id; also the per-surface icon key. */
19
+ id: SurfaceId
20
+ /** Menu label. */
21
+ label: string
22
+ /** Absolute product URL (opened in a new tab). */
23
+ href: string
24
+ /** The domain, shown as the tile subtitle. */
25
+ hint: string
26
+ }
27
+
28
+ /** The seven Hanzo surfaces (`console` opens the cloud AI console). */
29
+ export const SURFACES: Surface[] = [
30
+ { id: 'ai', label: 'Hanzo AI', href: 'https://hanzo.ai', hint: 'hanzo.ai' },
31
+ { id: 'console', label: 'Console', href: 'https://console.hanzo.ai', hint: 'console.hanzo.ai' },
32
+ { id: 'app', label: 'App', href: 'https://hanzo.app', hint: 'hanzo.app' },
33
+ { id: 'chat', label: 'Chat', href: 'https://hanzo.chat', hint: 'hanzo.chat' },
34
+ { id: 'bot', label: 'Bot', href: 'https://hanzo.bot', hint: 'hanzo.bot' },
35
+ { id: 'team', label: 'Team', href: 'https://hanzo.team', hint: 'hanzo.team' },
36
+ { id: 'billing', label: 'Billing', href: 'https://billing.hanzo.ai', hint: 'billing.hanzo.ai' },
37
+ ]
38
+
39
+ /** Every surface except `current` — a launcher never links to itself. */
40
+ export function otherSurfaces(current?: SurfaceId): Surface[] {
41
+ return current ? SURFACES.filter((s) => s.id !== current) : SURFACES
42
+ }
@@ -10,15 +10,7 @@
10
10
  * flex + truncation, the wordmark collapse is the brand motion itself.
11
11
  */
12
12
  import { type ReactNode } from 'react';
13
- /** One Hanzo surface the app switcher offers. */
14
- export type Surface = {
15
- id: string;
16
- label: string;
17
- href: string;
18
- hint?: string;
19
- };
20
- /** The five Hanzo surfaces (cloud opens the console). */
21
- export declare const SURFACES: Surface[];
13
+ import { type Surface, type SurfaceId } from './surfaces.data';
22
14
  export type AppHeaderProps = {
23
15
  /** Brand slot — defaults to the animated `<BrandMark/>`. */
24
16
  brand?: ReactNode;
@@ -30,7 +22,9 @@ export type AppHeaderProps = {
30
22
  org?: ReactNode;
31
23
  /** Free slot between the org slot and the right cluster. */
32
24
  children?: ReactNode;
33
- /** App-switcher surfaces; [] hides the switcher. */
25
+ /** The surface this header renders on — omitted from the switcher (no self-link). */
26
+ current?: SurfaceId;
27
+ /** App-switcher surfaces; defaults to every surface but `current`. [] hides it. */
34
28
  surfaces?: Surface[];
35
29
  /** Open a surface — default `window.open` (new tab). */
36
30
  open?: (surface: Surface) => void;
@@ -45,5 +39,5 @@ export type AppHeaderProps = {
45
39
  onBilling?: () => void;
46
40
  onSignOut?: () => void;
47
41
  };
48
- export declare function AppHeader({ brand, wordmark, onBrand, org, children, surfaces, open, user, menu, theme, onProfile, onTeam, onBilling, onSignOut, }: AppHeaderProps): import("react/jsx-runtime").JSX.Element;
42
+ export declare function AppHeader({ brand, wordmark, onBrand, org, children, current, surfaces, open, user, menu, theme, onProfile, onTeam, onBilling, onSignOut, }: AppHeaderProps): import("react/jsx-runtime").JSX.Element;
49
43
  //# sourceMappingURL=AppHeader.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AppHeader.d.ts","sourceRoot":"","sources":["../../src/product/AppHeader.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAOhD,iDAAiD;AACjD,MAAM,MAAM,OAAO,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAEhF,yDAAyD;AACzD,eAAO,MAAM,QAAQ,EAAE,OAAO,EAM7B,CAAA;AAiBD,MAAM,MAAM,cAAc,GAAG;IAC3B,4DAA4D;IAC5D,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,0EAA0E;IAC1E,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAA;IACpB,wDAAwD;IACxD,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAA;IACjC,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,+CAA+C;IAC/C,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,wEAAwE;IACxE,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB,CAAA;AAED,wBAAgB,SAAS,CAAC,EACxB,KAAK,EACL,QAAkB,EAClB,OAAO,EACP,GAAG,EACH,QAAQ,EACR,QAAmB,EACnB,IAAI,EACJ,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,SAAS,EACT,MAAM,EACN,SAAS,EACT,SAAS,GACV,EAAE,cAAc,2CAqFhB"}
1
+ {"version":3,"file":"AppHeader.d.ts","sourceRoot":"","sources":["../../src/product/AppHeader.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAMhD,OAAO,EAAiB,KAAK,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAA;AA4B7E,MAAM,MAAM,cAAc,GAAG;IAC3B,4DAA4D;IAC5D,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,0EAA0E;IAC1E,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,qFAAqF;IACrF,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,mFAAmF;IACnF,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAA;IACpB,wDAAwD;IACxD,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAA;IACjC,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,+CAA+C;IAC/C,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,wEAAwE;IACxE,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB,CAAA;AAED,wBAAgB,SAAS,CAAC,EACxB,KAAK,EACL,QAAkB,EAClB,OAAO,EACP,GAAG,EACH,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,IAAI,EACJ,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,SAAS,EACT,MAAM,EACN,SAAS,EACT,SAAS,GACV,EAAE,cAAc,2CA0FhB"}
@@ -1 +1 @@
1
- {"version":3,"file":"ComboBox.d.ts","sourceRoot":"","sources":["../../src/product/ComboBox.tsx"],"names":[],"mappings":"AAkBA,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEnE,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEpD,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,OAAe,EACf,KAAY,EACZ,OAAO,EACP,WAAW,EACX,QAAQ,EACR,SAAuD,EACvD,QAAc,GACf,EAAE;IACD,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7B,6DAA6D;IAC7D,OAAO,EAAE,WAAW,EAAE,CAAA;IACtB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,mFAAmF;IACnF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,2CAmEA"}
1
+ {"version":3,"file":"ComboBox.d.ts","sourceRoot":"","sources":["../../src/product/ComboBox.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAKnE,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEpD,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,OAAe,EACf,KAAY,EACZ,OAAO,EACP,WAAW,EACX,QAAQ,EACR,SAAuD,EACvD,QAAc,GACf,EAAE;IACD,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7B,6DAA6D;IAC7D,OAAO,EAAE,WAAW,EAAE,CAAA;IACtB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,mFAAmF;IACnF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,2CA4EA"}
@@ -1,9 +1,12 @@
1
1
  /**
2
- * SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover (the
3
- * console's proven dropdown idiom, same as OrgSwitcher/ScopeSwitcher). Renders a
4
- * labelled trigger ("All types", or the chosen option) and a popover list with a
5
- * check on the active option. Prop-driven + self-contained so it lifts into
6
- * `@hanzo/ui`. `value === null` is the "all"/unfiltered state.
2
+ * SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover. Renders a
3
+ * labelled trigger ("All types", or the chosen option) and a menu list with a check on
4
+ * the active option. Prop-driven + self-contained. `value === null` is the
5
+ * "all"/unfiltered state.
6
+ *
7
+ * Uses the ONE shared menu spec (MenuPanel + MenuItemView) so it is pixel-identical to
8
+ * DropdownMenu/ContextMenu, and the SAME portal-theme fix (PortalTheme) so it renders
9
+ * correctly through the portal under a nested `<Theme>`, light or dark.
7
10
  */
8
11
  import type { ReactElement } from 'react';
9
12
  export type SelectOption<T extends string> = {
@@ -1 +1 @@
1
- {"version":3,"file":"SelectMenu.d.ts","sourceRoot":"","sources":["../../src/product/SelectMenu.tsx"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAA;AAKzC,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,IAAI;IAAE,GAAG,EAAE,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAEtE,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EAAE,EAC3C,OAAO,EACP,KAAK,EACL,QAAQ,EACR,QAAgB,EAChB,IAAI,EACJ,QAAc,GACf,EAAE;IACD,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EAAE,CAAA;IAC1B,KAAK,EAAE,CAAC,GAAG,IAAI,CAAA;IACf,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,KAAK,IAAI,CAAA;IAC/B,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4CAA4C;IAC5C,IAAI,CAAC,EAAE,YAAY,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,2CAsCA"}
1
+ {"version":3,"file":"SelectMenu.d.ts","sourceRoot":"","sources":["../../src/product/SelectMenu.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAA;AAQzC,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,IAAI;IAAE,GAAG,EAAE,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAEtE,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EAAE,EAC3C,OAAO,EACP,KAAK,EACL,QAAQ,EACR,QAAgB,EAChB,IAAI,EACJ,QAAc,GACf,EAAE;IACD,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EAAE,CAAA;IAC1B,KAAK,EAAE,CAAC,GAAG,IAAI,CAAA;IACf,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,KAAK,IAAI,CAAA;IAC/B,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4CAA4C;IAC5C,IAAI,CAAC,EAAE,YAAY,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,2CAyCA"}
@@ -1,2 +1,22 @@
1
- export declare function ThemeToggle(): import("react/jsx-runtime").JSX.Element;
1
+ export type ThemeMode = 'light' | 'dark';
2
+ export type ThemeToggleProps = {
3
+ /** Controlled theme. Pair with `onToggle`/`onThemeChange` to fully control it. */
4
+ theme?: ThemeMode;
5
+ /** Seed for uncontrolled mode (default: read from the DOM `.dark` class, else 'dark'). */
6
+ defaultTheme?: ThemeMode;
7
+ /** Called with the next theme on toggle (alias of `onThemeChange`). */
8
+ onToggle?: (next: ThemeMode) => void;
9
+ /** Called with the next theme on toggle. */
10
+ onThemeChange?: (next: ThemeMode) => void;
11
+ /** @hanzo/gui Button size token (default "$2"). */
12
+ size?: string;
13
+ /** aria-label override. */
14
+ label?: string;
15
+ };
16
+ /**
17
+ * The framework-agnostic toggle button. Controlled when `theme` is provided; otherwise
18
+ * self-managed via the DOM `.dark` class. No framework dependency — safe on any host.
19
+ */
20
+ export declare function AgnosticThemeToggle({ theme, defaultTheme, onToggle, onThemeChange, size, label, }: ThemeToggleProps): import("react/jsx-runtime").JSX.Element;
21
+ export declare function ThemeToggle(props: ThemeToggleProps): import("react/jsx-runtime").JSX.Element;
2
22
  //# sourceMappingURL=ThemeToggle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ThemeToggle.d.ts","sourceRoot":"","sources":["../../src/product/ThemeToggle.tsx"],"names":[],"mappings":"AAYA,wBAAgB,WAAW,4CAY1B"}
1
+ {"version":3,"file":"ThemeToggle.d.ts","sourceRoot":"","sources":["../../src/product/ThemeToggle.tsx"],"names":[],"mappings":"AAoBA,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,MAAM,CAAA;AAExC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,kFAAkF;IAClF,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,0FAA0F;IAC1F,YAAY,CAAC,EAAE,SAAS,CAAA;IACxB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAA;IACpC,4CAA4C;IAC5C,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAA;IACzC,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AA+BD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,KAAK,EACL,YAAY,EACZ,QAAQ,EACR,aAAa,EACb,IAAW,EACX,KAAK,GACN,EAAE,gBAAgB,2CAyBlB;AAiBD,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,2CAelD"}
@@ -0,0 +1,5 @@
1
+ import { type ThemeToggleProps } from './ThemeToggle';
2
+ declare function ThemeToggleNext({ size, label }: Pick<ThemeToggleProps, 'size' | 'label'>): import("react/jsx-runtime").JSX.Element;
3
+ export default ThemeToggleNext;
4
+ export { ThemeToggleNext };
5
+ //# sourceMappingURL=ThemeToggleNext.d.ts.map