@everystack/admin 0.2.2 → 0.2.5

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.
@@ -4,7 +4,7 @@
4
4
  * Provides data fetching capabilities using the configured DataAdapter.
5
5
  */
6
6
 
7
- import { createContext, useContext, useState, useEffect, useCallback } from 'react';
7
+ import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
8
8
  import type { DataAdapter } from '../adapters/types';
9
9
  import type { ListParams, DataRecord } from '../types';
10
10
 
@@ -33,6 +33,12 @@ export function useDataAdapter(): DataAdapter {
33
33
  export interface UseAdminDataOptions extends ListParams {
34
34
  /** Skip initial fetch */
35
35
  skip?: boolean;
36
+ /**
37
+ * Reactive filters merged into every fetch beneath the in-view search filter
38
+ * (e.g. an analytics-header time range or segment). Changing it refetches.
39
+ * Memoize it at the call site to keep identity stable across renders.
40
+ */
41
+ baseFilter?: Record<string, unknown>;
36
42
  }
37
43
 
38
44
  export interface UseAdminDataResult<T extends DataRecord = DataRecord> {
@@ -77,34 +83,66 @@ export function useAdminData<T extends DataRecord = DataRecord>(
77
83
  const [order, setOrder] = useState(options.order ?? 'asc');
78
84
  const [filter, setFilterState] = useState(options.filter ?? {});
79
85
 
80
- const fetchData = useCallback(async () => {
86
+ // Manual-refetch trigger (bumped by refetch()).
87
+ const [refetchIndex, setRefetchIndex] = useState(0);
88
+
89
+ // A stable, value-based key for the whole request. The effect keys on this
90
+ // string rather than on object identity, so a re-render that produces a
91
+ // new-but-equal `filter`/`baseFilter` (e.g. FilterBar's initial empty search,
92
+ // or the auth refresh re-rendering the tree) does NOT re-fire the fetch.
93
+ // This is what collapses the per-load double request.
94
+ const reqKey = JSON.stringify({
95
+ resource,
96
+ page,
97
+ perPage,
98
+ sort: sort ?? null,
99
+ order,
100
+ baseFilter: options.baseFilter ?? {},
101
+ filter,
102
+ search: options.search ?? null,
103
+ refetchIndex,
104
+ });
105
+
106
+ // Latest-request guard: only the most recent fetch may write state, so a
107
+ // superseded in-flight response can't clobber newer data (out-of-order races).
108
+ const seqRef = useRef(0);
109
+
110
+ useEffect(() => {
111
+ if (options.skip) return;
112
+ const seq = ++seqRef.current;
81
113
  setLoading(true);
82
114
  setError(null);
83
115
 
84
- try {
85
- const result = await adapter.fetchList<T>(resource, {
116
+ adapter
117
+ .fetchList<T>(resource, {
86
118
  page,
87
119
  perPage,
88
120
  sort,
89
121
  order,
90
- filter,
122
+ // Base filters (time range, segment) sit beneath the in-view search filter.
123
+ filter: { ...(options.baseFilter ?? {}), ...filter },
91
124
  search: options.search,
125
+ })
126
+ .then(result => {
127
+ if (seq !== seqRef.current) return;
128
+ setData(result.data);
129
+ setTotal(result.total);
130
+ })
131
+ .catch(err => {
132
+ if (seq !== seqRef.current) return;
133
+ setError(err instanceof Error ? err : new Error(String(err)));
134
+ })
135
+ .finally(() => {
136
+ if (seq === seqRef.current) setLoading(false);
92
137
  });
138
+ // reqKey encodes every input; the per-field deps are folded into it.
139
+ // eslint-disable-next-line react-hooks/exhaustive-deps
140
+ }, [adapter, reqKey, options.skip]);
93
141
 
94
- setData(result.data);
95
- setTotal(result.total);
96
- } catch (err) {
97
- setError(err instanceof Error ? err : new Error(String(err)));
98
- } finally {
99
- setLoading(false);
100
- }
101
- }, [adapter, resource, page, perPage, sort, order, filter, options.search]);
102
-
103
- useEffect(() => {
104
- if (!options.skip) {
105
- fetchData();
106
- }
107
- }, [fetchData, options.skip]);
142
+ // Refetch by bumping the trigger — runs through the same effect/guard path.
143
+ const refetch = useCallback(async () => {
144
+ setRefetchIndex(i => i + 1);
145
+ }, []);
108
146
 
109
147
  const setSort = useCallback((field: string, newOrder?: 'asc' | 'desc') => {
110
148
  setSortState(field);
@@ -121,7 +159,7 @@ export function useAdminData<T extends DataRecord = DataRecord>(
121
159
  total,
122
160
  loading,
123
161
  error,
124
- refetch: fetchData,
162
+ refetch,
125
163
  page,
126
164
  perPage,
127
165
  setPage,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Editor registry context.
3
+ *
4
+ * Apps register editor adapters (TipTap, MDXEditor, a GrapesJS email builder,
5
+ * …) on AdminRoot/AdminShell; fields select one via `field.editor`. The core
6
+ * admin depends on no editor library — the registry is the plug socket.
7
+ * See docs/plans/admin-editors.md.
8
+ */
9
+
10
+ import { createContext, useContext } from 'react';
11
+ import type { EditorRegistry } from '../types';
12
+
13
+ export const EditorRegistryContext = createContext<EditorRegistry>({});
14
+
15
+ export function useEditors(): EditorRegistry {
16
+ return useContext(EditorRegistryContext);
17
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * useViewState — read/write the admin's in-view state as URL query params.
3
+ *
4
+ * Router-agnostic: AdminRouter populates the context from the `searchParams` it
5
+ * receives (e.g. expo-router's useLocalSearchParams) and a setter built on the
6
+ * navigation port. Screens read the current params and push updates, so every
7
+ * filtered/scrubbed view is a direct link and drill-downs (`?level=error`) work.
8
+ */
9
+
10
+ import { createContext, useContext } from 'react';
11
+
12
+ /** Current query params, normalized to single strings (first value wins). */
13
+ export type ViewParams = Record<string, string>;
14
+
15
+ export interface ViewStateContextValue {
16
+ params: ViewParams;
17
+ /**
18
+ * Merge `next` into the current params and navigate. Keys set to undefined are
19
+ * removed from the URL. The path is preserved; only the query changes.
20
+ */
21
+ setParams: (next: Record<string, string | undefined>) => void;
22
+ }
23
+
24
+ export const ViewStateContext = createContext<ViewStateContextValue | null>(null);
25
+
26
+ /** Safe outside a provider: returns empty params and a no-op setter. */
27
+ export function useViewState(): ViewStateContextValue {
28
+ return useContext(ViewStateContext) ?? { params: {}, setParams: () => {} };
29
+ }
30
+
31
+ /** Normalize a raw searchParams object (values may be arrays) to single strings. */
32
+ export function normalizeParams(raw?: Record<string, string | string[] | undefined>): ViewParams {
33
+ const out: ViewParams = {};
34
+ if (!raw) return out;
35
+ for (const [k, v] of Object.entries(raw)) {
36
+ if (v == null) continue;
37
+ out[k] = Array.isArray(v) ? String(v[0] ?? '') : String(v);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Build a `?a=1&b=2` query string from params; empty → ''. Undefined dropped. */
43
+ export function buildQueryString(params: Record<string, string | undefined>): string {
44
+ const usp = new URLSearchParams();
45
+ for (const [k, v] of Object.entries(params)) {
46
+ if (v == null || v === '') continue;
47
+ usp.set(k, v);
48
+ }
49
+ const s = usp.toString();
50
+ return s ? `?${s}` : '';
51
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,14 @@
10
10
  export type {
11
11
  FieldType,
12
12
  FieldDefinition,
13
+ ImageSize,
14
+ CellContext,
15
+ ActionContext,
16
+ InputContext,
17
+ ContentFormat,
18
+ EditorProps,
19
+ EditorComponent,
20
+ EditorRegistry,
13
21
  ResourceConfig,
14
22
  StorageConfig,
15
23
  WidgetType,
@@ -69,6 +77,14 @@ export {
69
77
 
70
78
  // useRpc hook
71
79
  export { useRpc, type UseRpcOptions, type UseRpcResult } from './hooks/useRpc';
80
+ export {
81
+ useViewState,
82
+ ViewStateContext,
83
+ normalizeParams,
84
+ buildQueryString,
85
+ type ViewParams,
86
+ type ViewStateContextValue,
87
+ } from './hooks/useViewState';
72
88
 
73
89
  // RPC Factory
74
90
  export {
@@ -104,6 +120,8 @@ export {
104
120
  ResourceCreate,
105
121
  ResourceEdit,
106
122
  ResourceDashboard,
123
+ AdminRouter,
124
+ AdminShell,
107
125
  type AdminLayoutProps,
108
126
  type AdminDashboardProps,
109
127
  type ResourceListProps,
@@ -111,18 +129,35 @@ export {
111
129
  type ResourceCreateProps,
112
130
  type ResourceEditProps,
113
131
  type ResourceDashboardProps,
132
+ type AdminRouterProps,
133
+ type AdminShellProps,
134
+ type AdminNavigator,
114
135
  } from './screens';
115
136
 
137
+ // Routing (router-agnostic path↔route mapping behind AdminRouter)
138
+ export {
139
+ resolveAdminRoute,
140
+ adminHref,
141
+ pathToSegments,
142
+ type AdminScreen,
143
+ type AdminRouteMatch,
144
+ type AdminHrefParts,
145
+ } from './utils/admin-routing';
146
+
116
147
  // Context (virtual routing for expo-router)
117
148
  export { createAdminContext, type RequireContext } from './context';
118
149
 
119
150
  // Storage hooks
120
151
  export { useStorageConfig, StorageConfigContext } from './hooks/useStorageConfig';
121
152
 
153
+ // Editor registry (pluggable rich-text/content editors)
154
+ export { useEditors, EditorRegistryContext } from './hooks/useEditors';
155
+
122
156
  // Image field components
123
157
  export { ImageFieldInput } from './components/ImageFieldInput';
124
158
  export { AvatarFieldInput } from './components/AvatarFieldInput';
125
159
  export { ImageDisplay } from './components/ImageDisplay';
160
+ export { RowActionButton, type RowActionButtonProps } from './components/RowActionButton';
126
161
 
127
162
  // Image utilities
128
163
  export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
@@ -35,7 +35,7 @@ const profilesResource: ResourceConfig = {
35
35
  labelPlural: 'Profiles',
36
36
  icon: 'person',
37
37
  primaryKey: 'id',
38
- actions: { list: true, show: true, create: false, edit: true, delete: false },
38
+ actions: { list: true, show: true, create: true, edit: true, delete: true },
39
39
  fields: [
40
40
  { name: 'id', type: 'string', label: 'ID' },
41
41
  { name: 'username', type: 'string', label: 'Username', sortable: true, searchable: true },
@@ -72,7 +72,7 @@ const postsResource: ResourceConfig = {
72
72
  labelPlural: 'Posts',
73
73
  icon: 'chatbubble',
74
74
  primaryKey: 'id',
75
- actions: { list: true, show: true, create: false, edit: false, delete: true },
75
+ actions: { list: true, show: true, create: true, edit: true, delete: true },
76
76
  fields: [
77
77
  { name: 'id', type: 'string', label: 'ID' },
78
78
  {
@@ -111,7 +111,7 @@ const likesResource: ResourceConfig = {
111
111
  labelPlural: 'Likes',
112
112
  icon: 'heart',
113
113
  primaryKey: 'user_id',
114
- actions: { list: true, show: false, create: false, edit: false, delete: false },
114
+ actions: { list: true, show: true, create: true, edit: false, delete: true },
115
115
  fields: [
116
116
  {
117
117
  name: 'user_id',
@@ -154,7 +154,7 @@ const followsResource: ResourceConfig = {
154
154
  labelPlural: 'Follows',
155
155
  icon: 'people',
156
156
  primaryKey: 'follower_id',
157
- actions: { list: true, show: false, create: false, edit: false, delete: false },
157
+ actions: { list: true, show: true, create: true, edit: false, delete: true },
158
158
  fields: [
159
159
  {
160
160
  name: 'follower_id',
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { sql, type SQL } from 'drizzle-orm';
18
+ import { toCamelCaseKeys } from '../utils/naming';
18
19
  import type { ResourceDashboardConfig, DimensionConfig, StatDialConfig } from '../types';
19
20
 
20
21
  interface DbExecutor {
@@ -413,6 +414,61 @@ function createEventListHandler(
413
414
  };
414
415
  }
415
416
 
417
+ // ────────────────────────────────────────────────────────────
418
+ // Event Detail RPC
419
+ //
420
+ // The show view's data source for detailVia: 'rpc' resources — one row
421
+ // by primary key, the static where still applied (an STI discriminator
422
+ // must gate detail exactly like it gates the list), keys camelCased to
423
+ // match field definitions. Keeps telemetry off the REST surface entirely.
424
+ // ────────────────────────────────────────────────────────────
425
+
426
+ const MAX_ID_LENGTH = 256;
427
+
428
+ function createEventDetailHandler(
429
+ tableName: string,
430
+ config: ResourceDashboardConfig,
431
+ db: DbExecutor,
432
+ ): (body: unknown) => Promise<unknown> {
433
+ const pkColumn = Array.isArray(config.primaryKey)
434
+ ? config.primaryKey[0]
435
+ : config.primaryKey ?? 'id';
436
+
437
+ return async (body: unknown) => {
438
+ const id = (body as { id?: unknown } | null)?.id;
439
+ if (
440
+ (typeof id !== 'string' && typeof id !== 'number') ||
441
+ String(id).length === 0 ||
442
+ String(id).length > MAX_ID_LENGTH
443
+ ) {
444
+ throw new Error('Invalid id');
445
+ }
446
+
447
+ const conditions: SQL[] = [sql`${sql.raw(pkColumn)} = ${String(id)}`];
448
+ for (const predicate of config.where ?? []) {
449
+ conditions.push(sql`${sql.raw(predicate.column)} = ${predicate.equals}`);
450
+ }
451
+ let where = conditions[0];
452
+ for (let i = 1; i < conditions.length; i++) {
453
+ where = sql`${where} AND ${conditions[i]}`;
454
+ }
455
+
456
+ const result = await db.execute(sql`
457
+ SELECT row_to_json(r) AS row
458
+ FROM (SELECT * FROM ${sql.raw(tableName)} WHERE ${where} LIMIT 1) r
459
+ `);
460
+
461
+ const rows = extractRows(result);
462
+ const first = rows[0] as { row: Record<string, unknown> | string } | undefined;
463
+ if (!first) {
464
+ throw new Error(`Record not found: ${id}`);
465
+ }
466
+
467
+ const raw = typeof first.row === 'string' ? JSON.parse(first.row) : first.row;
468
+ return toCamelCaseKeys(raw);
469
+ };
470
+ }
471
+
416
472
  // ────────────────────────────────────────────────────────────
417
473
  // Public API
418
474
  // ────────────────────────────────────────────────────────────
@@ -420,6 +476,7 @@ function createEventListHandler(
420
476
  export interface ResourceDashboardRpcs {
421
477
  dashboardData: (body: unknown) => Promise<unknown>;
422
478
  eventList: (body: unknown) => Promise<unknown>;
479
+ eventDetail: (body: unknown) => Promise<unknown>;
423
480
  }
424
481
 
425
482
  /**
@@ -438,6 +495,7 @@ export function createResourceDashboardRpcs(
438
495
  return {
439
496
  dashboardData: createDashboardDataHandler(tableName, config, db),
440
497
  eventList: createEventListHandler(tableName, config, db),
498
+ eventDetail: createEventDetailHandler(tableName, config, db),
441
499
  };
442
500
  }
443
501
 
@@ -464,6 +522,7 @@ export function createAllDashboardRpcs(
464
522
  const prefix = resource.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
465
523
  rpcs[`${prefix}DashboardData`] = handlers.dashboardData;
466
524
  rpcs[`${prefix}EventList`] = handlers.eventList;
525
+ rpcs[`${prefix}EventDetail`] = handlers.eventDetail;
467
526
  }
468
527
 
469
528
  return rpcs;
@@ -5,10 +5,24 @@
5
5
  * Reads menu items from AdminConfig via useAdminConfig().
6
6
  */
7
7
 
8
- import React, { useState } from 'react';
8
+ import React, { useEffect, useState } from 'react';
9
9
  import { View, Text, Pressable, ScrollView } from 'react-native';
10
10
  import { useAdminConfig } from '../hooks/useAdminConfig';
11
11
  import type { MenuItem } from '../types';
12
+ import type { Crumb } from '../utils/breadcrumbs';
13
+
14
+ /** The group id whose children contain the active item, if any. */
15
+ function activeGroupId(menu: MenuItem[] | undefined, activeItem: string | undefined): string | null {
16
+ for (const top of menu ?? []) {
17
+ if (top.children?.some(c => c.id === activeItem)) return top.id;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ /** First top-level group (the default-open one \u2014 e.g. "Content"). */
23
+ function firstGroupId(menu: MenuItem[] | undefined): string | null {
24
+ return menu?.find(m => m.children && m.children.length > 0)?.id ?? null;
25
+ }
12
26
 
13
27
  export interface AdminLayoutProps {
14
28
  children?: React.ReactNode;
@@ -16,34 +30,73 @@ export interface AdminLayoutProps {
16
30
  activeItem?: string;
17
31
  /** Called when a menu item is pressed */
18
32
  onNavigate?: (item: MenuItem) => void;
33
+ /** Breadcrumb trail for the content top bar. */
34
+ breadcrumbs?: Crumb[];
35
+ /** Navigate to a breadcrumb's admin path. */
36
+ onNavigatePath?: (path: string) => void;
19
37
  }
20
38
 
21
- export function AdminLayout({ children, activeItem, onNavigate }: AdminLayoutProps): React.ReactElement {
39
+ export function AdminLayout({
40
+ children,
41
+ activeItem,
42
+ onNavigate,
43
+ breadcrumbs,
44
+ onNavigatePath,
45
+ }: AdminLayoutProps): React.ReactElement {
22
46
  const config = useAdminConfig();
23
47
  const [sidebarOpen, setSidebarOpen] = useState(true);
24
48
 
49
+ // Accordion: at most one group open. Default to the active group, else the
50
+ // first group ("Content"). Navigating into a group opens it (and closes the
51
+ // others); the active item is always visible.
52
+ const activeGroup = activeGroupId(config.menu, activeItem);
53
+ const [openGroup, setOpenGroup] = useState<string | null>(activeGroup ?? firstGroupId(config.menu));
54
+ useEffect(() => {
55
+ if (activeGroup) setOpenGroup(activeGroup);
56
+ }, [activeGroup]);
57
+
25
58
  return (
26
59
  <View className="flex-1 flex-row bg-gray-50">
27
60
  {/* Sidebar */}
28
61
  {sidebarOpen && (
29
62
  <View className="w-64 bg-white border-r border-gray-200">
30
- {/* Header */}
31
- <View className="px-4 py-5 border-b border-gray-200">
32
- <Text className="text-lg font-bold text-gray-900">{config.title}</Text>
33
- {config.subtitle && (
34
- <Text className="text-xs text-gray-500 mt-0.5">{config.subtitle}</Text>
35
- )}
63
+ {/* Header \u2014 collapse toggle sits to the LEFT of the title. */}
64
+ <View className="flex-row items-center px-4 py-5 border-b border-gray-200">
65
+ <Pressable
66
+ className="p-2 -ml-2 mr-1 rounded-lg"
67
+ accessibilityLabel="Collapse menu"
68
+ onPress={() => setSidebarOpen(false)}
69
+ >
70
+ <Text className="text-lg text-gray-600">{'\u2630'}</Text>
71
+ </Pressable>
72
+ <View className="flex-1">
73
+ <Text className="text-lg font-bold text-gray-900">{config.title}</Text>
74
+ {config.subtitle && (
75
+ <Text className="text-xs text-gray-500 mt-0.5">{config.subtitle}</Text>
76
+ )}
77
+ </View>
36
78
  </View>
37
79
 
38
80
  {/* Menu */}
39
81
  <ScrollView className="flex-1 py-2">
40
82
  {config.menu?.map(item => (
41
- <MenuItemRow
42
- key={item.id}
43
- item={item}
44
- active={activeItem === item.id}
45
- onPress={onNavigate}
46
- />
83
+ item.children && item.children.length > 0 ? (
84
+ <MenuGroup
85
+ key={item.id}
86
+ item={item}
87
+ activeItem={activeItem}
88
+ open={openGroup === item.id}
89
+ onToggle={() => setOpenGroup(g => (g === item.id ? null : item.id))}
90
+ onPress={onNavigate}
91
+ />
92
+ ) : (
93
+ <MenuItemRow
94
+ key={item.id}
95
+ item={item}
96
+ active={activeItem === item.id}
97
+ onPress={onNavigate}
98
+ />
99
+ )
47
100
  ))}
48
101
  </ScrollView>
49
102
  </View>
@@ -51,14 +104,18 @@ export function AdminLayout({ children, activeItem, onNavigate }: AdminLayoutPro
51
104
 
52
105
  {/* Main content */}
53
106
  <View className="flex-1">
54
- {/* Top bar */}
107
+ {/* Top bar \u2014 breadcrumbs (and a menu-open button when collapsed). */}
55
108
  <View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-200">
56
- <Pressable
57
- className="p-2 rounded-lg mr-2"
58
- onPress={() => setSidebarOpen(!sidebarOpen)}
59
- >
60
- <Text className="text-lg text-gray-600">{sidebarOpen ? '\u2190' : '\u2192'}</Text>
61
- </Pressable>
109
+ {!sidebarOpen && (
110
+ <Pressable
111
+ className="p-2 -ml-2 mr-2 rounded-lg"
112
+ accessibilityLabel="Open menu"
113
+ onPress={() => setSidebarOpen(true)}
114
+ >
115
+ <Text className="text-lg text-gray-600">{'\u2630'}</Text>
116
+ </Pressable>
117
+ )}
118
+ <Breadcrumbs crumbs={breadcrumbs} onNavigatePath={onNavigatePath} />
62
119
  </View>
63
120
 
64
121
  {/* Content */}
@@ -68,6 +125,42 @@ export function AdminLayout({ children, activeItem, onNavigate }: AdminLayoutPro
68
125
  );
69
126
  }
70
127
 
128
+ // =============================================================================
129
+ // Breadcrumbs
130
+ // =============================================================================
131
+
132
+ function Breadcrumbs({
133
+ crumbs,
134
+ onNavigatePath,
135
+ }: {
136
+ crumbs?: Crumb[];
137
+ onNavigatePath?: (path: string) => void;
138
+ }): React.ReactElement | null {
139
+ if (!crumbs || crumbs.length === 0) return null;
140
+ return (
141
+ <View className="flex-row items-center flex-wrap">
142
+ {crumbs.map((crumb, i) => {
143
+ const last = i === crumbs.length - 1;
144
+ const navigable = crumb.href != null && !last;
145
+ return (
146
+ <View key={`${crumb.label}-${i}`} className="flex-row items-center">
147
+ {i > 0 && <Text className="text-sm text-gray-300 mx-2">{'/'}</Text>}
148
+ {navigable ? (
149
+ <Pressable onPress={() => onNavigatePath?.(crumb.href!)}>
150
+ <Text className="text-sm text-blue-600">{crumb.label}</Text>
151
+ </Pressable>
152
+ ) : (
153
+ <Text className={last ? 'text-sm font-semibold text-gray-900' : 'text-sm text-gray-500'}>
154
+ {crumb.label}
155
+ </Text>
156
+ )}
157
+ </View>
158
+ );
159
+ })}
160
+ </View>
161
+ );
162
+ }
163
+
71
164
  // =============================================================================
72
165
  // MenuItemRow
73
166
  // =============================================================================
@@ -76,12 +169,24 @@ interface MenuItemRowProps {
76
169
  item: MenuItem;
77
170
  active: boolean;
78
171
  onPress?: (item: MenuItem) => void;
172
+ indent?: boolean;
79
173
  }
80
174
 
81
- function MenuItemRow({ item, active, onPress }: MenuItemRowProps): React.ReactElement {
175
+ function MenuItemRow({ item, active, onPress, indent }: MenuItemRowProps): React.ReactElement {
176
+ // react-native-tailwind can't resolve dynamic/template-literal classNames — it
177
+ // falls back to passing the string as `style`, which crashes react-dom. Keep
178
+ // every className a static string literal (a ternary of literals is fine).
82
179
  return (
83
180
  <Pressable
84
- className={active ? "flex-row items-center px-4 py-2.5 mx-2 rounded-lg bg-blue-50" : "flex-row items-center px-4 py-2.5 mx-2 rounded-lg"}
181
+ className={
182
+ indent
183
+ ? (active
184
+ ? 'flex-row items-center pl-7 pr-4 py-2 mx-2 rounded-lg bg-blue-50'
185
+ : 'flex-row items-center pl-7 pr-4 py-2 mx-2 rounded-lg')
186
+ : (active
187
+ ? 'flex-row items-center px-4 py-2.5 mx-2 rounded-lg bg-blue-50'
188
+ : 'flex-row items-center px-4 py-2.5 mx-2 rounded-lg')
189
+ }
85
190
  onPress={() => onPress?.(item)}
86
191
  >
87
192
  <Text className={active ? "text-sm text-blue-700 font-semibold" : "text-sm text-gray-700"}>
@@ -95,3 +200,41 @@ function MenuItemRow({ item, active, onPress }: MenuItemRowProps): React.ReactEl
95
200
  </Pressable>
96
201
  );
97
202
  }
203
+
204
+ // =============================================================================
205
+ // MenuGroup — collapsible section. The group header toggles; children navigate.
206
+ // =============================================================================
207
+
208
+ interface MenuGroupProps {
209
+ item: MenuItem;
210
+ activeItem?: string;
211
+ /** Controlled open state (accordion — at most one group open). */
212
+ open: boolean;
213
+ onToggle: () => void;
214
+ onPress?: (item: MenuItem) => void;
215
+ }
216
+
217
+ function MenuGroup({ item, activeItem, open, onToggle, onPress }: MenuGroupProps): React.ReactElement {
218
+ return (
219
+ <View className="mt-2">
220
+ <Pressable
221
+ className="flex-row items-center px-4 py-1.5"
222
+ onPress={onToggle}
223
+ >
224
+ <Text className="text-xs font-semibold uppercase tracking-wide text-gray-400">
225
+ {item.label}
226
+ </Text>
227
+ <Text className="ml-auto text-xs text-gray-400">{open ? '⌄' : '›'}</Text>
228
+ </Pressable>
229
+ {open && item.children?.map(child => (
230
+ <MenuItemRow
231
+ key={child.id}
232
+ item={child}
233
+ active={activeItem === child.id}
234
+ onPress={onPress}
235
+ indent
236
+ />
237
+ ))}
238
+ </View>
239
+ );
240
+ }