@rebasepro/ui 0.14.0 → 0.14.1-canary.g7e666eb

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,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { VirtualTableSortKey } from "../../components/VirtualTable/VirtualTableProps";
2
3
  /** Supported view modes */
3
4
  export type CollectionViewMode = "table" | "cards" | "kanban" | "list";
4
5
  /** Supported collection sizes */
@@ -57,8 +58,9 @@ export interface CollectionDataController<T = Record<string, unknown>> {
57
58
  error?: Error;
58
59
  filterValues?: Record<string, [string, unknown]>;
59
60
  setFilterValues?: (values: Record<string, [string, unknown]> | undefined) => void;
60
- sortBy?: [string, "asc" | "desc"];
61
- setSortBy?: (sort?: [string, "asc" | "desc"]) => void;
61
+ /** The sort keys in order of significance; the second breaks ties on the first. */
62
+ sortBy?: VirtualTableSortKey[];
63
+ setSortBy?: (sort?: VirtualTableSortKey[]) => void;
62
64
  searchString?: string;
63
65
  setSearchString?: (search?: string) => void;
64
66
  clearFilter?: () => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/ui",
3
3
  "type": "module",
4
- "version": "0.14.0",
4
+ "version": "0.14.1-canary.g7e666eb",
5
5
  "description": "Rebase design system — React components and design tokens built on Radix UI, Tailwind CSS and lucide-react",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -116,7 +116,7 @@
116
116
  "build": "rm -rf dist || true && vite build && cp dist/src/index.css dist/src/theme.css dist/ && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
117
117
  "test:lint": "eslint \"src/**\" --quiet",
118
118
  "test": "jest --passWithNoTests",
119
- "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
119
+ "clean": "rm -rf dist && find ./src \\( -name '*.js' -o -name '*.d.ts' \\) -type f | xargs rm -f",
120
120
  "generateIcons": "bun --esm src/scripts/generateIcons.ts"
121
121
  }
122
122
  }
@@ -13,6 +13,7 @@ import {
13
13
  VirtualTableColumn,
14
14
  VirtualTableFilterValues,
15
15
  VirtualTableProps,
16
+ VirtualTableSortKey,
16
17
  VirtualTableWhereFilterOp
17
18
  } from "./VirtualTableProps";
18
19
 
@@ -142,8 +143,18 @@ const VirtualTableInner = React.memo<VirtualTableProps<Record<string, unknown>>>
142
143
  extraData
143
144
  }: VirtualTableProps<T>) {
144
145
 
145
- const sortByProperty: string | undefined = sortBy ? sortBy[0] : undefined;
146
- const currentSort: "asc" | "desc" | undefined = sortBy ? sortBy[1] : undefined;
146
+ // Where each sorted column sits in the order, so a header can show both
147
+ // which way it runs and how much it counts: `roles ↑1, created_at ↓2`
148
+ // reads as "by role, newest first within each role". Without the rank a
149
+ // multi-key sort shows two arrows and no way to tell which one wins.
150
+ const sortIndex = useMemo(() => {
151
+ const index = new Map<string, { direction: "asc" | "desc"; position: number }>();
152
+ (sortBy ?? []).forEach(([key, direction], position) => {
153
+ if (!index.has(key)) index.set(key, { direction,
154
+ position });
155
+ });
156
+ return index;
157
+ }, [sortBy]);
147
158
 
148
159
  const [columns, setColumns] = useState(columnsProp);
149
160
 
@@ -243,16 +254,37 @@ const VirtualTableInner = React.memo<VirtualTableProps<Record<string, unknown>>>
243
254
  }
244
255
  }, []);
245
256
 
