@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,92 @@
1
+ import { Tabs as TabsPrimitive } from 'radix-ui'
2
+ import { type ComponentProps, createContext, useContext } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import { focusRing, themedBorder, transition } from '../../utils/styles'
5
+ import { tv, type VariantProps } from '../../utils/tv'
6
+
7
+ export const tabsStyles = tv({
8
+ slots: {
9
+ root: 'flex flex-col gap-4',
10
+ list: 'flex items-center',
11
+ trigger: [
12
+ 'relative inline-flex cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap text-small font-medium text-muted-foreground',
13
+ transition,
14
+ focusRing,
15
+ 'hover:text-foreground data-[state=active]:text-foreground disabled:pointer-events-none disabled:opacity-50',
16
+ '[&>svg]:size-4',
17
+ ],
18
+ content: 'outline-none',
19
+ },
20
+ variants: {
21
+ variant: {
22
+ underline: {
23
+ list: 'gap-5 border-b border-border',
24
+ trigger:
25
+ 'h-9 after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:rounded-pill after:bg-transparent data-[state=active]:after:bg-foreground',
26
+ },
27
+ pill: {
28
+ list: ['inline-flex w-fit gap-0.5 rounded-md border-border bg-muted p-0.5', themedBorder],
29
+ trigger:
30
+ 'h-7 rounded-[calc(var(--radius-md)-2px)] px-3 data-[state=active]:bg-raised data-[state=active]:shadow-xs',
31
+ },
32
+ },
33
+ },
34
+ defaultVariants: { variant: 'underline' },
35
+ })
36
+
37
+ type TabsVariants = VariantProps<typeof tabsStyles>
38
+ const TabsContext = createContext<TabsVariants>({})
39
+
40
+ export interface TabsProps extends ComponentProps<typeof TabsPrimitive.Root>, TabsVariants {}
41
+
42
+ export function Tabs(inProps: TabsProps) {
43
+ const [{ variant, className, ...rest }, slots] = useComponentConfig('Tabs', inProps)
44
+ return (
45
+ <TabsContext.Provider value={{ variant }}>
46
+ <TabsPrimitive.Root
47
+ data-slot="tabs"
48
+ className={tabsStyles({ variant }).root({ class: [slots.root, className] })}
49
+ {...rest}
50
+ />
51
+ </TabsContext.Provider>
52
+ )
53
+ }
54
+
55
+ function useTabsParts() {
56
+ const { variant } = useContext(TabsContext)
57
+ const [, slots] = useComponentConfig('Tabs', {})
58
+ return { s: tabsStyles({ variant }), slots }
59
+ }
60
+
61
+ export function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
62
+ const { s, slots } = useTabsParts()
63
+ return (
64
+ <TabsPrimitive.List
65
+ data-slot="tabs-list"
66
+ className={s.list({ class: [slots.list, className] })}
67
+ {...props}
68
+ />
69
+ )
70
+ }
71
+
72
+ export function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
73
+ const { s, slots } = useTabsParts()
74
+ return (
75
+ <TabsPrimitive.Trigger
76
+ data-slot="tabs-trigger"
77
+ className={s.trigger({ class: [slots.trigger, className] })}
78
+ {...props}
79
+ />
80
+ )
81
+ }
82
+
83
+ export function TabsContent({ className, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
84
+ const { s, slots } = useTabsParts()
85
+ return (
86
+ <TabsPrimitive.Content
87
+ data-slot="tabs-content"
88
+ className={s.content({ class: [slots.content, className] })}
89
+ {...props}
90
+ />
91
+ )
92
+ }
@@ -0,0 +1,2 @@
1
+ export { Text, type TextProps } from './text'
2
+ export { type TextVariants, textStyles } from './text.styles'
@@ -0,0 +1,32 @@
1
+ import { tv, type VariantProps } from '../../utils/tv'
2
+
3
+ export const textStyles = tv({
4
+ slots: { base: '' },
5
+ variants: {
6
+ size: {
7
+ lead: { base: 'text-lead' },
8
+ body: { base: 'text-body' },
9
+ small: { base: 'text-small' },
10
+ caption: { base: 'text-caption' },
11
+ },
12
+ tone: {
13
+ default: { base: 'text-foreground' },
14
+ muted: { base: 'text-muted-foreground' },
15
+ faint: { base: 'text-faint-foreground' },
16
+ primary: { base: 'text-primary' },
17
+ success: { base: 'text-success' },
18
+ warning: { base: 'text-warning' },
19
+ danger: { base: 'text-danger' },
20
+ },
21
+ weight: {
22
+ regular: { base: 'font-normal' },
23
+ medium: { base: 'font-medium' },
24
+ semibold: { base: 'font-semibold' },
25
+ },
26
+ mono: { true: { base: 'font-mono' } },
27
+ truncate: { true: { base: 'truncate' } },
28
+ },
29
+ defaultVariants: { size: 'body', tone: 'default' },
30
+ })
31
+
32
+ export type TextVariants = VariantProps<typeof textStyles>
@@ -0,0 +1,17 @@
1
+ import { Slot } from 'radix-ui'
2
+ import type { ComponentProps } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import { type TextVariants, textStyles } from './text.styles'
5
+
6
+ export interface TextProps extends ComponentProps<'p'>, TextVariants {
7
+ /** Render the child element (e.g. `<span>`, `<label>`) with text styles instead of a `<p>`. */
8
+ asChild?: boolean
9
+ }
10
+
11
+ export function Text(inProps: TextProps) {
12
+ const [props, slots] = useComponentConfig('Text', inProps)
13
+ const { size, tone, weight, mono, truncate, asChild, className, ...rest } = props
14
+ const s = textStyles({ size, tone, weight, mono, truncate })
15
+ const Comp = asChild ? Slot.Root : 'p'
16
+ return <Comp data-slot="text" className={s.base({ class: [slots.base, className] })} {...rest} />
17
+ }
@@ -0,0 +1 @@
1
+ export { Textarea, textareaStyles } from './textarea'
@@ -0,0 +1,27 @@
1
+ import type { ComponentProps } from 'react'
2
+ import { useComponentConfig } from '../../hooks/use-component-config'
3
+ import { tv } from '../../utils/tv'
4
+ import { useFieldControl } from '../field/field-context'
5
+ import { fieldControl } from '../input/input.styles'
6
+
7
+ export const textareaStyles = tv({
8
+ slots: {
9
+ base: [
10
+ ...fieldControl,
11
+ 'field-sizing-content min-h-20 resize-y rounded-[min(var(--radius-md),var(--radius-lg))] px-3 py-2 text-small',
12
+ 'outline-none placeholder:text-faint-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20',
13
+ 'disabled:cursor-not-allowed disabled:opacity-50',
14
+ ],
15
+ },
16
+ })
17
+
18
+ export function Textarea(inProps: ComponentProps<'textarea'>) {
19
+ const [{ className, ...rest }, slots] = useComponentConfig('Textarea', useFieldControl(inProps))
20
+ return (
21
+ <textarea
22
+ data-slot="textarea"
23
+ className={textareaStyles().base({ class: [slots.base, className] })}
24
+ {...rest}
25
+ />
26
+ )
27
+ }
@@ -0,0 +1 @@
1
+ export { Tooltip, type TooltipProps, tooltipStyles } from './tooltip'
@@ -0,0 +1,42 @@
1
+ import { Tooltip as TooltipPrimitive } from 'radix-ui'
2
+ import type { ComponentProps, ReactNode } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import { tv } from '../../utils/tv'
5
+
6
+ export const tooltipStyles = tv({
7
+ slots: {
8
+ content:
9
+ 'z-50 max-w-xs animate-pop-in rounded-sm bg-secondary px-2 py-1 text-caption text-secondary-foreground shadow-md',
10
+ },
11
+ })
12
+
13
+ export interface TooltipProps
14
+ extends Omit<ComponentProps<typeof TooltipPrimitive.Content>, 'content'> {
15
+ content: ReactNode
16
+ children: ReactNode
17
+ /** Skip the tooltip entirely, e.g. when the label is already visible. */
18
+ disabled?: boolean
19
+ delayDuration?: number
20
+ }
21
+
22
+ /** A short hint on hover or focus. The child must be focusable (a button or link). */
23
+ export function Tooltip(inProps: TooltipProps) {
24
+ const [props, slots] = useComponentConfig('Tooltip', inProps)
25
+ const { content, children, disabled, delayDuration, className, sideOffset = 6, ...rest } = props
26
+ if (disabled) return children
27
+ return (
28
+ <TooltipPrimitive.Root {...(delayDuration !== undefined && { delayDuration })}>
29
+ <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
30
+ <TooltipPrimitive.Portal>
31
+ <TooltipPrimitive.Content
32
+ data-slot="tooltip"
33
+ sideOffset={sideOffset}
34
+ className={tooltipStyles().content({ class: [slots.content, className] })}
35
+ {...rest}
36
+ >
37
+ {content}
38
+ </TooltipPrimitive.Content>
39
+ </TooltipPrimitive.Portal>
40
+ </TooltipPrimitive.Root>
41
+ )
42
+ }
@@ -0,0 +1,4 @@
1
+ export { type SlotClasses, useComponentConfig } from './use-component-config'
2
+ export { useControllableState } from './use-controllable-state'
3
+ export { useMediaQuery } from './use-media-query'
4
+ export { useScrolled } from './use-scrolled'
@@ -0,0 +1,42 @@
1
+ import type { ComponentName, GlyphComponentRegistry } from '@glyphui/tokens'
2
+ import { useContext } from 'react'
3
+ import { GlyphContext } from '../provider/glyph-context'
4
+
5
+ type SlotsOf<K extends ComponentName> = GlyphComponentRegistry[K] extends {
6
+ slots: infer S extends string
7
+ }
8
+ ? S
9
+ : never
10
+
11
+ export type SlotClasses<K extends ComponentName> = Partial<Record<SlotsOf<K>, string>>
12
+
13
+ interface LooseOverride {
14
+ defaultVariants?: Record<string, unknown>
15
+ className?: Record<string, string>
16
+ }
17
+
18
+ /**
19
+ * Applies the theme's per-component settings (`theme.components[name]`):
20
+ * fills unset variant props from `defaultVariants` and returns the extra slot classes.
21
+ * Every styled component calls this first.
22
+ */
23
+ export function useComponentConfig<K extends ComponentName, P extends object>(
24
+ name: K,
25
+ props: P,
26
+ ): [props: P, slotClasses: SlotClasses<K>] {
27
+ const theme = useContext(GlyphContext)?.theme
28
+ const override = theme?.components[name] as LooseOverride | undefined
29
+ if (!override) return [props, {}]
30
+
31
+ let merged = props
32
+ const defaults = override.defaultVariants
33
+ if (defaults) {
34
+ merged = { ...props }
35
+ for (const [key, value] of Object.entries(defaults)) {
36
+ if ((merged as Record<string, unknown>)[key] === undefined) {
37
+ ;(merged as Record<string, unknown>)[key] = value
38
+ }
39
+ }
40
+ }
41
+ return [merged, (override.className ?? {}) as SlotClasses<K>]
42
+ }
@@ -0,0 +1,27 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ interface Options<T> {
4
+ value?: T | undefined
5
+ defaultValue: T
6
+ onChange?: ((value: T) => void) | undefined
7
+ }
8
+
9
+ /** State that can be controlled by a parent (`value` + `onChange`) or left uncontrolled. */
10
+ export function useControllableState<T>({ value, defaultValue, onChange }: Options<T>) {
11
+ const [internal, setInternal] = useState(defaultValue)
12
+ const isControlled = value !== undefined
13
+ const current = isControlled ? value : internal
14
+ const onChangeRef = useRef(onChange)
15
+ onChangeRef.current = onChange
16
+
17
+ const setValue = useCallback(
18
+ (next: T | ((prev: T) => T)) => {
19
+ const resolved = typeof next === 'function' ? (next as (prev: T) => T)(current) : next
20
+ if (!isControlled) setInternal(resolved)
21
+ onChangeRef.current?.(resolved)
22
+ },
23
+ [current, isControlled],
24
+ )
25
+
26
+ return [current, setValue] as const
27
+ }
@@ -0,0 +1,14 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ /** Subscribes to a CSS media query. Returns `fallback` during SSR. */
4
+ export function useMediaQuery(query: string, fallback = false): boolean {
5
+ return useSyncExternalStore(
6
+ (onChange) => {
7
+ const list = window.matchMedia(query)
8
+ list.addEventListener('change', onChange)
9
+ return () => list.removeEventListener('change', onChange)
10
+ },
11
+ () => window.matchMedia(query).matches,
12
+ () => fallback,
13
+ )
14
+ }
@@ -0,0 +1,14 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /** True once the window has scrolled past `threshold` pixels. */
4
+ export function useScrolled(threshold = 8, enabled = true): boolean {
5
+ const [scrolled, setScrolled] = useState(false)
6
+ useEffect(() => {
7
+ if (!enabled) return
8
+ const update = () => setScrolled(window.scrollY > threshold)
9
+ update()
10
+ window.addEventListener('scroll', update, { passive: true })
11
+ return () => window.removeEventListener('scroll', update)
12
+ }, [threshold, enabled])
13
+ return scrolled
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ import './register'
2
+
3
+ // Theme engine (re-exported so apps only import from @glyphui/ui).
4
+ export {
5
+ type ColorMode,
6
+ type ColorToken,
7
+ type ComponentOverrides,
8
+ defineTheme,
9
+ type RadiusToken,
10
+ type ShadowToken,
11
+ type TextToken,
12
+ type Theme,
13
+ type ThemeInput,
14
+ type Tone,
15
+ themeToCss,
16
+ tokenVar,
17
+ } from '@glyphui/tokens'
18
+ // Components
19
+ export * from './components/avatar'
20
+ export * from './components/badge'
21
+ export * from './components/button'
22
+ export * from './components/card'
23
+ export * from './components/checkbox'
24
+ export * from './components/container'
25
+ export * from './components/eyebrow'
26
+ export * from './components/field'
27
+ export * from './components/heading'
28
+ export * from './components/input'
29
+ export * from './components/kbd'
30
+ export * from './components/label'
31
+ export * from './components/section'
32
+ export * from './components/segmented-control'
33
+ export * from './components/select'
34
+ export * from './components/separator'
35
+ export * from './components/skeleton'
36
+ export * from './components/spinner'
37
+ export * from './components/stack'
38
+ export * from './components/switch'
39
+ export * from './components/table'
40
+ export * from './components/tabs'
41
+ export * from './components/text'
42
+ export * from './components/textarea'
43
+ export * from './components/tooltip'
44
+ export * from './hooks'
45
+ // Patterns
46
+ export * from './patterns/app-shell'
47
+ export * from './patterns/async'
48
+ // Providers and hooks
49
+ export * from './provider'
50
+ export { brutalist, editorial, glyph, type PresetName, playful, presets } from './themes'
51
+ export { type ClassValue, cn } from './utils/cn'
52
+ export { tv, type VariantProps } from './utils/tv'
@@ -0,0 +1,99 @@
1
+ import type { SVGProps } from 'react'
2
+
3
+ /** Minimal internal icon set, so components don't force an icon library on consumers. */
4
+ type IconProps = SVGProps<SVGSVGElement>
5
+
6
+ function Icon({ children, ...props }: IconProps) {
7
+ return (
8
+ <svg
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ width="16"
11
+ height="16"
12
+ viewBox="0 0 24 24"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ strokeWidth="2"
16
+ strokeLinecap="round"
17
+ strokeLinejoin="round"
18
+ aria-hidden="true"
19
+ {...props}
20
+ >
21
+ {children}
22
+ </svg>
23
+ )
24
+ }
25
+
26
+ export const CheckIcon = (p: IconProps) => (
27
+ <Icon {...p}>
28
+ <path d="M20 6 9 17l-5-5" />
29
+ </Icon>
30
+ )
31
+ export const MinusIcon = (p: IconProps) => (
32
+ <Icon {...p}>
33
+ <path d="M5 12h14" />
34
+ </Icon>
35
+ )
36
+ export const ChevronDownIcon = (p: IconProps) => (
37
+ <Icon {...p}>
38
+ <path d="m6 9 6 6 6-6" />
39
+ </Icon>
40
+ )
41
+ export const ChevronLeftIcon = (p: IconProps) => (
42
+ <Icon {...p}>
43
+ <path d="m15 18-6-6 6-6" />
44
+ </Icon>
45
+ )
46
+ export const ChevronRightIcon = (p: IconProps) => (
47
+ <Icon {...p}>
48
+ <path d="m9 18 6-6-6-6" />
49
+ </Icon>
50
+ )
51
+ export const ArrowUpIcon = (p: IconProps) => (
52
+ <Icon {...p}>
53
+ <path d="m5 12 7-7 7 7M12 19V5" />
54
+ </Icon>
55
+ )
56
+ export const ArrowDownIcon = (p: IconProps) => (
57
+ <Icon {...p}>
58
+ <path d="M12 5v14M19 12l-7 7-7-7" />
59
+ </Icon>
60
+ )
61
+ export const ChevronsUpDownIcon = (p: IconProps) => (
62
+ <Icon {...p}>
63
+ <path d="m7 15 5 5 5-5M7 9l5-5 5 5" />
64
+ </Icon>
65
+ )
66
+ export const XIcon = (p: IconProps) => (
67
+ <Icon {...p}>
68
+ <path d="M18 6 6 18M6 6l12 12" />
69
+ </Icon>
70
+ )
71
+ export const MenuIcon = (p: IconProps) => (
72
+ <Icon {...p}>
73
+ <path d="M4 6h16M4 12h16M4 18h16" />
74
+ </Icon>
75
+ )
76
+ export const PanelLeftIcon = (p: IconProps) => (
77
+ <Icon {...p}>
78
+ <rect width="18" height="18" x="3" y="3" rx="2" />
79
+ <path d="M9 3v18" />
80
+ </Icon>
81
+ )
82
+ export const SearchIcon = (p: IconProps) => (
83
+ <Icon {...p}>
84
+ <circle cx="11" cy="11" r="7" />
85
+ <path d="m20 20-3.5-3.5" />
86
+ </Icon>
87
+ )
88
+ export const InboxIcon = (p: IconProps) => (
89
+ <Icon {...p}>
90
+ <path d="M22 12h-6l-2 3h-4l-2-3H2" />
91
+ <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
92
+ </Icon>
93
+ )
94
+ export const AlertIcon = (p: IconProps) => (
95
+ <Icon {...p}>
96
+ <circle cx="12" cy="12" r="10" />
97
+ <path d="M12 8v4M12 16h.01" />
98
+ </Icon>
99
+ )
@@ -0,0 +1,27 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ export interface AppShellContextValue {
4
+ /** Desktop sidebar is collapsed to an icon rail. */
5
+ collapsed: boolean
6
+ setCollapsed: (collapsed: boolean) => void
7
+ /** Mobile navigation drawer is open. */
8
+ mobileOpen: boolean
9
+ setMobileOpen: (open: boolean) => void
10
+ /** True for the sidebar copy rendered inside the mobile drawer. */
11
+ inDrawer: boolean
12
+ }
13
+
14
+ export const AppShellContext = createContext<AppShellContextValue | null>(null)
15
+
16
+ /** Sidebar state. Works outside an AppShell too (always expanded). */
17
+ export function useAppShell(): AppShellContextValue {
18
+ return (
19
+ useContext(AppShellContext) ?? {
20
+ collapsed: false,
21
+ setCollapsed: () => {},
22
+ mobileOpen: false,
23
+ setMobileOpen: () => {},
24
+ inDrawer: false,
25
+ }
26
+ )
27
+ }
@@ -0,0 +1,127 @@
1
+ import { focusRing, transition } from '../../utils/styles'
2
+ import { tv, type VariantProps } from '../../utils/tv'
3
+
4
+ export const appShellStyles = tv({
5
+ slots: {
6
+ root: 'flex w-full bg-background',
7
+ sidebar: 'hidden shrink-0 md:flex',
8
+ main: 'flex min-w-0 flex-1 flex-col',
9
+ mobileBar: [
10
+ 'flex h-14 shrink-0 items-center gap-2 border-b-border px-3 md:hidden',
11
+ 'border-b-(length:--glyph-border-width)',
12
+ ],
13
+ overlay: 'fixed inset-0 z-50 animate-fade-in bg-overlay',
14
+ drawer:
15
+ 'fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] animate-slide-in-left shadow-lg outline-none [&>[data-slot=sidebar]]:w-full',
16
+ },
17
+ variants: {
18
+ layout: {
19
+ page: {
20
+ root: 'min-h-dvh',
21
+ sidebar: 'sticky top-0 h-dvh',
22
+ mobileBar: 'sticky top-0 z-30 bg-background/80 backdrop-blur-xl',
23
+ },
24
+ embedded: {
25
+ root: 'h-full min-h-0 overflow-hidden',
26
+ sidebar: 'h-full',
27
+ main: 'overflow-y-auto',
28
+ },
29
+ },
30
+ },
31
+ defaultVariants: { layout: 'page' },
32
+ })
33
+
34
+ export type AppShellVariants = VariantProps<typeof appShellStyles>
35
+
36
+ export const sidebarStyles = tv({
37
+ slots: {
38
+ root: [
39
+ 'group/sidebar flex h-full w-60 flex-col overflow-hidden border-r-border bg-subtle',
40
+ 'border-r-(length:--glyph-border-width)',
41
+ 'transition-[width] duration-(--glyph-duration-base) ease-standard data-[collapsed=true]:w-15',
42
+ ],
43
+ header: 'flex h-14 shrink-0 items-center gap-2 px-3',
44
+ content: 'flex flex-1 flex-col gap-5 overflow-y-auto overflow-x-hidden px-3 py-3',
45
+ section: 'flex flex-col gap-0.5',
46
+ sectionLabel:
47
+ 'mb-1 truncate px-2 font-mono text-caption uppercase tracking-[0.1em] text-faint-foreground group-data-[collapsed=true]/sidebar:sr-only',
48
+ item: [
49
+ 'relative flex h-8 w-full shrink-0 cursor-pointer items-center gap-2.5 rounded-sm px-2 text-small font-medium text-muted-foreground',
50
+ transition,
51
+ focusRing,
52
+ 'hover:bg-muted hover:text-foreground',
53
+ 'aria-[current=page]:bg-muted aria-[current=page]:text-foreground',
54
+ 'before:absolute before:inset-y-1.5 before:-left-3 before:w-0.5 before:rounded-r-pill before:bg-transparent aria-[current=page]:before:bg-primary',
55
+ 'group-data-[collapsed=true]/sidebar:justify-center group-data-[collapsed=true]/sidebar:px-0',
56
+ ],
57
+ itemIcon: 'flex size-4 shrink-0 items-center justify-center [&>svg]:size-4',
58
+ itemLabel: 'min-w-0 flex-1 truncate text-left group-data-[collapsed=true]/sidebar:hidden',
59
+ itemBadge:
60
+ 'ml-auto font-mono text-caption text-faint-foreground tabular-nums group-data-[collapsed=true]/sidebar:hidden',
61
+ footer: [
62
+ 'mt-auto flex shrink-0 items-center gap-2 border-t-border px-3 py-3 group-data-[collapsed=true]/sidebar:justify-center',
63
+ 'border-t-(length:--glyph-border-width)',
64
+ ],
65
+ },
66
+ })
67
+
68
+ export const navbarStyles = tv({
69
+ slots: {
70
+ root: [
71
+ 'top-0 z-40 w-full border-b-transparent',
72
+ 'border-b-(length:--glyph-border-width)',
73
+ 'transition-[background-color,border-color,backdrop-filter] duration-(--glyph-duration-base) ease-standard',
74
+ ],
75
+ inner: 'flex h-16 items-center gap-6',
76
+ brand: 'flex shrink-0 items-center gap-2 font-display text-h4 text-foreground',
77
+ links: 'hidden items-center gap-1 md:flex',
78
+ link: [
79
+ 'rounded-sm px-3 py-1.5 text-small font-medium text-muted-foreground',
80
+ transition,
81
+ focusRing,
82
+ 'hover:text-foreground aria-[current=page]:text-foreground',
83
+ ],
84
+ actions: 'ml-auto hidden items-center gap-2 md:flex',
85
+ menuButton: 'ml-auto md:hidden',
86
+ overlay: 'fixed inset-0 z-50 animate-fade-in bg-overlay',
87
+ panel: [
88
+ 'fixed inset-x-0 top-0 z-50 max-h-dvh overflow-y-auto border-b-border bg-background px-4 pb-6 shadow-lg outline-none animate-slide-in-top',
89
+ 'border-b-(length:--glyph-border-width)',
90
+ ],
91
+ panelHeader: 'flex h-16 items-center justify-between',
92
+ panelLinks: 'flex flex-col gap-1',
93
+ panelLink: [
94
+ 'rounded-md px-3 py-2.5 text-body font-medium text-muted-foreground',
95
+ focusRing,
96
+ 'hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground',
97
+ ],
98
+ panelActions: [
99
+ 'mt-4 flex flex-col gap-2 border-t-border pt-4',
100
+ 'border-t-(length:--glyph-border-width)',
101
+ ],
102
+ },
103
+ variants: {
104
+ sticky: { true: { root: 'sticky' }, false: { root: 'relative' } },
105
+ blur: {
106
+ true: {
107
+ root: 'data-[scrolled=true]:border-b-border data-[scrolled=true]:bg-background/72 data-[scrolled=true]:backdrop-blur-xl data-[scrolled=true]:backdrop-saturate-150',
108
+ },
109
+ false: { root: 'border-b-border bg-background' },
110
+ },
111
+ },
112
+ defaultVariants: { sticky: true, blur: true },
113
+ })
114
+
115
+ export const pageHeaderStyles = tv({
116
+ slots: {
117
+ root: [
118
+ 'flex flex-col gap-4 border-b-border pb-5 sm:flex-row sm:items-end sm:justify-between',
119
+ 'border-b-(length:--glyph-border-width)',
120
+ ],
121
+ text: 'grid min-w-0 gap-1',
122
+ eyebrow: 'font-mono text-caption uppercase tracking-[0.12em] text-faint-foreground',
123
+ title: 'font-display text-h3 text-foreground',
124
+ description: 'max-w-2xl text-small text-muted-foreground',
125
+ actions: 'flex shrink-0 flex-wrap items-center gap-2',
126
+ },
127
+ })