@vendure-io/ui 1.1.0 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vendure-io/ui",
3
- "version": "1.1.0",
3
+ "version": "1.2.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,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,