Skip to content
This repository was archived by the owner on Jul 31, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/components/AppLayout/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Text } from '../Text'
import { Icon } from '../Icon'
import { Button } from '../Button'
import React, { useState } from 'react'
import { MoonshineConfigProvider } from '@/context/ConfigContext'

type Story = StoryObj<typeof AppLayout>

Expand Down Expand Up @@ -436,3 +437,57 @@ export const WithFullScreenSurface: Story = {
</AppLayoutProvider>
),
}

export const CustomExtraSidebarChildren: Story = {
name: 'Custom Extra Sidebar Children',
args: {
children: [
<AppLayout.Sidebar className="max-h-full" key="sidebar">
<AppLayout.Nav>
<AppLayout.NavItemGroup name="General">
<AppLayout.NavItem
onClick={() => alert('Home')}
title="Home"
icon="house"
/>
<AppLayout.NavItem title="Settings" icon="settings" />
<AppLayout.NavItem title="Users" icon="users" />
</AppLayout.NavItemGroup>
<AppLayout.NavItemGroup name="Activity">
<AppLayout.NavItem title="Activity" icon="activity" />
<AppLayout.NavItem title="Notifications" icon="bell" />
<AppLayout.NavItem title="Messages" icon="message-circle" />
<AppLayout.NavItem title="Users" icon="users" disabled />
</AppLayout.NavItemGroup>
</AppLayout.Nav>

<AppLayout.ThemeSwitcher />
</AppLayout.Sidebar>,
<AppLayout.SurfaceHeader key="surface-header">
<AppLayout.CollapseButton />
<AppLayout.HeaderDivider />
<AppLayout.Breadcrumb>
<AppLayout.BreadcrumbItem>Home</AppLayout.BreadcrumbItem>
<AppLayout.BreadcrumbItem>Settings</AppLayout.BreadcrumbItem>
<AppLayout.BreadcrumbItem active>Users</AppLayout.BreadcrumbItem>
</AppLayout.Breadcrumb>
</AppLayout.SurfaceHeader>,
<AppLayout.Surface className="p-4" key="surface">
<SurfaceContent />
</AppLayout.Surface>,
],
},
render: (args, context) => (
<AppLayoutProvider>
<MoonshineConfigProvider
theme={context.globals.theme}
/** TODO: support changing the storybook context value from the story */
setTheme={(theme) => {
console.log('theme', theme)
}}
>
<AppLayout {...args} />
</MoonshineConfigProvider>
</AppLayoutProvider>
),
}
45 changes: 32 additions & 13 deletions src/components/AppLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cn } from '@/lib/utils'
import { cn, partitionBy } from '@/lib/utils'
import React, {
Children,
isValidElement,
Expand All @@ -21,6 +21,7 @@ import {
import { Key } from '../KeyHint'
import { useAppLayoutKeys } from './useAppLayoutKeys'
import { IconName } from '../Icon/names'
import { ThemeSwitcher } from '../ThemeSwitcher'

interface AppLayoutProps extends PropsWithChildren {
className?: string
Expand Down Expand Up @@ -57,7 +58,7 @@ const AppLayoutBase = ({ children, className }: AppLayoutProps) => {
return (
<div
className={cn(
'bg-surface-secondary flex h-screen w-full overflow-hidden p-3 pr-0 pb-0',
'bg-surface-secondary flex h-screen w-full gap-3 overflow-hidden p-3 pr-0 pb-0',
className
)}
>
Expand Down Expand Up @@ -116,7 +117,7 @@ interface AppLayoutSidebarProps {
const AppLayoutSidebar = ({ children, className }: AppLayoutSidebarProps) => {
const { collapsed } = useAppLayout()

const nav = Children.toArray(children).find((child) => {
const [nav, rest] = partitionBy(Children.toArray(children), (child) => {
if (!isValidElement(child)) return false
const type = child.type as { displayName?: string }
return type.displayName === 'AppLayout.Nav'
Expand All @@ -127,23 +128,41 @@ const AppLayoutSidebar = ({ children, className }: AppLayoutSidebarProps) => {
initial={false}
layout="position"
className={cn(
'mt-4 mr-9 ml-2 flex w-fit flex-col gap-4',
'mt-4 flex w-fit flex-col items-start',
className,
collapsed && 'mr-6'
!collapsed && 'mr-5'
)}
transition={{ duration: 0.25, type: 'spring', bounce: 0 }}
>
{/* TODO: Gram will use a different logo so we need a way of making this dynamic */}
<Logo
variant={collapsed ? 'icon' : 'wordmark'}
className={cn(!collapsed && 'min-w-[140px]')}
/>
<div className={cn('flex flex-col')}>{nav}</div>
<div className="flex flex-col gap-4 px-2">
{/* TODO: Gram will use a different logo so we need a way of making this dynamic */}
<Logo
variant={collapsed ? 'icon' : 'wordmark'}
className={cn(!collapsed && 'min-w-[140px]')}
/>
{nav}
</div>
{rest}
</motion.div>
)
}
AppLayoutSidebar.displayName = 'AppLayout.Sidebar'

interface AppLayoutThemeSwitcherProps {
className?: string
}

const AppLayoutThemeSwitcher = ({ className }: AppLayoutThemeSwitcherProps) => {
const { collapsed } = useAppLayout()
return (
<motion.div className={cn('mt-auto mb-6', className)}>
<ThemeSwitcher orientation={collapsed ? 'vertical' : 'horizontal'} />
</motion.div>
)
}

AppLayoutThemeSwitcher.displayName = 'AppLayout.ThemeSwitcher'

interface AppLayoutBreadcrumbProps extends PropsWithChildren {
className?: string
}
Expand Down Expand Up @@ -323,7 +342,7 @@ interface AppLayoutNavProps extends HTMLAttributes<HTMLDivElement> {

const AppLayoutNav = ({ children, className, ...props }: AppLayoutNavProps) => {
return (
<nav className={cn('mt-3 flex flex-col', className)} {...props}>
<nav className={cn('mt-3 flex flex-col items-start', className)} {...props}>
{children}
</nav>
)
Expand Down Expand Up @@ -450,7 +469,7 @@ export const AppLayout = Object.assign(AppLayoutBase, {
CollapseButton: AppLayoutCollapseButton,
HeaderDivider: AppLayoutHeaderDivider,
Header: AppLayoutHeader,

ThemeSwitcher: AppLayoutThemeSwitcher,
// Nav
Nav: AppLayoutNav,
NavItem: AppLayoutNavItem,
Expand Down
35 changes: 30 additions & 5 deletions src/components/ThemeSwitcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ const THEMES: { key: Theme; icon: ReactNode }[] = [
export interface ThemeSwitcherProps {
onThemeSwitch?: (theme: string) => void
className?: string
orientation?: 'horizontal' | 'vertical'
}

export function ThemeSwitcher({
className,
onThemeSwitch,
orientation = 'horizontal',
}: ThemeSwitcherProps) {
const isMounted = useIsMounted()
const { theme, setTheme } = useConfig()
Expand All @@ -31,12 +33,29 @@ export function ThemeSwitcher({
return 100 * themeIndex
}, [theme])

if (!isMounted)
return <div className="h-[2.125rem] w-[calc(2.125rem*3+2px)]" />
const isVertical = orientation === 'vertical'
const knobSizeRem = 2.125
const placeholderStyle = isVertical
? {
width: `${knobSizeRem}rem`,
height: `calc(${knobSizeRem}rem * ${THEMES.length} + 2px)`,
}
: {
width: `calc(${knobSizeRem}rem * ${THEMES.length} + 2px)`,
height: `${knobSizeRem}rem`,
}

if (!isMounted) return <div style={placeholderStyle} />

return (
<div className="relative h-fit w-fit overflow-hidden rounded-full border border-neutral-300 bg-neutral-100 p-0 dark:border-neutral-800/30 dark:bg-neutral-900">
<fieldset className={cn('group m-0 flex items-center', className)}>
<fieldset
className={cn(
'group m-0 flex',
isVertical ? 'flex-col items-stretch' : 'flex-row items-center',
className
)}
>
<legend className="sr-only">Select a display theme:</legend>
{THEMES.map(({ key, icon }) => {
const checked = key === theme
Expand Down Expand Up @@ -80,9 +99,15 @@ export function ThemeSwitcher({
})}
</fieldset>
<motion.div
initial={{ transform: `translateX(${posX}%)` }}
initial={{
transform: isVertical
? `translateY(${posX}%)`
: `translateX(${posX}%)`,
}}
animate={{
transform: `translateX(${posX}%)`,
transform: isVertical
? `translateY(${posX}%)`
: `translateX(${posX}%)`,
}}
transition={{ type: 'spring', duration: 0.7, bounce: 0.3 }}
className="absolute top-0 left-0 z-[10] size-[2.125rem] rounded-full shadow-[0px_0px_4px_0px_rgba(0,0,0,0.10),0px_2px_1px_0px_#FFF_inset,0px_-2px_1px_0px_rgba(0,0,0,0.05)_inset] dark:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.60),0px_2px_1px_0px_#282828_inset,0px_-2px_1px_0px_rgba(0,0,0,0.05)_inset]"
Expand Down