tembro 4.1.0 → 4.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.
@@ -0,0 +1,303 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { CheckIcon, ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react"
5
+
6
+ import { Button } from "@/components/ui/button"
7
+ import { Input } from "@/components/ui/input"
8
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
9
+ import { cn, stopInteractivePropagation } from "@/lib/utils"
10
+
11
+ export type ComboboxOption<TValue extends string = string, TData = unknown> = {
12
+ value: TValue
13
+ label: React.ReactNode
14
+ description?: React.ReactNode
15
+ disabled?: boolean
16
+ disabledReason?: React.ReactNode
17
+ keywords?: string[]
18
+ data?: TData
19
+ }
20
+
21
+ export type ComboboxGroup<TValue extends string = string, TData = unknown> = {
22
+ label?: React.ReactNode
23
+ options: ComboboxOption<TValue, TData>[]
24
+ }
25
+
26
+ export type ComboboxLabels = {
27
+ placeholder?: string
28
+ searchPlaceholder?: string
29
+ empty?: string
30
+ clear?: string
31
+ }
32
+
33
+ export type ComboboxProps<TValue extends string = string, TData = unknown> = Omit<React.ComponentProps<"div">, "onChange"> & {
34
+ value?: TValue
35
+ defaultValue?: TValue
36
+ onValueChange?: (value: TValue | undefined, option?: ComboboxOption<TValue, TData>) => void
37
+ options?: ComboboxOption<TValue, TData>[]
38
+ groups?: ComboboxGroup<TValue, TData>[]
39
+ disabled?: boolean
40
+ clearable?: boolean
41
+ searchable?: boolean
42
+ open?: boolean
43
+ defaultOpen?: boolean
44
+ onOpenChange?: (open: boolean) => void
45
+ labels?: ComboboxLabels
46
+ invalid?: boolean
47
+ renderOption?: (option: ComboboxOption<TValue, TData>, state: { selected: boolean }) => React.ReactNode
48
+ renderValue?: (option: ComboboxOption<TValue, TData>) => React.ReactNode
49
+ filterOption?: (option: ComboboxOption<TValue, TData>, search: string) => boolean
50
+ triggerClassName?: string
51
+ contentClassName?: string
52
+ searchClassName?: string
53
+ optionClassName?: string
54
+ }
55
+
56
+ function useControllableState<TValue>({
57
+ value,
58
+ defaultValue,
59
+ onChange,
60
+ }: {
61
+ value?: TValue
62
+ defaultValue: TValue
63
+ onChange?: (value: TValue) => void
64
+ }) {
65
+ const [internalValue, setInternalValue] = React.useState(defaultValue)
66
+ const isControlled = value !== undefined
67
+ const currentValue = isControlled ? value : internalValue
68
+
69
+ const setValue = React.useCallback(
70
+ (nextValue: TValue) => {
71
+ if (!isControlled) {
72
+ setInternalValue(nextValue)
73
+ }
74
+ onChange?.(nextValue)
75
+ },
76
+ [isControlled, onChange]
77
+ )
78
+
79
+ return [currentValue, setValue] as const
80
+ }
81
+
82
+ function normalizeComboboxGroups<TValue extends string, TData>({
83
+ options,
84
+ groups,
85
+ }: Pick<ComboboxProps<TValue, TData>, "options" | "groups">) {
86
+ if (groups?.length) return groups
87
+ if (options?.length) return [{ options }]
88
+ return []
89
+ }
90
+
91
+ function getComboboxOptionText(option: ComboboxOption) {
92
+ return [
93
+ option.value,
94
+ typeof option.label === "string" || typeof option.label === "number" ? String(option.label) : "",
95
+ typeof option.description === "string" || typeof option.description === "number" ? String(option.description) : "",
96
+ ...(option.keywords ?? []),
97
+ ]
98
+ .join(" ")
99
+ .toLowerCase()
100
+ }
101
+
102
+ function defaultFilterOption(option: ComboboxOption, search: string) {
103
+ if (!search.trim()) return true
104
+ return getComboboxOptionText(option).includes(search.trim().toLowerCase())
105
+ }
106
+
107
+ function findOption<TValue extends string, TData>(
108
+ groups: ComboboxGroup<TValue, TData>[],
109
+ value?: TValue
110
+ ) {
111
+ if (!value) return undefined
112
+
113
+ for (const group of groups) {
114
+ const option = group.options.find((item) => item.value === value)
115
+ if (option) return option
116
+ }
117
+
118
+ return undefined
119
+ }
120
+
121
+ function Combobox<TValue extends string = string, TData = unknown>({
122
+ className,
123
+ value,
124
+ defaultValue,
125
+ onValueChange,
126
+ options,
127
+ groups,
128
+ disabled = false,
129
+ clearable = true,
130
+ searchable = true,
131
+ open,
132
+ defaultOpen = false,
133
+ onOpenChange,
134
+ labels,
135
+ invalid,
136
+ renderOption,
137
+ renderValue,
138
+ filterOption = defaultFilterOption,
139
+ triggerClassName,
140
+ contentClassName,
141
+ searchClassName,
142
+ optionClassName,
143
+ ...props
144
+ }: ComboboxProps<TValue, TData>) {
145
+ const normalizedGroups = React.useMemo(() => normalizeComboboxGroups({ options, groups }), [groups, options])
146
+ const [currentValue, setCurrentValue] = useControllableState<TValue | undefined>({
147
+ value,
148
+ defaultValue,
149
+ onChange: (nextValue) => onValueChange?.(nextValue, findOption(normalizedGroups, nextValue)),
150
+ })
151
+ const [isOpen, setIsOpen] = useControllableState({
152
+ value: open,
153
+ defaultValue: defaultOpen,
154
+ onChange: onOpenChange,
155
+ })
156
+ const [search, setSearch] = React.useState("")
157
+ const selectedOption = findOption(normalizedGroups, currentValue)
158
+ const filteredGroups = React.useMemo(
159
+ () =>
160
+ normalizedGroups
161
+ .map((group) => ({
162
+ ...group,
163
+ options: group.options.filter((option) => filterOption(option, search)),
164
+ }))
165
+ .filter((group) => group.options.length > 0),
166
+ [filterOption, normalizedGroups, search]
167
+ )
168
+ const hasMatches = filteredGroups.some((group) => group.options.length > 0)
169
+
170
+ const selectOption = (option: ComboboxOption<TValue, TData>) => {
171
+ if (option.disabled) return
172
+
173
+ setCurrentValue(option.value)
174
+ setIsOpen(false)
175
+ setSearch("")
176
+ }
177
+
178
+ const clearSelection = () => {
179
+ setCurrentValue(undefined)
180
+ setSearch("")
181
+ }
182
+
183
+ const handleClear = (event: React.MouseEvent<HTMLElement>) => {
184
+ stopInteractivePropagation(event)
185
+ clearSelection()
186
+ }
187
+
188
+ return (
189
+ <div data-slot="combobox" className={cn("w-full", className)} {...props}>
190
+ <Popover open={isOpen} onOpenChange={setIsOpen}>
191
+ <PopoverTrigger
192
+ render={
193
+ <Button
194
+ type="button"
195
+ variant="outline"
196
+ disabled={disabled}
197
+ aria-expanded={isOpen}
198
+ aria-invalid={invalid || undefined}
199
+ data-slot="combobox-trigger"
200
+ className={cn("w-full justify-between text-left", triggerClassName)}
201
+ />
202
+ }
203
+ >
204
+ <span className="min-w-0 flex-1 truncate">
205
+ {selectedOption ? renderValue?.(selectedOption) ?? selectedOption.label : (
206
+ <span className="text-muted-foreground">{labels?.placeholder ?? "Select option"}</span>
207
+ )}
208
+ </span>
209
+ <span className="ml-2 inline-flex shrink-0 items-center gap-1">
210
+ {clearable && currentValue && !disabled ? (
211
+ <span
212
+ role="button"
213
+ tabIndex={0}
214
+ data-slot="combobox-clear"
215
+ aria-label={labels?.clear ?? "Clear selection"}
216
+ onClick={handleClear}
217
+ onKeyDown={(event) => {
218
+ if (event.key !== "Enter" && event.key !== " ") return
219
+ event.preventDefault()
220
+ stopInteractivePropagation(event)
221
+ clearSelection()
222
+ }}
223
+ >
224
+ <XIcon data-icon="clear" />
225
+ </span>
226
+ ) : null}
227
+ <ChevronsUpDownIcon className="size-4 opacity-60" />
228
+ </span>
229
+ </PopoverTrigger>
230
+ <PopoverContent align="start" data-slot="combobox-content" className={cn("w-(--anchor-width) min-w-72 gap-2 p-2", contentClassName)}>
231
+ {searchable ? (
232
+ <div data-slot="combobox-search-wrap" className="relative">
233
+ <SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
234
+ <Input
235
+ data-slot="combobox-search"
236
+ value={search}
237
+ onChange={(event) => setSearch(event.currentTarget.value)}
238
+ placeholder={labels?.searchPlaceholder ?? "Search..."}
239
+ className={cn("pl-9", searchClassName)}
240
+ autoFocus
241
+ />
242
+ </div>
243
+ ) : null}
244
+
245
+ <div data-slot="combobox-list" className="flex max-h-72 flex-col gap-1 overflow-y-auto pr-1">
246
+ {!hasMatches ? (
247
+ <div data-slot="combobox-empty" className="rounded-[var(--radius-md)] px-3 py-6 text-center text-sm text-muted-foreground">
248
+ {labels?.empty ?? "No options found"}
249
+ </div>
250
+ ) : null}
251
+
252
+ {filteredGroups.map((group, groupIndex) => (
253
+ <div key={groupIndex} data-slot="combobox-group">
254
+ {group.label ? (
255
+ <div data-slot="combobox-group-label" className="sticky top-0 z-10 bg-popover px-2 py-1.5 text-xs font-medium text-muted-foreground">
256
+ {group.label}
257
+ </div>
258
+ ) : null}
259
+ {group.options.map((option) => {
260
+ const selected = option.value === currentValue
261
+
262
+ return (
263
+ <button
264
+ key={option.value}
265
+ type="button"
266
+ disabled={option.disabled}
267
+ data-slot="combobox-option"
268
+ data-selected={selected || undefined}
269
+ data-disabled={option.disabled || undefined}
270
+ className={cn(
271
+ "flex w-full items-start gap-2.5 rounded-[var(--radius-md)] px-2.5 py-2 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[selected]:bg-accent/70",
272
+ optionClassName
273
+ )}
274
+ onClick={() => selectOption(option)}
275
+ >
276
+ <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
277
+ {selected ? <CheckIcon className="size-4" /> : null}
278
+ </span>
279
+ <span className="min-w-0 flex-1">
280
+ {renderOption?.(option, { selected }) ?? (
281
+ <span className="flex min-w-0 flex-col">
282
+ <span className="truncate font-medium">{option.label}</span>
283
+ {(option.description || option.disabledReason) ? (
284
+ <span className="truncate text-xs text-muted-foreground">
285
+ {option.disabled ? option.disabledReason ?? option.description : option.description}
286
+ </span>
287
+ ) : null}
288
+ </span>
289
+ )}
290
+ </span>
291
+ </button>
292
+ )
293
+ })}
294
+ </div>
295
+ ))}
296
+ </div>
297
+ </PopoverContent>
298
+ </Popover>
299
+ </div>
300
+ )
301
+ }
302
+
303
+ export { Combobox }
@@ -1,4 +1,5 @@
1
1
  export * from "./async-select"
2
+ export * from "./combobox"
2
3
  export * from "./otp-input"
3
4
  export * from "./rating"
4
5
  export * from "./json-input"
@@ -1,4 +1,5 @@
1
1
  export * from "./async-select"
2
+ export * from "./combobox"
2
3
  export * from "./otp-input"
3
4
  export * from "./rating"
4
5
  export * from "./slider"
@@ -5,20 +5,32 @@ export type PublicComponentSurfaceEntry = {
5
5
  }
6
6
 
7
7
  export const documentedPublicComponentSurfaces: readonly PublicComponentSurfaceEntry[] = [
8
- { slug: "button", registryName: "button", surface: "documented" },
9
- { slug: "input", registryName: "input", surface: "documented" },
10
- { slug: "select", registryName: "select", surface: "documented" },
11
- { slug: "card", registryName: "card", surface: "documented" },
12
- { slug: "badge", registryName: "badge", surface: "documented" },
13
- { slug: "checkbox", registryName: "checkbox", surface: "documented" },
14
- { slug: "overlay", registryName: "dialog", surface: "documented" },
15
- { slug: "data-table", registryName: "data-table", surface: "documented" },
16
- { slug: "calendar", registryName: "calendar", surface: "documented" },
17
- { slug: "date-picker", registryName: "date-picker", surface: "documented" },
18
- { slug: "date-range-picker", registryName: "date-range-picker", surface: "documented" },
19
- { slug: "alert", registryName: "alert", surface: "documented" },
20
- { slug: "bar-chart", registryName: "charts", surface: "documented" },
8
+ { slug: "button", registryName: "button", surface: "documented" },
9
+ { slug: "input", registryName: "input", surface: "documented" },
10
+ { slug: "textarea", registryName: "textarea", surface: "documented" },
11
+ { slug: "select", registryName: "select", surface: "documented" },
12
+ { slug: "combobox", registryName: "combobox", surface: "documented" },
13
+ { slug: "async-select", registryName: "async-select", surface: "documented" },
14
+ { slug: "card", registryName: "card", surface: "documented" },
15
+ { slug: "badge", registryName: "badge", surface: "documented" },
16
+ { slug: "checkbox", registryName: "checkbox", surface: "documented" },
17
+ { slug: "switch", registryName: "switch", surface: "documented" },
18
+ { slug: "radio-group", registryName: "radio-group", surface: "documented" },
19
+ { slug: "overlay", registryName: "dialog", surface: "documented" },
20
+ { slug: "dropdown-menu", registryName: "dropdown-menu", surface: "documented" },
21
+ { slug: "popover", registryName: "popover", surface: "documented" },
22
+ { slug: "tooltip", registryName: "tooltip", surface: "documented" },
23
+ { slug: "data-table", registryName: "data-table", surface: "documented" },
24
+ { slug: "pagination", registryName: "pagination", surface: "documented" },
25
+ { slug: "calendar", registryName: "calendar", surface: "documented" },
26
+ { slug: "date-picker", registryName: "date-picker", surface: "documented" },
27
+ { slug: "date-range-picker", registryName: "date-range-picker", surface: "documented" },
28
+ { slug: "alert", registryName: "alert", surface: "documented" },
29
+ { slug: "toast", registryName: "toast", surface: "documented" },
30
+ { slug: "notification-center", registryName: "notification-center", surface: "documented" },
31
+ { slug: "bar-chart", registryName: "charts", surface: "documented" },
21
32
  { slug: "sidebar", registryName: "sidebar", surface: "documented" },
33
+ { slug: "breadcrumbs", registryName: "breadcrumbs", surface: "documented" },
22
34
  { slug: "workspace-layout", registryName: "workspace-layout", surface: "documented" },
23
35
  { slug: "state-view", registryName: "state-view", surface: "documented" },
24
36
  { slug: "file-upload", registryName: "file-upload", surface: "documented" },
@@ -41,15 +53,7 @@ export const documentedPublicComponentSurfaces: readonly PublicComponentSurfaceE
41
53
  { slug: "tree-view", registryName: "tree-view", surface: "documented" },
42
54
  { slug: "copy-button", registryName: "copy-button", surface: "documented" },
43
55
  { slug: "section", registryName: "section", surface: "documented" },
44
- { slug: "form-builder", registryName: "form-builder", surface: "documented" },
45
56
  { slug: "empty-state", registryName: "empty-state", surface: "documented" },
46
- { slug: "page-toolbar", registryName: "page-toolbar", surface: "documented" },
47
- { slug: "bulk-action-bar", registryName: "bulk-action-bar", surface: "documented" },
48
- { slug: "detail-layout", registryName: "detail-layout", surface: "documented" },
49
- { slug: "settings-page", registryName: "settings-page", surface: "documented" },
50
- { slug: "data-view", registryName: "data-view", surface: "documented" },
51
- { slug: "resource-page", registryName: "resource-page", surface: "documented" },
52
- { slug: "resource-detail-page", registryName: "resource-detail-page", surface: "documented" },
53
57
  { slug: "calendar-scheduler", registryName: "calendar-scheduler", surface: "documented" },
54
58
  { slug: "dual-list-picker", registryName: "dual-list-picker", surface: "documented" },
55
59
  { slug: "resizable-panel", registryName: "resizable-panel", surface: "documented" },
@@ -67,7 +71,16 @@ export const documentedPublicComponentSurfaces: readonly PublicComponentSurfaceE
67
71
  { slug: "image-cropper", registryName: "image-cropper", surface: "documented" },
68
72
  ] as const
69
73
 
70
- export const standalonePublicComponentSurfaces: readonly PublicComponentSurfaceEntry[] = [] as const
74
+ export const standalonePublicComponentSurfaces: readonly PublicComponentSurfaceEntry[] = [
75
+ { slug: "form-builder", registryName: "form-builder", surface: "standalone" },
76
+ { slug: "page-toolbar", registryName: "page-toolbar", surface: "standalone" },
77
+ { slug: "bulk-action-bar", registryName: "bulk-action-bar", surface: "standalone" },
78
+ { slug: "detail-layout", registryName: "detail-layout", surface: "standalone" },
79
+ { slug: "settings-page", registryName: "settings-page", surface: "standalone" },
80
+ { slug: "data-view", registryName: "data-view", surface: "standalone" },
81
+ { slug: "resource-page", registryName: "resource-page", surface: "standalone" },
82
+ { slug: "resource-detail-page", registryName: "resource-detail-page", surface: "standalone" },
83
+ ] as const
71
84
 
72
85
  export const publicComponentSurfaceEntries = [
73
86
  ...documentedPublicComponentSurfaces,
@@ -25,7 +25,7 @@ export function HeroSection() {
25
25
  <div className="flex flex-wrap gap-2">
26
26
  <Button leftIcon={<PlusIcon className="size-4" />}>Create</Button>
27
27
  <Button variant="outline" leftIcon={<SettingsIcon className="size-4" />}>Settings</Button>
28
- <CopyButton value="npx tembro@4.1.0 list --json">Copy list command</CopyButton>
28
+ <CopyButton value="npx tembro@4.2.0 list --json">Copy list command</CopyButton>
29
29
  </div>
30
30
  </div>
31
31
 
@@ -33,7 +33,7 @@ export function HeroSection() {
33
33
  <StatisticCard label="Registry components" value={moduleCount} change="visible below" trend="up" description="tembro add <name>" />
34
34
  <StatisticCard label="Local source files" value="152" change="with hooks/lib" trend="up" description="installed by CLI" />
35
35
  <StatisticCard label="Categories" value={registryGroups.length} change="all shown" trend="up" description="actions to wizard" />
36
- <StatisticCard label="Build" value="Pass" change="doctor pass" trend="up" description="tembro@4.1.0" />
36
+ <StatisticCard label="Build" value="Pass" change="doctor pass" trend="up" description="tembro@4.2.0" />
37
37
  </StatisticGrid>
38
38
  </div>
39
39
  </section>
@@ -72,7 +72,7 @@ export function WorkbenchSidebar({ selectedKey, onSelect }: WorkbenchSidebarProp
72
72
  }),
73
73
  ]}
74
74
  footerAccount={{
75
- label: "tembro@4.1.0",
75
+ label: "tembro@4.2.0",
76
76
  description: `${moduleCount} registry components`,
77
77
  }}
78
78
  activeIndicator="bar"
package/registry.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://tembro.dev/registry.schema.json",
3
3
  "name": "tembro",
4
- "version": "4.1.0",
4
+ "version": "4.2.0",
5
5
  "description": "Reusable React + TypeScript UI kit registry",
6
6
  "groups": {
7
7
  "ui": [
@@ -39,6 +39,7 @@
39
39
  "pagination"
40
40
  ],
41
41
  "inputs": [
42
+ "combobox",
42
43
  "async-select",
43
44
  "otp-input",
44
45
  "rating",
@@ -180,6 +181,7 @@
180
181
  "dialog",
181
182
  "popover",
182
183
  "select",
184
+ "combobox",
183
185
  "table",
184
186
  "badge",
185
187
  "card",
@@ -216,6 +218,7 @@
216
218
  "dialog",
217
219
  "popover",
218
220
  "select",
221
+ "combobox",
219
222
  "table",
220
223
  "badge",
221
224
  "card",
@@ -259,18 +262,30 @@
259
262
  "documented": [
260
263
  "button",
261
264
  "input",
265
+ "textarea",
262
266
  "select",
267
+ "combobox",
268
+ "async-select",
263
269
  "card",
264
270
  "badge",
265
271
  "checkbox",
272
+ "switch",
273
+ "radio-group",
266
274
  "dialog",
275
+ "dropdown-menu",
276
+ "popover",
277
+ "tooltip",
267
278
  "data-table",
279
+ "pagination",
268
280
  "calendar",
269
281
  "date-picker",
270
282
  "date-range-picker",
271
283
  "alert",
284
+ "toast",
285
+ "notification-center",
272
286
  "charts",
273
287
  "sidebar",
288
+ "breadcrumbs",
274
289
  "workspace-layout",
275
290
  "state-view",
276
291
  "file-upload",
@@ -293,15 +308,7 @@
293
308
  "tree-view",
294
309
  "copy-button",
295
310
  "section",
296
- "form-builder",
297
311
  "empty-state",
298
- "page-toolbar",
299
- "bulk-action-bar",
300
- "detail-layout",
301
- "settings-page",
302
- "data-view",
303
- "resource-page",
304
- "resource-detail-page",
305
312
  "calendar-scheduler",
306
313
  "dual-list-picker",
307
314
  "resizable-panel",
@@ -318,7 +325,16 @@
318
325
  "rich-text-editor",
319
326
  "image-cropper"
320
327
  ],
321
- "standalone": []
328
+ "standalone": [
329
+ "form-builder",
330
+ "page-toolbar",
331
+ "bulk-action-bar",
332
+ "detail-layout",
333
+ "settings-page",
334
+ "data-view",
335
+ "resource-page",
336
+ "resource-detail-page"
337
+ ]
322
338
  },
323
339
  "migrationAliases": {
324
340
  "clearable-input": "input",
@@ -332,7 +348,6 @@
332
348
  "date-input": "input",
333
349
  "date-range-input": "input",
334
350
  "simple-select": "select",
335
- "combobox": "select",
336
351
  "hover-card": "popover",
337
352
  "copy-field": "copy-button",
338
353
  "comparison-card": "card",