@glyphui/ui 0.0.0-stage → 0.1.1

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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -2
  3. package/dist/data-table.d.ts +110 -0
  4. package/dist/data-table.js +225 -0
  5. package/dist/field-DXZD0l8w.js +107 -0
  6. package/dist/field-context-B8cYheXz.js +321 -0
  7. package/dist/form.d.ts +79 -0
  8. package/dist/form.js +84 -0
  9. package/dist/index-BWK4czIG.d.ts +233 -0
  10. package/dist/index.d.ts +2155 -0
  11. package/dist/index.js +1529 -0
  12. package/dist/registry-BGSJoiLq.d.ts +9 -0
  13. package/dist/states-BxPjCUxz.js +287 -0
  14. package/dist/tv-DuMh7UH2.d.ts +6 -0
  15. package/package.json +113 -3
  16. package/src/components/avatar/avatar.styles.ts +22 -0
  17. package/src/components/avatar/avatar.tsx +62 -0
  18. package/src/components/avatar/index.ts +2 -0
  19. package/src/components/badge/badge.styles.ts +69 -0
  20. package/src/components/badge/badge.tsx +30 -0
  21. package/src/components/badge/index.ts +2 -0
  22. package/src/components/button/button.styles.ts +43 -0
  23. package/src/components/button/button.tsx +77 -0
  24. package/src/components/button/index.ts +2 -0
  25. package/src/components/card/card.styles.ts +32 -0
  26. package/src/components/card/card.tsx +91 -0
  27. package/src/components/card/index.ts +11 -0
  28. package/src/components/checkbox/checkbox.tsx +39 -0
  29. package/src/components/checkbox/index.ts +1 -0
  30. package/src/components/container/container.tsx +35 -0
  31. package/src/components/container/index.ts +1 -0
  32. package/src/components/eyebrow/eyebrow.styles.ts +14 -0
  33. package/src/components/eyebrow/eyebrow.tsx +19 -0
  34. package/src/components/eyebrow/index.ts +2 -0
  35. package/src/components/field/field-context.ts +40 -0
  36. package/src/components/field/field.styles.ts +23 -0
  37. package/src/components/field/field.tsx +109 -0
  38. package/src/components/field/index.ts +3 -0
  39. package/src/components/heading/heading.styles.ts +16 -0
  40. package/src/components/heading/heading.tsx +26 -0
  41. package/src/components/heading/index.ts +2 -0
  42. package/src/components/input/index.ts +2 -0
  43. package/src/components/input/input.styles.ts +36 -0
  44. package/src/components/input/input.tsx +33 -0
  45. package/src/components/kbd/index.ts +1 -0
  46. package/src/components/kbd/kbd.tsx +21 -0
  47. package/src/components/label/index.ts +1 -0
  48. package/src/components/label/label.tsx +21 -0
  49. package/src/components/section/index.ts +1 -0
  50. package/src/components/section/section.tsx +39 -0
  51. package/src/components/segmented-control/index.ts +6 -0
  52. package/src/components/segmented-control/segmented-control.tsx +79 -0
  53. package/src/components/select/index.ts +1 -0
  54. package/src/components/select/select.tsx +133 -0
  55. package/src/components/separator/index.ts +1 -0
  56. package/src/components/separator/separator.tsx +29 -0
  57. package/src/components/skeleton/index.ts +1 -0
  58. package/src/components/skeleton/skeleton.tsx +20 -0
  59. package/src/components/spinner/index.ts +2 -0
  60. package/src/components/spinner/spinner.styles.ts +19 -0
  61. package/src/components/spinner/spinner.tsx +42 -0
  62. package/src/components/stack/index.ts +1 -0
  63. package/src/components/stack/stack.tsx +62 -0
  64. package/src/components/switch/index.ts +1 -0
  65. package/src/components/switch/switch.tsx +48 -0
  66. package/src/components/table/index.ts +10 -0
  67. package/src/components/table/table.tsx +90 -0
  68. package/src/components/tabs/index.ts +1 -0
  69. package/src/components/tabs/tabs.tsx +92 -0
  70. package/src/components/text/index.ts +2 -0
  71. package/src/components/text/text.styles.ts +32 -0
  72. package/src/components/text/text.tsx +17 -0
  73. package/src/components/textarea/index.ts +1 -0
  74. package/src/components/textarea/textarea.tsx +27 -0
  75. package/src/components/tooltip/index.ts +1 -0
  76. package/src/components/tooltip/tooltip.tsx +42 -0
  77. package/src/hooks/index.ts +4 -0
  78. package/src/hooks/use-component-config.ts +42 -0
  79. package/src/hooks/use-controllable-state.ts +27 -0
  80. package/src/hooks/use-media-query.ts +14 -0
  81. package/src/hooks/use-scrolled.ts +14 -0
  82. package/src/index.ts +52 -0
  83. package/src/internal/icons.tsx +99 -0
  84. package/src/patterns/app-shell/app-shell-context.ts +27 -0
  85. package/src/patterns/app-shell/app-shell.styles.ts +127 -0
  86. package/src/patterns/app-shell/app-shell.tsx +81 -0
  87. package/src/patterns/app-shell/index.ts +16 -0
  88. package/src/patterns/app-shell/navbar.tsx +119 -0
  89. package/src/patterns/app-shell/page-header.tsx +30 -0
  90. package/src/patterns/app-shell/sidebar.tsx +163 -0
  91. package/src/patterns/async/async.tsx +83 -0
  92. package/src/patterns/async/index.ts +10 -0
  93. package/src/patterns/async/states.tsx +128 -0
  94. package/src/patterns/data-table/data-table.tsx +367 -0
  95. package/src/patterns/data-table/index.ts +2 -0
  96. package/src/patterns/form/form.tsx +150 -0
  97. package/src/patterns/form/index.ts +13 -0
  98. package/src/provider/color-mode.tsx +133 -0
  99. package/src/provider/glyph-context.ts +42 -0
  100. package/src/provider/glyph-provider.tsx +41 -0
  101. package/src/provider/index.ts +18 -0
  102. package/src/provider/theme-scope.tsx +36 -0
  103. package/src/provider/theme-style.tsx +19 -0
  104. package/src/register.ts +69 -0
  105. package/src/themes.ts +56 -0
  106. package/src/utils/cn.ts +12 -0
  107. package/src/utils/compose-refs.ts +11 -0
  108. package/src/utils/registry.ts +9 -0
  109. package/src/utils/styles.ts +13 -0
  110. package/src/utils/tv.ts +7 -0
  111. package/src/utils/tw-merge-config.ts +14 -0
  112. 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,10 @@
1
+ export { Async, type AsyncProps, type AsyncResult, type AsyncStatus, useAsync } from './async'
2
+ export {
3
+ EmptyState,
4
+ ErrorState,
5
+ type ErrorStateProps,
6
+ LoadingState,
7
+ type LoadingStateProps,
8
+ type StateProps,
9
+ stateStyles,
10
+ } from './states'
@@ -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
+ }