@glyphui/ui 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/data-table.d.ts +110 -0
- package/dist/data-table.js +225 -0
- package/dist/field-DXZD0l8w.js +107 -0
- package/dist/field-context-B8cYheXz.js +321 -0
- package/dist/form.d.ts +79 -0
- package/dist/form.js +84 -0
- package/dist/index-BWK4czIG.d.ts +233 -0
- package/dist/index.d.ts +2155 -0
- package/dist/index.js +1529 -0
- package/dist/registry-BGSJoiLq.d.ts +9 -0
- package/dist/states-BxPjCUxz.js +287 -0
- package/dist/tv-DuMh7UH2.d.ts +6 -0
- package/package.json +113 -3
- package/src/components/avatar/avatar.styles.ts +22 -0
- package/src/components/avatar/avatar.tsx +62 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/badge/badge.styles.ts +69 -0
- package/src/components/badge/badge.tsx +30 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/button/button.styles.ts +43 -0
- package/src/components/button/button.tsx +77 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.styles.ts +32 -0
- package/src/components/card/card.tsx +91 -0
- package/src/components/card/index.ts +11 -0
- package/src/components/checkbox/checkbox.tsx +39 -0
- package/src/components/checkbox/index.ts +1 -0
- package/src/components/container/container.tsx +35 -0
- package/src/components/container/index.ts +1 -0
- package/src/components/eyebrow/eyebrow.styles.ts +14 -0
- package/src/components/eyebrow/eyebrow.tsx +19 -0
- package/src/components/eyebrow/index.ts +2 -0
- package/src/components/field/field-context.ts +40 -0
- package/src/components/field/field.styles.ts +23 -0
- package/src/components/field/field.tsx +109 -0
- package/src/components/field/index.ts +3 -0
- package/src/components/heading/heading.styles.ts +16 -0
- package/src/components/heading/heading.tsx +26 -0
- package/src/components/heading/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.styles.ts +36 -0
- package/src/components/input/input.tsx +33 -0
- package/src/components/kbd/index.ts +1 -0
- package/src/components/kbd/kbd.tsx +21 -0
- package/src/components/label/index.ts +1 -0
- package/src/components/label/label.tsx +21 -0
- package/src/components/section/index.ts +1 -0
- package/src/components/section/section.tsx +39 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.tsx +79 -0
- package/src/components/select/index.ts +1 -0
- package/src/components/select/select.tsx +133 -0
- package/src/components/separator/index.ts +1 -0
- package/src/components/separator/separator.tsx +29 -0
- package/src/components/skeleton/index.ts +1 -0
- package/src/components/skeleton/skeleton.tsx +20 -0
- package/src/components/spinner/index.ts +2 -0
- package/src/components/spinner/spinner.styles.ts +19 -0
- package/src/components/spinner/spinner.tsx +42 -0
- package/src/components/stack/index.ts +1 -0
- package/src/components/stack/stack.tsx +62 -0
- package/src/components/switch/index.ts +1 -0
- package/src/components/switch/switch.tsx +48 -0
- package/src/components/table/index.ts +10 -0
- package/src/components/table/table.tsx +90 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/tabs/tabs.tsx +92 -0
- package/src/components/text/index.ts +2 -0
- package/src/components/text/text.styles.ts +32 -0
- package/src/components/text/text.tsx +17 -0
- package/src/components/textarea/index.ts +1 -0
- package/src/components/textarea/textarea.tsx +27 -0
- package/src/components/tooltip/index.ts +1 -0
- package/src/components/tooltip/tooltip.tsx +42 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/use-component-config.ts +42 -0
- package/src/hooks/use-controllable-state.ts +27 -0
- package/src/hooks/use-media-query.ts +14 -0
- package/src/hooks/use-scrolled.ts +14 -0
- package/src/index.ts +52 -0
- package/src/internal/icons.tsx +99 -0
- package/src/patterns/app-shell/app-shell-context.ts +27 -0
- package/src/patterns/app-shell/app-shell.styles.ts +127 -0
- package/src/patterns/app-shell/app-shell.tsx +81 -0
- package/src/patterns/app-shell/index.ts +16 -0
- package/src/patterns/app-shell/navbar.tsx +119 -0
- package/src/patterns/app-shell/page-header.tsx +30 -0
- package/src/patterns/app-shell/sidebar.tsx +163 -0
- package/src/patterns/async/async.tsx +83 -0
- package/src/patterns/async/index.ts +10 -0
- package/src/patterns/async/states.tsx +128 -0
- package/src/patterns/data-table/data-table.tsx +367 -0
- package/src/patterns/data-table/index.ts +2 -0
- package/src/patterns/form/form.tsx +150 -0
- package/src/patterns/form/index.ts +13 -0
- package/src/provider/color-mode.tsx +133 -0
- package/src/provider/glyph-context.ts +42 -0
- package/src/provider/glyph-provider.tsx +41 -0
- package/src/provider/index.ts +18 -0
- package/src/provider/theme-scope.tsx +36 -0
- package/src/provider/theme-style.tsx +19 -0
- package/src/register.ts +69 -0
- package/src/themes.ts +56 -0
- package/src/utils/cn.ts +12 -0
- package/src/utils/compose-refs.ts +11 -0
- package/src/utils/registry.ts +9 -0
- package/src/utils/styles.ts +13 -0
- package/src/utils/tv.ts +7 -0
- package/src/utils/tw-merge-config.ts +14 -0
- package/styles.css +178 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Dialog } from 'radix-ui'
|
|
2
|
+
import { type ComponentProps, type ReactNode, useMemo, useState } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { useControllableState } from '../../hooks/use-controllable-state'
|
|
5
|
+
import { type AppShellVariants, appShellStyles } from './app-shell.styles'
|
|
6
|
+
import { AppShellContext } from './app-shell-context'
|
|
7
|
+
import { SidebarTrigger } from './sidebar'
|
|
8
|
+
|
|
9
|
+
export interface AppShellProps extends Omit<ComponentProps<'div'>, 'children'>, AppShellVariants {
|
|
10
|
+
/** A `<Sidebar>`. Shown fixed on desktop and in a drawer on mobile. */
|
|
11
|
+
sidebar: ReactNode
|
|
12
|
+
children: ReactNode
|
|
13
|
+
collapsed?: boolean
|
|
14
|
+
defaultCollapsed?: boolean
|
|
15
|
+
onCollapsedChange?: (collapsed: boolean) => void
|
|
16
|
+
/** Shown next to the menu button in the mobile top bar. */
|
|
17
|
+
mobileBrand?: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* App layout: sidebar plus main content. Handles desktop collapse to an icon rail,
|
|
22
|
+
* the mobile drawer (focus-trapped, closes on navigation), and the mobile top bar.
|
|
23
|
+
*
|
|
24
|
+
* `layout="page"` fills the viewport; `layout="embedded"` fills its parent.
|
|
25
|
+
*/
|
|
26
|
+
export function AppShell(inProps: AppShellProps) {
|
|
27
|
+
const [props, slots] = useComponentConfig('AppShell', inProps)
|
|
28
|
+
const {
|
|
29
|
+
sidebar,
|
|
30
|
+
children,
|
|
31
|
+
collapsed: collapsedProp,
|
|
32
|
+
defaultCollapsed = false,
|
|
33
|
+
onCollapsedChange,
|
|
34
|
+
mobileBrand,
|
|
35
|
+
layout,
|
|
36
|
+
className,
|
|
37
|
+
...rest
|
|
38
|
+
} = props
|
|
39
|
+
const [collapsed, setCollapsed] = useControllableState({
|
|
40
|
+
value: collapsedProp,
|
|
41
|
+
defaultValue: defaultCollapsed,
|
|
42
|
+
onChange: onCollapsedChange,
|
|
43
|
+
})
|
|
44
|
+
const [mobileOpen, setMobileOpen] = useState(false)
|
|
45
|
+
const s = appShellStyles({ layout })
|
|
46
|
+
|
|
47
|
+
const desktop = useMemo(
|
|
48
|
+
() => ({ collapsed, setCollapsed, mobileOpen, setMobileOpen, inDrawer: false }),
|
|
49
|
+
[collapsed, setCollapsed, mobileOpen],
|
|
50
|
+
)
|
|
51
|
+
const drawer = useMemo(() => ({ ...desktop, collapsed: false, inDrawer: true }), [desktop])
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<AppShellContext.Provider value={desktop}>
|
|
55
|
+
<div data-slot="app-shell" className={s.root({ class: [slots.root, className] })} {...rest}>
|
|
56
|
+
<div className={s.sidebar({ class: slots.sidebar })}>{sidebar}</div>
|
|
57
|
+
|
|
58
|
+
<Dialog.Root open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
59
|
+
<Dialog.Portal>
|
|
60
|
+
<Dialog.Overlay className={s.overlay({ class: slots.overlay })} />
|
|
61
|
+
<Dialog.Content
|
|
62
|
+
className={s.drawer({ class: slots.drawer })}
|
|
63
|
+
aria-describedby={undefined}
|
|
64
|
+
>
|
|
65
|
+
<Dialog.Title className="sr-only">Navigation</Dialog.Title>
|
|
66
|
+
<AppShellContext.Provider value={drawer}>{sidebar}</AppShellContext.Provider>
|
|
67
|
+
</Dialog.Content>
|
|
68
|
+
</Dialog.Portal>
|
|
69
|
+
</Dialog.Root>
|
|
70
|
+
|
|
71
|
+
<div data-slot="app-shell-main" className={s.main({ class: slots.main })}>
|
|
72
|
+
<div className={s.mobileBar({ class: slots.mobileBar })}>
|
|
73
|
+
<SidebarTrigger />
|
|
74
|
+
{mobileBrand}
|
|
75
|
+
</div>
|
|
76
|
+
{children}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</AppShellContext.Provider>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { AppShell, type AppShellProps } from './app-shell'
|
|
2
|
+
export { appShellStyles, navbarStyles, pageHeaderStyles, sidebarStyles } from './app-shell.styles'
|
|
3
|
+
export { type AppShellContextValue, useAppShell } from './app-shell-context'
|
|
4
|
+
export { Navbar, type NavbarProps, type NavLink } from './navbar'
|
|
5
|
+
export { PageHeader, type PageHeaderProps } from './page-header'
|
|
6
|
+
export {
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarFooter,
|
|
10
|
+
SidebarHeader,
|
|
11
|
+
SidebarItem,
|
|
12
|
+
type SidebarItemProps,
|
|
13
|
+
SidebarSection,
|
|
14
|
+
type SidebarSectionProps,
|
|
15
|
+
SidebarTrigger,
|
|
16
|
+
} from './sidebar'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Dialog } from 'radix-ui'
|
|
2
|
+
import { type ComponentProps, type ReactNode, useState } from 'react'
|
|
3
|
+
import { IconButton } from '../../components/button'
|
|
4
|
+
import { Container, type ContainerProps } from '../../components/container'
|
|
5
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
6
|
+
import { useScrolled } from '../../hooks/use-scrolled'
|
|
7
|
+
import { MenuIcon, XIcon } from '../../internal/icons'
|
|
8
|
+
import { useLinkComponent } from '../../provider/glyph-context'
|
|
9
|
+
import { navbarStyles } from './app-shell.styles'
|
|
10
|
+
|
|
11
|
+
export interface NavLink {
|
|
12
|
+
label: ReactNode
|
|
13
|
+
href: string
|
|
14
|
+
active?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NavbarProps extends Omit<ComponentProps<'header'>, 'children'> {
|
|
18
|
+
brand: ReactNode
|
|
19
|
+
links?: readonly NavLink[]
|
|
20
|
+
/** Right side on desktop; bottom of the menu panel on mobile. */
|
|
21
|
+
actions?: ReactNode
|
|
22
|
+
/** Stick to the top of the viewport. Default true. */
|
|
23
|
+
sticky?: boolean
|
|
24
|
+
/** Transparent at the top; translucent with blur and a border once scrolled. Default true. */
|
|
25
|
+
blurOnScroll?: boolean
|
|
26
|
+
containerSize?: ContainerProps['size']
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Site header from data: brand, links, actions. Responsive out of the box: links collapse
|
|
31
|
+
* into a focus-trapped menu panel on small screens.
|
|
32
|
+
*/
|
|
33
|
+
export function Navbar(inProps: NavbarProps) {
|
|
34
|
+
const [props, slots] = useComponentConfig('Navbar', inProps)
|
|
35
|
+
const {
|
|
36
|
+
brand,
|
|
37
|
+
links = [],
|
|
38
|
+
actions,
|
|
39
|
+
sticky = true,
|
|
40
|
+
blurOnScroll = true,
|
|
41
|
+
containerSize,
|
|
42
|
+
className,
|
|
43
|
+
...rest
|
|
44
|
+
} = props
|
|
45
|
+
const [open, setOpen] = useState(false)
|
|
46
|
+
const scrolled = useScrolled(8, blurOnScroll)
|
|
47
|
+
const Link = useLinkComponent()
|
|
48
|
+
const s = navbarStyles({ sticky, blur: blurOnScroll })
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<header
|
|
52
|
+
data-slot="navbar"
|
|
53
|
+
data-scrolled={scrolled}
|
|
54
|
+
className={s.root({ class: [slots.root, className] })}
|
|
55
|
+
{...rest}
|
|
56
|
+
>
|
|
57
|
+
<Container size={containerSize} className={s.inner({ class: slots.inner })}>
|
|
58
|
+
<div className={s.brand({ class: slots.brand })}>{brand}</div>
|
|
59
|
+
{links.length > 0 && (
|
|
60
|
+
<nav aria-label="Main" className={s.links({ class: slots.links })}>
|
|
61
|
+
{links.map((link) => (
|
|
62
|
+
<Link
|
|
63
|
+
key={link.href}
|
|
64
|
+
href={link.href}
|
|
65
|
+
aria-current={link.active ? 'page' : undefined}
|
|
66
|
+
className={s.link({ class: slots.link })}
|
|
67
|
+
>
|
|
68
|
+
{link.label}
|
|
69
|
+
</Link>
|
|
70
|
+
))}
|
|
71
|
+
</nav>
|
|
72
|
+
)}
|
|
73
|
+
{actions && <div className={s.actions({ class: slots.actions })}>{actions}</div>}
|
|
74
|
+
|
|
75
|
+
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
76
|
+
<Dialog.Trigger asChild>
|
|
77
|
+
<IconButton
|
|
78
|
+
icon={<MenuIcon />}
|
|
79
|
+
aria-label="Open menu"
|
|
80
|
+
className={s.menuButton({ class: slots.menuButton })}
|
|
81
|
+
/>
|
|
82
|
+
</Dialog.Trigger>
|
|
83
|
+
<Dialog.Portal>
|
|
84
|
+
<Dialog.Overlay className={s.overlay({ class: slots.overlay })} />
|
|
85
|
+
<Dialog.Content
|
|
86
|
+
className={s.panel({ class: slots.panel })}
|
|
87
|
+
aria-describedby={undefined}
|
|
88
|
+
>
|
|
89
|
+
<div className={s.panelHeader({ class: slots.panelHeader })}>
|
|
90
|
+
<Dialog.Title asChild>
|
|
91
|
+
<div className={s.brand({ class: slots.brand })}>{brand}</div>
|
|
92
|
+
</Dialog.Title>
|
|
93
|
+
<Dialog.Close asChild>
|
|
94
|
+
<IconButton icon={<XIcon />} aria-label="Close menu" />
|
|
95
|
+
</Dialog.Close>
|
|
96
|
+
</div>
|
|
97
|
+
<nav aria-label="Main" className={s.panelLinks({ class: slots.panelLinks })}>
|
|
98
|
+
{links.map((link) => (
|
|
99
|
+
<Link
|
|
100
|
+
key={link.href}
|
|
101
|
+
href={link.href}
|
|
102
|
+
aria-current={link.active ? 'page' : undefined}
|
|
103
|
+
className={s.panelLink({ class: slots.panelLink })}
|
|
104
|
+
onClick={() => setOpen(false)}
|
|
105
|
+
>
|
|
106
|
+
{link.label}
|
|
107
|
+
</Link>
|
|
108
|
+
))}
|
|
109
|
+
</nav>
|
|
110
|
+
{actions && (
|
|
111
|
+
<div className={s.panelActions({ class: slots.panelActions })}>{actions}</div>
|
|
112
|
+
)}
|
|
113
|
+
</Dialog.Content>
|
|
114
|
+
</Dialog.Portal>
|
|
115
|
+
</Dialog.Root>
|
|
116
|
+
</Container>
|
|
117
|
+
</header>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { pageHeaderStyles } from './app-shell.styles'
|
|
4
|
+
|
|
5
|
+
export interface PageHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {
|
|
6
|
+
title: ReactNode
|
|
7
|
+
description?: ReactNode
|
|
8
|
+
eyebrow?: ReactNode
|
|
9
|
+
/** Buttons aligned to the end on desktop. */
|
|
10
|
+
actions?: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Title block at the top of a page: optional eyebrow, title, description, actions. */
|
|
14
|
+
export function PageHeader(inProps: PageHeaderProps) {
|
|
15
|
+
const [props, slots] = useComponentConfig('PageHeader', inProps)
|
|
16
|
+
const { title, description, eyebrow, actions, className, ...rest } = props
|
|
17
|
+
const s = pageHeaderStyles()
|
|
18
|
+
return (
|
|
19
|
+
<div data-slot="page-header" className={s.root({ class: [slots.root, className] })} {...rest}>
|
|
20
|
+
<div className={s.text({ class: slots.text })}>
|
|
21
|
+
{eyebrow && <p className={s.eyebrow({ class: slots.eyebrow })}>{eyebrow}</p>}
|
|
22
|
+
<h1 className={s.title({ class: slots.title })}>{title}</h1>
|
|
23
|
+
{description && (
|
|
24
|
+
<p className={s.description({ class: slots.description })}>{description}</p>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
{actions && <div className={s.actions({ class: slots.actions })}>{actions}</div>}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { ComponentProps, MouseEvent, ReactNode } from 'react'
|
|
2
|
+
import { IconButton, type IconButtonProps } from '../../components/button'
|
|
3
|
+
import { Tooltip } from '../../components/tooltip'
|
|
4
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
5
|
+
import { useMediaQuery } from '../../hooks/use-media-query'
|
|
6
|
+
import { PanelLeftIcon } from '../../internal/icons'
|
|
7
|
+
import { useLinkComponent } from '../../provider/glyph-context'
|
|
8
|
+
import { sidebarStyles } from './app-shell.styles'
|
|
9
|
+
import { useAppShell } from './app-shell-context'
|
|
10
|
+
|
|
11
|
+
const s = sidebarStyles()
|
|
12
|
+
|
|
13
|
+
function useSlots() {
|
|
14
|
+
return useComponentConfig('Sidebar', {})[1]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Navigation sidebar. Place inside `AppShell`'s `sidebar` prop. */
|
|
18
|
+
export function Sidebar({ className, ...props }: ComponentProps<'aside'>) {
|
|
19
|
+
const { collapsed } = useAppShell()
|
|
20
|
+
const slots = useSlots()
|
|
21
|
+
return (
|
|
22
|
+
<aside
|
|
23
|
+
data-slot="sidebar"
|
|
24
|
+
data-collapsed={collapsed}
|
|
25
|
+
className={s.root({ class: [slots.root, className] })}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SidebarHeader({ className, ...props }: ComponentProps<'div'>) {
|
|
32
|
+
const slots = useSlots()
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
data-slot="sidebar-header"
|
|
36
|
+
className={s.header({ class: [slots.header, className] })}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SidebarContent({ className, ...props }: ComponentProps<'nav'>) {
|
|
43
|
+
const slots = useSlots()
|
|
44
|
+
return (
|
|
45
|
+
<nav
|
|
46
|
+
data-slot="sidebar-content"
|
|
47
|
+
aria-label="Main"
|
|
48
|
+
className={s.content({ class: [slots.content, className] })}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SidebarSectionProps extends ComponentProps<'div'> {
|
|
55
|
+
label?: ReactNode
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function SidebarSection({ label, className, children, ...props }: SidebarSectionProps) {
|
|
59
|
+
const slots = useSlots()
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
data-slot="sidebar-section"
|
|
63
|
+
className={s.section({ class: [slots.section, className] })}
|
|
64
|
+
{...props}
|
|
65
|
+
>
|
|
66
|
+
{label && <p className={s.sectionLabel({ class: slots.sectionLabel })}>{label}</p>}
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SidebarItemProps {
|
|
73
|
+
/** Renders a link through the provider's `linkComponent`. Without it, renders a button. */
|
|
74
|
+
href?: string
|
|
75
|
+
icon?: ReactNode
|
|
76
|
+
/** Count or short status shown at the end. */
|
|
77
|
+
badge?: ReactNode
|
|
78
|
+
active?: boolean
|
|
79
|
+
onClick?: (event: MouseEvent<HTMLElement>) => void
|
|
80
|
+
className?: string
|
|
81
|
+
children: ReactNode
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function SidebarItem({
|
|
85
|
+
href,
|
|
86
|
+
icon,
|
|
87
|
+
badge,
|
|
88
|
+
active,
|
|
89
|
+
onClick,
|
|
90
|
+
className,
|
|
91
|
+
children,
|
|
92
|
+
}: SidebarItemProps) {
|
|
93
|
+
const { collapsed, inDrawer, setMobileOpen } = useAppShell()
|
|
94
|
+
const Link = useLinkComponent()
|
|
95
|
+
const slots = useSlots()
|
|
96
|
+
|
|
97
|
+
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
|
98
|
+
onClick?.(event)
|
|
99
|
+
if (inDrawer) setMobileOpen(false)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const content = (
|
|
103
|
+
<>
|
|
104
|
+
{icon && (
|
|
105
|
+
<span className={s.itemIcon({ class: slots.itemIcon })} aria-hidden="true">
|
|
106
|
+
{icon}
|
|
107
|
+
</span>
|
|
108
|
+
)}
|
|
109
|
+
<span className={s.itemLabel({ class: slots.itemLabel })}>{children}</span>
|
|
110
|
+
{badge !== undefined && (
|
|
111
|
+
<span className={s.itemBadge({ class: slots.itemBadge })}>{badge}</span>
|
|
112
|
+
)}
|
|
113
|
+
</>
|
|
114
|
+
)
|
|
115
|
+
const shared = {
|
|
116
|
+
'data-slot': 'sidebar-item',
|
|
117
|
+
'aria-current': active ? ('page' as const) : undefined,
|
|
118
|
+
className: s.item({ class: [slots.item, className] }),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const element = href ? (
|
|
122
|
+
<Link href={href} onClick={handleClick} {...shared}>
|
|
123
|
+
{content}
|
|
124
|
+
</Link>
|
|
125
|
+
) : (
|
|
126
|
+
<button type="button" onClick={handleClick} {...shared}>
|
|
127
|
+
{content}
|
|
128
|
+
</button>
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Tooltip content={children} side="right" disabled={!collapsed}>
|
|
133
|
+
{element}
|
|
134
|
+
</Tooltip>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function SidebarFooter({ className, ...props }: ComponentProps<'div'>) {
|
|
139
|
+
const slots = useSlots()
|
|
140
|
+
return (
|
|
141
|
+
<div
|
|
142
|
+
data-slot="sidebar-footer"
|
|
143
|
+
className={s.footer({ class: [slots.footer, className] })}
|
|
144
|
+
{...props}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Collapses the sidebar on desktop; opens the navigation drawer on mobile. */
|
|
150
|
+
export function SidebarTrigger(props: Partial<IconButtonProps>) {
|
|
151
|
+
const { collapsed, setCollapsed, setMobileOpen, inDrawer } = useAppShell()
|
|
152
|
+
const desktop = useMediaQuery('(min-width: 48rem)')
|
|
153
|
+
if (inDrawer) return null
|
|
154
|
+
return (
|
|
155
|
+
<IconButton
|
|
156
|
+
icon={<PanelLeftIcon />}
|
|
157
|
+
aria-label={desktop ? (collapsed ? 'Expand sidebar' : 'Collapse sidebar') : 'Open navigation'}
|
|
158
|
+
size="sm"
|
|
159
|
+
onClick={() => (desktop ? setCollapsed(!collapsed) : setMobileOpen(true))}
|
|
160
|
+
{...props}
|
|
161
|
+
/>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type DependencyList, type ReactNode, useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { EmptyState, ErrorState, LoadingState } from './states'
|
|
3
|
+
|
|
4
|
+
export type AsyncStatus = 'pending' | 'error' | 'success'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shape of an async result. TanStack Query's `useQuery()` result matches it, as does
|
|
8
|
+
* Glyph's own `useAsync()`.
|
|
9
|
+
*/
|
|
10
|
+
export interface AsyncResult<T> {
|
|
11
|
+
status: AsyncStatus
|
|
12
|
+
data?: T | undefined
|
|
13
|
+
error?: unknown
|
|
14
|
+
refetch?: () => unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AsyncProps<T> {
|
|
18
|
+
result: AsyncResult<T>
|
|
19
|
+
/** Rendered once data is ready and not empty. */
|
|
20
|
+
children: (data: NonNullable<T>) => ReactNode
|
|
21
|
+
/** Treat ready data as empty. Defaults to: an empty array. */
|
|
22
|
+
isEmpty?: (data: NonNullable<T>) => boolean
|
|
23
|
+
loading?: ReactNode
|
|
24
|
+
empty?: ReactNode
|
|
25
|
+
error?: ReactNode | ((error: unknown, retry: (() => unknown) | undefined) => ReactNode)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaultIsEmpty = (data: unknown) => Array.isArray(data) && data.length === 0
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Renders the right UI for every state of an async result: loading, error (with retry),
|
|
32
|
+
* empty, or your content. One convention for every data-driven screen.
|
|
33
|
+
*
|
|
34
|
+
* ```tsx
|
|
35
|
+
* <Async result={useQuery(...)} empty={<EmptyState title="No invoices yet" />}>
|
|
36
|
+
* {(invoices) => <InvoiceList invoices={invoices} />}
|
|
37
|
+
* </Async>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function Async<T>({
|
|
41
|
+
result,
|
|
42
|
+
children,
|
|
43
|
+
isEmpty = defaultIsEmpty,
|
|
44
|
+
loading,
|
|
45
|
+
empty,
|
|
46
|
+
error,
|
|
47
|
+
}: AsyncProps<T>) {
|
|
48
|
+
if (result.status === 'pending') return loading ?? <LoadingState />
|
|
49
|
+
if (result.status === 'error') {
|
|
50
|
+
if (typeof error === 'function') return error(result.error, result.refetch)
|
|
51
|
+
return error ?? <ErrorState error={result.error} onRetry={result.refetch} />
|
|
52
|
+
}
|
|
53
|
+
const data = result.data
|
|
54
|
+
if (data === undefined || data === null || isEmpty(data)) {
|
|
55
|
+
return empty ?? <EmptyState title="Nothing here yet" />
|
|
56
|
+
}
|
|
57
|
+
return children(data)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Runs an async function and tracks its state as an `AsyncResult`. Re-runs when `deps`
|
|
62
|
+
* change; stale responses are ignored. For caching and deduping, use TanStack Query.
|
|
63
|
+
*/
|
|
64
|
+
export function useAsync<T>(fn: () => Promise<T>, deps: DependencyList): AsyncResult<T> {
|
|
65
|
+
const [state, setState] = useState<Omit<AsyncResult<T>, 'refetch'>>({ status: 'pending' })
|
|
66
|
+
const [run, setRun] = useState(0)
|
|
67
|
+
|
|
68
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: caller controls re-runs through deps
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let active = true
|
|
71
|
+
setState((prev) => ({ ...prev, status: 'pending' }))
|
|
72
|
+
fn().then(
|
|
73
|
+
(data) => active && setState({ status: 'success', data }),
|
|
74
|
+
(error: unknown) => active && setState({ status: 'error', error }),
|
|
75
|
+
)
|
|
76
|
+
return () => {
|
|
77
|
+
active = false
|
|
78
|
+
}
|
|
79
|
+
}, [...deps, run])
|
|
80
|
+
|
|
81
|
+
const refetch = useCallback(() => setRun((n) => n + 1), [])
|
|
82
|
+
return { ...state, refetch }
|
|
83
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
2
|
+
import { Button } from '../../components/button'
|
|
3
|
+
import { Spinner } from '../../components/spinner'
|
|
4
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
5
|
+
import { AlertIcon, InboxIcon } from '../../internal/icons'
|
|
6
|
+
import { tv } from '../../utils/tv'
|
|
7
|
+
|
|
8
|
+
export const stateStyles = tv({
|
|
9
|
+
slots: {
|
|
10
|
+
root: 'flex flex-col items-center justify-center gap-3 px-6 py-12 text-center',
|
|
11
|
+
icon: 'flex size-10 items-center justify-center rounded-md border border-border bg-surface text-muted-foreground shadow-xs [&>svg]:size-5',
|
|
12
|
+
text: 'grid max-w-sm gap-1',
|
|
13
|
+
title: 'text-body font-medium text-foreground',
|
|
14
|
+
description: 'text-small text-muted-foreground',
|
|
15
|
+
action: 'mt-1 flex items-center gap-2',
|
|
16
|
+
},
|
|
17
|
+
variants: {
|
|
18
|
+
tone: {
|
|
19
|
+
neutral: {},
|
|
20
|
+
danger: { icon: 'border-danger/30 bg-danger/10 text-danger' },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export interface StateProps extends Omit<ComponentProps<'div'>, 'title'> {
|
|
26
|
+
icon?: ReactNode
|
|
27
|
+
title: ReactNode
|
|
28
|
+
description?: ReactNode
|
|
29
|
+
action?: ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Shown when a list or page has no content yet. Say what will appear and how to add it. */
|
|
33
|
+
export function EmptyState(inProps: StateProps) {
|
|
34
|
+
const [props, slots] = useComponentConfig('EmptyState', inProps)
|
|
35
|
+
const { icon = <InboxIcon />, title, description, action, className, ...rest } = props
|
|
36
|
+
const s = stateStyles({ tone: 'neutral' })
|
|
37
|
+
return (
|
|
38
|
+
<div data-slot="empty-state" className={s.root({ class: [slots.root, className] })} {...rest}>
|
|
39
|
+
<div className={s.icon({ class: slots.icon })} aria-hidden="true">
|
|
40
|
+
{icon}
|
|
41
|
+
</div>
|
|
42
|
+
<div className={s.text({ class: slots.text })}>
|
|
43
|
+
<p className={s.title({ class: slots.title })}>{title}</p>
|
|
44
|
+
{description && (
|
|
45
|
+
<p className={s.description({ class: slots.description })}>{description}</p>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
{action && <div className={s.action({ class: slots.action })}>{action}</div>}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ErrorStateProps extends Omit<StateProps, 'title'> {
|
|
54
|
+
title?: ReactNode
|
|
55
|
+
/** The caught error. Its message is shown when no `description` is given. */
|
|
56
|
+
error?: unknown
|
|
57
|
+
/** Shows a "Try again" button. */
|
|
58
|
+
onRetry?: (() => unknown) | undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function messageOf(error: unknown): string | undefined {
|
|
62
|
+
if (error instanceof Error) return error.message
|
|
63
|
+
if (typeof error === 'string') return error
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shown when loading failed. Explains what went wrong and offers a retry. */
|
|
68
|
+
export function ErrorState(inProps: ErrorStateProps) {
|
|
69
|
+
const [props, slots] = useComponentConfig('EmptyState', inProps)
|
|
70
|
+
const {
|
|
71
|
+
icon = <AlertIcon />,
|
|
72
|
+
title = 'Couldn’t load this',
|
|
73
|
+
description,
|
|
74
|
+
error,
|
|
75
|
+
onRetry,
|
|
76
|
+
action,
|
|
77
|
+
className,
|
|
78
|
+
...rest
|
|
79
|
+
} = props
|
|
80
|
+
const s = stateStyles({ tone: 'danger' })
|
|
81
|
+
const detail = description ?? messageOf(error)
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
data-slot="error-state"
|
|
85
|
+
role="alert"
|
|
86
|
+
className={s.root({ class: [slots.root, className] })}
|
|
87
|
+
{...rest}
|
|
88
|
+
>
|
|
89
|
+
<div className={s.icon({ class: slots.icon })} aria-hidden="true">
|
|
90
|
+
{icon}
|
|
91
|
+
</div>
|
|
92
|
+
<div className={s.text({ class: slots.text })}>
|
|
93
|
+
<p className={s.title({ class: slots.title })}>{title}</p>
|
|
94
|
+
{detail && <p className={s.description({ class: slots.description })}>{detail}</p>}
|
|
95
|
+
</div>
|
|
96
|
+
{(action || onRetry) && (
|
|
97
|
+
<div className={s.action({ class: slots.action })}>
|
|
98
|
+
{onRetry && (
|
|
99
|
+
<Button variant="outline" size="sm" onClick={() => onRetry()}>
|
|
100
|
+
Try again
|
|
101
|
+
</Button>
|
|
102
|
+
)}
|
|
103
|
+
{action}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface LoadingStateProps extends ComponentProps<'div'> {
|
|
111
|
+
label?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A centered spinner with a label, for content that has no natural skeleton. */
|
|
115
|
+
export function LoadingState({ label = 'Loading…', className, ...props }: LoadingStateProps) {
|
|
116
|
+
const s = stateStyles()
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
data-slot="loading-state"
|
|
120
|
+
role="status"
|
|
121
|
+
className={s.root({ class: className })}
|
|
122
|
+
{...props}
|
|
123
|
+
>
|
|
124
|
+
<Spinner size="lg" className="text-faint-foreground" />
|
|
125
|
+
<p className={s.description()}>{label}</p>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|