246
- const onColumnSort = useCallback((key: string) => {
247
-
248
- const isDesc = sortByProperty === key && currentSort === "desc";
249
- const isAsc = sortByProperty === key && currentSort === "asc";
250
- const newSort = isAsc ? "desc" : (isDesc ? undefined : "asc");
251
- const newSortProperty: string | undefined = isDesc ? undefined : key;
257
+ /**
258
+ * Click a header to sort by that column: ascending, descending, then
259
+ * off. Shift-click to keep the sort you already have and add this
260
+ * column under it — the same idiom spreadsheets use, and the only way
261
+ * to build "by role, newest first" out of two clicks.
262
+ *
263
+ * Shift cycles the column it lands on through the same three states, so
264
+ * a key added by mistake is removed by shift-clicking it twice more,
265
+ * without disturbing the keys around it.
266
+ */
267
+ const onColumnSort = useCallback((key: string, additive = false) => {
268
+
269
+ const current = sortIndex.get(key);
270
+ const next: "asc" | "desc" | undefined = current === undefined
271
+ ? "asc"
272
+ : current.direction === "asc" ? "desc" : undefined;
273
+
274
+ const existing = sortBy ?? [];
275
+ let newSortBy: VirtualTableSortKey[] | undefined;
276
+ if (!additive) {
277
+ newSortBy = next ? [[key, next]] : undefined;
278
+ } else if (next === undefined) {
279
+ newSortBy = existing.filter(([existingKey]) => existingKey !== key);
280
+ } else if (current === undefined) {
281
+ newSortBy = [...existing, [key, next]];
282
+ } else {
283
+ newSortBy = existing.map((entry) => entry[0] === key ? [key, next] as VirtualTableSortKey : entry);
284
+ }
285
+ if (newSortBy?.length === 0) newSortBy = undefined;
252
286
 
253
287
  const filter = filterRef.current;
254
-
255
- const newSortBy: [string, "asc" | "desc"] | undefined = newSort && newSortProperty ? [newSortProperty, newSort] : undefined;
256
288
  if (filter) {
257
289
  if (checkFilterCombination && !checkFilterCombination(filter, newSortBy)) {
258
290
  if (onFilterUpdate)
@@ -269,7 +301,7 @@ const VirtualTableInner = React.memo<VirtualTableProps<Record<string, unknown>>>
269
301
  }
270
302
 
271
303
  scrollToTop();
272
- }, [checkFilterCombination, currentSort, onFilterUpdate, onResetPagination, onSortByUpdate, scrollToTop, sortByProperty]);
304
+ }, [checkFilterCombination, onFilterUpdate, onResetPagination, onSortByUpdate, scrollToTop, sortBy, sortIndex]);
273
305
 
274
306
  const maxScroll = Math.max((data?.length ?? 0) * rowHeight - bounds.height, 0);
275
307
 
@@ -311,14 +343,13 @@ const VirtualTableInner = React.memo<VirtualTableProps<Record<string, unknown>>>
311
343
  } else {
312
344
  newFilterValue[column.key] = filterForProperty;
313
345
  }
314
- const newSortBy: [string, "asc" | "desc"] | undefined = sortByProperty && currentSort ? [sortByProperty, currentSort] : undefined;
315
- const isNewFilterCombinationValid = !checkFilterCombination || checkFilterCombination(newFilterValue, newSortBy);
346
+ const isNewFilterCombinationValid = !checkFilterCombination || checkFilterCombination(newFilterValue, sortBy);
316
347
  if (!isNewFilterCombinationValid) {
317
348
  newFilterValue = filterForProperty ? { [column.key]: filterForProperty } as VirtualTableFilterValues<Extract<keyof T, string>> : {};
318
349
  }
319
350
 
320
351
  if (onFilterUpdate) onFilterUpdate(newFilterValue);
321
- }, [checkFilterCombination, currentSort, onFilterUpdate, sortByProperty]);
352
+ }, [checkFilterCombination, onFilterUpdate, sortBy]);
322
353
 
323
354
  const empty = !loading && (data?.length ?? 0) === 0;
324
355
  const customView = (error && (data?.length ?? 0) === 0)
@@ -349,7 +380,6 @@ const VirtualTableInner = React.memo<VirtualTableProps<Record<string, unknown>>>
349
380
  headerHeight: headerHeight,
350
381
  cellRenderer,
351
382
  columns,
352
- currentSort,
353
383
  onRowClick,
354
384
  customView,
355
385
  onColumnResize: onColumnResizeInternal,
@@ -357,7 +387,7 @@ const VirtualTableInner = React.memo<VirtualTableProps<Record<string, unknown>>>
357
387
  filter: filterRef.current,
358
388
  onColumnSort,
359
389
  onFilterUpdate: onFilterUpdateInternal,
360
- sortByProperty,
390
+ sortIndex,
361
391
  hoverRow: hoverRow ?? false,
362
392
  createFilterField,
363
393
  rowClassName,
