@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.
- 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,77 @@
|
|
|
1
|
+
import { Slot } from 'radix-ui'
|
|
2
|
+
import { type ComponentProps, Fragment, type ReactNode } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { Spinner } from '../spinner'
|
|
5
|
+
import { type ButtonVariants, buttonStyles } from './button.styles'
|
|
6
|
+
|
|
7
|
+
export interface ButtonProps extends ComponentProps<'button'>, ButtonVariants {
|
|
8
|
+
/** Render the child element (e.g. a router link) with button styles instead of a `<button>`. */
|
|
9
|
+
asChild?: boolean
|
|
10
|
+
/** Shows a spinner, disables the button and sets `aria-busy`. */
|
|
11
|
+
loading?: boolean
|
|
12
|
+
leftIcon?: ReactNode
|
|
13
|
+
rightIcon?: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Button(inProps: ButtonProps) {
|
|
17
|
+
const [props, slots] = useComponentConfig('Button', inProps)
|
|
18
|
+
const {
|
|
19
|
+
variant,
|
|
20
|
+
size,
|
|
21
|
+
iconOnly,
|
|
22
|
+
fullWidth,
|
|
23
|
+
asChild = false,
|
|
24
|
+
loading = false,
|
|
25
|
+
leftIcon,
|
|
26
|
+
rightIcon,
|
|
27
|
+
disabled,
|
|
28
|
+
type,
|
|
29
|
+
className,
|
|
30
|
+
children,
|
|
31
|
+
...rest
|
|
32
|
+
} = props
|
|
33
|
+
const s = buttonStyles({ variant, size, iconOnly, fullWidth })
|
|
34
|
+
const Comp = asChild ? Slot.Root : 'button'
|
|
35
|
+
const icon = (node: ReactNode) =>
|
|
36
|
+
node ? (
|
|
37
|
+
<span data-slot="button-icon" className={s.icon({ class: slots.icon })} aria-hidden="true">
|
|
38
|
+
{node}
|
|
39
|
+
</span>
|
|
40
|
+
) : null
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Comp
|
|
44
|
+
data-slot="button"
|
|
45
|
+
data-variant={variant ?? 'primary'}
|
|
46
|
+
className={s.base({ class: [slots.base, className] })}
|
|
47
|
+
aria-busy={loading || undefined}
|
|
48
|
+
{...(!asChild && { type: type ?? 'button', disabled: disabled || loading })}
|
|
49
|
+
{...rest}
|
|
50
|
+
>
|
|
51
|
+
{iconOnly
|
|
52
|
+
? icon(loading ? <Spinner size={size === 'lg' ? 'md' : 'sm'} /> : children)
|
|
53
|
+
: // Children stay direct (no Fragment) so Slot can find the Slottable when asChild is set.
|
|
54
|
+
[
|
|
55
|
+
<Fragment key="start">{icon(loading ? <Spinner size="sm" /> : leftIcon)}</Fragment>,
|
|
56
|
+
<Slot.Slottable key="content">{children}</Slot.Slottable>,
|
|
57
|
+
<Fragment key="end">{icon(rightIcon)}</Fragment>,
|
|
58
|
+
]}
|
|
59
|
+
</Comp>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IconButtonProps
|
|
64
|
+
extends Omit<ButtonProps, 'leftIcon' | 'rightIcon' | 'iconOnly' | 'fullWidth' | 'children'> {
|
|
65
|
+
icon: ReactNode
|
|
66
|
+
/** Required: an icon-only button has no visible text. */
|
|
67
|
+
'aria-label': string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A square button holding one icon. Shares Button's variants and theme overrides. */
|
|
71
|
+
export function IconButton({ icon, variant = 'ghost', ...props }: IconButtonProps) {
|
|
72
|
+
return (
|
|
73
|
+
<Button variant={variant} {...props} iconOnly>
|
|
74
|
+
{icon}
|
|
75
|
+
</Button>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { themedBorder, transition } from '../../utils/styles'
|
|
2
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
3
|
+
|
|
4
|
+
export const cardStyles = tv({
|
|
5
|
+
slots: {
|
|
6
|
+
root: ['flex flex-col rounded-lg text-foreground', themedBorder, transition],
|
|
7
|
+
header: 'flex flex-col gap-1',
|
|
8
|
+
title: 'font-display text-h4 text-foreground',
|
|
9
|
+
description: 'text-small text-muted-foreground',
|
|
10
|
+
body: 'min-w-0',
|
|
11
|
+
footer: 'mt-auto flex items-center gap-2 pt-1',
|
|
12
|
+
},
|
|
13
|
+
variants: {
|
|
14
|
+
variant: {
|
|
15
|
+
outline: { root: 'border-border bg-surface' },
|
|
16
|
+
elevated: { root: 'border-border bg-surface shadow-md' },
|
|
17
|
+
ghost: { root: 'border-transparent bg-subtle' },
|
|
18
|
+
interactive: {
|
|
19
|
+
root: 'cursor-pointer border-border bg-surface shadow-sm hover:-translate-y-0.5 hover:border-border-strong hover:shadow-lg',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
padding: {
|
|
23
|
+
none: { root: 'gap-0 p-0' },
|
|
24
|
+
sm: { root: 'gap-3 p-4' },
|
|
25
|
+
md: { root: 'gap-4 p-6' },
|
|
26
|
+
lg: { root: 'gap-5 p-8' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: { variant: 'elevated', padding: 'md' },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export type CardVariants = VariantProps<typeof cardStyles>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Slot } from 'radix-ui'
|
|
2
|
+
import { type ComponentProps, createContext, useContext } from 'react'
|
|
3
|
+
import { type SlotClasses, useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { type CardVariants, cardStyles } from './card.styles'
|
|
5
|
+
|
|
6
|
+
type CardStyles = ReturnType<typeof cardStyles>
|
|
7
|
+
|
|
8
|
+
const CardContext = createContext<{ s: CardStyles; slots: SlotClasses<'Card'> } | null>(null)
|
|
9
|
+
|
|
10
|
+
function useCardParts() {
|
|
11
|
+
const ctx = useContext(CardContext)
|
|
12
|
+
return ctx ?? { s: cardStyles(), slots: {} }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CardProps extends ComponentProps<'div'>, CardVariants {
|
|
16
|
+
asChild?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A surface that groups related content. Compose with CardHeader, CardTitle, CardBody, CardFooter. */
|
|
20
|
+
export function Card(inProps: CardProps) {
|
|
21
|
+
const [props, slots] = useComponentConfig('Card', inProps)
|
|
22
|
+
const { variant, padding, asChild, className, ...rest } = props
|
|
23
|
+
const s = cardStyles({ variant, padding })
|
|
24
|
+
const Comp = asChild ? Slot.Root : 'div'
|
|
25
|
+
return (
|
|
26
|
+
<CardContext.Provider value={{ s, slots }}>
|
|
27
|
+
<Comp
|
|
28
|
+
data-slot="card"
|
|
29
|
+
data-variant={variant}
|
|
30
|
+
className={s.root({ class: [slots.root, className] })}
|
|
31
|
+
{...rest}
|
|
32
|
+
/>
|
|
33
|
+
</CardContext.Provider>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CardHeader({ className, ...props }: ComponentProps<'div'>) {
|
|
38
|
+
const { s, slots } = useCardParts()
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
data-slot="card-header"
|
|
42
|
+
className={s.header({ class: [slots.header, className] })}
|
|
43
|
+
{...props}
|
|
44
|
+
/>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CardTitleProps extends ComponentProps<'h3'> {
|
|
49
|
+
asChild?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function CardTitle({ asChild, className, ...props }: CardTitleProps) {
|
|
53
|
+
const { s, slots } = useCardParts()
|
|
54
|
+
const Comp = asChild ? Slot.Root : 'h3'
|
|
55
|
+
return (
|
|
56
|
+
<Comp
|
|
57
|
+
data-slot="card-title"
|
|
58
|
+
className={s.title({ class: [slots.title, className] })}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function CardDescription({ className, ...props }: ComponentProps<'p'>) {
|
|
65
|
+
const { s, slots } = useCardParts()
|
|
66
|
+
return (
|
|
67
|
+
<p
|
|
68
|
+
data-slot="card-description"
|
|
69
|
+
className={s.description({ class: [slots.description, className] })}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function CardBody({ className, ...props }: ComponentProps<'div'>) {
|
|
76
|
+
const { s, slots } = useCardParts()
|
|
77
|
+
return (
|
|
78
|
+
<div data-slot="card-body" className={s.body({ class: [slots.body, className] })} {...props} />
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function CardFooter({ className, ...props }: ComponentProps<'div'>) {
|
|
83
|
+
const { s, slots } = useCardParts()
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
data-slot="card-footer"
|
|
87
|
+
className={s.footer({ class: [slots.footer, className] })}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { CheckIcon, MinusIcon } from '../../internal/icons'
|
|
5
|
+
import { focusRing, themedBorder, transition } from '../../utils/styles'
|
|
6
|
+
import { tv } from '../../utils/tv'
|
|
7
|
+
import { useFieldControl } from '../field/field-context'
|
|
8
|
+
|
|
9
|
+
export const checkboxStyles = tv({
|
|
10
|
+
slots: {
|
|
11
|
+
root: [
|
|
12
|
+
'peer mt-0.5 inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-xs border-input bg-surface text-primary-foreground shadow-xs',
|
|
13
|
+
themedBorder,
|
|
14
|
+
transition,
|
|
15
|
+
focusRing,
|
|
16
|
+
'hover:border-border-strong aria-invalid:border-danger',
|
|
17
|
+
'data-[state=checked]:border-primary data-[state=checked]:bg-primary',
|
|
18
|
+
'data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary',
|
|
19
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
20
|
+
],
|
|
21
|
+
indicator: 'flex items-center justify-center [&>svg]:size-3.5 [&>svg]:stroke-[3]',
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export function Checkbox(inProps: ComponentProps<typeof CheckboxPrimitive.Root>) {
|
|
26
|
+
const [{ className, ...rest }, slots] = useComponentConfig('Checkbox', useFieldControl(inProps))
|
|
27
|
+
const s = checkboxStyles()
|
|
28
|
+
return (
|
|
29
|
+
<CheckboxPrimitive.Root
|
|
30
|
+
data-slot="checkbox"
|
|
31
|
+
className={s.root({ class: [slots.root, className] })}
|
|
32
|
+
{...rest}
|
|
33
|
+
>
|
|
34
|
+
<CheckboxPrimitive.Indicator className={s.indicator({ class: slots.indicator })}>
|
|
35
|
+
{rest.checked === 'indeterminate' ? <MinusIcon /> : <CheckIcon />}
|
|
36
|
+
</CheckboxPrimitive.Indicator>
|
|
37
|
+
</CheckboxPrimitive.Root>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Checkbox, checkboxStyles } from './checkbox'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Slot } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
3
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
4
|
+
|
|
5
|
+
export const containerStyles = tv({
|
|
6
|
+
base: 'mx-auto w-full px-4 sm:px-6',
|
|
7
|
+
variants: {
|
|
8
|
+
size: {
|
|
9
|
+
sm: 'max-w-2xl',
|
|
10
|
+
md: 'max-w-3xl',
|
|
11
|
+
lg: 'max-w-5xl',
|
|
12
|
+
xl: 'max-w-7xl',
|
|
13
|
+
full: 'max-w-none',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: { size: 'xl' },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export interface ContainerProps
|
|
20
|
+
extends ComponentProps<'div'>,
|
|
21
|
+
VariantProps<typeof containerStyles> {
|
|
22
|
+
asChild?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Centers content at a readable max width with consistent side gutters. */
|
|
26
|
+
export function Container({ size, asChild, className, ...props }: ContainerProps) {
|
|
27
|
+
const Comp = asChild ? Slot.Root : 'div'
|
|
28
|
+
return (
|
|
29
|
+
<Comp
|
|
30
|
+
data-slot="container"
|
|
31
|
+
className={containerStyles({ size, class: className })}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Container, type ContainerProps, containerStyles } from './container'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
2
|
+
|
|
3
|
+
export const eyebrowStyles = tv({
|
|
4
|
+
slots: { base: 'font-mono text-caption uppercase tracking-[0.12em]' },
|
|
5
|
+
variants: {
|
|
6
|
+
tone: {
|
|
7
|
+
muted: { base: 'text-muted-foreground' },
|
|
8
|
+
primary: { base: 'text-primary' },
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
defaultVariants: { tone: 'muted' },
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export type EyebrowVariants = VariantProps<typeof eyebrowStyles>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Slot } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { type EyebrowVariants, eyebrowStyles } from './eyebrow.styles'
|
|
5
|
+
|
|
6
|
+
export interface EyebrowProps extends ComponentProps<'p'>, EyebrowVariants {
|
|
7
|
+
asChild?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Small uppercase label that sits above a heading. */
|
|
11
|
+
export function Eyebrow(inProps: EyebrowProps) {
|
|
12
|
+
const [props, slots] = useComponentConfig('Eyebrow', inProps)
|
|
13
|
+
const { tone, asChild, className, ...rest } = props
|
|
14
|
+
const s = eyebrowStyles({ tone })
|
|
15
|
+
const Comp = asChild ? Slot.Root : 'p'
|
|
16
|
+
return (
|
|
17
|
+
<Comp data-slot="eyebrow" className={s.base({ class: [slots.base, className] })} {...rest} />
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface FieldContextValue {
|
|
4
|
+
id: string
|
|
5
|
+
descriptionId: string | undefined
|
|
6
|
+
errorId: string | undefined
|
|
7
|
+
invalid: boolean
|
|
8
|
+
required: boolean
|
|
9
|
+
disabled: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FieldContext = createContext<FieldContextValue | null>(null)
|
|
13
|
+
|
|
14
|
+
interface ControlProps {
|
|
15
|
+
id?: string | undefined
|
|
16
|
+
disabled?: boolean | undefined
|
|
17
|
+
required?: boolean | undefined
|
|
18
|
+
'aria-describedby'?: string | undefined
|
|
19
|
+
'aria-invalid'?: boolean | 'true' | 'false' | 'grammar' | 'spelling' | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Connects a form control to the surrounding `Field`: id for the label, `aria-describedby`
|
|
24
|
+
* for description and error, `aria-invalid`, `required`, `disabled`. Props passed directly win.
|
|
25
|
+
*/
|
|
26
|
+
export function useFieldControl<P extends ControlProps>(props: P): P {
|
|
27
|
+
const field = useContext(FieldContext)
|
|
28
|
+
if (!field) return props
|
|
29
|
+
const describedBy = [field.descriptionId, field.errorId, props['aria-describedby']]
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(' ')
|
|
32
|
+
return {
|
|
33
|
+
...props,
|
|
34
|
+
id: props.id ?? field.id,
|
|
35
|
+
disabled: props.disabled ?? (field.disabled || undefined),
|
|
36
|
+
required: props.required ?? (field.required || undefined),
|
|
37
|
+
'aria-describedby': describedBy || undefined,
|
|
38
|
+
'aria-invalid': props['aria-invalid'] ?? (field.invalid || undefined),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
2
|
+
|
|
3
|
+
export const fieldStyles = tv({
|
|
4
|
+
slots: {
|
|
5
|
+
root: 'grid gap-1.5',
|
|
6
|
+
label: '',
|
|
7
|
+
required: 'text-danger',
|
|
8
|
+
text: 'grid gap-0.5',
|
|
9
|
+
description: 'text-small text-faint-foreground',
|
|
10
|
+
error: 'text-small font-medium text-danger',
|
|
11
|
+
},
|
|
12
|
+
variants: {
|
|
13
|
+
orientation: {
|
|
14
|
+
vertical: {},
|
|
15
|
+
horizontal: {
|
|
16
|
+
root: 'grid-cols-[auto_1fr] items-start gap-x-3 [&>[data-slot=field-text]]:col-start-2',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: { orientation: 'vertical' },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export type FieldVariants = VariantProps<typeof fieldStyles>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactNode, useId, useMemo } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { Label } from '../label'
|
|
4
|
+
import { type FieldVariants, fieldStyles } from './field.styles'
|
|
5
|
+
import { FieldContext } from './field-context'
|
|
6
|
+
|
|
7
|
+
export interface FieldProps extends Omit<ComponentProps<'div'>, 'children'>, FieldVariants {
|
|
8
|
+
label?: ReactNode
|
|
9
|
+
description?: ReactNode
|
|
10
|
+
/** Error message. Marks the control invalid and is announced to screen readers. */
|
|
11
|
+
error?: ReactNode
|
|
12
|
+
required?: boolean
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
/** Id for the control. Generated when omitted. */
|
|
15
|
+
controlId?: string
|
|
16
|
+
children: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wraps one form control with its label, description and error, and wires the ARIA
|
|
21
|
+
* attributes between them. Controls inside (Input, Select, Checkbox, ...) pick this up.
|
|
22
|
+
*
|
|
23
|
+
* `orientation="horizontal"` puts the control first with text beside it, for checkboxes
|
|
24
|
+
* and switches.
|
|
25
|
+
*/
|
|
26
|
+
export function Field(inProps: FieldProps) {
|
|
27
|
+
const [props, slots] = useComponentConfig('Field', inProps)
|
|
28
|
+
const {
|
|
29
|
+
label,
|
|
30
|
+
description,
|
|
31
|
+
error,
|
|
32
|
+
required = false,
|
|
33
|
+
disabled = false,
|
|
34
|
+
controlId,
|
|
35
|
+
orientation,
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
...rest
|
|
39
|
+
} = props
|
|
40
|
+
const generated = useId()
|
|
41
|
+
const id = controlId ?? generated
|
|
42
|
+
const invalid = Boolean(error)
|
|
43
|
+
const descriptionId = description ? `${id}-description` : undefined
|
|
44
|
+
const errorId = invalid ? `${id}-error` : undefined
|
|
45
|
+
const s = fieldStyles({ orientation })
|
|
46
|
+
|
|
47
|
+
const context = useMemo(
|
|
48
|
+
() => ({ id, descriptionId, errorId, invalid, required, disabled }),
|
|
49
|
+
[id, descriptionId, errorId, invalid, required, disabled],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const labelNode = label ? (
|
|
53
|
+
<Label htmlFor={id} className={s.label({ class: slots.label })}>
|
|
54
|
+
{label}
|
|
55
|
+
{required && (
|
|
56
|
+
<span className={s.required({ class: slots.required })} aria-hidden="true">
|
|
57
|
+
*
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
</Label>
|
|
61
|
+
) : null
|
|
62
|
+
|
|
63
|
+
const messages = (
|
|
64
|
+
<>
|
|
65
|
+
{description && (
|
|
66
|
+
<p
|
|
67
|
+
id={descriptionId}
|
|
68
|
+
data-slot="field-description"
|
|
69
|
+
className={s.description({ class: slots.description })}
|
|
70
|
+
>
|
|
71
|
+
{description}
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
{invalid && (
|
|
75
|
+
<p id={errorId} data-slot="field-error" className={s.error({ class: slots.error })}>
|
|
76
|
+
{error}
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
</>
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<FieldContext.Provider value={context}>
|
|
84
|
+
<div
|
|
85
|
+
data-slot="field"
|
|
86
|
+
data-invalid={invalid || undefined}
|
|
87
|
+
data-disabled={disabled || undefined}
|
|
88
|
+
className={s.root({ class: [slots.root, className] })}
|
|
89
|
+
{...rest}
|
|
90
|
+
>
|
|
91
|
+
{orientation === 'horizontal' ? (
|
|
92
|
+
<>
|
|
93
|
+
{children}
|
|
94
|
+
<div data-slot="field-text" className={s.text({ class: slots.text })}>
|
|
95
|
+
{labelNode}
|
|
96
|
+
{messages}
|
|
97
|
+
</div>
|
|
98
|
+
</>
|
|
99
|
+
) : (
|
|
100
|
+
<>
|
|
101
|
+
{labelNode}
|
|
102
|
+
{children}
|
|
103
|
+
{messages}
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</FieldContext.Provider>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
2
|
+
|
|
3
|
+
export const headingStyles = tv({
|
|
4
|
+
slots: { base: 'font-display text-balance text-foreground' },
|
|
5
|
+
variants: {
|
|
6
|
+
size: {
|
|
7
|
+
display: { base: 'text-display' },
|
|
8
|
+
h1: { base: 'text-h1' },
|
|
9
|
+
h2: { base: 'text-h2' },
|
|
10
|
+
h3: { base: 'text-h3' },
|
|
11
|
+
h4: { base: 'text-h4' },
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export type HeadingVariants = VariantProps<typeof headingStyles>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Slot } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { type HeadingVariants, headingStyles } from './heading.styles'
|
|
5
|
+
|
|
6
|
+
type Level = 1 | 2 | 3 | 4 | 5 | 6
|
|
7
|
+
type Size = NonNullable<HeadingVariants['size']>
|
|
8
|
+
|
|
9
|
+
const sizeForLevel: Record<Level, Size> = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4', 5: 'h4', 6: 'h4' }
|
|
10
|
+
|
|
11
|
+
export interface HeadingProps extends ComponentProps<'h2'>, HeadingVariants {
|
|
12
|
+
/** Document outline level (h1–h6). Visual size follows it unless `size` is set. */
|
|
13
|
+
level?: Level
|
|
14
|
+
asChild?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A heading whose semantic level and visual size are set independently. */
|
|
18
|
+
export function Heading(inProps: HeadingProps) {
|
|
19
|
+
const [props, slots] = useComponentConfig('Heading', inProps)
|
|
20
|
+
const { level = 2, size, asChild, className, ...rest } = props
|
|
21
|
+
const s = headingStyles({ size: size ?? sizeForLevel[level] })
|
|
22
|
+
const Comp = asChild ? Slot.Root : (`h${level}` as const)
|
|
23
|
+
return (
|
|
24
|
+
<Comp data-slot="heading" className={s.base({ class: [slots.base, className] })} {...rest} />
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { themedBorder, transition } from '../../utils/styles'
|
|
2
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
3
|
+
|
|
4
|
+
/** Shared by Input, Textarea and the Select trigger so all fields look alike. */
|
|
5
|
+
export const fieldControl = [
|
|
6
|
+
'w-full rounded-md border-input bg-surface text-foreground shadow-xs',
|
|
7
|
+
themedBorder,
|
|
8
|
+
transition,
|
|
9
|
+
'hover:border-border-strong',
|
|
10
|
+
'data-invalid:border-danger aria-invalid:border-danger',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export const inputStyles = tv({
|
|
14
|
+
slots: {
|
|
15
|
+
root: [
|
|
16
|
+
...fieldControl,
|
|
17
|
+
'flex items-center gap-2 px-3',
|
|
18
|
+
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20',
|
|
19
|
+
'data-invalid:focus-within:ring-danger/20',
|
|
20
|
+
'has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50',
|
|
21
|
+
],
|
|
22
|
+
input:
|
|
23
|
+
'h-full w-full min-w-0 bg-transparent text-foreground outline-none placeholder:text-faint-foreground disabled:cursor-not-allowed',
|
|
24
|
+
adornment: 'flex shrink-0 items-center text-faint-foreground [&>svg]:size-4',
|
|
25
|
+
},
|
|
26
|
+
variants: {
|
|
27
|
+
size: {
|
|
28
|
+
sm: { root: 'h-(--glyph-control-sm) text-small' },
|
|
29
|
+
md: { root: 'h-(--glyph-control-md) text-small' },
|
|
30
|
+
lg: { root: 'h-(--glyph-control-lg) text-body' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: { size: 'md' },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type InputVariants = VariantProps<typeof inputStyles>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { useFieldControl } from '../field/field-context'
|
|
4
|
+
import { type InputVariants, inputStyles } from './input.styles'
|
|
5
|
+
|
|
6
|
+
export interface InputProps
|
|
7
|
+
extends Omit<ComponentProps<'input'>, 'size' | 'prefix'>,
|
|
8
|
+
InputVariants {
|
|
9
|
+
/** Content before the text, e.g. an icon or `https://`. */
|
|
10
|
+
prefix?: ReactNode
|
|
11
|
+
/** Content after the text, e.g. a unit or a Kbd hint. */
|
|
12
|
+
suffix?: ReactNode
|
|
13
|
+
/** Classes for the `<input>` element itself. `className` styles the outer frame. */
|
|
14
|
+
inputClassName?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Input(inProps: InputProps) {
|
|
18
|
+
const [props, slots] = useComponentConfig('Input', useFieldControl(inProps))
|
|
19
|
+
const { size, prefix, suffix, className, inputClassName, ...rest } = props
|
|
20
|
+
const s = inputStyles({ size })
|
|
21
|
+
const invalid = rest['aria-invalid'] === true || rest['aria-invalid'] === 'true'
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
data-slot="input"
|
|
25
|
+
data-invalid={invalid || undefined}
|
|
26
|
+
className={s.root({ class: [slots.root, className] })}
|
|
27
|
+
>
|
|
28
|
+
{prefix && <span className={s.adornment({ class: slots.adornment })}>{prefix}</span>}
|
|
29
|
+
<input className={s.input({ class: [slots.input, inputClassName] })} {...rest} />
|
|
30
|
+
{suffix && <span className={s.adornment({ class: slots.adornment })}>{suffix}</span>}
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Kbd, kbdStyles } from './kbd'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { tv } from '../../utils/tv'
|
|
4
|
+
|
|
5
|
+
export const kbdStyles = tv({
|
|
6
|
+
slots: {
|
|
7
|
+
base: 'inline-flex h-5 min-w-5 items-center justify-center rounded-xs border border-border bg-muted px-1 font-mono text-[0.6875rem] font-medium text-muted-foreground shadow-xs',
|
|
8
|
+
},
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
/** A keyboard key, e.g. `<Kbd>⌘K</Kbd>`. */
|
|
12
|
+
export function Kbd(inProps: ComponentProps<'kbd'>) {
|
|
13
|
+
const [{ className, ...rest }, slots] = useComponentConfig('Kbd', inProps)
|
|
14
|
+
return (
|
|
15
|
+
<kbd
|
|
16
|
+
data-slot="kbd"
|
|
17
|
+
className={kbdStyles().base({ class: [slots.base, className] })}
|
|
18
|
+
{...rest}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Label, labelStyles } from './label'
|