Skip to content

Commit f1f0445

Browse files
committed
Add variant support to the nav menu component.
1 parent 1a2fe2a commit f1f0445

8 files changed

Lines changed: 869 additions & 35 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import type { NavItem } from '@purestack/ts-common'
2+
import {
3+
type ComponentVariant,
4+
type ComponentVariantMode,
5+
defineButtonComponents,
6+
defineFlexComponents,
7+
defineFormComponents,
8+
defineFormSelectField,
9+
defineGridComponents,
10+
defineIconComponents,
11+
defineNavigationComponents,
12+
definePanelComponents,
13+
defineSignInComponents,
14+
type FormSelectOption,
15+
} from '@purestack/ts-components'
16+
import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style'
17+
import {
18+
iconoir_pin,
19+
iconoir_pin_slash,
20+
lucide_chevron_down,
21+
tabler_bell,
22+
tabler_book,
23+
tabler_chart_bar,
24+
tabler_chevron_down,
25+
tabler_components,
26+
tabler_credit_card,
27+
tabler_file_invoice,
28+
tabler_home,
29+
tabler_key,
30+
tabler_layout_dashboard,
31+
tabler_package,
32+
tabler_palette,
33+
tabler_rocket,
34+
tabler_settings,
35+
tabler_users,
36+
} from '@purestack/ts-svg-icons'
37+
import {
38+
batch,
39+
type ComputedRef,
40+
computed,
41+
createApp,
42+
defineComponent,
43+
html,
44+
observe,
45+
type Ref,
46+
ref,
47+
} from 'regor'
48+
49+
type TreeName = 'docs' | 'app'
50+
type Backdrop = 'page' | 'spotlight'
51+
52+
interface PageOption {
53+
label: string
54+
value: string
55+
}
56+
57+
export interface NavMenuPlayground {
58+
treeName: Ref<TreeName>
59+
currentUrl: Ref<string>
60+
showIcons: Ref<boolean>
61+
tone: Ref<SemanticTone>
62+
variant: Ref<ComponentVariant>
63+
variantMode: Ref<ComponentVariantMode>
64+
backdrop: Ref<Backdrop>
65+
items: ComputedRef<NavItem[]>
66+
pages: ComputedRef<PageOption[]>
67+
stageClass: ComputedRef<string>
68+
trees: FormSelectOption[]
69+
tones: FormSelectOption[]
70+
variants: FormSelectOption[]
71+
modes: FormSelectOption[]
72+
backdrops: FormSelectOption[]
73+
followLink: (event: Event) => void
74+
reset: () => void
75+
}
76+
77+
const TREES: Record<TreeName, NavItem[]> = {
78+
docs: [
79+
{ title: 'Home', url: '/', icon: 'tabler:home' },
80+
{
81+
title: 'Getting started',
82+
icon: 'tabler:rocket',
83+
children: [
84+
{ title: 'Installation', url: '/start/install/' },
85+
{ title: 'Your first page', url: '/start/first-page/' },
86+
],
87+
},
88+
{
89+
title: 'Guides',
90+
icon: 'tabler:book',
91+
children: [
92+
{ title: 'Themes', url: '/guides/themes/', icon: 'tabler:palette' },
93+
{
94+
title: 'Components',
95+
icon: 'tabler:components',
96+
children: [
97+
{ title: 'Buttons', url: '/guides/components/buttons/' },
98+
{ title: 'Navigation', url: '/guides/components/navigation/' },
99+
],
100+
},
101+
{ title: 'Deployment', url: '/guides/deploy/' },
102+
],
103+
},
104+
{ title: 'Reference', url: '/reference/', icon: 'tabler:package' },
105+
],
106+
app: [
107+
{ title: 'Dashboard', url: '/app/', icon: 'tabler:layout-dashboard' },
108+
{ title: 'Customers', url: '/app/customers/', icon: 'tabler:users' },
109+
{
110+
title: 'Reports',
111+
icon: 'tabler:chart-bar',
112+
children: [
113+
{ title: 'Revenue', url: '/app/reports/revenue/' },
114+
{ title: 'Usage', url: '/app/reports/usage/' },
115+
],
116+
},
117+
{
118+
title: 'Billing',
119+
icon: 'tabler:credit-card',
120+
children: [
121+
{
122+
title: 'Invoices',
123+
url: '/app/billing/invoices/',
124+
icon: 'tabler:file-invoice',
125+
},
126+
{ title: 'Plans', url: '/app/billing/plans/' },
127+
],
128+
},
129+
{
130+
title: 'Settings',
131+
icon: 'tabler:settings',
132+
children: [
133+
{ title: 'API keys', url: '/app/settings/keys/', icon: 'tabler:key' },
134+
{
135+
title: 'Notifications',
136+
url: '/app/settings/notifications/',
137+
icon: 'tabler:bell',
138+
},
139+
],
140+
},
141+
],
142+
}
143+
144+
const DEFAULTS = {
145+
treeName: 'docs' as TreeName,
146+
currentUrl: '/guides/themes/',
147+
showIcons: true,
148+
tone: 'neutral' as SemanticTone,
149+
variant: 'flat' as ComponentVariant,
150+
variantMode: 'stateless' as ComponentVariantMode,
151+
backdrop: 'page' as Backdrop,
152+
}
153+
154+
const navMenuPlaygroundTemplate = html`<Flex direction="column">
155+
<div
156+
class="rounded-md p-4 spotlight-from-top-right"
157+
:class="stageClass"
158+
@click="followLink"
159+
>
160+
<div style="max-width: 18rem; margin-inline: auto; --ps-doc-layout-active-shell-padding-inline-start: 0px">
161+
<NavMenu
162+
:items="items"
163+
:currentUrl="currentUrl"
164+
:tone="tone"
165+
:variant="variant"
166+
:variantMode="variantMode"/>
167+
</div>
168+
</div>
169+
<p class="fs-xs text-subtle mb-0">
170+
Links stay in the playground: choose one to make it the current page.
171+
</p>
172+
<Grid columns="1" columnsSm="2" columnsLg="3">
173+
<FormSelectField id="nav-menu-tree" label="Navigation" :model="treeName" :options="trees"/>
174+
<FormSelectField id="nav-menu-current" label="Current page" :model="currentUrl" :options="pages"/>
175+
<FormSelectField id="nav-menu-backdrop" label="Backdrop" :model="backdrop" :options="backdrops"/>
176+
<FormSelectField id="nav-menu-tone" label="Tone" :model="tone" :options="tones"/>
177+
<FormSelectField id="nav-menu-variant" label="Variant" :model="variant" :options="variants"/>
178+
<FormSelectField id="nav-menu-mode" label="Variant mode" :model="variantMode" :options="modes"/>
179+
</Grid>
180+
<Flex wrap="true" align="center">
181+
<FormCheck id="nav-menu-icons" label="Show item icons" :checked="showIcons"/>
182+
<Btn variant="link" @click="reset">Reset playground</Btn>
183+
</Flex>
184+
</Flex>`
185+
186+
function withoutIcons(items: NavItem[]): NavItem[] {
187+
return items.map(({ icon: _icon, children, ...item }) =>
188+
children ? { ...item, children: withoutIcons(children) } : item,
189+
)
190+
}
191+
192+
function listPages(items: NavItem[], trail = ''): PageOption[] {
193+
return items.flatMap((item) => {
194+
const label = trail ? `${trail} / ${item.title}` : item.title
195+
const page = item.url ? [{ label, value: item.url }] : []
196+
return [...page, ...listPages(item.children ?? [], label)]
197+
})
198+
}
199+
200+
function options(values: readonly string[]): FormSelectOption[] {
201+
return values.map((value) => ({ label: value, value }))
202+
}
203+
204+
function createNavMenuPlayground(): NavMenuPlayground {
205+
const treeName = ref(DEFAULTS.treeName)
206+
const currentUrl = ref(DEFAULTS.currentUrl)
207+
const showIcons = ref(DEFAULTS.showIcons)
208+
const tone = ref(DEFAULTS.tone)
209+
const variant = ref(DEFAULTS.variant)
210+
const variantMode = ref(DEFAULTS.variantMode)
211+
const backdrop = ref(DEFAULTS.backdrop)
212+
213+
const pages = computed(() => listPages(TREES[treeName()]))
214+
// A new tree starts on its first page, so the menu always shows one.
215+
observe(treeName, () => {
216+
const first = pages()[0]
217+
if (first) currentUrl(first.value)
218+
})
219+
220+
return {
221+
treeName,
222+
currentUrl,
223+
showIcons,
224+
tone,
225+
variant,
226+
variantMode,
227+
backdrop,
228+
items: computed(() => {
229+
const tree = TREES[treeName()]
230+
return showIcons() ? tree : withoutIcons(tree)
231+
}),
232+
pages,
233+
stageClass: computed<string>(() =>
234+
backdrop() === 'spotlight'
235+
? 'tone--accent tone-fill-spotlight'
236+
: 'b-1 b-subtle',
237+
),
238+
trees: [
239+
{ label: 'Documentation site', value: 'docs' },
240+
{ label: 'Application', value: 'app' },
241+
],
242+
tones: options(SEMANTIC_TONES),
243+
variants: options([
244+
'flat',
245+
'flatAlt',
246+
'flatSolid',
247+
'surface',
248+
'surfaceAlt',
249+
'solid',
250+
'glass',
251+
'spotlight',
252+
'outlineFill',
253+
'outline',
254+
'subtle',
255+
'none',
256+
]),
257+
modes: options(['stateless', 'stateful']),
258+
backdrops: [
259+
{ label: 'Page', value: 'page' },
260+
{ label: 'Spotlight (for glass)', value: 'spotlight' },
261+
],
262+
followLink: (event) => {
263+
const link = (event.target as Element | null)?.closest('a[href]')
264+
if (!link) return
265+
event.preventDefault()
266+
currentUrl(link.getAttribute('href') ?? '')
267+
},
268+
reset: () =>
269+
batch(() => {
270+
treeName(DEFAULTS.treeName)
271+
currentUrl(DEFAULTS.currentUrl)
272+
showIcons(DEFAULTS.showIcons)
273+
tone(DEFAULTS.tone)
274+
variant(DEFAULTS.variant)
275+
variantMode(DEFAULTS.variantMode)
276+
backdrop(DEFAULTS.backdrop)
277+
}),
278+
}
279+
}
280+
281+
const icons: Record<string, string> = {
282+
'lucide:chevron-down': lucide_chevron_down,
283+
'tabler:chevron-down': tabler_chevron_down,
284+
'iconoir:pin': iconoir_pin,
285+
'iconoir:pin-slash': iconoir_pin_slash,
286+
'tabler:home': tabler_home,
287+
'tabler:rocket': tabler_rocket,
288+
'tabler:book': tabler_book,
289+
'tabler:palette': tabler_palette,
290+
'tabler:components': tabler_components,
291+
'tabler:package': tabler_package,
292+
'tabler:layout-dashboard': tabler_layout_dashboard,
293+
'tabler:users': tabler_users,
294+
'tabler:chart-bar': tabler_chart_bar,
295+
'tabler:credit-card': tabler_credit_card,
296+
'tabler:file-invoice': tabler_file_invoice,
297+
'tabler:settings': tabler_settings,
298+
'tabler:key': tabler_key,
299+
'tabler:bell': tabler_bell,
300+
}
301+
302+
const component = defineComponent<NavMenuPlayground>(
303+
navMenuPlaygroundTemplate,
304+
{ context: createNavMenuPlayground },
305+
)
306+
307+
createApp(
308+
{
309+
components: {
310+
NavMenuPlayground: component,
311+
...defineButtonComponents(),
312+
...defineFlexComponents(),
313+
...defineGridComponents(),
314+
...defineFormComponents(),
315+
...defineFormSelectField(),
316+
...defineNavigationComponents(),
317+
...definePanelComponents(),
318+
...defineSignInComponents(),
319+
...defineIconComponents((name) => {
320+
if (!icons[name]) throw new Error(`Icon is not registered: ${name}`)
321+
return icons[name]
322+
}),
323+
},
324+
},
325+
{
326+
selector: 'app#nav-menu-playground',
327+
template: html`<NavMenuPlayground/>`,
328+
},
329+
)

0 commit comments

Comments
 (0)