@rebasepro/ui 0.13.0 → 0.13.1-canary.g249daa1

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.
@@ -25,6 +25,12 @@ export type ListViewProps<T> = {
25
25
  emptyComponent?: React.ReactNode;
26
26
  size?: CollectionSize;
27
27
  selectedEntityId?: string | number;
28
+ /**
29
+ * Rendered above the rows, inside the list's own frame, and only when there
30
+ * are rows to head. A column header over an empty state or a spinner labels
31
+ * nothing.
32
+ */
33
+ header?: React.ReactNode;
28
34
  renderRow: (params: {
29
35
  item: T;
30
36
  index: number;
@@ -37,4 +43,4 @@ export type ListViewProps<T> = {
37
43
  onSelectionChange: (selected: boolean) => void;
38
44
  }) => React.ReactNode;
39
45
  };
40
- export declare function ListView<T>({ data, dataLoading, noMoreToLoad, dataLoadingError, itemCount, setItemCount, pageSize, paginationEnabled, onItemClick, selectedIds, highlightedIds, selectionEnabled, onSelectionChange, emptyComponent, size, selectedEntityId, renderRow }: ListViewProps<T>): React.JSX.Element;
46
+ export declare function ListView<T>({ data, dataLoading, noMoreToLoad, dataLoadingError, itemCount, setItemCount, pageSize, paginationEnabled, onItemClick, selectedIds, highlightedIds, selectionEnabled, onSelectionChange, emptyComponent, size, selectedEntityId, header, renderRow }: ListViewProps<T>): React.JSX.Element;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/ui",
3
3
  "type": "module",
4
- "version": "0.13.0",
4
+ "version": "0.13.1-canary.g249daa1",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -31,6 +31,15 @@ const sizeClasses = {
31
31
  // Outer box is the hit area and sets the control's rendered height, so it
32
32
  // tracks the shared 28/32/40/48 scale. `smallest` used to reuse `small`'s
33
33
  // 32px box, which made the two sizes identical.
34
+ //
35
+ // A checkbox with no `onCheckedChange` is not a hit area — it is a tick mark
36
+ // standing in for a value, as in `BooleanPreview`. There the box was 10px of
37
+ // dead space on each side of a `medium` tick: on top of a read-only field's
38
+ // own padding, it started the tick 10px further in than the text of the field
39
+ // beside it. `padding={false}` reads as the request to drop it, and until now
40
+ // did nothing at all, because it only removed `p-2` from a box whose size was
41
+ // fixed regardless. Kept for anything clickable — the same box is the click
42
+ // target for table row selection and for editor task items.
34
43
  const outerSizeClasses = {
35
44
  medium: "w-10 h-10",
36
45
  small: "w-8 h-8",
@@ -85,7 +94,7 @@ export const Checkbox = React.memo(({
85
94
 
86
95
  <div className={cls(
87
96
  padding ? paddingClasses[size] : "",
88
- outerSizeClasses[size],
97
+ padding || onCheckedChange ? outerSizeClasses[size] : "",
89
98
  "inline-flex items-center justify-center text-sm font-medium focus:outline-none transition-colors ease-in-out duration-150",
90
99
  onCheckedChange ? "rounded-full hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-700 dark:hover:bg-opacity-75 dark:hover:bg-surface-accent-700/75" : "",
91
100
  onCheckedChange ? "cursor-pointer" : "cursor-default"
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
4
  import { paperMixin } from "../styles";
5
5
  import { cls } from "../util";
6
- import { usePortalContainer } from "../hooks/PortalContainerContext";
6
+ import { PortalContainerProvider, usePortalContainer } from "../hooks/PortalContainerContext";
7
7
 
8
8
  export type DialogProps = {
9
9
  open?: boolean;
@@ -65,6 +65,12 @@ export const Dialog = ({
65
65
  "aria-describedby": ariaDescribedby
66
66
  }: DialogProps) => {
67
67
  const [displayed, setDisplayed] = useState(false);
68
+ // Where popups rendered *inside* this dialog get portaled to. A modal
69
+ // dialog locks scrolling everywhere but its own content, so a popup
70
+ // portaled to `document.body` renders fine and then refuses to scroll:
71
+ // `react-remove-scroll` cancels every wheel event outside the lock.
72
+ // Handing descendants this host keeps them inside it.
73
+ const [popupHost, setPopupHost] = useState<HTMLDivElement | null>(null);
68
74
 
69
75
  // Get the portal container from context
70
76
  const contextContainer = usePortalContainer();
@@ -134,9 +140,16 @@ export const Dialog = ({
134
140
  maxWidth && !fullScreen ? widthClasses[maxWidth] : undefined,
135
141
  className
136
142
  )}>
137
- {children}
143
+ <PortalContainerProvider container={popupHost}>
144
+ {children}
145
+ </PortalContainerProvider>
138
146
  </div>
139
147
 
148
+ {/* Sized to nothing so it changes no layout, but positioned and
149
+ stacked above the paper so popups portaled here are not
150
+ painted behind it. */}
151
+ <div ref={setPopupHost} className={"relative z-70 w-0 h-0"}/>
152
+
140
153
  </DialogPrimitive.Content>
141
154
  </div>
142
155
 
@@ -57,7 +57,15 @@ const Menu = React.forwardRef<
57
57
  side={side}
58
58
  sideOffset={sideOffset}
59
59
  align={align}
60
- className={cls(paperMixin, focusedDisabled, "py-2 z-30", className)}>
60
+ // `z-50` is the floating layer — the tier `Dialog`, `Sheet`,
61
+ // `Select` and `Tooltip` all sit on. Radix copies this value
62
+ // onto the popper wrapper it portals next to them, so at any
63
+ // lower value the menu opens *behind* the dialog or sheet it
64
+ // was opened from: visible in the DOM, painted under the
65
+ // overlay, and unclickable. Peers on this tier stack by mount
66
+ // order, which is what puts a menu above the dialog holding
67
+ // its trigger, and a dialog that menu opens above them both.
68
+ className={cls(paperMixin, focusedDisabled, "py-2 z-50", className)}>
61
69
  {children}
62
70
  </DropdownMenu.Content>
63
71
  </DropdownMenu.Portal>
@@ -109,7 +109,11 @@ export function MenubarContent({
109
109
  return (
110
110
  <MenubarPrimitive.Content
111
111
  onSelect={onSelect}
112
- className={cls("min-w-[220px] bg-white dark:bg-surface-900 rounded-md p-[6px] shadow-[0px_10px_38px_-10px_rgba(22,_23,_24,_0.35),_0px_10px_20px_-15px_rgba(22,_23,_24,_0.2)] [animation-duration:_400ms] [animation-timing-function:_cubic-bezier(0.16,_1,_0.3,_1)] will-change-[transform,opacity]", className)}
112
+ // `z-50`, the floating layer — see the note in `Menu`. This carried
113
+ // no z-index at all, so the wrapper Radix portals out to the body
114
+ // got `auto` and the menu opened underneath any dialog or panel it
115
+ // was hosted in.
116
+ className={cls("z-50 min-w-[220px] bg-white dark:bg-surface-900 rounded-md p-[6px] shadow-[0px_10px_38px_-10px_rgba(22,_23,_24,_0.35),_0px_10px_20px_-15px_rgba(22,_23,_24,_0.2)] [animation-duration:_400ms] [animation-timing-function:_cubic-bezier(0.16,_1,_0.3,_1)] will-change-[transform,opacity]", className)}
113
117
  align={align ?? "start"}
114
118
  sideOffset={sideOffset ?? 5}
115
119
  alignOffset={alignOffset ?? -3}
@@ -194,11 +194,13 @@ export const MultiSelect = React.forwardRef<
194
194
  onPopoverOpenChange(false);
195
195
  };
196
196
 
197
+ // Scoped to this popover. Unscoped, it reached every other cmdk list on
198
+ // the page — the relation and user selectors set their own height on
199
+ // the list and got a second, competing scroll container inside it.
197
200
  useInjectStyles("MultiSelect", `
198
- [cmdk-group] {
201
+ [data-multi-select-content] [cmdk-group] {
199
202
  max-height: 45vh;
200
203
  overflow-y: auto;
201
- // width: 400px;
202
204
  } `)
203
205
 
204
206
  const contextValue = React.useMemo(() => ({
@@ -306,6 +308,7 @@ export const MultiSelect = React.forwardRef<
306
308
  </PopoverPrimitive.Trigger>
307
309
  <PopoverPrimitive.Portal container={finalContainer}>
308
310
  <PopoverPrimitive.Content
311
+ data-multi-select-content
309
312
  className={cls("z-50 overflow-hidden border bg-white dark:bg-surface-800 rounded-lg w-[400px]", defaultBorderMixin)}
310
313
  align="start"
311
314
  sideOffset={8}
@@ -73,8 +73,11 @@ export function Popover({
73
73
 
74
74
  <PopoverPrimitive.Portal container={finalContainer}>
75
75
  <PopoverPrimitive.Content
76
+ // `z-50`, the floating layer — see the note in `Menu`. Below it
77
+ // a popover opened from inside a dialog or a side panel is
78
+ // painted under the thing that owns its trigger.
76
79
  className={cls(paperMixin,
77
- "PopoverContent z-40", className)}
80
+ "PopoverContent z-50", className)}
78
81
  side={side}
79
82
  sideOffset={sideOffset}
80
83
  align={align}
@@ -29,14 +29,21 @@ export function Skeleton({
29
29
 
30
30
  return <span
31
31
  style={{
32
- width: width ? `${width}px` : "100%",
33
- height: height ? `${height}px` : "12px"
32
+ width: width === undefined ? undefined : `${width}px`,
33
+ height: height === undefined ? undefined : `${height}px`
34
34
  }}
35
35
  className={
36
36
  cls(
37
37
  "block relative overflow-hidden",
38
38
  "bg-surface-accent-50 dark:bg-surface-accent-800 rounded-md",
39
39
  "max-w-full max-h-full",
40
+ // The defaults are classes, not inline styles, so that a caller can
41
+ // override them: an inline style beats every utility, which is why
42
+ // `<Skeleton className="w-full h-full"/>` inside a 40px thumbnail
43
+ // box used to render a 12px bar. The `width`/`height` props still
44
+ // win over any class, since those do land inline.
45
+ width === undefined ? "w-full" : undefined,
46
+ height === undefined ? "h-3" : undefined,
40
47
  className)
41
48
  }>
42
49
  <span
@@ -275,6 +275,16 @@ export type OnVirtualTableColumnResizeParams = {
275
275
  };
276
276
 
277
277
  /**
278
+ * The filter operators this table can express.
279
+ *
280
+ * A deliberate copy of `WhereFilterOp` in `@rebasepro/types`, not an import:
281
+ * `@rebasepro/ui` has no dependency on the core types and is meant to stay
282
+ * usable on its own. The cost is that two published packages export the same
283
+ * name, so the two must be kept identical by hand —
284
+ * `packages/types/test/filter-operators-duplication.test.ts` fails if they
285
+ * diverge, because a table that cannot express an operator the query layer
286
+ * supports is a silent gap: the filter simply is not offered.
287
+ *
278
288
  * @see Table
279
289
  * @group Components
280
290
  */
@@ -312,8 +322,13 @@ export type VirtualTableSort = "asc" | "desc" | undefined;
312
322
  export type VirtualTableFilterValues<Key extends string> = FilterValues<Key>;
313
323
 
314
324
  /**
315
- * Filter conditions in a `Query.where()` clause are specified using the
316
- * strings `<`, `<=`, `==`, `>=`, `>`, `array-contains`, `in`, and `array-contains-any`.
325
+ * Filter conditions in a `Query.where()` clause, named by {@link WhereFilterOp}.
326
+ *
327
+ * The list is not repeated here: this comment used to name eight operators when
328
+ * the type had sixteen, having been written before the SQL pattern and null
329
+ * checks (`like`, `ilike`, `not-like`, `not-ilike`, `is-null`, `is-not-null`)
330
+ * were added. A prose copy of a union drifts from it silently.
331
+ *
317
332
  * @see Table
318
333
  * @group Models
319
334
  */
@@ -22,6 +22,13 @@ export function VirtualTableDateField(props: {
22
22
  <DateTimeField
23
23
  value={internalValue ?? undefined}
24
24
  onChange={(dateValue) => updateValue(dateValue ?? null)}
25
+ // Same omission as `VirtualTableInput`: destructured and forwarded
26
+ // nowhere, so a disabled date cell was still editable. `error` went
27
+ // the same way — it drives `DateTimeField`'s invalid styling, and a
28
+ // prop a caller passes should reach something rather than sit in a
29
+ // destructure.
30
+ disabled={disabled}
31
+ error={Boolean(error)}
25
32
  invisible={true}
26
33
  // Without this the field falls back to DateTimeField's own default of
27
34
  // `size="large"` (min-h-64px), which overflows a table cell and makes
@@ -50,6 +50,13 @@ export function VirtualTableInput(props: {
50
50
  <TextareaAutosize
51
51
  className={focusedDisabled}
52
52
  ref={ref}
53
+ // Destructured above and passed to nothing, so a property carrying
54
+ // `admin: { disabled: true }` stayed typeable in the table and the
55
+ // debounced write fired on blur. `readOnly` as well as `disabled`:
56
+ // a disabled textarea is skipped by keyboard navigation, and a cell
57
+ // you cannot tab through is worse than one you cannot edit.
58
+ disabled={disabled}
59
+ readOnly={disabled}
53
60
  style={{
54
61
  padding: 0,
55
62
  margin: 0,
@@ -100,7 +100,9 @@ export {
100
100
  MoonIcon,
101
101
  MoreVerticalIcon,
102
102
  Music2Icon,
103
+ PanelLeftCloseIcon,
103
104
  PanelLeftIcon,
105
+ PanelLeftOpenIcon,
104
106
  PauseIcon,
105
107
  PenLineIcon,
106
108
  PencilIcon,
@@ -323,7 +323,13 @@ export function CollectionView<T extends Record<string, unknown> = Record<string
323
323
  <CollectionKanbanView<T>
324
324
  {...sharedProps}
325
325
  kanbanProperty={resolvedKanbanProperty}
326
- onRowCreate={onRowCreate}
326
+ // `canCreate` is documented as "whether creation is
327
+ // allowed (shows the + button)" and was destructured
328
+ // and read nowhere, so the button showed regardless.
329
+ // The kanban view draws its add affordance exactly when
330
+ // it has a callback, so withholding the callback is
331
+ // what the prop means.
332
+ onRowCreate={canCreate ? onRowCreate : undefined}
327
333
  />
328
334
  ) : (
329
335
  <div className="flex items-center justify-center p-8">
@@ -36,6 +36,13 @@ export type ListViewProps<T> = {
36
36
  size?: CollectionSize;
37
37
  selectedEntityId?: string | number;
38
38
 
39
+ /**
40
+ * Rendered above the rows, inside the list's own frame, and only when there
41
+ * are rows to head. A column header over an empty state or a spinner labels
42
+ * nothing.
43
+ */
44
+ header?: React.ReactNode;
45
+
39
46
  renderRow: (params: {
40
47
  item: T;
41
48
  index: number;
@@ -97,9 +104,14 @@ export function ListView<T>({
97
104
 
98
105
  size = "m",
99
106
  selectedEntityId,
107
+ header,
100
108
  renderRow
101
109
  }: ListViewProps<T>) {
102
110
  const containerRef = useRef<HTMLDivElement>(null);
111
+ // The rows' own box, which a header shifts down from the container's top.
112
+ // Virtualization measures against it rather than against the container, so
113
+ // the window of rendered rows stays the one the viewport is actually over.
114
+ const rowsRef = useRef<HTMLDivElement>(null);
103
115
  const isLoadingMore = useRef(false);
104
116
 
105
117
  // Reset loading gate when loading stops
@@ -128,7 +140,7 @@ export function ListView<T>({
128
140
  const update = () => {
129
141
  rafId = null;
130
142
  const scrollRect = scrollEl.getBoundingClientRect();
131
- const listRect = el.getBoundingClientRect();
143
+ const listRect = (rowsRef.current ?? el).getBoundingClientRect();
132
144
 
133
145
  const listTopRelative = listRect.top - scrollRect.top;
134
146
  setEffectiveScrollTop(Math.max(0, -listTopRelative));
@@ -213,68 +225,71 @@ export function ListView<T>({
213
225
  )}
214
226
  </div>
215
227
  ) : (
216
- <div style={{ height: totalHeight + footerHeight, position: "relative" }}>
217
- <div style={{ position: "absolute", top: offsetY, left: 0, right: 0 }}>
218
- {visibleData.map((item, i) => {
219
- const actualIndex = startIndex + i;
220
- const isLast = actualIndex === data.length - 1;
221
- const id = getItemId(item);
222
- const selected = selectedIds?.has(id) ?? false;
223
- const highlighted = highlightedIds?.has(id) ?? false;
224
-
225
- const handleClick = (e: React.MouseEvent) => {
226
- if ((e.metaKey || e.ctrlKey) && selectionEnabled) {
227
- e.preventDefault();
228
- onSelectionChange?.(item, !selected);
229
- return;
230
- }
231
- onItemClick?.(item);
232
- };
233
-
234
- const handleSelectionChange = (val: boolean) => {
235
- onSelectionChange?.(item, val);
236
- };
237
-
238
- return (
239
- <React.Fragment key={id || actualIndex}>
240
- {renderRow({
241
- item,
242
- index: actualIndex,
243
- style: { height: estimatedRowHeight, overflow: "hidden" },
244
- className: cls(
245
- !isLast && "border-b",
246
- !isLast && borderMixinClass
247
- ),
248
- selected,
249
- highlighted,
250
- isLast,
251
- onClick: handleClick,
252
- onSelectionChange: handleSelectionChange
253
- })}
254
- </React.Fragment>
255
- );
256
- })}
257
- </div>
258
-
259
- {dataLoading && (
260
- <div
261
- className="flex items-center justify-center py-3"
262
- style={{ position: "absolute", top: totalHeight, left: 0, right: 0 }}
263
- >
264
- <CircularProgress size="small" />
265
- </div>
266
- )}
267
- {!dataLoading && noMoreToLoad && data.length > 0 && (
268
- <div
269
- className="flex items-center justify-center py-2 dark:bg-surface-900"
270
- style={{ position: "absolute", top: totalHeight, left: 0, right: 0 }}
271
- >
272
- <Typography variant="caption" color="secondary">
273
- All {data.length} entries loaded
274
- </Typography>
228
+ <>
229
+ {header}
230
+ <div ref={rowsRef} style={{ height: totalHeight + footerHeight, position: "relative" }}>
231
+ <div style={{ position: "absolute", top: offsetY, left: 0, right: 0 }}>
232
+ {visibleData.map((item, i) => {
233
+ const actualIndex = startIndex + i;
234
+ const isLast = actualIndex === data.length - 1;
235
+ const id = getItemId(item);
236
+ const selected = selectedIds?.has(id) ?? false;
237
+ const highlighted = highlightedIds?.has(id) ?? false;
238
+
239
+ const handleClick = (e: React.MouseEvent) => {
240
+ if ((e.metaKey || e.ctrlKey) && selectionEnabled) {
241
+ e.preventDefault();
242
+ onSelectionChange?.(item, !selected);
243
+ return;
244
+ }
245
+ onItemClick?.(item);
246
+ };
247
+
248
+ const handleSelectionChange = (val: boolean) => {
249
+ onSelectionChange?.(item, val);
250
+ };
251
+
252
+ return (
253
+ <React.Fragment key={id || actualIndex}>
254
+ {renderRow({
255
+ item,
256
+ index: actualIndex,
257
+ style: { height: estimatedRowHeight, overflow: "hidden" },
258
+ className: cls(
259
+ !isLast && "border-b",
260
+ !isLast && borderMixinClass
261
+ ),
262
+ selected,
263
+ highlighted,
264
+ isLast,
265
+ onClick: handleClick,
266
+ onSelectionChange: handleSelectionChange
267
+ })}
268
+ </React.Fragment>
269
+ );
270
+ })}
275
271
  </div>
276
- )}
277
- </div>
272
+
273
+ {dataLoading && (
274
+ <div
275
+ className="flex items-center justify-center py-3"
276
+ style={{ position: "absolute", top: totalHeight, left: 0, right: 0 }}
277
+ >
278
+ <CircularProgress size="small" />
279
+ </div>
280
+ )}
281
+ {!dataLoading && noMoreToLoad && data.length > 0 && (
282
+ <div
283
+ className="flex items-center justify-center py-2 dark:bg-surface-900"
284
+ style={{ position: "absolute", top: totalHeight, left: 0, right: 0 }}
285
+ >
286
+ <Typography variant="caption" color="secondary">
287
+ All {data.length} entries loaded
288
+ </Typography>
289
+ </div>
290
+ )}
291
+ </div>
292
+ </>
278
293
  )}
279
294
  </div>
280
295
  );