@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.
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,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,2 @@
1
+ export { Button, type ButtonProps, IconButton, type IconButtonProps } from './button'
2
+ export { type ButtonVariants, buttonStyles } from './button.styles'
@@ -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,11 @@
1
+ export {
2
+ Card,
3
+ CardBody,
4
+ CardDescription,
5
+ CardFooter,
6
+ CardHeader,
7
+ type CardProps,
8
+ CardTitle,
9
+ type CardTitleProps,
10
+ } from './card'
11
+ export { type CardVariants, cardStyles } from './card.styles'
@@ -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,2 @@
1
+ export { Eyebrow, type EyebrowProps } from './eyebrow'
2
+ export { type EyebrowVariants, eyebrowStyles } from './eyebrow.styles'
@@ -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,3 @@
1
+ export { Field, type FieldProps } from './field'
2
+ export { type FieldVariants, fieldStyles } from './field.styles'
3
+ export { FieldContext, type FieldContextValue, useFieldControl } from './field-context'
@@ -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,2 @@
1
+ export { Heading, type HeadingProps } from './heading'
2
+ export { type HeadingVariants, headingStyles } from './heading.styles'
@@ -0,0 +1,2 @@
1
+ export { Input, type InputProps } from './input'
2
+ export { fieldControl, type InputVariants, inputStyles } from './input.styles'
@@ -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'