@glyphui/ui 0.1.0 → 0.2.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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/data-table.js +2 -2
  3. package/dist/{field-DXZD0l8w.js → field-CCecIzdq.js} +1 -1
  4. package/dist/{field-context-B8cYheXz.js → field-context-B6WOcK5m.js} +99 -99
  5. package/dist/form.js +2 -2
  6. package/dist/index.d.ts +2743 -93
  7. package/dist/index.js +2748 -111
  8. package/dist/{states-BxPjCUxz.js → states-Rp8wZ_jH.js} +1 -1
  9. package/package.json +4 -2
  10. package/src/components/accordion/accordion.styles.ts +41 -0
  11. package/src/components/accordion/accordion.tsx +86 -0
  12. package/src/components/accordion/index.ts +8 -0
  13. package/src/components/alert/alert-icons.tsx +66 -0
  14. package/src/components/alert/alert.styles.ts +57 -0
  15. package/src/components/alert/alert.tsx +86 -0
  16. package/src/components/alert/index.ts +2 -0
  17. package/src/components/alert-dialog/alert-dialog.styles.ts +24 -0
  18. package/src/components/alert-dialog/alert-dialog.tsx +118 -0
  19. package/src/components/alert-dialog/confirm.tsx +111 -0
  20. package/src/components/alert-dialog/index.ts +15 -0
  21. package/src/components/aspect-ratio/aspect-ratio.tsx +40 -0
  22. package/src/components/aspect-ratio/index.ts +1 -0
  23. package/src/components/badge/badge.styles.ts +1 -1
  24. package/src/components/banner/banner.styles.ts +40 -0
  25. package/src/components/banner/banner.tsx +101 -0
  26. package/src/components/banner/index.ts +2 -0
  27. package/src/components/blockquote/blockquote.tsx +59 -0
  28. package/src/components/blockquote/index.ts +1 -0
  29. package/src/components/breadcrumb/breadcrumb.styles.ts +20 -0
  30. package/src/components/breadcrumb/breadcrumb.tsx +135 -0
  31. package/src/components/breadcrumb/index.ts +12 -0
  32. package/src/components/button/button.tsx +11 -8
  33. package/src/components/code/code.styles.ts +33 -0
  34. package/src/components/code/code.tsx +96 -0
  35. package/src/components/code/index.ts +2 -0
  36. package/src/components/combobox/combobox.styles.ts +50 -0
  37. package/src/components/combobox/combobox.tsx +203 -0
  38. package/src/components/combobox/index.ts +2 -0
  39. package/src/components/command-menu/command-menu.styles.ts +41 -0
  40. package/src/components/command-menu/command-menu.tsx +217 -0
  41. package/src/components/command-menu/index.ts +14 -0
  42. package/src/components/description-list/description-list.styles.ts +31 -0
  43. package/src/components/description-list/description-list.tsx +56 -0
  44. package/src/components/description-list/index.ts +7 -0
  45. package/src/components/dialog/close-icon.tsx +22 -0
  46. package/src/components/dialog/dialog.styles.ts +41 -0
  47. package/src/components/dialog/dialog.tsx +111 -0
  48. package/src/components/dialog/index.ts +13 -0
  49. package/src/components/dropdown-menu/dropdown-menu.styles.ts +45 -0
  50. package/src/components/dropdown-menu/dropdown-menu.tsx +230 -0
  51. package/src/components/dropdown-menu/index.ts +19 -0
  52. package/src/components/grid/grid.tsx +144 -0
  53. package/src/components/grid/index.ts +1 -0
  54. package/src/components/link/index.ts +2 -0
  55. package/src/components/link/link.styles.ts +39 -0
  56. package/src/components/link/link.tsx +62 -0
  57. package/src/components/pagination/index.ts +3 -0
  58. package/src/components/pagination/pagination-range.ts +35 -0
  59. package/src/components/pagination/pagination.styles.ts +41 -0
  60. package/src/components/pagination/pagination.tsx +130 -0
  61. package/src/components/popover/index.ts +9 -0
  62. package/src/components/popover/popover.tsx +66 -0
  63. package/src/components/progress/index.ts +7 -0
  64. package/src/components/progress/progress.styles.ts +68 -0
  65. package/src/components/progress/progress.tsx +158 -0
  66. package/src/components/prose/index.ts +1 -0
  67. package/src/components/prose/prose.tsx +82 -0
  68. package/src/components/radio-group/index.ts +7 -0
  69. package/src/components/radio-group/radio-group.styles.ts +64 -0
  70. package/src/components/radio-group/radio-group.tsx +128 -0
  71. package/src/components/scroll-area/index.ts +1 -0
  72. package/src/components/scroll-area/scroll-area.tsx +68 -0
  73. package/src/components/sheet/index.ts +13 -0
  74. package/src/components/sheet/sheet.styles.ts +60 -0
  75. package/src/components/sheet/sheet.tsx +108 -0
  76. package/src/components/slider/index.ts +2 -0
  77. package/src/components/slider/slider.styles.ts +32 -0
  78. package/src/components/slider/slider.tsx +131 -0
  79. package/src/components/stat-card/index.ts +2 -0
  80. package/src/components/stat-card/stat-card.styles.ts +24 -0
  81. package/src/components/stat-card/stat-card.tsx +108 -0
  82. package/src/components/toast/index.ts +1 -0
  83. package/src/components/toast/toast.tsx +91 -0
  84. package/src/index.ts +25 -0
  85. package/src/provider/glyph-provider.tsx +16 -2
