@vendure-io/ui 1.2.0 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vendure-io/ui",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "React component library for Vendure, built on shadcn/ui and Tailwind v4",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "homepage": "https://github.com/vendurehq/design/tree/main/packages/ui",
@@ -0,0 +1,156 @@
1
+ 'use client';
2
+
3
+ import { Autocomplete } from '@base-ui/react/autocomplete';
4
+ import { ComboboxContent, ComboboxItem, ComboboxList } from '@vendure-io/ui/components/ui/combobox';
5
+ import {
6
+ InputGroup,
7
+ InputGroupAddon,
8
+ InputGroupInput,
9
+ } from '@vendure-io/ui/components/ui/input-group';
10
+ import { Spinner } from '@vendure-io/ui/components/ui/spinner';
11
+ import { cn } from '@vendure-io/ui/lib/utils';
12
+ import * as React from 'react';
13
+
14
+ export interface ComboboxFreeTextItem {
15
+ /**
16
+ * The string committed to `value` when this row is chosen. Must be unique
17
+ * across `items`: it is used as the React key and to resolve a picked row back
18
+ * to its record for `onSelectItem`.
19
+ */
20
+ value: string;
21
+ /** Primary line of the suggestion row. */
22
+ label: string;
23
+ /** Optional secondary line. */
24
+ description?: string;
25
+ }
26
+
27
+ export interface ComboboxFreeTextProps<T extends ComboboxFreeTextItem = ComboboxFreeTextItem> {
28
+ /** The input text — the source of truth. Free-form: it need not match any item. */
29
+ value: string;
30
+ /**
31
+ * Fires on every keystroke and when a suggestion is chosen (with the chosen
32
+ * item's `value`). Always a string — never the item object, because most
33
+ * fires (keystrokes, free text) have no item behind them.
34
+ */
35
+ onValueChange: (value: string) => void;
36
+ /**
37
+ * Fires only when a suggestion is chosen (by click or keyboard) — never on a
38
+ * keystroke or free-text entry, since those have no underlying record. Hands
39
+ * back the full item, so attach extra fields (e.g. an `id`) to your `items`
40
+ * and read them here.
41
+ */
42
+ onSelectItem?: (item: T) => void;
43
+ /**
44
+ * Suggestions for the current input. The caller fetches these — typically
45
+ * debounced against `value` — and they are assumed to be already filtered
46
+ * (the component does no client-side filtering). Items may carry extra fields
47
+ * beyond `value`/`label`/`description`; those flow through to `onSelectItem`.
48
+ */
49
+ items: readonly T[];
50
+ /** Async state of the suggestion source. Shows a trailing spinner while true. */
51
+ loading?: boolean;
52
+ /** Render a suggestion row. Defaults to `label` over `description`. */
53
+ renderItem?: (item: T) => React.ReactNode;
54
+ /** id forwarded to the input. */
55
+ id?: string;
56
+ placeholder?: string;
57
+ /** Render the error state (sets `aria-invalid` on the input). */
58
+ invalid?: boolean;
59
+ disabled?: boolean;
60
+ /** Class applied to the input container. */
61
+ className?: string;
62
+ }
63
+
64
+ function defaultRenderItem(item: ComboboxFreeTextItem): React.ReactNode {
65
+ return (
66
+ <div className="flex min-w-0 flex-col">
67
+ <span className="truncate">{item.label}</span>
68
+ {item.description ? (
69
+ <span className="text-muted-foreground truncate text-xs">{item.description}</span>
70
+ ) : null}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * A text input with debounced, server-driven suggestions where free text wins:
77
+ * pick a suggestion to commit its value, or keep typing and your text is the
78
+ * value. A choice is never forced — Enter, blur and no-match all keep the text.
79
+ *
80
+ * Built on Base UI's `Autocomplete` (the free-text primitive — `selectionMode`
81
+ * is fixed to `none` and the input value is the source of truth), not the
82
+ * pick-only `Combobox`. `mode="none"` keeps suggestions static (no client-side
83
+ * filtering, since the caller pre-filters server-side) and disables
84
+ * inline-autocompletion. Fetching, debounce and validation live with the caller.
85
+ */
86
+ function ComboboxFreeText<T extends ComboboxFreeTextItem = ComboboxFreeTextItem>({
87
+ value,
88
+ onValueChange,
89
+ onSelectItem,
90
+ items,
91
+ loading = false,
92
+ renderItem = defaultRenderItem,
93
+ id,
94
+ placeholder,
95
+ invalid,
96
+ disabled,
97
+ className,
98
+ }: ComboboxFreeTextProps<T>) {
99
+ // Base UI opens the popup on every keystroke; we only want it open when there
100
+ // is something pickable, so a no-match closes it silently instead of flashing
101
+ // an empty box. `open` tracks the user's intent (typing, Escape, blur,
102
+ // select) and we AND it with "items present" to derive the rendered state.
103
+ const [open, setOpen] = React.useState(false);
104
+
105
+ return (
106
+ <Autocomplete.Root
107
+ mode="none"
108
+ autoHighlight={false}
109
+ value={value}
110
+ onValueChange={(next, details) => {
111
+ // Call onValueChange before onSelectItem so a record captured in the
112
+ // latter survives any reset the value handler performs.
113
+ onValueChange(next);
114
+ // `item-press` is the only reason a row selection (mouse or keyboard)
115
+ // drives a value change; keystrokes and free text use other reasons.
116
+ // The literal is checked against Base UI's public `reason` union, so a
117
+ // future rename surfaces as a type error rather than silent breakage.
118
+ if (onSelectItem && details.reason === 'item-press') {
119
+ const picked = items.find((i) => i.value === next);
120
+ if (picked) {
121
+ onSelectItem(picked);
122
+ }
123
+ }
124
+ }}
125
+ open={open && items.length > 0}
126
+ onOpenChange={setOpen}
127
+ disabled={disabled}
128
+ >
129
+ <InputGroup className={cn('w-full', className)}>
130
+ <Autocomplete.Input
131
+ render={<InputGroupInput />}
132
+ id={id}
133
+ placeholder={placeholder}
134
+ disabled={disabled}
135
+ aria-invalid={invalid || undefined}
136
+ />
137
+ {loading ? (
138
+ <InputGroupAddon align="inline-end">
139
+ <Spinner />
140
+ </InputGroupAddon>
141
+ ) : null}
142
+ </InputGroup>
143
+ <ComboboxContent>
144
+ <ComboboxList>
145
+ {items.map((item) => (
146
+ <ComboboxItem key={item.value} value={item.value}>
147
+ {renderItem(item)}
148
+ </ComboboxItem>
149
+ ))}
150
+ </ComboboxList>
151
+ </ComboboxContent>
152
+ </Autocomplete.Root>
153
+ );
154
+ }
155
+
156
+ export { ComboboxFreeText };
@@ -24,6 +24,7 @@
24
24
 
25
25
  export { Accordion as AccordionPrimitive } from '@base-ui/react/accordion';
26
26
  export { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog';
27
+ export { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete';
27
28
  export { Avatar as AvatarPrimitive } from '@base-ui/react/avatar';
28
29
  export { Button as ButtonPrimitive } from '@base-ui/react/button';
29
30
  export { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox';