@firecms/core 3.3.0 → 3.4.0-canary.f6a889a

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.
Files changed (65) hide show
  1. package/dist/app/useApp.d.ts +1 -0
  2. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  3. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  4. package/dist/components/EntityPreview.d.ts +3 -1
  5. package/dist/core/DefaultDrawer.d.ts +10 -3
  6. package/dist/core/field_configs.d.ts +1 -1
  7. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  8. package/dist/form/index.d.ts +1 -0
  9. package/dist/index.es.js +1979 -721
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/index.umd.js +1978 -720
  12. package/dist/index.umd.js.map +1 -1
  13. package/dist/locales/pl.d.ts +2 -0
  14. package/dist/preview/index.d.ts +1 -0
  15. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  16. package/dist/types/collections.d.ts +17 -0
  17. package/dist/util/entities.d.ts +1 -0
  18. package/dist/util/geopoint.d.ts +22 -0
  19. package/dist/util/index.d.ts +1 -0
  20. package/dist/util/navigation_blocking.d.ts +22 -0
  21. package/dist/util/navigation_from_path.d.ts +10 -0
  22. package/dist/util/navigation_utils.d.ts +20 -0
  23. package/package.json +16 -9
  24. package/src/app/Scaffold.tsx +34 -44
  25. package/src/app/useApp.tsx +1 -0
  26. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +2 -1
  27. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  28. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  29. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
  30. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  31. package/src/components/EntityPreview.tsx +41 -40
  32. package/src/components/ReferenceWidget.tsx +1 -1
  33. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  34. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  35. package/src/components/common/useDataSourceTableController.tsx +66 -10
  36. package/src/core/DefaultDrawer.tsx +150 -64
  37. package/src/core/DrawerNavigationGroup.tsx +27 -29
  38. package/src/core/DrawerNavigationItem.tsx +10 -12
  39. package/src/core/EntityEditView.tsx +57 -32
  40. package/src/core/field_configs.tsx +15 -0
  41. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  42. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  43. package/src/form/index.tsx +1 -0
  44. package/src/i18n/FireCMSi18nProvider.tsx +2 -0
  45. package/src/internal/useBuildSideEntityController.tsx +2 -1
  46. package/src/locales/de.ts +2 -2
  47. package/src/locales/en.ts +2 -2
  48. package/src/locales/es.ts +2 -2
  49. package/src/locales/fr.ts +2 -2
  50. package/src/locales/hi.ts +2 -2
  51. package/src/locales/it.ts +2 -2
  52. package/src/locales/pl.ts +730 -0
  53. package/src/locales/pt.ts +2 -2
  54. package/src/preview/PropertyPreview.tsx +12 -0
  55. package/src/preview/index.ts +1 -0
  56. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  57. package/src/routes/FireCMSRoute.tsx +44 -25
  58. package/src/types/collections.ts +18 -0
  59. package/src/util/entities.ts +13 -0
  60. package/src/util/geopoint.ts +81 -0
  61. package/src/util/index.ts +1 -0
  62. package/src/util/navigation_blocking.ts +45 -0
  63. package/src/util/navigation_from_path.ts +23 -6
  64. package/src/util/navigation_utils.ts +36 -2
  65. package/src/util/parent_references_from_path.ts +4 -2
@@ -200,43 +200,44 @@ export type EntityPreviewContainerProps = {
200
200
  onClick?: (e: React.SyntheticEvent) => void;
201
201
  };
202
202
 
203
- export const EntityPreviewContainer = React.forwardRef<HTMLDivElement, EntityPreviewContainerProps>(({
204
- children,
205
- hover,
206
- onClick,
207
- size = "medium",
208
- style,
209
- className,
210
- fullwidth = true,
211
- ...props
212
- }, ref) => {
213
- return <div
214
- ref={ref}
215
- style={{
216
- ...style,
217
- // @ts-ignore
218
- tabindex: 0
219
- }}
220
- className={cls(
221
- "bg-white dark:bg-surface-900",
222
- "min-h-[44px]",
223
- fullwidth ? "w-full" : "",
224
- "items-center",
225
- hover ? "hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800" : "",
226
- size === "small" ? "p-1" : "px-2 py-1",
227
- "flex border rounded-lg",
228
- onClick ? "cursor-pointer" : "",
229
- defaultBorderMixin,
230
- className)}
231
- onClick={(event) => {
232
- if (onClick) {
233
- event.preventDefault();
234
- onClick(event);
235
- }
236
- }}
237
- {...props}>
238
- {children}
239
- </div>;
240
- });
241
-
242
- EntityPreviewContainer.displayName = "EntityPreviewContainer";
203
+ export function EntityPreviewContainer({
204
+ children,
205
+ hover,
206
+ onClick,
207
+ size = "medium",
208
+ style,
209
+ className,
210
+ fullwidth = true,
211
+ ref
212
+ }: EntityPreviewContainerProps & { ref?: React.Ref<HTMLDivElement> }) {
213
+ const divClassName = cls(
214
+ "bg-white dark:bg-surface-900",
215
+ "min-h-[44px]",
216
+ fullwidth ? "w-full" : "",
217
+ "items-center",
218
+ hover ? "hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800" : "",
219
+ size === "small" ? "p-1" : "px-2 py-1",
220
+ "flex border rounded-lg",
221
+ onClick ? "cursor-pointer" : "",
222
+ defaultBorderMixin,
223
+ className
224
+ );
225
+
226
+ const handleClick = onClick
227
+ ? (event: React.MouseEvent<HTMLDivElement>) => {
228
+ event.preventDefault();
229
+ onClick(event);
230
+ }
231
+ : undefined;
232
+
233
+ const divProps: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> } = {
234
+ ref,
235
+ tabIndex: 0,
236
+ style,
237
+ className: divClassName,
238
+ onClick: handleClick,
239
+ };
240
+
241
+ return <div {...divProps}>{children}</div>;
242
+ }
243
+
@@ -73,7 +73,7 @@ export function ReferenceWidget<M extends Record<string, any>>({
73
73
  if (disabled)
74
74
  return;
75
75
  if (onMultipleReferenceSelected) {
76
- const references = entities ? entities.map(e => getReferenceFrom(e)) : null;
76
+ const references = entities ? entities.filter(Boolean).map(e => getReferenceFrom(e)) : null;
77
77
  onMultipleReferenceSelected({
78
78
  references,
79
79
  entities
@@ -113,11 +113,11 @@ export function ReferenceFilterField({
113
113
  }, [path]);
114
114
 
115
115
  const onSingleEntitySelected = (entity: Entity<any>) => {
116
- updateFilter(operation, getReferenceFrom(entity));
116
+ if (entity) updateFilter(operation, getReferenceFrom(entity));
117
117
  };
118
118
 
119
119
  const onMultipleEntitiesSelected = (entities: Entity<any>[]) => {
120
- updateFilter(operation, entities.map(e => getReferenceFrom(e)));
120
+ updateFilter(operation, entities.filter(Boolean).map(e => getReferenceFrom(e)));
121
121
  };
122
122
 
123
123
  const multiple = multipleSelectOperations.includes(operation);
@@ -135,7 +135,7 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
135
135
  const [columns, setColumns] = useState(columnsProp);
136
136
 
137
137
  const tableRef = useRef<HTMLDivElement>(null);
138
- const endReachCallbackThreshold = useRef<number>(0);
138
+ const lastEndReachedDataLength = useRef<number | undefined>(undefined);
139
139
 
140
140
  const debouncedScroll = useDebounceCallback(onScrollProp, 200);
141
141
 
@@ -242,7 +242,7 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
242
242
  }, [filterInput]);
243
243
 
244
244
  const scrollToTop = useCallback(() => {
245
- endReachCallbackThreshold.current = 0;
245
+ lastEndReachedDataLength.current = undefined;
246
246
  if (tableRef.current) {
247
247
  tableRef.current.scrollTo(tableRef.current?.scrollLeft, 0);
248
248
  }
@@ -278,13 +278,24 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
278
278
 
279
279
  const maxScroll = Math.max((data?.length ?? 0) * rowHeight - bounds.height, 0);
280
280
 
281
- const onEndReachedInternal = useCallback((scrollOffset: number) => {
282
- if (onEndReached && (data?.length ?? 0) > 0 && scrollOffset > endReachCallbackThreshold.current + endOffset) {
283
- endReachCallbackThreshold.current = scrollOffset;
281
+ const onEndReachedInternal = useCallback(() => {
282
+ const dataLength = data?.length ?? 0;
283
+
284
+ if (onEndReached && dataLength > 0 && lastEndReachedDataLength.current !== dataLength) {
285
+ lastEndReachedDataLength.current = dataLength;
284
286
  onEndReached();
285
287
  }
286
288
  }, [data?.length, onEndReached]);
287
289
 
290
+ useEffect(() => {
291
+ if (!onEndReached || loading || !data?.length || !bounds.height || !tableRef.current) {
292
+ return;
293
+ }
294
+ if (tableRef.current.scrollHeight <= tableRef.current.clientHeight) {
295
+ onEndReachedInternal();
296
+ }
297
+ }, [bounds.height, data?.length, loading, onEndReached, onEndReachedInternal]);
298
+
288
299
  const onScroll = useCallback(({
289
300
  scrollDirection,
290
301
  scrollOffset,
@@ -302,12 +313,12 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
302
313
  })
303
314
  }
304
315
  if (!scrollUpdateWasRequested && (scrollOffset >= maxScroll - endOffset))
305
- onEndReachedInternal(scrollOffset);
306
- }, [maxScroll, onEndReachedInternal]);
316
+ onEndReachedInternal();
317
+ }, [endOffset, maxScroll, onEndReachedInternal]);
307
318
 
308
319
  const onFilterUpdateInternal = useCallback((column: VirtualTableColumn, filterForProperty?: [VirtualTableWhereFilterOp, any]) => {
309
320
 
310
- endReachCallbackThreshold.current = 0;
321
+ lastEndReachedDataLength.current = undefined;
311
322
  const filter = filterRef.current;
312
323
  let newFilterValue: VirtualTableFilterValues<any> = filter ? { ...filter } : {};
313
324
 
@@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom";
4
4
  import { useDataSource, useFireCMSContext, useNavigationController } from "../../hooks";
5
5
  import { useDataOrder } from "../../hooks/data/useDataOrder";
6
6
  import {
7
+ DataType,
7
8
  Entity,
8
9
  EntityCollection,
9
10
  EntityReference,
@@ -16,6 +17,8 @@ import {
16
17
  } from "../../types";
17
18
  import { useDebouncedData } from "./useDebouncedData";
18
19
  import { ScrollRestorationController } from "./useScrollRestoration";
20
+ import { isDataTypeFilterable } from "../../util";
21
+ import { decodeEntityId, encodeEntityId } from "../../util/navigation_utils";
19
22
 
20
23
  const DEFAULT_PAGE_SIZE = 50;
21
24
 
@@ -135,8 +138,41 @@ export function useDataSourceTableController<M extends Record<string, any> = any
135
138
  filterValues: initialFilterUrl,
136
139
  sortBy: initialSortUrl,
137
140
  } = parseFilterAndSort(location.search);
141
+
142
+ const availableFilterKeys = collection.allowedFilters ?? Object.keys(collection.properties);
143
+ const forcedFilterKeys = collection.forceFilter ? Object.keys(collection.forceFilter) : [];
138
144
 
139
- const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(forceFilter ?? (updateUrl ? initialFilterUrl : undefined) ?? initialFilter ?? undefined);
145
+ const allowedFilterKeys = useMemo(() => {
146
+ const availableKeys = availableFilterKeys.filter((key) => {
147
+ const property = collection.properties[key];
148
+
149
+ if (!property) return false;
150
+
151
+ if (typeof property === "function") return false;
152
+
153
+ const dataType = property.dataType;
154
+ const filterable = dataType === "array"
155
+ ? isDataTypeFilterable((property as { of?: { dataType: DataType } }).of?.dataType as DataType, true)
156
+ : isDataTypeFilterable(dataType);
157
+
158
+ return filterable;
159
+ });
160
+
161
+ const forcedKeys = forcedFilterKeys.filter((key) => !availableKeys.includes(key));
162
+
163
+ return [...availableKeys, ...forcedKeys];
164
+
165
+ }, [collection.properties, availableFilterKeys, forcedFilterKeys]);
166
+
167
+ const removeUnallowedFilters = useCallback((filters?: FilterValues<Extract<keyof M, string>>) => {
168
+ if (!filters) return;
169
+
170
+ return Object.fromEntries(Object.entries(filters).filter(([key]) => allowedFilterKeys.includes(key as keyof M))) as FilterValues<Extract<keyof M, string>>;
171
+ }, [allowedFilterKeys]);
172
+
173
+ const initFilters = forceFilter ?? (updateUrl ? initialFilterUrl : undefined) ?? initialFilter ?? undefined
174
+
175
+ const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(removeUnallowedFilters(initFilters));
140
176
  const [sortBy, setSortBy] = React.useState<[Extract<keyof M, string>, "asc" | "desc"] | undefined>((updateUrl ? initialSortUrl : undefined) ?? initialSortInternal);
141
177
 
142
178
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
@@ -168,7 +204,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
168
204
  const [dataLoadingError, setDataLoadingError] = useState<Error | undefined>();
169
205
  const [noMoreToLoad, setNoMoreToLoad] = useState<boolean>(false);
170
206
 
171
- const clearFilter = useCallback(() => setFilterValues(forceFilter ?? undefined), [forceFilter]);
207
+ const clearFilter = useCallback(() => setFilterValues(removeUnallowedFilters(forceFilter)), [forceFilter, removeUnallowedFilters]);
172
208
 
173
209
  const updateFilterValues = useCallback((updatedFilter: FilterValues<Extract<keyof M, string>> | undefined) => {
174
210
  if (forceFilter) {
@@ -178,9 +214,9 @@ export function useDataSourceTableController<M extends Record<string, any> = any
178
214
  if (updatedFilter && Object.keys(updatedFilter).length === 0) {
179
215
  setFilterValues(undefined);
180
216
  } else {
181
- setFilterValues(updatedFilter);
217
+ setFilterValues(removeUnallowedFilters(updatedFilter));
182
218
  }
183
- }, [forceFilter]);
219
+ }, [forceFilter, removeUnallowedFilters]);
184
220
 
185
221
  useEffect(() => {
186
222
 
@@ -268,6 +304,8 @@ export function useDataSourceTableController<M extends Record<string, any> = any
268
304
  dataLoadingError,
269
305
  filterValues,
270
306
  setFilterValues: updateFilterValues,
307
+ allowedFilters: allowedFilterKeys,
308
+ forcedFilters: forcedFilterKeys,
271
309
  sortBy,
272
310
  setSortBy,
273
311
  searchString,
@@ -342,7 +380,13 @@ function encodeFilterAndSort(filterValues?: FilterValues<string>, sortBy?: [stri
342
380
  }
343
381
  if (encodedValue !== undefined) {
344
382
  entries[encodeURIComponent(`${key}_op`)] = encodeURIComponent(op);
345
- entries[encodeURIComponent(`${key}_value`)] = encodedValue ? encodeURIComponent(encodedValue.toString()) : "null";
383
+ // Note: check for null/undefined explicitly instead of truthiness,
384
+ // otherwise falsy-but-valid values (boolean `false`, number `0`)
385
+ // would be serialized as "null" and round-trip back as `null`,
386
+ // breaking filters like `archived == false`.
387
+ entries[encodeURIComponent(`${key}_value`)] = encodedValue !== null && encodedValue !== undefined
388
+ ? encodeURIComponent(encodedValue.toString())
389
+ : "null";
346
390
  }
347
391
  }
348
392
  });
@@ -394,7 +438,21 @@ function isDate(dateString: string): boolean {
394
438
  }
395
439
 
396
440
  function encodeRef(val: EntityReference) {
397
- return `ref::${val.path}/${val.id}`;
441
+ return `ref::${val.path}/${encodeEntityId(val.id)}`;
442
+ }
443
+
444
+ /**
445
+ * Split a "path/id" reference on its LAST separator: the path may itself be a
446
+ * subcollection path with several segments, and the id is escaped so it is always exactly
447
+ * one segment.
448
+ */
449
+ function decodeRef(encoded: string): EntityReference {
450
+ const separatorIndex = encoded.lastIndexOf("/");
451
+ if (separatorIndex < 0) return new EntityReference(encoded, "");
452
+ return new EntityReference(
453
+ decodeEntityId(encoded.substring(separatorIndex + 1)),
454
+ encoded.substring(0, separatorIndex)
455
+ );
398
456
  }
399
457
 
400
458
  function decodeString(val: string): EntityReference | Date | string {
@@ -410,8 +468,7 @@ function decodeString(val: string): EntityReference | Date | string {
410
468
  try {
411
469
  parsedFilterVal = JSON.parse(parsedFilterVal, (key, value) => {
412
470
  if (typeof value === "string" && value.startsWith("ref::")) {
413
- const [path, id] = value.substring(5).split("/");
414
- return new EntityReference(id, path);
471
+ return decodeRef(value.substring(5));
415
472
  }
416
473
  return value;
417
474
  });
@@ -421,8 +478,7 @@ function decodeString(val: string): EntityReference | Date | string {
421
478
  }
422
479
 
423
480
  if (typeof parsedFilterVal === "string" && parsedFilterVal.startsWith("ref::")) {
424
- const [path, id] = parsedFilterVal.substring(5).split("/");
425
- return new EntityReference(id, path);
481
+ return decodeRef(parsedFilterVal.substring(5));
426
482
  }
427
483
  return parsedFilterVal;
428
484
  }
@@ -10,7 +10,16 @@ import {
10
10
  import { Link, useNavigate } from "react-router-dom";
11
11
  import { CMSAnalyticsEvent, NavigationEntry, NavigationResult } from "../types";
12
12
  import { IconForView } from "../util";
13
- import { cls, IconButton, Menu, MenuItem, MoreVertIcon, Tooltip } from "@firecms/ui";
13
+ import {
14
+ cls,
15
+ KeyboardDoubleArrowLeftIcon,
16
+ KeyboardDoubleArrowRightIcon,
17
+ Menu,
18
+ MenuItem,
19
+ MoreVertIcon,
20
+ Tooltip,
21
+ Typography
22
+ } from "@firecms/ui";
14
23
  import { useAnalyticsController } from "../hooks/useAnalyticsController";
15
24
  import { DrawerNavigationGroup } from "./DrawerNavigationGroup";
16
25
  import { FireCMSLogo } from "../components";
@@ -37,6 +46,14 @@ export function DefaultDrawer({
37
46
 
38
47
  const [adminMenuOpen, setAdminMenuOpen] = React.useState(false);
39
48
 
49
+ const scrollRef = React.useRef<HTMLDivElement>(null);
50
+ const [scrolled, setScrolled] = React.useState(false);
51
+ const handleScroll = () => {
52
+ if (scrollRef.current) {
53
+ setScrolled(scrollRef.current.scrollTop > 0);
54
+ }
55
+ };
56
+
40
57
  const analyticsController = useAnalyticsController();
41
58
  const navigation = useNavigationController();
42
59
  const { t } = useTranslation();
@@ -59,6 +76,8 @@ export function DefaultDrawer({
59
76
  // Collapsible groups state - using "drawer" namespace for independent state from home page
60
77
  const { isGroupCollapsed, toggleGroupCollapsed } = useCollapsedGroups(groupsWithoutAdmin, "drawer");
61
78
 
79
+ const drawerVisuallyOpen = drawerOpen || drawerHovered;
80
+
62
81
  const onItemClick = (view: NavigationEntry) => {
63
82
  const eventName: CMSAnalyticsEvent = view.type === "collection"
64
83
  ? "drawer_navigate_to_collection"
@@ -69,56 +88,66 @@ export function DefaultDrawer({
69
88
  };
70
89
 
71
90
  return (
72
- <>
73
- <div className={cls("flex flex-col h-full relative flex-grow w-full", className)} style={style}>
74
-
75
- <DrawerLogo logo={logo} />
76
-
77
- <div className={"mt-4 flex-grow overflow-scroll no-scrollbar"}
78
- style={{
79
- maskImage: "linear-gradient(to bottom, transparent 0, black 20px, black calc(100% - 20px), transparent 100%)",
80
- }}>
81
-
82
- {groupsWithoutAdmin.map((group) => {
83
- const entriesInGroup = Object.values(navigationEntries).filter(e => e.group === group);
84
- return (
85
- <DrawerNavigationGroup
86
- key={`drawer_group_${group}`}
87
- group={group}
88
- entries={entriesInGroup}
89
- collapsed={isGroupCollapsed(group)}
90
- onToggleCollapsed={() => toggleGroupCollapsed(group)}
91
- drawerOpen={drawerOpen}
92
- tooltipsOpen={tooltipsOpen}
93
- adminMenuOpen={adminMenuOpen}
94
- onItemClick={onItemClick}
95
- />
96
- );
97
- })}
91
+ <div role="navigation" aria-label="Main navigation"
92
+ className={cls("flex flex-col h-full relative grow w-full", className)} style={style}>
98
93
 
99
- </div>
94
+ <DrawerLogo logo={logo} />
95
+
96
+ <div
97
+ ref={scrollRef}
98
+ onScroll={handleScroll}
99
+ className={"flex-grow min-h-0 overflow-y-auto overflow-x-hidden no-scrollbar px-2"}
100
+ style={{
101
+ maskImage: scrolled
102
+ ? "linear-gradient(to bottom, transparent 0, black 20px, black calc(100% - 20px), transparent 100%)"
103
+ : "linear-gradient(to bottom, black 0, black calc(100% - 20px), transparent 100%)"
104
+ }}>
105
+
106
+ {groupsWithoutAdmin.map((group) => {
107
+ const entriesInGroup = Object.values(navigationEntries).filter(e => e.group === group);
108
+ return (
109
+ <DrawerNavigationGroup
110
+ key={`drawer_group_${group}`}
111
+ group={group}
112
+ entries={entriesInGroup}
113
+ collapsed={isGroupCollapsed(group)}
114
+ onToggleCollapsed={() => toggleGroupCollapsed(group)}
115
+ drawerOpen={drawerVisuallyOpen}
116
+ tooltipsOpen={tooltipsOpen}
117
+ adminMenuOpen={adminMenuOpen}
118
+ onItemClick={onItemClick}
119
+ />
120
+ );
121
+ })}
100
122
 
101
- {adminViews.length > 0 && <Menu
123
+ </div>
124
+
125
+ {adminViews.length > 0 && <div className={"shrink-0 px-4"}>
126
+ <Menu
102
127
  side={"right"}
103
128
  open={adminMenuOpen}
104
129
  onOpenChange={setAdminMenuOpen}
105
130
  trigger={
106
- <IconButton
107
- shape={"square"}
108
- className={"m-4 text-surface-900 dark:text-white w-fit"}>
109
- <Tooltip title={"Admin"}
110
- open={tooltipsOpen}
111
- side={"right"} sideOffset={28}>
112
- <MoreVertIcon />
113
- </Tooltip>
114
- {drawerOpen && <div
131
+ <div
132
+ className={cls(
133
+ "flex flex-row items-center rounded-lg cursor-pointer w-full",
134
+ "hover:bg-surface-accent-100 dark:hover:bg-surface-800 transition-colors duration-150 h-[30px]"
135
+ )}>
136
+ <div
137
+ className={"shrink-0 flex items-center justify-center w-[44px] h-[30px] text-surface-500 dark:text-surface-400"}>
138
+ <Tooltip title={"Admin"}
139
+ open={drawerVisuallyOpen || adminMenuOpen ? false : tooltipsOpen}
140
+ side={"right"} sideOffset={28}>
141
+ <MoreVertIcon size={"small"} />
142
+ </Tooltip>
143
+ </div>
144
+ {drawerVisuallyOpen && <div
115
145
  className={cls(
116
- drawerOpen ? "opacity-100" : "opacity-0 hidden",
117
- "mx-4 font-inherit text-inherit"
146
+ "font-semibold text-[11px] uppercase tracking-wider text-surface-400"
118
147
  )}>
119
- ADMIN
148
+ {t("admin")}
120
149
  </div>}
121
- </IconButton>}
150
+ </div>}
122
151
  >
123
152
  {adminViews.map((entry) =>
124
153
  <MenuItem
@@ -131,46 +160,103 @@ export function DefaultDrawer({
131
160
  {t(entry.name)}
132
161
  </MenuItem>)}
133
162
 
134
- </Menu>}
135
- </div>
163
+ </Menu>
164
+ </div>}
136
165
 
137
- </>
166
+ <DrawerToggle />
167
+ </div>
138
168
  );
139
169
  }
140
170
 
141
171
  /**
142
- * This is the logo displayed in the drawer
143
- * It expands when the drawer is open.
172
+ * Collapse/expand toggle rendered at the bottom of the drawer.
173
+ * Uses double-chevron icons to indicate direction. Reads the drawer state
174
+ * from {@link useApp} so it can be dropped into any drawer.
144
175
  *
145
- * @param logo
176
+ * @group Core
177
+ */
178
+ export function DrawerToggle() {
179
+ const {
180
+ drawerOpen,
181
+ drawerHovered,
182
+ openDrawer,
183
+ closeDrawer
184
+ } = useApp();
185
+ const isExpanded = drawerOpen;
186
+ const isHovered = drawerHovered && !drawerOpen;
187
+ const showFullContent = isExpanded || isHovered;
188
+
189
+ return (
190
+ <div className="shrink-0 mt-auto px-4 pt-0.5 pb-2">
191
+ <Tooltip
192
+ title={isExpanded ? "Collapse" : "Expand"}
193
+ side="right"
194
+ sideOffset={12}
195
+ open={isHovered ? false : undefined}
196
+ >
197
+ <div
198
+ className={cls(
199
+ "flex flex-row items-center rounded-lg cursor-pointer",
200
+ "hover:bg-surface-accent-100 dark:hover:bg-surface-800",
201
+ "transition-colors duration-150 h-[30px]"
202
+ )}
203
+ role="button"
204
+ tabIndex={0}
205
+ aria-expanded={isExpanded}
206
+ aria-label={isExpanded ? "Collapse" : "Expand"}
207
+ onClick={() => isExpanded ? closeDrawer() : openDrawer()}
208
+ onKeyDown={(e) => {
209
+ if (e.key === "Enter" || e.key === " ") {
210
+ e.preventDefault();
211
+ isExpanded ? closeDrawer() : openDrawer();
212
+ }
213
+ }}
214
+ >
215
+ <div className="shrink-0 flex items-center justify-center w-[44px] h-[30px] text-surface-500 dark:text-surface-400">
216
+ {isExpanded
217
+ ? <KeyboardDoubleArrowLeftIcon size={"small"} />
218
+ : <KeyboardDoubleArrowRightIcon size={"small"} />}
219
+ </div>
220
+ <div className={cls(
221
+ "overflow-hidden transition-all duration-200 ease-in-out",
222
+ showFullContent ? "opacity-100 w-auto" : "opacity-0 w-0"
223
+ )}>
224
+ <Typography
225
+ variant="body2"
226
+ className="text-surface-500 dark:text-surface-400 select-none whitespace-nowrap"
227
+ >
228
+ {isExpanded ? "Collapse" : "Expand"}
229
+ </Typography>
230
+ </div>
231
+ </div>
232
+ </Tooltip>
233
+ </div>
234
+ );
235
+ }
146
236
 
237
+ /**
238
+ * This is the logo displayed in the drawer.
239
+ * A compact logo aligned to the left of the drawer header.
240
+ *
241
+ * @param logo
147
242
  */
148
243
  export function DrawerLogo({ logo }: {
149
244
  logo?: string;
150
245
  }) {
151
246
 
152
247
  const navigation = useNavigationController();
153
- const { drawerOpen } = useApp();
154
- return <div
155
- style={{
156
- transition: "padding 100ms cubic-bezier(0.4, 0, 0.6, 1) 0ms",
157
- padding: drawerOpen ? "32px 144px 0px 24px" : "72px 12px 0px 12px"
158
- }}
159
- className={cls("cursor-pointer rounded ml-3 mr-1")}>
160
-
248
+ return <div className="flex flex-row items-center shrink-0 pt-4 pb-0 px-2">
161
249
  <Tooltip title={"Home"}
162
- sideOffset={20}
163
- side="right">
250
+ sideOffset={20}
251
+ side="right">
164
252
  <Link
165
- className={"block"}
253
+ className={"shrink-0 flex items-center justify-center w-[56px] h-[40px]"}
166
254
  to={navigation.basePath}>
167
255
  {logo
168
256
  ? <img src={logo}
169
- alt="Logo"
170
- className={cls("max-w-full max-h-full transition-all object-contain",
171
- drawerOpen ? "w-[96px] h-[96px]" : "w-[32px] h-[32px]")} />
172
- : <FireCMSLogo />}
173
-
257
+ alt="Logo"
258
+ className={"w-[28px] h-[28px] object-contain"} />
259
+ : <FireCMSLogo width={"28px"} height={"28px"} />}
174
260
  </Link>
175
261
  </Tooltip>
176
262
  </div>;