@recursica/mantine-adapter 0.41.0 → 0.42.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.
@@ -1,9 +1,419 @@
1
- import React from "react";
2
- import { type RecursicaTransferListProps } from "@recursica/adapter-common";
1
+ import { forwardRef, useCallback, useId, useMemo, useState } from "react";
2
+ import {
3
+ filterStylingProps,
4
+ type RecursicaOverStyled,
5
+ } from "../../utils/filterStylingProps";
6
+ import { type FormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
7
+ import { WithReadOnlyWrapper } from "../ReadOnlyField/WithReadOnlyWrapper";
8
+ import { Badge } from "../Badge/Badge";
9
+ import { Button } from "../Button/Button";
10
+ import { TextField } from "../TextField/TextField";
11
+ import { Checkbox } from "../Checkbox/Checkbox";
12
+ import { CheckboxGroup } from "../Checkbox/CheckboxGroup";
13
+ import styles from "./TransferList.module.css";
3
14
 
4
- export type TransferListProps = React.HTMLAttributes<HTMLDivElement> &
5
- RecursicaTransferListProps;
15
+ import {
16
+ type ReadOnlyControlProps,
17
+ type RecursicaTransferListProps as BaseRecursicaTransferListProps,
18
+ type RecursicaTransferListItem,
19
+ type RecursicaTransferListData,
20
+ } from "@recursica/adapter-common";
6
21
 
7
- export const TransferList: React.FC<TransferListProps> = (props) => {
8
- return <div {...props}>TransferList</div>;
9
- };
22
+ export type { RecursicaTransferListItem, RecursicaTransferListData };
23
+
24
+ export interface RecursicaTransferListProps
25
+ extends Omit<
26
+ FormControlWrapperProps,
27
+ | "children"
28
+ | "overStyled"
29
+ | "controlMaxWidth"
30
+ | "controlMinWidth"
31
+ | "onChange"
32
+ >,
33
+ ReadOnlyControlProps,
34
+ BaseRecursicaTransferListProps {
35
+ /** Disables the whole control */
36
+ disabled?: boolean;
37
+ }
38
+
39
+ export type TransferListProps = RecursicaOverStyled<RecursicaTransferListProps>;
40
+
41
+ /** Single/double chevron glyphs for the transfer buttons; flipped via CSS for the "left" direction
42
+ * so only one path needs to be maintained, same approach as Tree's `ExpandGlyph`. */
43
+ function ChevronIcon({ direction }: { direction: "left" | "right" }) {
44
+ return (
45
+ <svg
46
+ className={styles.chevron}
47
+ data-direction={direction}
48
+ viewBox="0 0 16 16"
49
+ width="1em"
50
+ height="1em"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ strokeWidth="2"
54
+ strokeLinecap="round"
55
+ strokeLinejoin="round"
56
+ aria-hidden="true"
57
+ >
58
+ <path d="M6 3l5 5-5 5" />
59
+ </svg>
60
+ );
61
+ }
62
+
63
+ function ChevronsIcon({ direction }: { direction: "left" | "right" }) {
64
+ return (
65
+ <svg
66
+ className={styles.chevron}
67
+ data-direction={direction}
68
+ viewBox="0 0 16 16"
69
+ width="1em"
70
+ height="1em"
71
+ fill="none"
72
+ stroke="currentColor"
73
+ strokeWidth="2"
74
+ strokeLinecap="round"
75
+ strokeLinejoin="round"
76
+ aria-hidden="true"
77
+ >
78
+ <path d="M3 3l5 5-5 5M9 3l5 5-5 5" />
79
+ </svg>
80
+ );
81
+ }
82
+
83
+ /** Splits items into grouped/ungrouped buckets, same shape Forge's reference groups by `group`. */
84
+ function groupItems(items: RecursicaTransferListItem[]) {
85
+ const groups: Record<string, RecursicaTransferListItem[]> = {};
86
+ const ungrouped: RecursicaTransferListItem[] = [];
87
+ items.forEach((item) => {
88
+ if (item.group) {
89
+ (groups[item.group] ??= []).push(item);
90
+ } else {
91
+ ungrouped.push(item);
92
+ }
93
+ });
94
+ return { groups, ungrouped };
95
+ }
96
+
97
+ export const TransferList = forwardRef<HTMLDivElement, TransferListProps>(
98
+ function TransferList(props, ref) {
99
+ const {
100
+ overStyled = false,
101
+ formLayout = "stacked",
102
+
103
+ // Label & Wrapper Maps
104
+ labelSize,
105
+ labelAlignment,
106
+ labelOptionalText,
107
+ labelWithEditIcon,
108
+ labelActionArea,
109
+ onLabelEditClick,
110
+
111
+ label,
112
+ assistiveText,
113
+ description,
114
+ helperText,
115
+ assistiveWithIcon,
116
+ error,
117
+ required,
118
+ id: userProvidedId,
119
+ readOnly,
120
+ readOnlyComponent,
121
+ emptyValueComponent,
122
+
123
+ data: controlledData,
124
+ defaultData,
125
+ onChange,
126
+ sourceLabel = "Available",
127
+ targetLabel = "Selected",
128
+ searchable = true,
129
+ searchPlaceholder = "Filter items...",
130
+ disabled = false,
131
+
132
+ className,
133
+ style,
134
+ ...rest
135
+ } = props;
136
+ const sanitizedProps = filterStylingProps(rest, overStyled);
137
+
138
+ const generatedId = useId();
139
+ const id = userProvidedId || `recursica-transfer-list-${generatedId}`;
140
+
141
+ // Manage internal state for uncontrolled mode
142
+ const [internalData, setInternalData] = useState<RecursicaTransferListData>(
143
+ () => defaultData ?? controlledData ?? [[], []],
144
+ );
145
+ const effectiveData =
146
+ controlledData !== undefined ? controlledData : internalData;
147
+
148
+ const handleChange = useCallback(
149
+ (newData: RecursicaTransferListData) => {
150
+ if (controlledData === undefined) {
151
+ setInternalData(newData);
152
+ }
153
+ onChange?.(newData);
154
+ },
155
+ [controlledData, onChange],
156
+ );
157
+
158
+ const [sourceSearch, setSourceSearch] = useState("");
159
+ const [targetSearch, setTargetSearch] = useState("");
160
+ const [sourceSelected, setSourceSelected] = useState<Set<string>>(
161
+ () => new Set(),
162
+ );
163
+ const [targetSelected, setTargetSelected] = useState<Set<string>>(
164
+ () => new Set(),
165
+ );
166
+
167
+ const filteredSource = useMemo(() => {
168
+ if (!sourceSearch) return effectiveData[0];
169
+ const query = sourceSearch.toLowerCase();
170
+ return effectiveData[0].filter((item) =>
171
+ item.label.toLowerCase().includes(query),
172
+ );
173
+ }, [effectiveData, sourceSearch]);
174
+
175
+ const filteredTarget = useMemo(() => {
176
+ if (!targetSearch) return effectiveData[1];
177
+ const query = targetSearch.toLowerCase();
178
+ return effectiveData[1].filter((item) =>
179
+ item.label.toLowerCase().includes(query),
180
+ );
181
+ }, [effectiveData, targetSearch]);
182
+
183
+ const transferToTarget = useCallback(() => {
184
+ if (sourceSelected.size === 0) return;
185
+ const newSource = effectiveData[0].filter(
186
+ (item) => !sourceSelected.has(item.value),
187
+ );
188
+ const moved = effectiveData[0].filter((item) =>
189
+ sourceSelected.has(item.value),
190
+ );
191
+ setSourceSelected(new Set());
192
+ handleChange([newSource, [...effectiveData[1], ...moved]]);
193
+ }, [effectiveData, sourceSelected, handleChange]);
194
+
195
+ const transferToSource = useCallback(() => {
196
+ if (targetSelected.size === 0) return;
197
+ const newTarget = effectiveData[1].filter(
198
+ (item) => !targetSelected.has(item.value),
199
+ );
200
+ const moved = effectiveData[1].filter((item) =>
201
+ targetSelected.has(item.value),
202
+ );
203
+ setTargetSelected(new Set());
204
+ handleChange([[...effectiveData[0], ...moved], newTarget]);
205
+ }, [effectiveData, targetSelected, handleChange]);
206
+
207
+ const transferAllToTarget = useCallback(() => {
208
+ if (effectiveData[0].length === 0) return;
209
+ setSourceSelected(new Set());
210
+ handleChange([[], [...effectiveData[1], ...effectiveData[0]]]);
211
+ }, [effectiveData, handleChange]);
212
+
213
+ const transferAllToSource = useCallback(() => {
214
+ if (effectiveData[1].length === 0) return;
215
+ setTargetSelected(new Set());
216
+ handleChange([[...effectiveData[0], ...effectiveData[1]], []]);
217
+ }, [effectiveData, handleChange]);
218
+
219
+ const toggleSourceItem = useCallback((value: string) => {
220
+ setSourceSelected((prev) => {
221
+ const next = new Set(prev);
222
+ if (next.has(value)) next.delete(value);
223
+ else next.add(value);
224
+ return next;
225
+ });
226
+ }, []);
227
+
228
+ const toggleTargetItem = useCallback((value: string) => {
229
+ setTargetSelected((prev) => {
230
+ const next = new Set(prev);
231
+ if (next.has(value)) next.delete(value);
232
+ else next.add(value);
233
+ return next;
234
+ });
235
+ }, []);
236
+
237
+ const renderPane = (
238
+ paneId: string,
239
+ paneLabel: string,
240
+ items: RecursicaTransferListItem[],
241
+ allItems: RecursicaTransferListItem[],
242
+ selected: Set<string>,
243
+ onToggle: (value: string) => void,
244
+ search: string,
245
+ onSearchChange: (value: string) => void,
246
+ ) => {
247
+ const { groups, ungrouped } = groupItems(items);
248
+ const groupNames = Object.keys(groups).sort();
249
+ const countText =
250
+ selected.size > 0
251
+ ? `${selected.size} / ${allItems.length}`
252
+ : `${allItems.length}`;
253
+
254
+ return (
255
+ <div className={styles.pane} data-pane={paneId}>
256
+ <div className={styles.paneHeader}>
257
+ <span>{paneLabel}</span>
258
+ <Badge>{countText}</Badge>
259
+ </div>
260
+
261
+ {searchable && (
262
+ <div className={styles.paneSearch}>
263
+ <TextField
264
+ value={search}
265
+ onChange={(e) => onSearchChange(e.currentTarget.value)}
266
+ placeholder={searchPlaceholder}
267
+ disabled={disabled}
268
+ aria-label={`Filter ${paneLabel.toLowerCase()}`}
269
+ />
270
+ </div>
271
+ )}
272
+
273
+ <div className={styles.paneList}>
274
+ {items.length === 0 && (
275
+ <div className={styles.emptyState}>No items</div>
276
+ )}
277
+
278
+ {ungrouped.length > 0 && (
279
+ <CheckboxGroup>
280
+ {ungrouped.map((item) => (
281
+ <Checkbox
282
+ key={item.value}
283
+ id={`${id}-${paneId}-${item.value}`}
284
+ label={item.label}
285
+ checked={selected.has(item.value)}
286
+ onChange={() => onToggle(item.value)}
287
+ disabled={disabled}
288
+ />
289
+ ))}
290
+ </CheckboxGroup>
291
+ )}
292
+
293
+ {groupNames.map((groupName) => (
294
+ <CheckboxGroup
295
+ key={groupName}
296
+ label={groupName}
297
+ labelSize="small"
298
+ >
299
+ {groups[groupName].map((item) => (
300
+ <Checkbox
301
+ key={item.value}
302
+ id={`${id}-${paneId}-${item.value}`}
303
+ label={item.label}
304
+ checked={selected.has(item.value)}
305
+ onChange={() => onToggle(item.value)}
306
+ disabled={disabled}
307
+ />
308
+ ))}
309
+ </CheckboxGroup>
310
+ ))}
311
+ </div>
312
+ </div>
313
+ );
314
+ };
315
+
316
+ const wrapperClass = className
317
+ ? `${styles.layoutOverride} ${className}`
318
+ : styles.layoutOverride;
319
+
320
+ return (
321
+ <WithReadOnlyWrapper
322
+ ref={ref}
323
+ formLayout={formLayout}
324
+ labelSize={labelSize}
325
+ labelAlignment={labelAlignment}
326
+ labelOptionalText={labelOptionalText}
327
+ labelWithEditIcon={labelWithEditIcon}
328
+ labelActionArea={labelActionArea}
329
+ onLabelEditClick={onLabelEditClick}
330
+ label={label}
331
+ assistiveText={assistiveText}
332
+ description={description}
333
+ helperText={helperText}
334
+ assistiveWithIcon={assistiveWithIcon}
335
+ error={error}
336
+ required={required}
337
+ id={id}
338
+ className={wrapperClass}
339
+ style={style}
340
+ overStyled={overStyled as true}
341
+ readOnly={readOnly}
342
+ readOnlyComponent={readOnlyComponent}
343
+ emptyValueComponent={emptyValueComponent}
344
+ readOnlyType="text"
345
+ readOnlyValue={effectiveData[1].map((item) => item.label)}
346
+ readOnlyNativeProps={props}
347
+ activeComponent={
348
+ <div
349
+ className={styles.root}
350
+ data-disabled={disabled ? "true" : undefined}
351
+ data-error={error ? "true" : undefined}
352
+ {...(sanitizedProps as Record<string, unknown>)}
353
+ >
354
+ <div className={styles.panes}>
355
+ {renderPane(
356
+ "source",
357
+ sourceLabel,
358
+ filteredSource,
359
+ effectiveData[0],
360
+ sourceSelected,
361
+ toggleSourceItem,
362
+ sourceSearch,
363
+ setSourceSearch,
364
+ )}
365
+
366
+ <div className={styles.transferColumn}>
367
+ <Button
368
+ variant="outline"
369
+ size="small"
370
+ icon={<ChevronsIcon direction="right" />}
371
+ aria-label={`Move all to ${targetLabel}`}
372
+ disabled={disabled || effectiveData[0].length === 0}
373
+ onClick={transferAllToTarget}
374
+ />
375
+ <Button
376
+ variant="outline"
377
+ size="small"
378
+ icon={<ChevronIcon direction="right" />}
379
+ aria-label={`Move selected to ${targetLabel}`}
380
+ disabled={disabled || sourceSelected.size === 0}
381
+ onClick={transferToTarget}
382
+ />
383
+ <Button
384
+ variant="outline"
385
+ size="small"
386
+ icon={<ChevronIcon direction="left" />}
387
+ aria-label={`Move selected to ${sourceLabel}`}
388
+ disabled={disabled || targetSelected.size === 0}
389
+ onClick={transferToSource}
390
+ />
391
+ <Button
392
+ variant="outline"
393
+ size="small"
394
+ icon={<ChevronsIcon direction="left" />}
395
+ aria-label={`Move all to ${sourceLabel}`}
396
+ disabled={disabled || effectiveData[1].length === 0}
397
+ onClick={transferAllToSource}
398
+ />
399
+ </div>
400
+
401
+ {renderPane(
402
+ "target",
403
+ targetLabel,
404
+ filteredTarget,
405
+ effectiveData[1],
406
+ targetSelected,
407
+ toggleTargetItem,
408
+ targetSearch,
409
+ setTargetSearch,
410
+ )}
411
+ </div>
412
+ </div>
413
+ }
414
+ />
415
+ );
416
+ },
417
+ );
418
+
419
+ TransferList.displayName = "TransferList";
@@ -1,6 +1,7 @@
1
1
  # TransferList - Usage Guide
2
2
 
3
- This document describes how to integrate and use the `TransferList` component in your projects using `@recursica/mantine-adapter`.
3
+ This document describes how to integrate and use the `TransferList` component in your projects
4
+ using `@recursica/mantine-adapter`.
4
5
 
5
6
  ---
6
7
 
@@ -21,26 +22,56 @@ import { TransferList } from "@recursica/mantine-adapter";
21
22
  export default function Demo() {
22
23
  return (
23
24
  <TransferList
24
- data={[
25
+ label="Assign users"
26
+ sourceLabel="Available"
27
+ targetLabel="Selected"
28
+ defaultData={[
25
29
  [
26
30
  { value: "1", label: "Item 1" },
27
31
  { value: "2", label: "Item 2" },
28
32
  ],
29
33
  [{ value: "3", label: "Item 3" }],
30
34
  ]}
35
+ onChange={(data) => console.log(data)}
31
36
  />
32
37
  );
33
38
  }
34
39
  ```
35
40
 
41
+ Items sharing a `group` field render under a `CheckboxGroup` heading in their pane:
42
+
43
+ ```tsx
44
+ <TransferList
45
+ label="Assign ingredients"
46
+ defaultData={[
47
+ [
48
+ { value: "apple", label: "Apple", group: "Fruit" },
49
+ { value: "carrot", label: "Carrot", group: "Vegetable" },
50
+ { value: "eagle", label: "Eagle" }, // ungrouped, renders above the groups
51
+ ],
52
+ [],
53
+ ]}
54
+ />
55
+ ```
56
+
57
+ Pass `data` instead of `defaultData` to control the selection yourself.
58
+
36
59
  ---
37
60
 
38
61
  ## 3. Design System Integration
39
62
 
40
- All Recursica components in the `@recursica/mantine-adapter` package adhere strictly to design system spacing, scaling, and behavior patterns.
63
+ All Recursica components in the `@recursica/mantine-adapter` package adhere strictly to design
64
+ system spacing, scaling, and behavior patterns.
41
65
 
42
66
  > [!IMPORTANT]
43
67
  >
44
- > - **Anti-override protection**: Rogues style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
45
- > - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
46
- > - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in `recursica_variables_scoped.css` and mapped in the component's CSS module.
68
+ > - **Anti-override protection**: Rogue style injections (like inline `style` or arbitrary
69
+ > `className`) are automatically blocked by our prop layer unless `overStyled={true}` is
70
+ > explicitly provided.
71
+ > - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific
72
+ > visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
73
+ > - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in
74
+ > `recursica_variables_scoped.css` and mapped in the component's CSS module.
75
+ > - **Form control layout**: `label`, `formLayout` (`stacked`/`side-by-side`), `required`,
76
+ > `assistiveText`/`description`/`helperText`, and `error` all route through the shared
77
+ > `FormControlWrapper`, same as every other form control.