@@ -369,7 +399,7 @@ const VirtualTableInner = React.memo<VirtualTableProps<Record<string, unknown>>>
369
399
  } : undefined,
370
400
  draggingColumnId,
371
401
  extraData
372
- }), [data, rowHeight, cellRenderer, columns, currentSort, onRowClick, customView, onColumnResizeInternal, onColumnResizeEndInternal, filterInput, onColumnSort, onFilterUpdateInternal, sortByProperty, hoverRow, createFilterField, rowClassName, endAdornment, AddColumnComponent, onColumnsOrderChange, draggingColumnId, extraData]);
402
+ }), [data, rowHeight, cellRenderer, columns, onRowClick, customView, onColumnResizeInternal, onColumnResizeEndInternal, filterInput, onColumnSort, onFilterUpdateInternal, sortIndex, hoverRow, createFilterField, rowClassName, endAdornment, AddColumnComponent, onColumnsOrderChange, draggingColumnId, extraData]);
373
403
 
374
404
  // Get sortable column keys (excluding frozen columns)
375
405
  const sortableColumnKeys = columns
@@ -36,9 +36,14 @@ type VirtualTableHeaderProps<M extends Record<string, unknown>> = {
36
36
  columnIndex: number;
37
37
  isResizingIndex: number;
38
38
  column: VirtualTableColumn<unknown>;
39
- onColumnSort: (key: Extract<keyof M, string>) => void;
39
+ onColumnSort: (key: Extract<keyof M, string>, additive?: boolean) => void;
40
40
  filter?: [VirtualTableWhereFilterOp, unknown];
41
41
  sort: VirtualTableSort;
42
+ /**
43
+ * This column's zero-based rank in a multi-key sort, or `undefined` when
44
+ * only one column is sorted and a rank would say nothing.
45
+ */
46
+ sortPosition?: number;
42
47
  onFilterUpdate: (column: VirtualTableColumn, filterForProperty?: [VirtualTableWhereFilterOp, unknown]) => void;
43
48
  onClickResizeColumn?: (columnIndex: number, column: VirtualTableColumn) => void;
44
49
  createFilterField?: (props: FilterFormFieldProps<unknown>) => React.ReactNode;
@@ -47,12 +52,27 @@ type VirtualTableHeaderProps<M extends Record<string, unknown>> = {
47
52
  isDraggable?: boolean;
48
53
  };
49
54
 
55
+ /**
56
+ * What the next click on this column's sort control will do, spelled out for
57
+ * the tooltip and for screen readers. An unlabelled arrow that means three
58
+ * different things depending on the state it is in is a control you have to
59
+ * click to learn.
60
+ */
61
+ function sortActionTitle(sort: VirtualTableSort, sortPosition?: number): string {
62
+ const rank = sortPosition !== undefined ? ` (sort key ${sortPosition + 1})` : "";
63
+ const addKey = " — shift-click to add it under the current sort instead";
64
+ if (sort === "asc") return `Sorted ascending${rank}. Click to sort descending.${addKey}`;
65
+ if (sort === "desc") return `Sorted descending${rank}. Click to remove this sort.${addKey}`;
66
+ return `Click to sort ascending.${addKey}`;
67
+ }
68
+
50
69
  export const VirtualTableHeader = React.memo<VirtualTableHeaderProps<Record<string, unknown>>>(
51
70
  function VirtualTableHeader<M extends Record<string, unknown>>({
52
71
  resizeHandleRef,
53
72
  columnIndex,
54
73
  isResizingIndex,
55
74
  sort,
75
+ sortPosition,
56
76
  onColumnSort,
57
77
  onFilterUpdate,
58
78
  filter,
@@ -141,14 +161,23 @@ export const VirtualTableHeader = React.memo<VirtualTableHeaderProps<Record<stri
141
161
  <AdditionalHeaderWidget onHover={onHover || openFilter}/>}
142
162
 
143
163
  {column.sortable && (sort || hovered || openFilter) &&
144
- <div className="flex-shrink-0">
164
+ <div className="flex-shrink-0 flex items-center">
145
165
  <Badge color="secondary"
146
166
  invisible={!sort}>
147
167
  <IconButton
148
168
  size={"small"}
149
169
  className={onHover || openFilter ? "bg-white dark:bg-surface-900" : undefined}
150
- onClick={() => {
151
- onColumnSort(column.key as Extract<keyof M, string>);
170
+ // Shift keeps the sort already in place
171
+ // and adds this column beneath it, which
172
+ // is how a two-key order is built from
173
+ // header clicks. Plain click still
174
+ // replaces the whole sort, so the common
175
+ // case takes no modifier and nothing a
176
+ // user knew before stops working.
177
+ title={sortActionTitle(sort, sortPosition)}
178
+ aria-label={sortActionTitle(sort, sortPosition)}
179
+ onClick={(event: React.MouseEvent) => {
180
+ onColumnSort(column.key as Extract<keyof M, string>, event.shiftKey);
152
181
  }}
153
182
  >
154
183
  {!sort &&
@@ -159,6 +188,18 @@ export const VirtualTableHeader = React.memo<VirtualTableHeaderProps<Record<stri
159
188
  <ArrowUpIcon size={14} className="flex-shrink-0 rotate-180" />}
160
189
  </IconButton>
161
190
  </Badge>
191
+ {/* The rank, shown only once there is more than
192
+ one key: two arrows with no numbers beside
193
+ them say a table is sorted by both columns
194
+ and nothing about which one decides. */}
195
+ {sort && sortPosition !== undefined && (
196
+ <span
197
+ className="flex-shrink-0 -ml-0.5 mr-0.5 text-[10px] font-bold leading-none tabular-nums text-text-secondary dark:text-text-secondary-dark"
198
+ aria-hidden={true}
199
+ >
200
+ {sortPosition + 1}
201
+ </span>
202
+ )}
162
203
  </div>
163
204
  }
164
205
  </>
@@ -16,8 +16,8 @@ const SortableColumnHeader = ({
16
16
  isResizing,
17
17
  onFilterUpdate,
18
18
  filter,
19
- sortByProperty,
20
- currentSort,
19
+ sortIndex,
20
+ sortCount,
21
21
  onColumnSort,
22
22
  onClickResizeColumn,
23
23
  createFilterField,
@@ -30,9 +30,9 @@ const SortableColumnHeader = ({
30
30
  isResizing: number;
31
31
  onFilterUpdate: (column: VirtualTableColumn, filterForProperty?: [VirtualTableWhereFilterOp, unknown]) => void;
32
32
  filter: [VirtualTableWhereFilterOp, unknown] | undefined;
33
- sortByProperty: string | undefined;
34
- currentSort: "asc" | "desc" | undefined;
35
- onColumnSort: (key: string) => void;
33
+ sortIndex: Map<string, { direction: "asc" | "desc"; position: number }>;
34
+ sortCount: number;
35
+ onColumnSort: (key: string, additive?: boolean) => void;
36
36
  onClickResizeColumn: (index: number) => void;
37
37
  createFilterField: ((props: import("./VirtualTableHeader").FilterFormFieldProps<unknown>) => React.ReactNode) | undefined;
38
38
  isDragging: boolean;
@@ -96,7 +96,8 @@ const SortableColumnHeader = ({
96
96
  isResizingIndex={isResizing}
97
97
  onFilterUpdate={onFilterUpdate}
98
98
  filter={filter}
99
- sort={sortByProperty === column.key ? currentSort : undefined}
99
+ sort={sortIndex.get(column.key)?.direction}
100
+ sortPosition={sortCount > 1 ? sortIndex.get(column.key)?.position : undefined}
100
101
  onColumnSort={onColumnSort}
101
102
  onClickResizeColumn={onClickResizeColumn}
102
103
  column={column}
@@ -110,10 +111,9 @@ const SortableColumnHeader = ({
110
111
 
111
112
  export const VirtualTableHeaderRow = ({
112
113
  columns,
113
- currentSort,
114
+ sortIndex,
114
115
  onColumnSort,
115
116
  onFilterUpdate,
116
- sortByProperty,
117
117
  filter,
118
118
  onColumnResize,
119
119
  onColumnResizeEnd,
@@ -230,8 +230,8 @@ export const VirtualTableHeaderRow = ({
230
230
  isResizing={isResizing}
231
231
  onFilterUpdate={onFilterUpdate}
232
232
  filter={filterForThisProperty}
233
- sortByProperty={sortByProperty}
234
- currentSort={currentSort}
233
+ sortIndex={sortIndex}
234
+ sortCount={sortIndex.size}
235
235
  onColumnSort={onColumnSort}
236
236
  onClickResizeColumn={onClickResizeColumn}
237
237
  createFilterField={createFilterField}
@@ -3,6 +3,9 @@
3
3
  import React from "react";
4
4
  import type { FilterFormFieldProps } from "./VirtualTableHeader";
5
5
 
6
+ /** One sort key: the column, and which way it runs. */
7
+ export type VirtualTableSortKey = [string, "asc" | "desc"];
8
+
6
9
  export type OnRowClickParams<T extends Record<string, unknown>> = {
7
10
  rowData: T;
8
11
  rowIndex: number;
@@ -39,7 +42,7 @@ export interface VirtualTableProps<T extends Record<string, unknown>> {
39
42
  * @param sortBy
40
43
  */
41
44
  checkFilterCombination?: (filterValues: VirtualTableFilterValues<Extract<keyof T, string>>,
42
- sortBy?: [string, "asc" | "desc"]) => boolean;
45
+ sortBy?: VirtualTableSortKey[]) => boolean;
43
46
 
44
47
  /**
45
48
  * A callback function when scrolling the table to near the end
@@ -96,15 +99,16 @@ export interface VirtualTableProps<T extends Record<string, unknown>> {
96
99
  }) => void;
97
100
 
98
101
  /**
99
- * Default sort applied to this collection
102
+ * Sort applied to this collection: the keys in order of significance, the
103
+ * second breaking ties on the first.
100
104
  */
101
- sortBy?: [string, "asc" | "desc"];
105
+ sortBy?: VirtualTableSortKey[];
102
106
 
103
107
  /**
104
108
  * Callback used when sorting is updated
105
109
  * @param sortBy
106
110
  */
107
- onSortByUpdate?: (sortBy?: [string, "asc" | "desc"]) => void;
111
+ onSortByUpdate?: (sortBy?: VirtualTableSortKey[]) => void;
108
112
 
109
113
  /**
110
114
  * If there is an error loading data you can pass it here, so it gets
@@ -27,14 +27,14 @@ export type VirtualTableContextProps<T> = {
27
27
  headerHeight?: number,
28
28
  columns: VirtualTableColumn[];
29
29
  cellRenderer: React.ComponentType<CellRendererParams<T>>;
30
- currentSort: "asc" | "desc" | undefined;
30
+ /** Each sorted column's direction and its rank in the sort, keyed by column. */
31
+ sortIndex: Map<string, { direction: "asc" | "desc"; position: number }>;
31
32
  filter?: VirtualTableFilterValues<string>;
32
33
  onRowClick?: (props: OnRowClickParams<Record<string, unknown>>) => void;
33
- onColumnSort: (key: string) => void;
34
+ onColumnSort: (key: string, additive?: boolean) => void;
34
35
  onColumnResize: (params: OnVirtualTableColumnResizeParams) => void;
35
36
  onColumnResizeEnd: (params: OnVirtualTableColumnResizeParams) => void;
36
37
  onFilterUpdate: (column: VirtualTableColumn, filterForProperty?: [VirtualTableWhereFilterOp, unknown]) => void;
37
- sortByProperty?: string;
38
38
  customView?: React.ReactNode,
39
39
  hoverRow: boolean;
40
40
  createFilterField?: (props: FilterFormFieldProps<unknown>) => React.ReactNode;
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { VirtualTableSortKey } from "../../components/VirtualTable/VirtualTableProps";
2
3
 
3
4
  /** Supported view modes */
4
5
  export type CollectionViewMode = "table" | "cards" | "kanban" | "list";
@@ -80,8 +81,9 @@ export interface CollectionDataController<T = Record<string, unknown>> {
80
81
  filterValues?: Record<string, [string, unknown]>;
81
82
  setFilterValues?: (values: Record<string, [string, unknown]> | undefined) => void;
82
83
 
83
- sortBy?: [string, "asc" | "desc"];
84
- setSortBy?: (sort?: [string, "asc" | "desc"]) => void;
84
+ /** The sort keys in order of significance; the second breaks ties on the first. */
85
+ sortBy?: VirtualTableSortKey[];
86
+ setSortBy?: (sort?: VirtualTableSortKey[]) => void;
85
87
 
86
88
  searchString?: string;
87
89
  setSearchString?: (search?: string) => void;