@@ -0,0 +1,40 @@
1
+ import { AspectRatio as AspectRatioPrimitive } from 'radix-ui'
2
+ import type { ComponentProps } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import type { StylesEntry } from '../../utils/registry'
5
+ import { tv, type VariantProps } from '../../utils/tv'
6
+
7
+ export const aspectRatioStyles = tv({
8
+ slots: {
9
+ base: 'overflow-hidden [&>img]:size-full [&>img]:object-cover [&>video]:size-full [&>video]:object-cover',
10
+ },
11
+ variants: {
12
+ rounded: { true: { base: 'rounded-lg' } },
13
+ placeholder: { true: { base: 'bg-muted' } },
14
+ },
15
+ })
16
+
17
+ declare module '@glyphui/tokens' {
18
+ interface GlyphComponentRegistry {
19
+ AspectRatio: StylesEntry<typeof aspectRatioStyles>
20
+ }
21
+ }
22
+
23
+ export interface AspectRatioProps
24
+ extends ComponentProps<typeof AspectRatioPrimitive.Root>,
25
+ VariantProps<typeof aspectRatioStyles> {}
26
+
27
+ /** Keeps media at a fixed width-to-height ratio, e.g. `ratio={16 / 9}`. Images inside cover the box. */
28
+ export function AspectRatio(inProps: AspectRatioProps) {
29
+ const [props, slots] = useComponentConfig('AspectRatio', inProps)
30
+ const { rounded, placeholder, className, ...rest } = props
31
+ return (
32
+ <AspectRatioPrimitive.Root
33
+ data-slot="aspect-ratio"
34
+ className={aspectRatioStyles({ rounded, placeholder }).base({
35
+ class: [slots.base, className],
36
+ })}
37
+ {...rest}
38
+ />
39
+ )
40
+ }
@@ -0,0 +1 @@
1
+ export { AspectRatio, type AspectRatioProps, aspectRatioStyles } from './aspect-ratio'
@@ -46,7 +46,7 @@ type Variant = keyof (typeof tones)[Tone]
46
46
  export const badgeStyles = tv({
47
47
  slots: {
48
48
  base: [
49
- 'inline-flex h-5.5 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-pill border-transparent px-2',
49
+ 'inline-flex h-5.5 w-fit shrink-0 items-center gap-1.5 whitespace-nowrap rounded-pill border-transparent px-2',
50
50
  'text-caption tabular-nums',
51
51
  themedBorder,
52
52
  ],
@@ -0,0 +1,40 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { tv, type VariantProps } from '../../utils/tv'
3
+
4
+ export const bannerStyles = tv({
5
+ slots: {
6
+ root: ['w-full border-b-(length:--glyph-border-width) text-small'],
7
+ inner: 'mx-auto flex w-full max-w-7xl items-center gap-3 px-4 py-2.5 sm:px-6',
8
+ icon: 'flex size-4 shrink-0 items-center justify-center [&>svg]:size-4',
9
+ content: 'min-w-0 flex-1 [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-2',
10
+ action: 'flex shrink-0 items-center gap-2',
11
+ dismiss: '-my-1 -mr-2 shrink-0 text-current hover:bg-current/10 hover:text-current',
12
+ },
13
+ variants: {
14
+ tone: {
15
+ neutral: { root: 'border-border bg-subtle text-foreground', icon: 'text-muted-foreground' },
16
+ primary: { root: 'border-primary bg-primary text-primary-foreground' },
17
+ info: {
18
+ root: 'border-info/25 bg-info/10 text-[color-mix(in_oklab,var(--color-info)_55%,var(--color-foreground))]',
19
+ },
20
+ success: {
21
+ root: 'border-success/25 bg-success/10 text-[color-mix(in_oklab,var(--color-success)_55%,var(--color-foreground))]',
22
+ },
23
+ warning: {
24
+ root: 'border-warning/30 bg-warning/14 text-[color-mix(in_oklab,var(--color-warning)_50%,var(--color-foreground))]',
25
+ },
26
+ danger: {
27
+ root: 'border-danger/25 bg-danger/10 text-[color-mix(in_oklab,var(--color-danger)_55%,var(--color-foreground))]',
28
+ },
29
+ },
30
+ },
31
+ defaultVariants: { tone: 'neutral' },
32
+ })
33
+
34
+ export type BannerVariants = VariantProps<typeof bannerStyles>
35
+
36
+ declare module '@glyphui/tokens' {
37
+ interface GlyphComponentRegistry {
38
+ Banner: StylesEntry<typeof bannerStyles>
39
+ }
40
+ }
@@ -0,0 +1,101 @@
1
+ import { type ComponentProps, type ReactNode, useState } from 'react'
2
+ import { useComponentConfig } from '../../hooks/use-component-config'
3
+ import { XIcon } from '../../internal/icons'
4
+ import { toneIcons } from '../alert/alert-icons'
5
+ import { IconButton } from '../button'
6
+ import { type BannerVariants, bannerStyles } from './banner.styles'
7
+
8
+ export interface BannerProps extends ComponentProps<'section'>, BannerVariants {
9
+ /** Replaces the tone's default icon. `false` hides it. */
10
+ icon?: ReactNode | false
11
+ action?: ReactNode
12
+ /** Shows a close button. */
13
+ dismissible?: boolean
14
+ /** Remembers the dismissal in localStorage under this key, so the banner stays hidden. */
15
+ storageKey?: string
16
+ onDismiss?: () => void
17
+ dismissLabel?: string
18
+ }
19
+
20
+ function readDismissed(key: string | undefined): boolean {
21
+ if (!key || typeof window === 'undefined') return false
22
+ try {
23
+ return window.localStorage.getItem(key) === 'dismissed'
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ /** A full-width announcement at the top of a page or section. */
30
+ export function Banner(inProps: BannerProps) {
31
+ const [props, slots] = useComponentConfig('Banner', inProps)
32
+ const {
33
+ tone,
34
+ icon,
35
+ action,
36
+ dismissible = false,
37
+ storageKey,
38
+ onDismiss,
39
+ dismissLabel = 'Dismiss',
40
+ className,
41
+ children,
42
+ ...rest
43
+ } = props
44
+ const [dismissed, setDismissed] = useState(() => readDismissed(storageKey))
45
+ if (dismissed) return null
46
+
47
+ const resolvedTone = tone ?? 'neutral'
48
+ const s = bannerStyles({ tone })
49
+ const iconNode = icon === false ? null : (icon ?? toneIcons[resolvedTone]())
50
+
51
+ const dismiss = () => {
52
+ setDismissed(true)
53
+ if (storageKey) {
54
+ try {
55
+ window.localStorage.setItem(storageKey, 'dismissed')
56
+ } catch {
57
+ // Storage unavailable: the banner stays hidden for this visit only.
58
+ }
59
+ }
60
+ onDismiss?.()
61
+ }
62
+
63
+ return (
64
+ <section
65
+ data-slot="banner"
66
+ data-tone={resolvedTone}
67
+ aria-label="Announcement"
68
+ className={s.root({ class: [slots.root, className] })}
69
+ {...rest}
70
+ >
71
+ <div className={s.inner({ class: slots.inner })}>
72
+ {iconNode && (
73
+ <span
74
+ data-slot="banner-icon"
75
+ className={s.icon({ class: slots.icon })}
76
+ aria-hidden="true"
77
+ >
78
+ {iconNode}
79
+ </span>
80
+ )}
81
+ <div data-slot="banner-content" className={s.content({ class: slots.content })}>
82
+ {children}
83
+ </div>
84
+ {action && (
85
+ <div data-slot="banner-action" className={s.action({ class: slots.action })}>
86
+ {action}
87
+ </div>
88
+ )}
89
+ {dismissible && (
90
+ <IconButton
91
+ size="sm"
92
+ icon={<XIcon />}
93
+ aria-label={dismissLabel}
94
+ onClick={dismiss}
95
+ className={s.dismiss({ class: slots.dismiss })}
96
+ />
97
+ )}
98
+ </div>
99
+ </section>
100
+ )
101
+ }
@@ -0,0 +1,2 @@
1
+ export { Banner, type BannerProps } from './banner'
2
+ export { type BannerVariants, bannerStyles } from './banner.styles'
@@ -0,0 +1,59 @@
1
+ import type { ComponentProps, ReactNode } from 'react'
2
+ import { useComponentConfig } from '../../hooks/use-component-config'
3
+ import type { StylesEntry } from '../../utils/registry'
4
+ import { tv, type VariantProps } from '../../utils/tv'
5
+
6
+ export const blockquoteStyles = tv({
7
+ slots: {
8
+ root: 'grid gap-3 border-l-2 border-primary pl-5',
9
+ quote: 'font-display text-pretty text-foreground',
10
+ caption: 'text-small text-muted-foreground',
11
+ author: 'font-medium text-foreground not-italic',
12
+ },
13
+ variants: {
14
+ size: {
15
+ sm: { quote: 'text-body' },
16
+ md: { quote: 'text-lead' },
17
+ lg: { quote: 'text-h3' },
18
+ },
19
+ },
20
+ defaultVariants: { size: 'md' },
21
+ })
22
+
23
+ declare module '@glyphui/tokens' {
24
+ interface GlyphComponentRegistry {
25
+ Blockquote: StylesEntry<typeof blockquoteStyles>
26
+ }
27
+ }
28
+
29
+ export interface BlockquoteProps
30
+ extends ComponentProps<'figure'>,
31
+ VariantProps<typeof blockquoteStyles> {
32
+ /** Who said it. */
33
+ author?: ReactNode
34
+ /** Their role or the work it's from, shown after the author. */
35
+ source?: ReactNode
36
+ /** URL of the quoted source, set as the `cite` attribute. */
37
+ citeUrl?: string
38
+ }
39
+
40
+ /** A quotation with an optional attribution. */
41
+ export function Blockquote(inProps: BlockquoteProps) {
42
+ const [props, slots] = useComponentConfig('Blockquote', inProps)
43
+ const { author, source, citeUrl, size, className, children, ...rest } = props
44
+ const s = blockquoteStyles({ size })
45
+ return (
46
+ <figure data-slot="blockquote" className={s.root({ class: [slots.root, className] })} {...rest}>
47
+ <blockquote cite={citeUrl} className={s.quote({ class: slots.quote })}>
48
+ {children}
49
+ </blockquote>
50
+ {(author || source) && (
51
+ <figcaption className={s.caption({ class: slots.caption })}>
52
+ {author && <cite className={s.author({ class: slots.author })}>{author}</cite>}
53
+ {author && source && ', '}
54
+ {source}
55
+ </figcaption>
56
+ )}
57
+ </figure>
58
+ )
59
+ }
@@ -0,0 +1 @@
1
+ export { Blockquote, type BlockquoteProps, blockquoteStyles } from './blockquote'
@@ -0,0 +1,20 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { focusRing, transition } from '../../utils/styles'
3
+ import { tv } from '../../utils/tv'
4
+
5
+ export const breadcrumbStyles = tv({
6
+ slots: {
7
+ root: 'min-w-0',
8
+ list: 'flex flex-wrap items-center gap-1.5 text-small text-muted-foreground',
9
+ item: 'inline-flex min-w-0 items-center gap-1.5',
10
+ link: ['truncate rounded-xs hover:text-foreground', transition, focusRing],
11
+ page: 'truncate font-medium text-foreground',
12
+ separator: 'flex items-center text-faint-foreground [&>svg]:size-3.5',
13
+ },
14
+ })
15
+
16
+ declare module '@glyphui/tokens' {
17
+ interface GlyphComponentRegistry {
18
+ Breadcrumb: StylesEntry<typeof breadcrumbStyles>
19
+ }
20
+ }
@@ -0,0 +1,135 @@
1
+ import { Slot } from 'radix-ui'
2
+ import type { ComponentProps, ReactNode } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import { ChevronRightIcon } from '../../internal/icons'
5
+ import { useLinkComponent } from '../../provider/glyph-context'
6
+ import { breadcrumbStyles } from './breadcrumb.styles'
7
+
8
+ const s = breadcrumbStyles()
9
+
10
+ function useSlots() {
11
+ return useComponentConfig('Breadcrumb', {})[1]
12
+ }
13
+
14
+ export interface BreadcrumbItemData {
15
+ label: ReactNode
16
+ /** Omit for the current page (normally the last item). */
17
+ href?: string
18
+ }
19
+
20
+ export interface BreadcrumbProps extends ComponentProps<'nav'> {
21
+ /** Data-driven trail. The last item is the current page. Omit to compose the parts yourself. */
22
+ items?: readonly BreadcrumbItemData[]
23
+ /** Custom separator between items. A chevron by default. */
24
+ separator?: ReactNode
25
+ }
26
+
27
+ /** Shows where the current page sits in the hierarchy. */
28
+ export function Breadcrumb({ items, separator, className, children, ...props }: BreadcrumbProps) {
29
+ const slots = useSlots()
30
+ return (
31
+ <nav
32
+ aria-label="Breadcrumb"
33
+ data-slot="breadcrumb"
34
+ className={s.root({ class: [slots.root, className] })}
35
+ {...props}
36
+ >
37
+ {items ? (
38
+ <BreadcrumbList>
39
+ {items.map((item, index) => {
40
+ const last = index === items.length - 1
41
+ return (
42
+ <BreadcrumbItem key={index}>
43
+ {last || !item.href ? (
44
+ <BreadcrumbPage aria-current={last ? 'page' : undefined}>
45
+ {item.label}
46
+ </BreadcrumbPage>
47
+ ) : (
48
+ <BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
49
+ )}
50
+ {!last && <BreadcrumbSeparator>{separator}</BreadcrumbSeparator>}
51
+ </BreadcrumbItem>
52
+ )
53
+ })}
54
+ </BreadcrumbList>
55
+ ) : (
56
+ children
57
+ )}
58
+ </nav>
59
+ )
60
+ }
61
+
62
+ export function BreadcrumbList({ className, ...props }: ComponentProps<'ol'>) {
63
+ const slots = useSlots()
64
+ return (
65
+ <ol
66
+ data-slot="breadcrumb-list"
67
+ className={s.list({ class: [slots.list, className] })}
68
+ {...props}
69
+ />
70
+ )
71
+ }
72
+
73
+ export function BreadcrumbItem({ className, ...props }: ComponentProps<'li'>) {
74
+ const slots = useSlots()
75
+ return (
76
+ <li
77
+ data-slot="breadcrumb-item"
78
+ className={s.item({ class: [slots.item, className] })}
79
+ {...props}
80
+ />
81
+ )
82
+ }
83
+
84
+ export interface BreadcrumbLinkProps {
85
+ href: string
86
+ className?: string
87
+ children?: ReactNode
88
+ /** Render your own element (e.g. a router link) with breadcrumb link styles. */
89
+ asChild?: boolean
90
+ }
91
+
92
+ export function BreadcrumbLink({ href, className, children, asChild }: BreadcrumbLinkProps) {
93
+ const Link = useLinkComponent()
94
+ const slots = useSlots()
95
+ const classes = s.link({ class: [slots.link, className] })
96
+ if (asChild) {
97
+ return (
98
+ <Slot.Root data-slot="breadcrumb-link" className={classes}>
99
+ {children}
100
+ </Slot.Root>
101
+ )
102
+ }
103
+ return (
104
+ <Link href={href} className={classes}>
105
+ {children}
106
+ </Link>
107
+ )
108
+ }
109
+
110
+ export function BreadcrumbPage({ className, ...props }: ComponentProps<'span'>) {
111
+ const slots = useSlots()
112
+ return (
113
+ <span
114
+ data-slot="breadcrumb-page"
115
+ aria-current="page"
116
+ className={s.page({ class: [slots.page, className] })}
117
+ {...props}
118
+ />
119
+ )
120
+ }
121
+
122
+ export function BreadcrumbSeparator({ className, children, ...props }: ComponentProps<'span'>) {
123
+ const slots = useSlots()
124
+ return (
125
+ <span
126
+ data-slot="breadcrumb-separator"
127
+ role="presentation"
128
+ aria-hidden="true"
129
+ className={s.separator({ class: [slots.separator, className] })}
130
+ {...props}
131
+ >
132
+ {children ?? <ChevronRightIcon />}
133
+ </span>
134
+ )
135
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ Breadcrumb,
3
+ BreadcrumbItem,
4
+ type BreadcrumbItemData,
5
+ BreadcrumbLink,
6
+ type BreadcrumbLinkProps,
7
+ BreadcrumbList,
8
+ BreadcrumbPage,
9
+ type BreadcrumbProps,
10
+ BreadcrumbSeparator,
11
+ } from './breadcrumb'
12
+ export { breadcrumbStyles } from './breadcrumb.styles'
@@ -48,14 +48,17 @@ export function Button(inProps: ButtonProps) {
48
48
  {...(!asChild && { type: type ?? 'button', disabled: disabled || loading })}
49
49
  {...rest}
50
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
- ]}
51
+ {iconOnly && asChild
52
+ ? // The child element (e.g. a link) holds its own icon; Slot merges button props onto it.
53
+ children
54
+ : iconOnly
55
+ ? icon(loading ? <Spinner size={size === 'lg' ? 'md' : 'sm'} /> : children)
56
+ : // Children stay direct (no Fragment) so Slot can find the Slottable when asChild is set.
57
+ [
58
+ <Fragment key="start">{icon(loading ? <Spinner size="sm" /> : leftIcon)}</Fragment>,
59
+ <Slot.Slottable key="content">{children}</Slot.Slottable>,
60
+ <Fragment key="end">{icon(rightIcon)}</Fragment>,
61
+ ]}
59
62
  </Comp>
60
63
  )
61
64
  }
@@ -0,0 +1,33 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { themedBorder } from '../../utils/styles'
3
+ import { tv, type VariantProps } from '../../utils/tv'
4
+
5
+ export const codeStyles = tv({
6
+ slots: {
7
+ base: 'rounded-xs bg-muted px-[0.3em] py-[0.15em] font-mono text-[0.875em] text-foreground',
8
+ },
9
+ })
10
+
11
+ export const codeBlockStyles = tv({
12
+ slots: {
13
+ root: ['relative min-w-0 overflow-hidden rounded-lg border-border bg-subtle', themedBorder],
14
+ header:
15
+ 'flex min-h-10 items-center justify-between gap-2 border-b border-border py-1 pr-1.5 pl-4',
16
+ title: 'truncate font-mono text-caption text-faint-foreground',
17
+ copy: 'shrink-0',
18
+ floatingCopy: 'absolute top-2 right-2',
19
+ pre: 'overflow-x-auto p-4 font-mono text-small leading-relaxed text-foreground',
20
+ },
21
+ variants: {
22
+ wrap: { true: { pre: 'whitespace-pre-wrap break-words' } },
23
+ },
24
+ })
25
+
26
+ export type CodeBlockVariants = VariantProps<typeof codeBlockStyles>
27
+
28
+ declare module '@glyphui/tokens' {
29
+ interface GlyphComponentRegistry {
30
+ Code: StylesEntry<typeof codeStyles>
31
+ CodeBlock: StylesEntry<typeof codeBlockStyles>
32
+ }
33
+ }
@@ -0,0 +1,96 @@
1
+ import { type ComponentProps, type ReactNode, useEffect, useRef, useState } from 'react'
2
+ import { useComponentConfig } from '../../hooks/use-component-config'
3
+ import { CheckIcon } from '../../internal/icons'
4
+ import { IconButton } from '../button'
5
+ import { type CodeBlockVariants, codeBlockStyles, codeStyles } from './code.styles'
6
+
7
+ /** Inline code within running text. */
8
+ export function Code(inProps: ComponentProps<'code'>) {
9
+ const [{ className, ...rest }, slots] = useComponentConfig('Code', inProps)
10
+ return (
11
+ <code
12
+ data-slot="code"
13
+ className={codeStyles().base({ class: [slots.base, className] })}
14
+ {...rest}
15
+ />
16
+ )
17
+ }
18
+
19
+ const CopyIcon = () => (
20
+ <svg
21
+ viewBox="0 0 24 24"
22
+ fill="none"
23
+ stroke="currentColor"
24
+ strokeWidth="2"
25
+ strokeLinecap="round"
26
+ strokeLinejoin="round"
27
+ aria-hidden="true"
28
+ >
29
+ <rect width="14" height="14" x="8" y="8" rx="2" />
30
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
31
+ </svg>
32
+ )
33
+
34
+ export interface CodeBlockProps
35
+ extends Omit<ComponentProps<'figure'>, 'children' | 'title'>,
36
+ CodeBlockVariants {
37
+ /** The code to show and copy. */
38
+ code: string
39
+ /** File name or caption shown in the header. */
40
+ title?: ReactNode
41
+ /** Shown in the header when there is no title, e.g. "tsx". */
42
+ language?: string
43
+ /** Show a copy button. Default true. */
44
+ copyable?: boolean
45
+ }
46
+
47
+ /** A block of code with an optional file name and a copy button. No syntax highlighting is applied. */
48
+ export function CodeBlock(inProps: CodeBlockProps) {
49
+ const [props, slots] = useComponentConfig('CodeBlock', inProps)
50
+ const { code, title, language, copyable = true, wrap, className, ...rest } = props
51
+ const [copied, setCopied] = useState(false)
52
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
53
+ const s = codeBlockStyles({ wrap })
54
+ const heading = title ?? language
55
+
56
+ useEffect(() => () => clearTimeout(timer.current), [])
57
+
58
+ const copy = async () => {
59
+ try {
60
+ await navigator.clipboard.writeText(code)
61
+ setCopied(true)
62
+ clearTimeout(timer.current)
63
+ timer.current = setTimeout(() => setCopied(false), 1600)
64
+ } catch {
65
+ // Clipboard blocked (permissions, insecure context): the code stays selectable.
66
+ }
67
+ }
68
+
69
+ const copyButton = copyable ? (
70
+ <IconButton
71
+ size="sm"
72
+ icon={copied ? <CheckIcon /> : <CopyIcon />}
73
+ aria-label={copied ? 'Copied' : 'Copy code'}
74
+ onClick={copy}
75
+ className={
76
+ heading ? s.copy({ class: slots.copy }) : s.floatingCopy({ class: slots.floatingCopy })
77
+ }
78
+ />
79
+ ) : null
80
+
81
+ return (
82
+ <figure data-slot="code-block" className={s.root({ class: [slots.root, className] })} {...rest}>
83
+ {heading ? (
84
+ <figcaption className={s.header({ class: slots.header })}>
85
+ <span className={s.title({ class: slots.title })}>{heading}</span>
86
+ {copyButton}
87
+ </figcaption>
88
+ ) : (
89
+ copyButton
90
+ )}
91
+ <pre className={s.pre({ class: slots.pre })}>
92
+ <code>{code}</code>
93
+ </pre>
94
+ </figure>
95
+ )
96
+ }
@@ -0,0 +1,2 @@
1
+ export { Code, CodeBlock, type CodeBlockProps } from './code'
2
+ export { type CodeBlockVariants, codeBlockStyles, codeStyles } from './code.styles'
@@ -0,0 +1,50 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { popover } from '../../utils/styles'
3
+ import { tv, type VariantProps } from '../../utils/tv'
4
+ import { fieldControl } from '../input/input.styles'
5
+
6
+ export const comboboxStyles = tv({
7
+ slots: {
8
+ trigger: [
9
+ ...fieldControl,
10
+ 'flex cursor-pointer items-center justify-between gap-2 px-3 text-left outline-none',
11
+ 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20',
12
+ 'disabled:cursor-not-allowed disabled:opacity-50',
13
+ ],
14
+ value: 'flex min-w-0 flex-1 items-center gap-1 truncate',
15
+ placeholder: 'truncate text-faint-foreground',
16
+ chip: 'inline-flex h-5.5 max-w-40 shrink-0 items-center truncate rounded-pill bg-muted px-2 text-caption text-foreground',
17
+ icon: 'size-4 shrink-0 text-faint-foreground',
18
+ content: [popover, 'w-(--radix-popover-trigger-width) min-w-56 p-0'],
19
+ search:
20
+ 'flex items-center gap-2 border-b border-border px-3 text-faint-foreground [&>svg]:size-4 [&>svg]:shrink-0',
21
+ input:
22
+ 'h-10 w-full min-w-0 bg-transparent text-small text-foreground outline-none placeholder:text-faint-foreground',
23
+ list: 'max-h-72 overflow-y-auto overscroll-contain p-1',
24
+ empty: 'px-3 py-6 text-center text-small text-muted-foreground',
25
+ item: [
26
+ 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-small text-foreground outline-none',
27
+ 'data-[selected=true]:bg-muted data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
28
+ ],
29
+ itemText: 'grid min-w-0 flex-1',
30
+ itemDescription: 'truncate text-caption text-faint-foreground',
31
+ check: 'size-4 shrink-0 text-primary',
32
+ },
33
+ variants: {
34
+ size: {
35
+ sm: { trigger: 'min-h-(--glyph-control-sm) text-small' },
36
+ md: { trigger: 'min-h-(--glyph-control-md) text-small' },
37
+ lg: { trigger: 'min-h-(--glyph-control-lg) text-body' },
38
+ },
39
+ multiple: { true: { trigger: 'py-1', value: 'flex-wrap overflow-visible whitespace-normal' } },
40
+ },
41
+ defaultVariants: { size: 'md' },
42
+ })
43
+
44
+ export type ComboboxVariants = VariantProps<typeof comboboxStyles>
45
+
46
+ declare module '@glyphui/tokens' {
47
+ interface GlyphComponentRegistry {
48
+ Combobox: StylesEntry<typeof comboboxStyles>
49
+ }
50
+ }