@taicode/common-web 1.0.2 → 1.0.4

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.
@@ -0,0 +1,204 @@
1
+ import * as Headless from '@headlessui/react'
2
+ import clsx from 'clsx'
3
+ import React, { forwardRef } from 'react'
4
+ import { Link } from './link'
5
+
6
+ const styles = {
7
+ base: [
8
+ // Base
9
+ 'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
10
+ // Sizing
11
+ 'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
12
+ // Focus
13
+ 'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
14
+ // Disabled
15
+ 'data-disabled:opacity-50',
16
+ // Icon
17
+ '*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
18
+ ],
19
+ solid: [
20
+ // Optical border, implemented as the button background to avoid corner artifacts
21
+ 'border-transparent bg-(--btn-border)',
22
+ // Dark mode: border is rendered on `after` so background is set to button background
23
+ 'dark:bg-(--btn-bg)',
24
+ // Button background, implemented as foreground layer to stack on top of pseudo-border layer
25
+ 'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
26
+ // Drop shadow, applied to the inset `before` layer so it blends with the border
27
+ 'before:shadow-sm',
28
+ // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
29
+ 'dark:before:hidden',
30
+ // Dark mode: Subtle white outline is applied using a border
31
+ 'dark:border-white/5',
32
+ // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
33
+ 'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
34
+ // Inner highlight shadow
35
+ 'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
36
+ // White overlay on hover
37
+ 'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
38
+ // Dark mode: `after` layer expands to cover entire button
39
+ 'dark:after:-inset-px dark:after:rounded-lg',
40
+ // Disabled
41
+ 'data-disabled:before:shadow-none data-disabled:after:shadow-none',
42
+ ],
43
+ outline: [
44
+ // Base
45
+ 'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5',
46
+ // Dark mode
47
+ 'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5',
48
+ // Icon
49
+ '[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
50
+ ],
51
+ plain: [
52
+ // Base
53
+ 'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
54
+ // Dark mode
55
+ 'dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10',
56
+ // Icon
57
+ '[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
58
+ ],
59
+ colors: {
60
+ 'dark/zinc': [
61
+ 'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
62
+ 'dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5',
63
+ '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
64
+ ],
65
+ light: [
66
+ 'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
67
+ 'dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
68
+ '[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
69
+ ],
70
+ 'dark/white': [
71
+ 'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
72
+ 'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
73
+ '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
74
+ ],
75
+ dark: [
76
+ 'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
77
+ 'dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
78
+ '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
79
+ ],
80
+ white: [
81
+ 'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
82
+ 'dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
83
+ '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
84
+ ],
85
+ zinc: [
86
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
87
+ 'dark:[--btn-hover-overlay:var(--color-white)]/5',
88
+ '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
89
+ ],
90
+ indigo: [
91
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
92
+ '[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
93
+ ],
94
+ cyan: [
95
+ 'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
96
+ '[--btn-icon:var(--color-cyan-500)]',
97
+ ],
98
+ red: [
99
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
100
+ '[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
101
+ ],
102
+ orange: [
103
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
104
+ '[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
105
+ ],
106
+ amber: [
107
+ 'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
108
+ '[--btn-icon:var(--color-amber-600)]',
109
+ ],
110
+ yellow: [
111
+ 'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
112
+ '[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
113
+ ],
114
+ lime: [
115
+ 'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
116
+ '[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
117
+ ],
118
+ green: [
119
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
120
+ '[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
121
+ ],
122
+ emerald: [
123
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
124
+ '[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
125
+ ],
126
+ teal: [
127
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
128
+ '[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
129
+ ],
130
+ sky: [
131
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
132
+ '[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
133
+ ],
134
+ blue: [
135
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90',
136
+ '[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]',
137
+ ],
138
+ violet: [
139
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
140
+ '[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
141
+ ],
142
+ purple: [
143
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
144
+ '[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
145
+ ],
146
+ fuchsia: [
147
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
148
+ '[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
149
+ ],
150
+ pink: [
151
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
152
+ '[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
153
+ ],
154
+ rose: [
155
+ 'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
156
+ '[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
157
+ ],
158
+ },
159
+ }
160
+
161
+ type ButtonProps = (
162
+ | { color?: keyof typeof styles.colors; outline?: never; plain?: never }
163
+ | { color?: never; outline: true; plain?: never }
164
+ | { color?: never; outline?: never; plain: true }
165
+ ) & { className?: string; children: React.ReactNode } & (
166
+ | Omit<Headless.ButtonProps, 'as' | 'className'>
167
+ | Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>
168
+ )
169
+
170
+ export const Button = forwardRef(function Button(
171
+ { color, outline, plain, className, children, ...props }: ButtonProps,
172
+ ref: React.ForwardedRef<HTMLElement>
173
+ ) {
174
+ let classes = clsx(
175
+ className,
176
+ styles.base,
177
+ outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc'])
178
+ )
179
+
180
+ return 'href' in props ? (
181
+ <Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
182
+ <TouchTarget>{children}</TouchTarget>
183
+ </Link>
184
+ ) : (
185
+ <Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
186
+ <TouchTarget>{children}</TouchTarget>
187
+ </Headless.Button>
188
+ )
189
+ })
190
+
191
+ /**
192
+ * Expand the hit area to at least 44×44px on touch devices
193
+ */
194
+ export function TouchTarget({ children }: { children: React.ReactNode }) {
195
+ return (
196
+ <>
197
+ <span
198
+ className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
199
+ aria-hidden="true"
200
+ />
201
+ {children}
202
+ </>
203
+ )
204
+ }
@@ -0,0 +1,157 @@
1
+ import * as Headless from '@headlessui/react'
2
+ import clsx from 'clsx'
3
+ import type React from 'react'
4
+
5
+ export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
6
+ return (
7
+ <div
8
+ data-slot="control"
9
+ {...props}
10
+ className={clsx(
11
+ className,
12
+ // Basic groups
13
+ 'space-y-3',
14
+ // With descriptions
15
+ 'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
16
+ )}
17
+ />
18
+ )
19
+ }
20
+
21
+ export function CheckboxField({
22
+ className,
23
+ ...props
24
+ }: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
25
+ return (
26
+ <Headless.Field
27
+ data-slot="field"
28
+ {...props}
29
+ className={clsx(
30
+ className,
31
+ // Base layout
32
+ 'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
33
+ // Control layout
34
+ '*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
35
+ // Label layout
36
+ '*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
37
+ // Description layout
38
+ '*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
39
+ // With description
40
+ 'has-data-[slot=description]:**:data-[slot=label]:font-medium'
41
+ )}
42
+ />
43
+ )
44
+ }
45
+
46
+ const base = [
47
+ // Basic layout
48
+ 'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
49
+ // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
50
+ 'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
51
+ // Background color when checked
52
+ 'group-data-checked:before:bg-(--checkbox-checked-bg)',
53
+ // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
54
+ 'dark:before:hidden',
55
+ // Background color applied to control in dark mode
56
+ 'dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)',
57
+ // Border
58
+ 'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
59
+ 'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
60
+ // Inner highlight shadow
61
+ 'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
62
+ 'dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block',
63
+ // Focus ring
64
+ 'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
65
+ // Disabled state
66
+ 'group-data-disabled:opacity-50',
67
+ 'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
68
+ 'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
69
+ // Forced colors mode
70
+ 'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
71
+ 'dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
72
+ ]
73
+
74
+ const colors = {
75
+ 'dark/zinc': [
76
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
77
+ 'dark:[--checkbox-checked-bg:var(--color-zinc-600)]',
78
+ ],
79
+ 'dark/white': [
80
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
81
+ 'dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15',
82
+ ],
83
+ white:
84
+ '[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
85
+ dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
86
+ zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
87
+ red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
88
+ orange:
89
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
90
+ amber:
91
+ '[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
92
+ yellow:
93
+ '[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
94
+ lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
95
+ green:
96
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
97
+ emerald:
98
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
99
+ teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
100
+ cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
101
+ sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
102
+ blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
103
+ indigo:
104
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
105
+ violet:
106
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
107
+ purple:
108
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
109
+ fuchsia:
110
+ '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
111
+ pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
112
+ rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
113
+ }
114
+
115
+ type Color = keyof typeof colors
116
+
117
+ export function Checkbox({
118
+ color = 'dark/zinc',
119
+ className,
120
+ ...props
121
+ }: {
122
+ color?: Color
123
+ className?: string
124
+ } & Omit<Headless.CheckboxProps, 'as' | 'className'>) {
125
+ return (
126
+ <Headless.Checkbox
127
+ data-slot="control"
128
+ {...props}
129
+ className={clsx(className, 'group inline-flex focus:outline-hidden')}
130
+ >
131
+ <span className={clsx([base, colors[color]])}>
132
+ <svg
133
+ className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
134
+ viewBox="0 0 14 14"
135
+ fill="none"
136
+ >
137
+ {/* Checkmark icon */}
138
+ <path
139
+ className="opacity-100 group-data-indeterminate:opacity-0"
140
+ d="M3 8L6 11L11 3.5"
141
+ strokeWidth={2}
142
+ strokeLinecap="round"
143
+ strokeLinejoin="round"
144
+ />
145
+ {/* Indeterminate icon */}
146
+ <path
147
+ className="opacity-0 group-data-indeterminate:opacity-100"
148
+ d="M3 7H11"
149
+ strokeWidth={2}
150
+ strokeLinecap="round"
151
+ strokeLinejoin="round"
152
+ />
153
+ </svg>
154
+ </span>
155
+ </Headless.Checkbox>
156
+ )
157
+ }
@@ -0,0 +1,188 @@
1
+ 'use client'
2
+
3
+ import * as Headless from '@headlessui/react'
4
+ import clsx from 'clsx'
5
+ import { useState } from 'react'
6
+
7
+ export function Combobox<T>({
8
+ options,
9
+ displayValue,
10
+ filter,
11
+ anchor = 'bottom',
12
+ className,
13
+ placeholder,
14
+ autoFocus,
15
+ 'aria-label': ariaLabel,
16
+ children,
17
+ ...props
18
+ }: {
19
+ options: T[]
20
+ displayValue: (value: T | null) => string | undefined
21
+ filter?: (value: T, query: string) => boolean
22
+ className?: string
23
+ placeholder?: string
24
+ autoFocus?: boolean
25
+ 'aria-label'?: string
26
+ children: (value: NonNullable<T>) => React.ReactElement
27
+ } & Omit<Headless.ComboboxProps<T, false>, 'as' | 'multiple' | 'children'> & { anchor?: 'top' | 'bottom' }) {
28
+ const [query, setQuery] = useState('')
29
+
30
+ const filteredOptions =
31
+ query === ''
32
+ ? options
33
+ : options.filter((option) =>
34
+ filter ? filter(option, query) : displayValue(option)?.toLowerCase().includes(query.toLowerCase())
35
+ )
36
+
37
+ return (
38
+ <Headless.Combobox {...props} multiple={false} virtual={{ options: filteredOptions }} onClose={() => setQuery('')}>
39
+ <span
40
+ data-slot="control"
41
+ className={clsx([
42
+ className,
43
+ // Basic layout
44
+ 'relative block w-full',
45
+ // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
46
+ 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
47
+ // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
48
+ 'dark:before:hidden',
49
+ // Focus ring
50
+ 'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
51
+ // Disabled state
52
+ 'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
53
+ // Invalid state
54
+ 'has-data-invalid:before:shadow-red-500/10',
55
+ ])}
56
+ >
57
+ <Headless.ComboboxInput
58
+ autoFocus={autoFocus}
59
+ data-slot="control"
60
+ aria-label={ariaLabel}
61
+ displayValue={(option: T) => displayValue(option) ?? ''}
62
+ onChange={(event) => setQuery(event.target.value)}
63
+ placeholder={placeholder}
64
+ className={clsx([
65
+ className,
66
+ // Basic layout
67
+ 'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
68
+ // Horizontal padding
69
+ 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
70
+ // Typography
71
+ 'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
72
+ // Border
73
+ 'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
74
+ // Background color
75
+ 'bg-transparent dark:bg-white/5',
76
+ // Hide default focus styles
77
+ 'focus:outline-hidden',
78
+ // Invalid state
79
+ 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500',
80
+ // Disabled state
81
+ 'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
82
+ // System icons
83
+ 'dark:scheme-dark',
84
+ ])}
85
+ />
86
+ <Headless.ComboboxButton className="group absolute inset-y-0 right-0 flex items-center px-2">
87
+ <svg
88
+ className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 group-data-hover:stroke-zinc-700 sm:size-4 dark:stroke-zinc-400 dark:group-data-hover:stroke-zinc-300 forced-colors:stroke-[CanvasText]"
89
+ viewBox="0 0 16 16"
90
+ aria-hidden="true"
91
+ fill="none"
92
+ >
93
+ <path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
94
+ <path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
95
+ </svg>
96
+ </Headless.ComboboxButton>
97
+ </span>
98
+ <Headless.ComboboxOptions
99
+ transition
100
+ anchor={anchor}
101
+ className={clsx(
102
+ // Anchor positioning
103
+ '[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
104
+ // Base styles,
105
+ 'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
106
+ // Invisible border that is only visible in `forced-colors` mode for accessibility purposes
107
+ 'outline outline-transparent focus:outline-hidden',
108
+ // Handle scrolling when menu won't fit in viewport
109
+ 'overflow-y-scroll overscroll-contain',
110
+ // Popover background
111
+ 'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
112
+ // Shadows
113
+ 'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
114
+ // Transitions
115
+ 'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
116
+ )}
117
+ >
118
+ {({ option }) => children(option)}
119
+ </Headless.ComboboxOptions>
120
+ </Headless.Combobox>
121
+ )
122
+ }
123
+
124
+ export function ComboboxOption<T>({
125
+ children,
126
+ className,
127
+ ...props
128
+ }: { className?: string; children?: React.ReactNode } & Omit<
129
+ Headless.ComboboxOptionProps<'div', T>,
130
+ 'as' | 'className'
131
+ >) {
132
+ let sharedClasses = clsx(
133
+ // Base
134
+ 'flex min-w-0 items-center',
135
+ // Icons
136
+ '*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
137
+ '*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
138
+ 'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
139
+ // Avatars
140
+ '*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
141
+ )
142
+
143
+ return (
144
+ <Headless.ComboboxOption
145
+ {...props}
146
+ className={clsx(
147
+ // Basic layout
148
+ 'group/option grid w-full cursor-default grid-cols-[1fr_--spacing(5)] items-baseline gap-x-2 rounded-lg py-2.5 pr-2 pl-3.5 sm:grid-cols-[1fr_--spacing(4)] sm:py-1.5 sm:pr-2 sm:pl-3',
149
+ // Typography
150
+ 'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
151
+ // Focus
152
+ 'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
153
+ // Forced colors mode
154
+ 'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
155
+ // Disabled
156
+ 'data-disabled:opacity-50'
157
+ )}
158
+ >
159
+ <span className={clsx(className, sharedClasses)}>{children}</span>
160
+ <svg
161
+ className="relative col-start-2 hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
162
+ viewBox="0 0 16 16"
163
+ fill="none"
164
+ aria-hidden="true"
165
+ >
166
+ <path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
167
+ </svg>
168
+ </Headless.ComboboxOption>
169
+ )
170
+ }
171
+
172
+ export function ComboboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
173
+ return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
174
+ }
175
+
176
+ export function ComboboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
177
+ return (
178
+ <span
179
+ {...props}
180
+ className={clsx(
181
+ className,
182
+ 'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
183
+ )}
184
+ >
185
+ <span className="flex-1 truncate">{children}</span>
186
+ </span>
187
+ )
188
+ }
@@ -0,0 +1,37 @@
1
+ import clsx from 'clsx'
2
+
3
+ export function DescriptionList({ className, ...props }: React.ComponentPropsWithoutRef<'dl'>) {
4
+ return (
5
+ <dl
6
+ {...props}
7
+ className={clsx(
8
+ className,
9
+ 'grid grid-cols-1 text-base/6 sm:grid-cols-[min(50%,--spacing(80))_auto] sm:text-sm/6'
10
+ )}
11
+ />
12
+ )
13
+ }
14
+
15
+ export function DescriptionTerm({ className, ...props }: React.ComponentPropsWithoutRef<'dt'>) {
16
+ return (
17
+ <dt
18
+ {...props}
19
+ className={clsx(
20
+ className,
21
+ 'col-start-1 border-t border-zinc-950/5 pt-3 text-zinc-500 first:border-none sm:border-t sm:border-zinc-950/5 sm:py-3 dark:border-white/5 dark:text-zinc-400 sm:dark:border-white/5'
22
+ )}
23
+ />
24
+ )
25
+ }
26
+
27
+ export function DescriptionDetails({ className, ...props }: React.ComponentPropsWithoutRef<'dd'>) {
28
+ return (
29
+ <dd
30
+ {...props}
31
+ className={clsx(
32
+ className,
33
+ 'pt-1 pb-3 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 sm:nth-2:border-none dark:text-white dark:sm:border-white/5'
34
+ )}
35
+ />
36
+ )
37
+ }