@hanzo/ui 8.0.6 → 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.
- package/package.json +5 -1
- package/src/product/ComboBox.tsx +49 -69
- package/src/product/SelectMenu.tsx +23 -37
- package/src/product/ThemeToggle.tsx +121 -11
- package/src/product/ThemeToggleNext.tsx +27 -0
- package/src/product/index.ts +6 -0
- package/src/product/menu/ContextMenu.tsx +114 -0
- package/src/product/menu/DropdownMenu.tsx +76 -0
- package/src/product/menu/index.ts +15 -0
- package/src/product/menu/items.tsx +230 -0
- package/src/product/menu/portal-theme.tsx +33 -0
- package/src/product/menu/roving.ts +34 -0
- package/types/product/ComboBox.d.ts.map +1 -1
- package/types/product/SelectMenu.d.ts +8 -5
- package/types/product/SelectMenu.d.ts.map +1 -1
- package/types/product/ThemeToggle.d.ts +21 -1
- package/types/product/ThemeToggle.d.ts.map +1 -1
- package/types/product/ThemeToggleNext.d.ts +5 -0
- package/types/product/ThemeToggleNext.d.ts.map +1 -0
- package/types/product/index.d.ts +2 -0
- package/types/product/index.d.ts.map +1 -1
- package/types/product/menu/ContextMenu.d.ts +24 -0
- package/types/product/menu/ContextMenu.d.ts.map +1 -0
- package/types/product/menu/DropdownMenu.d.ts +34 -0
- package/types/product/menu/DropdownMenu.d.ts.map +1 -0
- package/types/product/menu/index.d.ts +5 -0
- package/types/product/menu/index.d.ts.map +1 -0
- package/types/product/menu/items.d.ts +69 -0
- package/types/product/menu/items.d.ts.map +1 -0
- package/types/product/menu/portal-theme.d.ts +29 -0
- package/types/product/menu/portal-theme.d.ts.map +1 -0
- package/types/product/menu/roving.d.ts +9 -0
- package/types/product/menu/roving.d.ts.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/ui",
|
|
3
|
-
"version": "8.0.
|
|
3
|
+
"version": "8.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hanzo UI \u2014 the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
|
|
6
6
|
"exports": {
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"@hanzo/gui": ">=7.2.2",
|
|
79
79
|
"@hanzo/ui-shadcn": ">=5.7.0",
|
|
80
80
|
"@hanzo/usage": ">=0.1.0",
|
|
81
|
+
"@hanzogui/next-theme": ">=7.3.0",
|
|
81
82
|
"react": ">=19"
|
|
82
83
|
},
|
|
83
84
|
"peerDependenciesMeta": {
|
|
@@ -95,6 +96,9 @@
|
|
|
95
96
|
},
|
|
96
97
|
"@hanzo/usage": {
|
|
97
98
|
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"@hanzogui/next-theme": {
|
|
101
|
+
"optional": true
|
|
98
102
|
}
|
|
99
103
|
},
|
|
100
104
|
"devDependencies": {
|
package/src/product/ComboBox.tsx
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* ComboBox — a typeable select: a text input the user can type any value into,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* console offers "pick from a live list OR type your own" (the model field, the
|
|
9
|
-
* tool field). Prop-driven + self-contained (options/loading/error injected by the
|
|
10
|
-
* caller), so it is orthogonal to the data source and lifts into `@hanzo/ui`.
|
|
4
|
+
* ComboBox — a typeable select: a text input the user can type any value into, PLUS a
|
|
5
|
+
* menu of LIVE options (filtered by what's typed) they can pick from. The value is
|
|
6
|
+
* always exactly the input text, so a custom id is inherently supported. Prop-driven +
|
|
7
|
+
* self-contained (options/loading/error injected by the caller).
|
|
11
8
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Uses the ONE shared menu spec (MenuPanel + MenuItemView) so options look identical to
|
|
10
|
+
* every other menu, and the SAME portal-theme fix (PortalTheme) so the list renders
|
|
11
|
+
* correctly through the portal above a SlideOver and under a nested `<Theme>`.
|
|
14
12
|
*/
|
|
15
13
|
import { useMemo, useState } from 'react'
|
|
16
|
-
import { Button, Input, Popover, Spinner, Text, XStack
|
|
17
|
-
import {
|
|
14
|
+
import { Button, Input, Popover, Spinner, Text, XStack } from '@hanzo/gui'
|
|
15
|
+
import { ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
|
|
18
16
|
|
|
19
17
|
import { filterOptions, type ComboOption } from './combobox/filter'
|
|
18
|
+
import { MenuItemView, MenuPanel } from './menu/items'
|
|
19
|
+
import { PortalTheme, useThemeName } from './menu/portal-theme'
|
|
20
|
+
import { menuKeyDown } from './menu/roving'
|
|
20
21
|
|
|
21
22
|
export type { ComboOption } from './combobox/filter'
|
|
22
23
|
|
|
@@ -49,6 +50,7 @@ export function ComboBox({
|
|
|
49
50
|
minWidth?: number
|
|
50
51
|
}) {
|
|
51
52
|
const [open, setOpen] = useState(false)
|
|
53
|
+
const themeName = useThemeName()
|
|
52
54
|
const filtered = useMemo(() => filterOptions(options, value), [options, value])
|
|
53
55
|
|
|
54
56
|
const pick = (v: string) => {
|
|
@@ -62,7 +64,7 @@ export function ComboBox({
|
|
|
62
64
|
<Input
|
|
63
65
|
flex={1}
|
|
64
66
|
value={value}
|
|
65
|
-
onChangeText={(v) => {
|
|
67
|
+
onChangeText={(v: string) => {
|
|
66
68
|
onChange(v)
|
|
67
69
|
if (!open) setOpen(true)
|
|
68
70
|
}}
|
|
@@ -83,65 +85,43 @@ export function ComboBox({
|
|
|
83
85
|
</Popover.Trigger>
|
|
84
86
|
</XStack>
|
|
85
87
|
|
|
86
|
-
<Popover.Content
|
|
87
|
-
<
|
|
88
|
-
{
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
<Popover.Content bg="transparent" borderWidth={0} p={0} elevation={0}>
|
|
89
|
+
<PortalTheme name={themeName}>
|
|
90
|
+
<MenuPanel minWidth={minWidth} maxHeight={300} onKeyDown={(e) => menuKeyDown(e, () => setOpen(false))}>
|
|
91
|
+
{loading ? (
|
|
92
|
+
<XStack items="center" gap="$2" px="$2" py="$2">
|
|
93
|
+
<Spinner size="small" color="$color11" />
|
|
94
|
+
<Text fontSize="$2" color="$color10">
|
|
95
|
+
Loading options…
|
|
96
|
+
</Text>
|
|
97
|
+
</XStack>
|
|
98
|
+
) : error ? (
|
|
99
|
+
<XStack items="center" gap="$2" px="$2" py="$2">
|
|
100
|
+
<Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
|
|
101
|
+
{error}
|
|
102
|
+
</Text>
|
|
103
|
+
{onRetry ? (
|
|
104
|
+
<Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
|
|
105
|
+
) : null}
|
|
106
|
+
</XStack>
|
|
107
|
+
) : filtered.length === 0 ? (
|
|
108
|
+
<Text fontSize="$2" color="$color10" px="$2" py="$2">
|
|
109
|
+
{emptyText}
|
|
93
110
|
</Text>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
</Text>
|
|
108
|
-
) : (
|
|
109
|
-
filtered.map((o) => (
|
|
110
|
-
<Row key={o.value} option={o} active={o.value === value} onPress={() => pick(o.value)} />
|
|
111
|
-
))
|
|
112
|
-
)}
|
|
113
|
-
</YStack>
|
|
111
|
+
) : (
|
|
112
|
+
filtered.map((o) => (
|
|
113
|
+
<MenuItemView
|
|
114
|
+
key={o.value}
|
|
115
|
+
label={o.label ?? o.value}
|
|
116
|
+
description={o.hint}
|
|
117
|
+
selected={o.value === value}
|
|
118
|
+
onSelect={() => pick(o.value)}
|
|
119
|
+
/>
|
|
120
|
+
))
|
|
121
|
+
)}
|
|
122
|
+
</MenuPanel>
|
|
123
|
+
</PortalTheme>
|
|
114
124
|
</Popover.Content>
|
|
115
125
|
</Popover>
|
|
116
126
|
)
|
|
117
127
|
}
|
|
118
|
-
|
|
119
|
-
function Row({ option, active, onPress }: { option: ComboOption; active: boolean; onPress: () => void }) {
|
|
120
|
-
return (
|
|
121
|
-
<XStack
|
|
122
|
-
items="center"
|
|
123
|
-
gap="$2"
|
|
124
|
-
px="$2.5"
|
|
125
|
-
py="$1.5"
|
|
126
|
-
rounded="$3"
|
|
127
|
-
cursor="pointer"
|
|
128
|
-
hoverStyle={{ bg: '$color4' }}
|
|
129
|
-
bg={active ? '$color4' : 'transparent'}
|
|
130
|
-
onPress={onPress}
|
|
131
|
-
>
|
|
132
|
-
<XStack width={14} items="center" justify="center">
|
|
133
|
-
{active ? <Check size={13} /> : null}
|
|
134
|
-
</XStack>
|
|
135
|
-
<YStack flex={1} minW={0}>
|
|
136
|
-
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
|
137
|
-
{option.label ?? option.value}
|
|
138
|
-
</Text>
|
|
139
|
-
{option.hint ? (
|
|
140
|
-
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
|
141
|
-
{option.hint}
|
|
142
|
-
</Text>
|
|
143
|
-
) : null}
|
|
144
|
-
</YStack>
|
|
145
|
-
</XStack>
|
|
146
|
-
)
|
|
147
|
-
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover. Renders a
|
|
5
|
+
* labelled trigger ("All types", or the chosen option) and a menu list with a check on
|
|
6
|
+
* the active option. Prop-driven + self-contained. `value === null` is the
|
|
7
|
+
* "all"/unfiltered state.
|
|
8
|
+
*
|
|
9
|
+
* Uses the ONE shared menu spec (MenuPanel + MenuItemView) so it is pixel-identical to
|
|
10
|
+
* DropdownMenu/ContextMenu, and the SAME portal-theme fix (PortalTheme) so it renders
|
|
11
|
+
* correctly through the portal under a nested `<Theme>`, light or dark.
|
|
9
12
|
*/
|
|
10
13
|
import type { ReactElement } from 'react'
|
|
11
14
|
import { useState } from 'react'
|
|
12
|
-
import { Button, Popover, Text
|
|
13
|
-
import { ChevronDown
|
|
15
|
+
import { Button, Popover, Text } from '@hanzo/gui'
|
|
16
|
+
import { ChevronDown } from '@hanzogui/lucide-icons-2'
|
|
17
|
+
import { MenuItemView, MenuPanel } from './menu/items'
|
|
18
|
+
import { PortalTheme, useThemeName } from './menu/portal-theme'
|
|
19
|
+
import { menuKeyDown } from './menu/roving'
|
|
14
20
|
|
|
15
21
|
export type SelectOption<T extends string> = { key: T; label: string }
|
|
16
22
|
|
|
@@ -32,6 +38,7 @@ export function SelectMenu<T extends string>({
|
|
|
32
38
|
minWidth?: number
|
|
33
39
|
}) {
|
|
34
40
|
const [open, setOpen] = useState(false)
|
|
41
|
+
const themeName = useThemeName()
|
|
35
42
|
const active = value === null ? null : options.find((o) => o.key === value) ?? null
|
|
36
43
|
const triggerLabel = active ? active.label : allLabel
|
|
37
44
|
|
|
@@ -58,37 +65,16 @@ export function SelectMenu<T extends string>({
|
|
|
58
65
|
</Text>
|
|
59
66
|
</Button>
|
|
60
67
|
</Popover.Trigger>
|
|
61
|
-
<Popover.Content
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
<Popover.Content bg="transparent" borderWidth={0} p={0} elevation={0}>
|
|
69
|
+
<PortalTheme name={themeName}>
|
|
70
|
+
<MenuPanel minWidth={minWidth} maxHeight={320} onKeyDown={(e) => menuKeyDown(e, () => setOpen(false))}>
|
|
71
|
+
<MenuItemView label={allLabel} selected={value === null} onSelect={() => pick(null)} />
|
|
72
|
+
{options.map((o) => (
|
|
73
|
+
<MenuItemView key={o.key} label={o.label} selected={value === o.key} onSelect={() => pick(o.key)} />
|
|
74
|
+
))}
|
|
75
|
+
</MenuPanel>
|
|
76
|
+
</PortalTheme>
|
|
68
77
|
</Popover.Content>
|
|
69
78
|
</Popover>
|
|
70
79
|
)
|
|
71
80
|
}
|
|
72
|
-
|
|
73
|
-
function Row({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
74
|
-
return (
|
|
75
|
-
<XStack
|
|
76
|
-
items="center"
|
|
77
|
-
gap="$2"
|
|
78
|
-
px="$2.5"
|
|
79
|
-
py="$1.5"
|
|
80
|
-
rounded="$3"
|
|
81
|
-
cursor="pointer"
|
|
82
|
-
hoverStyle={{ bg: '$color4' }}
|
|
83
|
-
bg={active ? '$color4' : 'transparent'}
|
|
84
|
-
onPress={onPress}
|
|
85
|
-
>
|
|
86
|
-
<XStack width={14} items="center" justify="center">
|
|
87
|
-
{active ? <Check size={13} /> : null}
|
|
88
|
-
</XStack>
|
|
89
|
-
<Text fontSize="$2" color="$color12" flex={1} numberOfLines={1}>
|
|
90
|
-
{label}
|
|
91
|
-
</Text>
|
|
92
|
-
</XStack>
|
|
93
|
-
)
|
|
94
|
-
}
|
|
@@ -1,25 +1,135 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* ThemeToggle — flips between dark and light. Framework-agnostic by default so
|
|
5
|
+
* Vite / Tauri / Express hosts can use it with NO Next dependency:
|
|
6
|
+
*
|
|
7
|
+
* • Controlled: <ThemeToggle theme={theme} onToggle={setTheme} /> (host owns theme)
|
|
8
|
+
* • Uncontrolled (agnostic): <ThemeToggle onThemeChange={fn} /> (toggles the DOM
|
|
9
|
+
* `.dark` class + persists to localStorage)
|
|
10
|
+
* • Uncontrolled (no props): <ThemeToggle /> (console/Next —
|
|
11
|
+
* falls back to the OPTIONAL @hanzogui/next-theme path;
|
|
12
|
+
* if it cannot load, degrades to the agnostic DOM toggle)
|
|
13
|
+
*
|
|
14
|
+
* Shows the action's target icon (a sun in dark mode, a moon in light mode).
|
|
8
15
|
*/
|
|
16
|
+
import type { ReactNode } from 'react'
|
|
17
|
+
import { Component, Suspense, lazy, useState } from 'react'
|
|
9
18
|
import { Button } from '@hanzo/gui'
|
|
10
|
-
import { useThemeSetting } from '@hanzogui/next-theme'
|
|
11
19
|
import { Moon, Sun } from '@hanzogui/lucide-icons-2'
|
|
12
20
|
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
export type ThemeMode = 'light' | 'dark'
|
|
22
|
+
|
|
23
|
+
export type ThemeToggleProps = {
|
|
24
|
+
/** Controlled theme. Pair with `onToggle`/`onThemeChange` to fully control it. */
|
|
25
|
+
theme?: ThemeMode
|
|
26
|
+
/** Seed for uncontrolled mode (default: read from the DOM `.dark` class, else 'dark'). */
|
|
27
|
+
defaultTheme?: ThemeMode
|
|
28
|
+
/** Called with the next theme on toggle (alias of `onThemeChange`). */
|
|
29
|
+
onToggle?: (next: ThemeMode) => void
|
|
30
|
+
/** Called with the next theme on toggle. */
|
|
31
|
+
onThemeChange?: (next: ThemeMode) => void
|
|
32
|
+
/** @hanzo/gui Button size token (default "$2"). */
|
|
33
|
+
size?: string
|
|
34
|
+
/** aria-label override. */
|
|
35
|
+
label?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STORAGE_KEY = 'theme'
|
|
39
|
+
|
|
40
|
+
function readDomTheme(): ThemeMode | undefined {
|
|
41
|
+
if (typeof document === 'undefined') return undefined
|
|
42
|
+
const el = document.documentElement
|
|
43
|
+
if (el.classList.contains('dark')) return 'dark'
|
|
44
|
+
if (el.classList.contains('light')) return 'light'
|
|
45
|
+
try {
|
|
46
|
+
const ls = window.localStorage.getItem(STORAGE_KEY)
|
|
47
|
+
if (ls === 'light' || ls === 'dark') return ls
|
|
48
|
+
} catch {
|
|
49
|
+
/* localStorage may be unavailable */
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function applyDomTheme(next: ThemeMode): void {
|
|
55
|
+
if (typeof document === 'undefined') return
|
|
56
|
+
const el = document.documentElement
|
|
57
|
+
el.classList.toggle('dark', next === 'dark')
|
|
58
|
+
el.classList.toggle('light', next === 'light')
|
|
59
|
+
el.style.colorScheme = next
|
|
60
|
+
try {
|
|
61
|
+
window.localStorage.setItem(STORAGE_KEY, next)
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The framework-agnostic toggle button. Controlled when `theme` is provided; otherwise
|
|
69
|
+
* self-managed via the DOM `.dark` class. No framework dependency — safe on any host.
|
|
70
|
+
*/
|
|
71
|
+
export function AgnosticThemeToggle({
|
|
72
|
+
theme,
|
|
73
|
+
defaultTheme,
|
|
74
|
+
onToggle,
|
|
75
|
+
onThemeChange,
|
|
76
|
+
size = '$2',
|
|
77
|
+
label,
|
|
78
|
+
}: ThemeToggleProps) {
|
|
79
|
+
const controlled = theme !== undefined
|
|
80
|
+
const [internal, setInternal] = useState<ThemeMode>(() => theme ?? defaultTheme ?? readDomTheme() ?? 'dark')
|
|
81
|
+
const current: ThemeMode = controlled ? (theme as ThemeMode) : internal
|
|
82
|
+
const isDark = current === 'dark'
|
|
83
|
+
|
|
84
|
+
const toggle = () => {
|
|
85
|
+
const next: ThemeMode = isDark ? 'light' : 'dark'
|
|
86
|
+
if (!controlled) {
|
|
87
|
+
setInternal(next)
|
|
88
|
+
applyDomTheme(next)
|
|
89
|
+
}
|
|
90
|
+
onToggle?.(next)
|
|
91
|
+
onThemeChange?.(next)
|
|
92
|
+
}
|
|
93
|
+
|
|
16
94
|
return (
|
|
17
95
|
<Button
|
|
18
|
-
size=
|
|
96
|
+
size={size as never}
|
|
19
97
|
chromeless
|
|
20
98
|
icon={isDark ? <Sun size={16} /> : <Moon size={16} />}
|
|
21
|
-
onPress={
|
|
22
|
-
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
|
99
|
+
onPress={toggle}
|
|
100
|
+
aria-label={label ?? (isDark ? 'Switch to light theme' : 'Switch to dark theme')}
|
|
23
101
|
/>
|
|
24
102
|
)
|
|
25
103
|
}
|
|
104
|
+
|
|
105
|
+
// @hanzogui/next-theme is an OPTIONAL dependency — loaded only on the uncontrolled,
|
|
106
|
+
// no-props path (console/Next). Lazily code-split so non-Next hosts never need it at
|
|
107
|
+
// runtime; if it fails to load, the boundary below degrades to the agnostic toggle.
|
|
108
|
+
const NextThemeToggle = lazy(() => import('./ThemeToggleNext'))
|
|
109
|
+
|
|
110
|
+
class NextThemeBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
|
111
|
+
state = { failed: false }
|
|
112
|
+
static getDerivedStateFromError() {
|
|
113
|
+
return { failed: true }
|
|
114
|
+
}
|
|
115
|
+
render() {
|
|
116
|
+
return this.state.failed ? this.props.fallback : this.props.children
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function ThemeToggle(props: ThemeToggleProps) {
|
|
121
|
+
const injected =
|
|
122
|
+
props.theme !== undefined || props.onToggle !== undefined || props.onThemeChange !== undefined
|
|
123
|
+
if (injected) return <AgnosticThemeToggle {...props} />
|
|
124
|
+
|
|
125
|
+
// No props → keep the existing console/Next behaviour via the optional next-theme
|
|
126
|
+
// path, degrading to the agnostic DOM toggle if it is not installed.
|
|
127
|
+
const fallback = <AgnosticThemeToggle {...props} />
|
|
128
|
+
return (
|
|
129
|
+
<NextThemeBoundary fallback={fallback}>
|
|
130
|
+
<Suspense fallback={fallback}>
|
|
131
|
+
<NextThemeToggle size={props.size} label={props.label} />
|
|
132
|
+
</Suspense>
|
|
133
|
+
</NextThemeBoundary>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ThemeToggleNext — the @hanzogui/next-theme binding (console / Next). This is the ONLY
|
|
5
|
+
* module that imports @hanzogui/next-theme, so it stays an OPTIONAL dependency: the base
|
|
6
|
+
* ThemeToggle lazy-loads it (default export) and non-Next hosts never pull it in unless
|
|
7
|
+
* they import this component directly. UI is delegated to the framework-agnostic button
|
|
8
|
+
* in controlled mode, so every toggle looks identical.
|
|
9
|
+
*/
|
|
10
|
+
import { useThemeSetting } from '@hanzogui/next-theme'
|
|
11
|
+
import { AgnosticThemeToggle, type ThemeToggleProps } from './ThemeToggle'
|
|
12
|
+
|
|
13
|
+
function ThemeToggleNext({ size, label }: Pick<ThemeToggleProps, 'size' | 'label'>) {
|
|
14
|
+
const { current, resolvedTheme, set } = useThemeSetting()
|
|
15
|
+
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
|
|
16
|
+
return (
|
|
17
|
+
<AgnosticThemeToggle
|
|
18
|
+
theme={isDark ? 'dark' : 'light'}
|
|
19
|
+
onToggle={(next) => set(next)}
|
|
20
|
+
size={size}
|
|
21
|
+
label={label}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default ThemeToggleNext
|
|
27
|
+
export { ThemeToggleNext }
|
package/src/product/index.ts
CHANGED
|
@@ -47,6 +47,11 @@ export * from './BrandMark'
|
|
|
47
47
|
export * from './OrgSwitcher'
|
|
48
48
|
export * from './scope'
|
|
49
49
|
|
|
50
|
+
// Menu — the ONE menu system: portal-theme-safe DropdownMenu (click) + ContextMenu
|
|
51
|
+
// (right-click), both rendering the SAME item spec (MenuPanel/MenuItemView) so every
|
|
52
|
+
// menu across the fleet is pixel-identical. SelectMenu/ComboBox share the same spec.
|
|
53
|
+
export * from './menu'
|
|
54
|
+
|
|
50
55
|
// The rest — every exported name below is unique across the layer.
|
|
51
56
|
export * from './DataTable'
|
|
52
57
|
export * from './EmptyState'
|
|
@@ -62,6 +67,7 @@ export * from './SelectMenu'
|
|
|
62
67
|
export * from './SlideOver'
|
|
63
68
|
export * from './StatusTag'
|
|
64
69
|
export * from './ThemeToggle'
|
|
70
|
+
export { ThemeToggleNext } from './ThemeToggleNext'
|
|
65
71
|
export * from './Toast'
|
|
66
72
|
export * from './color'
|
|
67
73
|
|
|
@@ -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'
|