@tallyui/components 3.4.0 → 3.5.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/components",
3
- "version": "3.4.0",
3
+ "version": "3.5.1",
4
4
  "type": "module",
5
5
  "description": "Universal POS UI components for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -27,22 +27,22 @@
27
27
  "directory": "packages/components"
28
28
  },
29
29
  "peerDependencies": {
30
- "@tallyui/core": "^3.4.0",
31
- "@tallyui/primitives": "^3.4.0",
32
- "@tallyui/theme": "^3.4.0",
30
+ "@tallyui/core": "^3.5.1",
31
+ "@tallyui/primitives": "^3.5.1",
32
+ "@tallyui/theme": "^3.5.1",
33
33
  "react": ">=18",
34
34
  "react-native": ">=0.74",
35
35
  "react-native-svg": ">=15"
36
36
  },
37
37
  "dependencies": {
38
38
  "class-variance-authority": "^0.7.1",
39
- "@tallyui/pos": "3.4.0"
39
+ "@tallyui/pos": "3.5.1"
40
40
  },
41
41
  "devDependencies": {
42
- "@tallyui/connector-medusa": "3.4.0",
43
- "@tallyui/connector-vendure": "3.4.0",
44
- "@tallyui/connector-woocommerce": "3.4.0",
45
- "@tallyui/mock-api": "3.4.0",
42
+ "@tallyui/connector-medusa": "3.5.1",
43
+ "@tallyui/connector-vendure": "3.5.1",
44
+ "@tallyui/connector-woocommerce": "3.5.1",
45
+ "@tallyui/mock-api": "3.5.1",
46
46
  "@types/react": ">=18",
47
47
  "react": "^19.2.4",
48
48
  "react-native-svg": "15.12.1",
package/src/index.ts CHANGED
@@ -12,6 +12,13 @@ export {
12
12
  ProductStockBadge,
13
13
  ProductCard,
14
14
  ProductGrid,
15
+ ProductTable,
16
+ ViewToggle,
17
+ type ViewToggleProps,
18
+ defaultProductColumns,
19
+ useDefaultProductColumns,
20
+ type ProductTableProps,
21
+ type ProductTableColumn,
15
22
  type ProductTitleProps,
16
23
  type ProductPriceProps,
17
24
  type ProductImageProps,
@@ -30,9 +30,12 @@ export function CategoryNav({
30
30
  return (
31
31
  <Pressable
32
32
  key={cat.id}
33
+ testID={`category-nav-${cat.id}`}
34
+ role="radio"
35
+ aria-checked={active}
33
36
  onPress={() => onSelect(cat.id)}
34
37
  className={cn(
35
- 'rounded-full px-3 py-1.5',
38
+ 'rounded-md px-3 py-1.5',
36
39
  active ? 'bg-primary' : 'bg-muted',
37
40
  )}
38
41
  >
@@ -55,6 +58,8 @@ export function CategoryNav({
55
58
  showsHorizontalScrollIndicator={false}
56
59
  contentContainerClassName="flex-row gap-2 px-3 py-2"
57
60
  className={className}
61
+ role="radiogroup"
62
+ accessibilityLabel="Categories"
58
63
  {...viewProps}
59
64
  >
60
65
  {content}
@@ -63,7 +68,7 @@ export function CategoryNav({
63
68
  }
64
69
 
65
70
  return (
66
- <View className={cn('gap-1 p-2', className)} {...viewProps}>
71
+ <View className={cn('gap-1 p-2', className)} role="radiogroup" accessibilityLabel="Categories" {...viewProps}>
67
72
  {content}
68
73
  </View>
69
74
  );
@@ -5,6 +5,8 @@ export { ProductSku, type ProductSkuProps } from './product-sku';
5
5
  export { ProductStockBadge, type ProductStockBadgeProps } from './product-stock-badge';
6
6
  export { ProductCard, type ProductCardProps } from './product-card';
7
7
  export { ProductGrid, type ProductGridProps } from './product-grid';
8
+ export { ViewToggle, type ViewToggleProps } from './view-toggle';
9
+ export { ProductTable, defaultProductColumns, useDefaultProductColumns, type ProductTableProps, type ProductTableColumn } from './product-table';
8
10
  export { ProductList, type ProductListProps } from './product-list';
9
11
  export { CategoryNav, type CategoryNavProps, type CategoryItem } from './category-nav';
10
12
  export { ProductVariantPicker, type ProductVariantPickerProps, type VariantOption } from './product-variant-picker';
@@ -0,0 +1,153 @@
1
+ import { useMemo, useState, type JSX, type ReactNode } from 'react';
2
+ import { FlatList, Pressable, View, type ViewProps } from 'react-native';
3
+ import { useProductTraits, useTraitContext, useStockOverlaid, type ProductTraits, type TraitContext } from '@tallyui/core';
4
+ import { productSortValue, sortProducts, type ProductSort, type ProductSortValue } from '@tallyui/pos';
5
+ import { cn } from '@tallyui/theme';
6
+ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Text, VStack } from '../ui';
7
+ import { ProductTitle } from './product-title';
8
+ import { ProductSku } from './product-sku';
9
+ import { ProductPrice } from './product-price';
10
+ import { ProductStockBadge } from './product-stock-badge';
11
+
12
+ // Limit initial mounts for a 2,000-product catalogue to eight rows.
13
+ const INITIAL_ROWS = 8;
14
+ // Keep each render batch small for a 2,000-product catalogue.
15
+ const BATCH_ROWS = 8;
16
+ // Keep nearby rows mounted without rendering the whole 2,000-product catalogue.
17
+ const WINDOW_SIZE = 5;
18
+
19
+ export interface ProductTableColumn<Doc = any> {
20
+ /** Stable id; the default columns use PRODUCT_SORT_FIELDS. */
21
+ id: string;
22
+ /** Header label. */
23
+ header: string;
24
+ render: (doc: Doc) => ReactNode;
25
+ /** The value the header sort orders by; omit to make the column unsortable. */
26
+ sortValue?: (doc: Doc) => ProductSortValue;
27
+ /** Flex weight of the column; default 1. */
28
+ flex?: number;
29
+ }
30
+
31
+ export interface ProductTableProps extends Omit<ViewProps, 'children'> {
32
+ items: any[];
33
+ /** Default: trait columns with a ConnectorProvider, else none. */
34
+ columns?: ProductTableColumn[];
35
+ /** Controlled sort when not undefined (null = no sort). */
36
+ sort?: ProductSort | null;
37
+ /** Initial sort when uncontrolled; default null. */
38
+ defaultSort?: ProductSort | null;
39
+ onSortChange?: (sort: ProductSort | null) => void;
40
+ /** Sort rows here (default true); false leaves ordering to the app. */
41
+ sortItems?: boolean;
42
+ onSelect?: (doc: any) => void;
43
+ searchSlot?: ReactNode;
44
+ filterSlot?: ReactNode;
45
+ emptyState?: ReactNode;
46
+ showUnsellable?: boolean;
47
+ /** Passed to the default price column's ProductPrice. */
48
+ currencySymbol?: string;
49
+ className?: string;
50
+ }
51
+
52
+ export function defaultProductColumns(
53
+ traits: ProductTraits, context?: TraitContext, options?: { currencySymbol?: string },
54
+ ): ProductTableColumn[] {
55
+ const columns: ProductTableColumn[] = [
56
+ { id: 'name', header: 'Name', flex: 3, render: (doc) => <ProductTitle doc={doc} numberOfLines={2} /> },
57
+ { id: 'sku', header: 'SKU', flex: 1, render: (doc) => <ProductSku doc={doc} /> },
58
+ { id: 'barcode', header: 'Barcode', flex: 1, render: (doc) => <Text>{traits.getBarcode(doc) ?? '—'}</Text> },
59
+ { id: 'price', header: 'Price', flex: 1, render: (doc) => <ProductPrice doc={doc} currencySymbol={options?.currencySymbol} /> },
60
+ { id: 'stock', header: 'Stock', flex: 1, render: (doc) => <ProductStockBadge doc={doc} showQuantity showAsOf={false} /> },
61
+ { id: 'category', header: 'Category', flex: 2, render: (doc) => <Text>{traits.getCategoryNames(doc).join(', ') || '—'}</Text> },
62
+ ];
63
+ return columns.map((column) => ({
64
+ ...column, sortValue: (doc) => productSortValue(doc, column.id, traits, context),
65
+ }));
66
+ }
67
+
68
+ export function useDefaultProductColumns(options?: { currencySymbol?: string }): ProductTableColumn[] {
69
+ const traits = useProductTraits();
70
+ const context = useTraitContext();
71
+ const currencySymbol = options?.currencySymbol;
72
+ return useMemo(() => defaultProductColumns(traits, context, { currencySymbol }), [traits, context, currencySymbol]);
73
+ }
74
+
75
+ export function ProductTable({
76
+ items, columns, sort, defaultSort = null, onSortChange, sortItems = true, onSelect,
77
+ searchSlot, filterSlot, emptyState, showUnsellable = false, currencySymbol, className, ...viewProps
78
+ }: ProductTableProps): JSX.Element {
79
+ // Without a ConnectorProvider, custom columns can render without traits.
80
+ let traits: ReturnType<typeof useProductTraits> | undefined;
81
+ try {
82
+ traits = useProductTraits();
83
+ } catch {
84
+ traits = undefined;
85
+ }
86
+ const context = useTraitContext();
87
+ const resolvedColumns = useMemo(() => columns ?? (traits
88
+ ? defaultProductColumns(traits, context, { currencySymbol }) : []), [columns, traits, context, currencySymbol]);
89
+ const [internalSort, setInternalSort] = useState(defaultSort);
90
+ const activeSort = sort === undefined ? internalSort : sort;
91
+ const visibleItems = showUnsellable || !traits ? items : items.filter((item) => traits!.isSellable(item));
92
+ const overlaidItems = useStockOverlaid(visibleItems);
93
+ const rows = sortItems ? sortProducts([...overlaidItems], activeSort,
94
+ (doc, field) => resolvedColumns.find((column) => column.id === field)?.sortValue?.(doc)) : overlaidItems;
95
+
96
+ const changeSort = (id: string) => {
97
+ const next: ProductSort | null = activeSort?.field !== id ? { field: id, dir: 'asc' }
98
+ : activeSort.dir === 'asc' ? { field: id, dir: 'desc' } : null;
99
+ if (sort === undefined) setInternalSort(next);
100
+ onSortChange?.(next);
101
+ };
102
+
103
+ return (
104
+ <VStack space="none" className={cn('flex-1', className)} {...viewProps}>
105
+ {searchSlot && <View className="px-3 py-2">{searchSlot}</View>}
106
+ {filterSlot && <View className="px-3 pb-2">{filterSlot}</View>}
107
+ {rows.length ? (
108
+ <Table className="flex-1">
109
+ <TableHeader>
110
+ <TableRow>
111
+ {resolvedColumns.map((column) => (
112
+ <TableHead key={column.id} style={{ flex: column.flex ?? 1 }}
113
+ {...(column.sortValue ? { 'aria-sort': activeSort?.field === column.id
114
+ ? (activeSort.dir === 'asc' ? 'ascending' : 'descending') : 'none' } : {})}>
115
+ {column.sortValue ? (
116
+ <Pressable accessibilityRole="button" accessibilityLabel={'Sort by ' + column.header}
117
+ testID={'product-table-sort-' + column.id} onPress={() => changeSort(column.id)}>
118
+ <Text>{column.header}{activeSort?.field === column.id ? (activeSort.dir === 'asc' ? ' ▲' : ' ▼') : ''}</Text>
119
+ </Pressable>
120
+ ) : <Text>{column.header}</Text>}
121
+ </TableHead>
122
+ ))}
123
+ </TableRow>
124
+ </TableHeader>
125
+ <TableBody className="flex-1">
126
+ <FlatList
127
+ data={rows}
128
+ keyExtractor={(item, index) => String(item.id ?? index)}
129
+ initialNumToRender={INITIAL_ROWS}
130
+ maxToRenderPerBatch={BATCH_ROWS}
131
+ windowSize={WINDOW_SIZE}
132
+ renderItem={({ item, index }) => {
133
+ const key = String(item.id ?? index);
134
+ const row = (
135
+ <TableRow testID={onSelect ? undefined : 'product-row-' + key}>
136
+ {resolvedColumns.map((column) => (
137
+ <TableCell key={column.id} style={{ flex: column.flex ?? 1 }}>{column.render(item)}</TableCell>
138
+ ))}
139
+ </TableRow>
140
+ );
141
+ return onSelect ? (
142
+ <Pressable accessibilityRole="button" testID={'product-row-' + key} onPress={() => onSelect(item)}>
143
+ {row}
144
+ </Pressable>
145
+ ) : row;
146
+ }}
147
+ />
148
+ </TableBody>
149
+ </Table>
150
+ ) : emptyState ?? null}
151
+ </VStack>
152
+ );
153
+ }
@@ -0,0 +1,31 @@
1
+ import type { JSX } from 'react';
2
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
3
+ import { cn } from '@tallyui/theme';
4
+ import type { CatalogueView } from '@tallyui/pos';
5
+
6
+ export interface ViewToggleProps extends Omit<ViewProps, 'children'> {
7
+ value: CatalogueView;
8
+ onChange: (view: CatalogueView) => void;
9
+ /** Default { grid: 'Grid', table: 'Table' }. */
10
+ labels?: { grid?: string; table?: string };
11
+ className?: string;
12
+ }
13
+
14
+ export function ViewToggle({ value, onChange, labels, className, ...viewProps }: ViewToggleProps): JSX.Element {
15
+ return (
16
+ <View {...viewProps} role="radiogroup" accessibilityLabel="View" testID="view-toggle"
17
+ className={cn('flex-row self-start overflow-hidden rounded-md border border-border', className)}>
18
+ {(['grid', 'table'] as const).map((view) => {
19
+ const selected = value === view;
20
+ const label = labels?.[view] ?? (view === 'grid' ? 'Grid' : 'Table');
21
+ return (
22
+ <Pressable key={view} role="radio" aria-checked={selected} testID={`view-toggle-${view}`}
23
+ accessibilityLabel={label} onPress={() => { if (!selected) onChange(view); }}
24
+ className={cn('px-3 py-2', selected ? 'bg-primary' : 'bg-card')}>
25
+ <Text className={cn('text-sm font-medium', selected ? 'text-primary-foreground' : 'text-foreground')}>{label}</Text>
26
+ </Pressable>
27
+ );
28
+ })}
29
+ </View>
30
+ );
31
+ }
@@ -1,13 +1,14 @@
1
- import { useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import { Pressable, Text, View } from 'react-native';
3
- import { useStockOverlaid, useStockOverlayAsOf, type ProductTraits } from '@tallyui/core';
4
- import { catalogueEntries, findEntryByCode, searchProducts, variantPriceLabel, type CatalogueEntry } from '@tallyui/pos';
5
- import { ProductGrid, ProductImage, ProductPrice, ProductStockBadge, ProductTitle } from '../product';
3
+ import { useStockOverlaid, useStockOverlayAsOf, type ProductTraits, type ProductCategory } from '@tallyui/core';
4
+ import { catalogueEntries, findEntryByCode, searchProducts, variantPriceLabel, sortProducts, productSortValue,
5
+ catalogueViewReducer, normalizeCatalogueViewState, resolveGridColumns, DEFAULT_CATALOGUE_VIEW_STATE,
6
+ listCategories, inCategory,
7
+ type CatalogueEntry, type CatalogueViewState, type CatalogueViewAction } from '@tallyui/pos';
8
+ import { ProductGrid, ProductImage, ProductPrice, ProductStockBadge, ProductTitle, ProductTable, ViewToggle, CategoryNav } from '../product';
6
9
  import { SearchInput } from '../input';
7
10
  import { VStack } from '../ui';
8
11
 
9
- // Keep product names readable and touch targets at least 160 px wide where two columns fit.
10
- const MIN_TILE_WIDTH = 160;
11
12
  const STOCK_LABEL = {
12
13
  in_stock: 'In Stock', out_of_stock: 'Out of Stock', backorder: 'On Backorder', unknown: 'Unknown',
13
14
  };
@@ -32,7 +33,8 @@ function laterOf(a: Date | null | undefined, b: Date | null | undefined): Date |
32
33
  return a.getTime() >= b.getTime() ? a : b;
33
34
  }
34
35
 
35
- export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, loading, pullError, lastStockCheckAt, hour12, minCodeLength }: {
36
+ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, loading, pullError, lastStockCheckAt, hour12, minCodeLength,
37
+ viewState, defaultViewState, loadViewState, saveViewState, onViewStateError, onStateChange, showViewToggle = false, items, onQueryChange, showCategoryNav = false, categories }: {
36
38
  products: Doc[];
37
39
  traits: ProductTraits<Doc>;
38
40
  currency: string;
@@ -58,11 +60,80 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
58
60
  * search instead of doing a code lookup. A scanner's timing threshold is the app's own concern,
59
61
  * in its unfocused wedge listener — not this prop's job. */
60
62
  minCodeLength?: number;
63
+ /** Controlled view state; changes are reported through onStateChange. */
64
+ viewState?: CatalogueViewState;
65
+ /** Uncontrolled initial state, merged over DEFAULT_CATALOGUE_VIEW_STATE. */
66
+ defaultViewState?: Partial<CatalogueViewState>;
67
+ /** App-owned stored state, read once on mount in uncontrolled mode. */
68
+ loadViewState?: () => unknown | Promise<unknown>;
69
+ /** Saves cashier changes, never loads; uncontrolled only. */
70
+ saveViewState?: (state: CatalogueViewState) => void | Promise<void>;
71
+ /** Load/save errors; swallowed when omitted. */
72
+ onViewStateError?: (error: unknown) => void;
73
+ /** Loaded state or a cashier's next state (requested state when controlled). */
74
+ onStateChange?: (state: CatalogueViewState) => void;
75
+ /** Show the grid/table toggle in the header. Default false. */
76
+ showViewToggle?: boolean;
77
+ /** Show the category nav in the header. Default false. */
78
+ showCategoryNav?: boolean;
79
+ /** Replaces the categories derived from the listed products. */
80
+ categories?: ProductCategory[];
81
+ /** App-filtered, sorted list shown as given; products still drives scanning and variant choices. */
82
+ items?: Doc[];
83
+ /** Search text on every input change, for apps that filter items themselves. */
84
+ onQueryChange?: (query: string) => void;
61
85
  }) {
86
+ const [initial] = useState<{
87
+ state: CatalogueViewState; loaded?: CatalogueViewState; pending?: Promise<unknown>; failed?: boolean; error?: unknown;
88
+ }>(() => {
89
+ const state = normalizeCatalogueViewState({ ...DEFAULT_CATALOGUE_VIEW_STATE, ...defaultViewState });
90
+ if (viewState !== undefined || !loadViewState) return { state };
91
+ try {
92
+ const value = loadViewState();
93
+ if (value instanceof Promise) return { state, pending: value };
94
+ const loaded = normalizeCatalogueViewState(value, state);
95
+ return { state: loaded, loaded };
96
+ } catch (error) {
97
+ return { state, failed: true, error };
98
+ }
99
+ });
100
+ const [internalState, setInternalState] = useState(initial.state);
101
+ const state = viewState ?? internalState;
102
+ const cashierChanged = useRef(false);
103
+ useEffect(() => {
104
+ let mounted = true;
105
+ if (initial.loaded) onStateChange?.(initial.loaded);
106
+ if (initial.failed) onViewStateError?.(initial.error);
107
+ initial.pending?.then((value) => {
108
+ if (!mounted || cashierChanged.current) return;
109
+ const loaded = normalizeCatalogueViewState(value, initial.state);
110
+ setInternalState(loaded);
111
+ onStateChange?.(loaded);
112
+ }, (error) => { if (mounted) onViewStateError?.(error); });
113
+ return () => { mounted = false; };
114
+ }, [initial]);
115
+
116
+ function changeState(action: CatalogueViewAction) {
117
+ const next = catalogueViewReducer(state, action);
118
+ if (next === state) return;
119
+ cashierChanged.current = true;
120
+ if (viewState !== undefined) { onStateChange?.(next); return; }
121
+ setInternalState(next);
122
+ onStateChange?.(next);
123
+ try {
124
+ Promise.resolve(saveViewState?.(next)).catch((error) => onViewStateError?.(error));
125
+ } catch (error) {
126
+ onViewStateError?.(error);
127
+ }
128
+ }
129
+
62
130
  const isLoading = loading ?? (lastSyncedAt === null && pullError == null);
63
131
  // Idempotent (the adapter's overlay returns just the fields the stock map sets), so an app that already
64
132
  // merges the overlay itself keeps working, and can drop its own merge.
65
133
  const shown = useStockOverlaid(products) as Doc[];
134
+ const categoryList = useMemo(() => categories ?? listCategories(shown, traits), [categories, shown, traits]);
135
+ const activeCategory = showCategoryNav ? categoryList.find((category) => category.id === state.categoryId) : undefined;
136
+ const suppliedItems = useStockOverlaid(items ?? products) as Doc[];
66
137
  const overlayAsOf = useStockOverlayAsOf();
67
138
  const parsedOverlayAsOf = overlayAsOf ? new Date(overlayAsOf) : undefined;
68
139
  const validOverlayAsOf = parsedOverlayAsOf && !isNaN(parsedOverlayAsOf.getTime()) ? parsedOverlayAsOf : undefined;
@@ -72,10 +143,14 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
72
143
  // from the current entries below, so an open chooser shows each reconcile pass as it lands.
73
144
  const [chooserId, setChooserId] = useState<string | null>(null);
74
145
  const [width, setWidth] = useState(0);
75
- // ProductGrid has 4 px padding on each side of the content and each cell.
76
- const columns = Math.max(2, Math.min(6, Math.floor((width - 8) / (MIN_TILE_WIDTH + 8))));
146
+ const columns = resolveGridColumns(state.gridColumns, width);
77
147
  const entries = useMemo(() => catalogueEntries(shown, traits, { currency }), [shown, traits, currency]);
78
- const results = useMemo(() => searchProducts(shown, query, traits), [shown, query, traits]);
148
+ const results = useMemo(() => {
149
+ if (items !== undefined) return suppliedItems;
150
+ const filtered = searchProducts(activeCategory
151
+ ? shown.filter((doc) => inCategory(doc, activeCategory.id, traits)) : shown, query, traits);
152
+ return state.sort ? sortProducts(filtered, state.sort, (doc, field) => productSortValue(doc, field, traits, { currency })) : filtered;
153
+ }, [items, suppliedItems, shown, query, traits, state.sort, currency, showCategoryNav, activeCategory]);
79
154
  const choices = useMemo(() => (chooserId === null ? []
80
155
  : entries.filter((entry) => traits.getId(entry.product) === chooserId)), [entries, chooserId, traits]);
81
156
  // A product that leaves the catalogue (a resync, or no longer sellable) closes its chooser.
@@ -86,23 +161,38 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
86
161
  setChooserId(null);
87
162
  }
88
163
 
164
+ function selectProduct(product: Doc) {
165
+ const variants = entries.filter((entry) => traits.getId(entry.product) === traits.getId(product));
166
+ if (variants.length === 1) select(variants[0]);
167
+ else setChooserId(traits.getId(product));
168
+ }
169
+
170
+ const emptyState = <Text className="mt-10 text-center text-sm text-muted-foreground">
171
+ {query.trim() ? `No products match "${query.trim()}".` : activeCategory ? `No products in ${activeCategory.name}.`
172
+ : isLoading ? 'Loading products…' : 'No products yet.'}
173
+ </Text>;
174
+
89
175
  return (
90
176
  <View className="flex-1" onLayout={(event) => setWidth(event.nativeEvent.layout.width)}>
91
177
  <View className="gap-2 border-b border-border bg-card px-4 pb-3 pt-3">
92
- <SearchInput value={query} onChangeText={setQuery} placeholder="Search or scan barcode / SKU" autoFocus
178
+ <SearchInput value={query} onChangeText={(text) => { setQuery(text); onQueryChange?.(text); }} placeholder="Search or scan barcode / SKU" autoFocus
93
179
  onSubmitEditing={() => {
94
180
  if (minCodeLength && query.trim().length < minCodeLength) return;
95
181
  const entry = findEntryByCode(entries, query);
96
182
  if (entry) { select(entry); setQuery(''); }
97
183
  }} />
98
- {(statusText || statusAccessory) ? (
184
+ {(statusText || statusAccessory || showViewToggle) ? (
99
185
  <View testID="catalogue-status-row" className="flex-row items-center gap-2">
100
186
  {statusText ? <Text className="flex-1 text-xs text-muted-foreground" numberOfLines={statusAccessory ? 1 : undefined}>
101
187
  {statusText}{query.trim() ? ` · ${results.length.toLocaleString()} matching` : ''}
102
188
  </Text> : null}
103
189
  {statusAccessory}
190
+ {showViewToggle ? <ViewToggle value={state.view} onChange={(view) => changeState({ type: 'setView', view })} /> : null}
104
191
  </View>
105
192
  ) : null}
193
+ {showCategoryNav ? <CategoryNav categories={[{ id: '', name: 'All products' }, ...categoryList]}
194
+ selectedId={activeCategory?.id ?? ''}
195
+ onSelect={(id) => changeState({ type: 'setCategory', categoryId: id === '' ? null : id })} /> : null}
106
196
  {choices.length > 0 ? (
107
197
  <View accessibilityLabel="Choose variant" className="gap-2">
108
198
  {choices.map((entry) => (
@@ -120,16 +210,15 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
120
210
  </View>
121
211
  ) : null}
122
212
  </View>
123
- <ProductGrid items={results} numColumns={columns}
213
+ {state.view === 'table' ? (
214
+ <ProductTable items={results} sort={state.sort} onSortChange={(sort) => changeState({ type: 'setSort', sort })}
215
+ sortItems={false} onSelect={selectProduct} emptyState={emptyState} />
216
+ ) : <ProductGrid items={results} numColumns={columns}
124
217
  renderItem={(product: Doc) => (
125
218
  // TallyUI's ProductCard has no children slot, so the tile is composed directly here
126
219
  // (same structure/classes as ProductCard) to add the stock badge as a fourth child,
127
220
  // inside the card and centred with the rest, rather than adding a slot to ProductCard (out of scope for TV6b).
128
- <Pressable accessibilityRole="button" testID={`product-tile-${traits.getName(product)}`} onPress={() => {
129
- const variants = entries.filter((entry) => entry.product === product);
130
- if (variants.length === 1) select(variants[0]);
131
- else setChooserId(traits.getId(product));
132
- }}>
221
+ <Pressable accessibilityRole="button" testID={`product-tile-${traits.getName(product)}`} onPress={() => selectProduct(product)}>
133
222
  <VStack space="sm" className="items-center rounded-lg border border-border bg-card p-3">
134
223
  <ProductImage doc={product} size={80} className="rounded-md" />
135
224
  <ProductTitle doc={product} className="text-sm" numberOfLines={2} />
@@ -138,9 +227,7 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
138
227
  </VStack>
139
228
  </Pressable>
140
229
  )}
141
- emptyState={<Text className="mt-10 text-center text-sm text-muted-foreground">
142
- {query.trim() ? `No products match "${query.trim()}".` : isLoading ? 'Loading products…' : 'No products yet.'}
143
- </Text>} />
230
+ emptyState={emptyState} />}
144
231
  </View>
145
232
  );
146
233
  }