@vendure-io/ui 1.1.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.1.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",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@base-ui/react": "^1.2.0",
48
- "@vendure-io/design-tokens": "^1.1.2",
48
+ "@vendure-io/design-tokens": "^1.2.0",
49
49
  "class-variance-authority": "^0.7.1",
50
50
  "clsx": "^2.1.1",
51
51
  "cmdk": "^1.1.1",
@@ -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 };
@@ -0,0 +1,176 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectGroup,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from '@vendure-io/ui/components/ui/select';
11
+ import { cn } from '@vendure-io/ui/lib/utils';
12
+ import * as React from 'react';
13
+
14
+ type ItemValue = string | number;
15
+
16
+ /**
17
+ * Describes how to read a piece of data off an item. Pass a property key — only
18
+ * keys whose value is assignable to `R` are allowed — or a function for full control.
19
+ */
20
+ type AccessorKey<T, R> = { [K in keyof T]-?: T[K] extends R ? K : never }[keyof T];
21
+ type Accessor<T, R> = AccessorKey<T, R> | ((item: T) => R);
22
+
23
+ function resolveAccessor<T, R>(item: T, accessor: Accessor<T, R>): R {
24
+ return typeof accessor === 'function' ? accessor(item) : (item[accessor] as R);
25
+ }
26
+
27
+ export interface MultiSelectProps<T> {
28
+ /** The list of options to choose from. */
29
+ items: readonly T[];
30
+ /**
31
+ * How to render each option's label. Pass a property key for the common case
32
+ * (`itemToLabel="name"`), or a function for full control
33
+ * (`itemToLabel={(item) => <Flag code={item.code} />}`).
34
+ * @default String(item)
35
+ */
36
+ itemToLabel?: Accessor<T, React.ReactNode>;
37
+ /**
38
+ * How to derive the stable value stored in state for each option. Pass a
39
+ * property key (`itemToValue="id"`) or a function. Required when items are
40
+ * objects; for string/number items it defaults to the item itself.
41
+ */
42
+ itemToValue?: Accessor<T, ItemValue>;
43
+ /** Selected values. Use for controlled usage. */
44
+ value?: readonly ItemValue[];
45
+ /** Initially selected values for uncontrolled usage. */
46
+ defaultValue?: readonly ItemValue[];
47
+ /** Called with the next array of selected values whenever the selection changes. */
48
+ onValueChange?: (value: ItemValue[]) => void;
49
+ /** Text shown in the trigger when nothing is selected. */
50
+ placeholder?: React.ReactNode;
51
+ /**
52
+ * Override how the selected options are summarised in the trigger. Defaults
53
+ * to the selected labels joined with commas. Provide this when `itemToLabel`
54
+ * renders multi-line content, so the trigger stays a single, non-growing line.
55
+ */
56
+ renderValue?: (selectedItems: T[]) => React.ReactNode;
57
+ /** Disable the entire control. */
58
+ disabled?: boolean;
59
+ /** Render the error state (sets `aria-invalid` on the trigger). */
60
+ invalid?: boolean;
61
+ /** Trigger height. */
62
+ size?: 'sm' | 'default';
63
+ /** Class applied to the trigger. Defaults to a full-width, non-growing trigger; pass e.g. `w-[260px]` to fix the width. */
64
+ className?: string;
65
+ /** Class applied to the dropdown content. */
66
+ contentClassName?: string;
67
+ /** Form field name for the hidden inputs base-ui renders for each value. */
68
+ name?: string;
69
+ /** Whether a selection is required for form submission. */
70
+ required?: boolean;
71
+ /** id forwarded to the trigger. */
72
+ id?: string;
73
+ }
74
+
75
+ function MultiSelect<T>({
76
+ items,
77
+ itemToLabel = String as unknown as Accessor<T, React.ReactNode>,
78
+ itemToValue,
79
+ value,
80
+ defaultValue,
81
+ onValueChange,
82
+ placeholder = 'Select…',
83
+ renderValue,
84
+ disabled,
85
+ invalid,
86
+ size = 'default',
87
+ className,
88
+ contentClassName,
89
+ name,
90
+ required,
91
+ id,
92
+ }: MultiSelectProps<T>) {
93
+ const getValue = React.useCallback(
94
+ (item: T): ItemValue =>
95
+ itemToValue == null ? (item as unknown as ItemValue) : resolveAccessor(item, itemToValue),
96
+ [itemToValue],
97
+ );
98
+ const getLabel = React.useCallback(
99
+ (item: T): React.ReactNode => resolveAccessor(item, itemToLabel),
100
+ [itemToLabel],
101
+ );
102
+
103
+ const itemByValue = React.useMemo(() => {
104
+ const map = new Map<ItemValue, T>();
105
+ for (const item of items) {
106
+ const itemValue = getValue(item);
107
+ if (process.env.NODE_ENV !== 'production') {
108
+ if (typeof itemValue !== 'string' && typeof itemValue !== 'number') {
109
+ console.warn(
110
+ 'MultiSelect: `itemToValue` must resolve to a string or number. When `items` are ' +
111
+ 'objects, pass `itemToValue` (e.g. itemToValue="id") so the selection survives the ' +
112
+ 'items array being recreated.',
113
+ );
114
+ } else if (map.has(itemValue)) {
115
+ console.warn(
116
+ `MultiSelect: duplicate item value ${JSON.stringify(itemValue)} — each item must ` +
117
+ 'resolve to a unique `itemToValue`; later items override earlier ones.',
118
+ );
119
+ }
120
+ }
121
+ map.set(itemValue, item);
122
+ }
123
+ return map;
124
+ }, [items, getValue]);
125
+
126
+ return (
127
+ <Select
128
+ multiple
129
+ value={value as ItemValue[] | undefined}
130
+ defaultValue={defaultValue as ItemValue[] | undefined}
131
+ onValueChange={onValueChange}
132
+ disabled={disabled}
133
+ name={name}
134
+ required={required}
135
+ >
136
+ {/* [vendure] default to a stable, container-width trigger so it doesn't grow with the
137
+ selection — the underlying SelectTrigger is `w-fit`, which we override here. */}
138
+ <SelectTrigger
139
+ id={id}
140
+ size={size}
141
+ className={cn('w-full', className)}
142
+ aria-invalid={invalid || undefined}
143
+ >
144
+ <SelectValue placeholder={placeholder}>
145
+ {(selected: ItemValue[]) => {
146
+ const selectedItems = selected
147
+ .map((v) => itemByValue.get(v))
148
+ .filter((item): item is T => item !== undefined);
149
+ if (selectedItems.length === 0) return placeholder;
150
+ if (renderValue) return renderValue(selectedItems);
151
+ return selectedItems.map((item, index) => (
152
+ <React.Fragment key={getValue(item)}>
153
+ {index > 0 ? ', ' : null}
154
+ {getLabel(item)}
155
+ </React.Fragment>
156
+ ));
157
+ }}
158
+ </SelectValue>
159
+ </SelectTrigger>
160
+ <SelectContent className={contentClassName}>
161
+ <SelectGroup>
162
+ {items.map((item) => {
163
+ const itemValue = getValue(item);
164
+ return (
165
+ <SelectItem key={itemValue} value={itemValue}>
166
+ {getLabel(item)}
167
+ </SelectItem>
168
+ );
169
+ })}
170
+ </SelectGroup>
171
+ </SelectContent>
172
+ </Select>
173
+ );
174
+ }
175
+
176
+ export { MultiSelect };
@@ -9,6 +9,10 @@ function Popover({ ...props }: PopoverPrimitive.Root.Props) {
9
9
  return <PopoverPrimitive.Root data-slot="popover" {...props} />
10
10
  }
11
11
 
12
+ function PopoverPortal({ ...props }: PopoverPrimitive.Portal.Props) {
13
+ return <PopoverPrimitive.Portal data-slot="popover-portal" {...props} />
14
+ }
15
+
12
16
  function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
13
17
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
14
18
  }
@@ -82,6 +86,7 @@ function PopoverDescription({
82
86
 
83
87
  export {
84
88
  Popover,
89
+ PopoverPortal,
85
90
  PopoverContent,
86
91
  PopoverDescription,
87
92
  PopoverHeader,
@@ -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';