feat(grid): make grid feature-complete

This commit finishes the basic implementation of Grid by implementing the `Grid.EmptyView` component and updating its props. I've expanded the Grid API to support loading states, pagination, and empty views, bringing it more in line with the native Raycast API. To accommodate these new features, this commit rewrites the `useGridView` hook. It now handles complex 2D keyboard navigation, programmatic selection, and the logic for pagination and filtering. The `Grid.Item` component was also updated to support more complex content, including accessories and tooltips, and layout options like `aspectRatio` and `fit`.

Although there are still some bugs to iron out, this commit adds all the basic features in Grid.
This commit is contained in:
ByteAtATime 2025-07-08 11:01:35 -07:00
parent ca7c920567
commit 854786f0bb
No known key found for this signature in database
11 changed files with 522 additions and 181 deletions

View file

@ -7,6 +7,7 @@ const GridSection = createWrapperComponent('Grid.Section');
const GridDropdown = createWrapperComponent('Grid.Dropdown');
const GridDropdownItem = createWrapperComponent('Grid.Dropdown.Item');
const GridDropdownSection = createWrapperComponent('Grid.Dropdown.Section');
const GridEmptyView = createSlottedComponent('Grid.EmptyView', ['actions']);
const Inset = {
Small: 'small',
@ -14,11 +15,18 @@ const Inset = {
Large: 'large'
} as const;
const Fit = {
Contain: 'contain',
Fill: 'fill'
} as const;
Object.assign(Grid, {
Section: GridSection,
Item: GridItem,
Dropdown: GridDropdown,
Inset: Inset
EmptyView: GridEmptyView,
Inset,
Fit
});
Object.assign(GridDropdown, {
Item: GridDropdownItem,

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,19 +1,21 @@
<script lang="ts">
import type { UINode } from '$lib/types';
import type { GridItemProps } from '$lib/props';
import GridSection from './GridSection.svelte';
import GridItem from './GridItem.svelte';
import { useGridView } from '$lib/views';
import { useTypedNode } from '$lib/node.svelte';
import { VList, type VListHandle } from 'virtua/svelte';
import NodeRenderer from '../NodeRenderer.svelte';
import { Loader2 } from '@lucide/svelte';
type Props = {
nodeId: number;
uiTree: Map<number, UINode>;
onSelect: (nodeId: number | undefined) => void;
onDispatch: (instanceId: number, handlerName: string, args: unknown[]) => void;
searchText: string;
};
let { nodeId, uiTree, onSelect, searchText }: Props = $props();
let { nodeId, uiTree, onSelect, onDispatch, searchText }: Props = $props();
const { props: gridProps } = $derived.by(useTypedNode(() => ({ nodeId, uiTree, type: 'Grid' })));
@ -21,47 +23,68 @@
nodeId,
uiTree,
onSelect,
columns: gridProps?.columns ?? 6,
gridProps,
searchText,
filtering: gridProps?.filtering,
onSearchTextChange: !!gridProps?.onSearchTextChange,
inset: gridProps?.inset
onDispatch: (handlerName, args) => onDispatch(nodeId, handlerName, args)
}));
let vlist: VListHandle | null = null;
let vlist: VListHandle | null = $state(null);
$effect(() => {
view.vlistInstance = vlist ?? undefined;
});
const showEmptyView = $derived(
!gridProps?.isLoading && view.allItems.length === 0 && !!view.emptyViewNodeId
);
</script>
<svelte:window onkeydown={view.handleKeydown} />
<div class="flex h-full flex-col">
<div class="flex-grow overflow-y-auto px-4">
<VList bind:this={vlist} data={view.virtualListItems} getKey={(item) => item.id} class="h-full">
{#snippet children(item)}
<div class="h-2"></div>
{#if item.type === 'header'}
<GridSection props={item.props} />
{:else if item.type === 'row'}
<div
class="grid content-start gap-x-2.5"
style:grid-template-columns={`repeat(${gridProps?.columns ?? 6}, 1fr)`}
>
{#each item.items as gridItem (gridItem.id)}
{@const flatIndex = view.flatList.findIndex((f) => f.id === gridItem.id)}
<div id="item-{gridItem.id}">
<GridItem
props={gridItem.props as GridItemProps}
selected={view.selectedItemIndex === flatIndex}
onclick={() => view.setSelectedItemIndex(flatIndex)}
inset={gridItem.inset}
/>
</div>
{/each}
</div>
{/if}
{/snippet}
</VList>
<div class="grow overflow-y-auto px-4">
{#if showEmptyView}
<NodeRenderer nodeId={view.emptyViewNodeId!} {uiTree} {onDispatch} />
{:else if gridProps?.isLoading && view.allItems.length === 0}
<div class="flex h-full items-center justify-center">
<Loader2 class="size-6 animate-spin text-gray-500" />
</div>
{:else}
<VList
bind:this={vlist}
data={view.virtualListItems}
getKey={(item) => item.id}
class="h-full"
onscroll={view.onScroll}
>
{#snippet children(item)}
<div class="h-2"></div>
{#if item.type === 'header'}
<GridSection props={item.props} />
{:else if item.type === 'row'}
{@const { columns, ...styling } = item.styling}
<div
class="grid content-start gap-x-2.5"
style:grid-template-columns={`repeat(${columns}, 1fr)`}
>
{#each item.items as gridItem (gridItem.id)}
{@const flatIndex = view.allItems.findIndex((f) => f.id === gridItem.id)}
<div id="item-{gridItem.id}">
<GridItem
props={gridItem.props}
selected={view.selectedIndex === flatIndex}
onclick={() => view.setSelectedIndex(flatIndex)}
inset={styling.inset}
fit={styling.fit}
aspectRatio={styling.aspectRatio}
/>
</div>
{/each}
</div>
{:else if item.type === 'placeholder'}
<div class="aspect-square w-full animate-pulse rounded-md bg-white/5"></div>
{/if}
{/snippet}
</VList>
{/if}
</div>
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import type { UINode } from '$lib/types';
import { useTypedNode } from '$lib/node.svelte';
import Icon from '$lib/components/Icon.svelte';
import defaultIcon from '$lib/assets/no-results-placeholder-400100x78@2x.png';
type Props = {
nodeId: number;
uiTree: Map<number, UINode>;
};
let { nodeId, uiTree }: Props = $props();
const { props: componentProps } = $derived.by(
useTypedNode(() => ({
nodeId,
uiTree,
type: 'Grid.EmptyView'
}))
);
</script>
{#if componentProps}
<div class="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
{#if componentProps.icon}
<Icon icon={componentProps.icon} class="size-32 opacity-50" />
{:else}
<img src={defaultIcon} class="w-32" alt="No results" />
{/if}
<h2 class="text-lg font-medium">{componentProps.title}</h2>
{#if componentProps.description}
<p class="text-muted-foreground max-w-sm text-sm">
{componentProps.description}
</p>
{/if}
</div>
{/if}

View file

@ -1,44 +1,77 @@
<script lang="ts">
import type { GridItemProps } from '$lib/props';
import type { GridItemProps, GridInset, GridFit } from '$lib/props';
import type { HTMLButtonAttributes } from 'svelte/elements';
import { cn } from '$lib/utils';
import type { GridInset } from '$lib/props/grid';
import Icon from '../Icon.svelte';
type Props = {
props: GridItemProps;
selected: boolean;
inset?: GridInset;
fit?: GridFit;
aspectRatio?: string;
} & HTMLButtonAttributes;
let { props, selected, inset, ...restProps }: Props = $props();
let { props, selected, inset, fit, aspectRatio, ...restProps }: Props = $props();
const paddingClass = $derived(() => {
const paddingClass = $derived.by(() => {
switch (inset) {
case 'small':
return 'p-1';
return 'p-1.5';
case 'medium':
return 'p-2';
return 'p-2.5';
case 'large':
return 'p-4';
default:
return 'px-4 py-2';
return 'p-1';
}
});
const content = $derived(
typeof props.content === 'object' && 'value' in props.content
? props.content.value
: props.content
);
const tooltip = $derived(
typeof props.content === 'object' && 'tooltip' in props.content
? props.content.tooltip
: undefined
);
</script>
<button type="button" class={cn('flex w-full flex-col text-left', paddingClass)} {...restProps}>
<button
type="button"
class={cn('flex w-full flex-col text-left focus:outline-none', paddingClass)}
{...restProps}
>
<div
class="hover:border-foreground/50 bg-muted mb-1 aspect-square w-full rounded-md border-2 border-transparent"
class:!border-foreground={selected}
class="hover:border-foreground/50 bg-muted mb-1 w-full overflow-hidden rounded-md border-2 {selected
? 'border-foreground'
: 'border-transparent'}"
class:border-transparent={!selected}
style:aspect-ratio={aspectRatio ?? '1'}
title={tooltip}
>
<Icon icon={props.content} class="size-full" />
<Icon icon={content} class="size-full" style="object-fit: {fit ?? 'contain'}" />
</div>
{#if props.title}
<span class="text-sm font-medium">{props.title}</span>
<span class="truncate text-sm font-medium">{props.title}</span>
{/if}
{#if props.subtitle}
<span class="text-muted-foreground text-sm">{props.subtitle}</span>
<span class="text-muted-foreground truncate text-xs">{props.subtitle}</span>
{/if}
{#if props.accessory}
<div
class="text-muted-foreground mt-0.5 flex items-center gap-1 text-xs"
title={props.accessory.tooltip}
>
{#if props.accessory.icon}
<Icon icon={props.accessory.icon} class="size-3" />
{/if}
{#if props.accessory.text}
<span class="truncate">{props.accessory.text}</span>
{/if}
</div>
{/if}
</button>

View file

@ -1,13 +1,18 @@
<script lang="ts">
import type { ViewSectionProps } from '$lib/props';
import type { GridSectionProps } from '$lib/props';
type Props = {
props: ViewSectionProps;
props: GridSectionProps;
};
let { props }: Props = $props();
</script>
<h3 class="col-start-1 -col-end-1 -mb-1 pt-2.5 text-xs font-semibold text-gray-500 uppercase">
{props.title}
</h3>
<div class="col-start-1 -col-end-1 -mb-1 flex items-baseline gap-2 pt-2.5">
<h3 class="text-xs font-semibold text-gray-500 uppercase">
{props.title}
</h3>
{#if props.subtitle}
<p class="text-muted-foreground truncate text-xs">{props.subtitle}</p>
{/if}
</div>

View file

@ -44,7 +44,7 @@
$effect(() => {
if (componentProps && !isInitialized) {
const initial = componentProps.defaultValue;
const initial = componentProps.defaultValue ?? componentProps.value;
if (initial !== undefined) {
internalValue = initial;
} else if (firstItemValue !== undefined) {

View file

@ -17,6 +17,7 @@ import FormLinkAccessory from '$lib/components/nodes/form/LinkAccessory.svelte';
import AccessoryDropdown from '$lib/components/nodes/shared/AccessoryDropdown.svelte';
import DropdownItem from '$lib/components/nodes/shared/DropdownItem.svelte';
import DropdownSection from '$lib/components/nodes/shared/DropdownSection.svelte';
import GridEmptyView from '$lib/components/nodes/GridEmptyView.svelte';
export const componentMap = new Map<
string,
@ -36,6 +37,7 @@ export const componentMap = new Map<
['Grid.Dropdown', AccessoryDropdown],
['Grid.Dropdown.Section', DropdownSection],
['Grid.Dropdown.Item', DropdownItem],
['Grid.EmptyView', GridEmptyView],
['List.Dropdown', AccessoryDropdown],
['List.Dropdown.Item', DropdownItem],
['List.Dropdown.Section', DropdownSection],

View file

@ -1,24 +1,82 @@
import { ImageLikeSchema } from '@raycast-linux/protocol';
import { z } from 'zod/v4';
import { ColorLikeSchema } from './color';
export const GridFitSchema = z.enum(['contain', 'fill']);
export type GridFit = z.infer<typeof GridFitSchema>;
export const GridInsetSchema = z.enum(['small', 'medium', 'large']);
export type GridInset = z.infer<typeof GridInsetSchema>;
export const GridPropsSchema = z.object({
filtering: z.boolean().optional(),
filtering: z.union([z.boolean(), z.object({ keepSectionOrder: z.boolean() })]).optional(),
throttle: z.boolean().default(false),
columns: z.number().default(6),
searchBarPlaceholder: z.string().optional(),
onSearchTextChange: z.boolean().optional(),
onSelectionChange: z.boolean().optional(),
isLoading: z.boolean().default(false),
inset: GridInsetSchema.optional()
inset: GridInsetSchema.optional(),
aspectRatio: z.enum(['1', '3/2', '2/3', '4/3', '3/4', '16/9', '9/16']).optional(),
fit: GridFitSchema.optional(),
pagination: z
.object({
hasMore: z.boolean(),
onLoadMore: z.boolean(),
pageSize: z.number()
})
.optional(),
selectedItemId: z.string().optional()
});
export type GridProps = z.infer<typeof GridPropsSchema>;
const GridItemContentValueSchema = z.union([
ImageLikeSchema,
z.object({
color: ColorLikeSchema
})
]);
export const GridItemContentSchema = z.union([
GridItemContentValueSchema,
z.object({
tooltip: z.string(),
value: GridItemContentValueSchema
})
]);
export type GridItemContent = z.infer<typeof GridItemContentSchema>;
export const GridItemAccessorySchema = z.object({
icon: ImageLikeSchema.optional(),
text: z.string().optional(),
tooltip: z.string().optional()
});
export type GridItemAccessory = z.infer<typeof GridItemAccessorySchema>;
export const GridItemPropsSchema = z.object({
content: ImageLikeSchema,
id: z.string().optional(),
content: GridItemContentSchema,
title: z.string().optional(),
subtitle: z.string().optional(),
keywords: z.array(z.string())
keywords: z.array(z.string()).optional(),
accessory: GridItemAccessorySchema.optional(),
quickLook: z.object({ name: z.string().optional(), path: z.string() }).optional()
});
export type GridItemProps = z.infer<typeof GridItemPropsSchema>;
export const GridSectionPropsSchema = z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
columns: z.number().optional(),
aspectRatio: z.enum(['1', '3/2', '2/3', '4/3', '3/4', '16/9', '9/16']).optional(),
fit: GridFitSchema.optional(),
inset: GridInsetSchema.optional()
});
export type GridSectionProps = z.infer<typeof GridSectionPropsSchema>;
export const GridEmptyViewPropsSchema = z.object({
title: z.string(),
description: z.string().optional(),
icon: ImageLikeSchema.optional().catch(undefined)
});
export type GridEmptyViewProps = z.infer<typeof GridEmptyViewPropsSchema>;

View file

@ -18,7 +18,12 @@ import {
DetailMetadataTagListItemPropsSchema,
DetailMetadataSeparatorPropsSchema
} from './detail';
import { GridPropsSchema, GridItemPropsSchema } from './grid';
import {
GridPropsSchema,
GridItemPropsSchema,
GridSectionPropsSchema,
GridEmptyViewPropsSchema
} from './grid';
import { ViewSectionPropsSchema } from './section';
import {
FormPropsSchema,
@ -73,8 +78,9 @@ export const componentSchemas = {
'List.Item.Detail.Metadata.Separator': DetailMetadataSeparatorPropsSchema,
Grid: GridPropsSchema,
'Grid.Section': ViewSectionPropsSchema,
'Grid.Section': GridSectionPropsSchema,
'Grid.Item': GridItemPropsSchema,
'Grid.EmptyView': GridEmptyViewPropsSchema,
'Grid.Dropdown': DropdownPropsSchema,
'Grid.Dropdown.Section': DropdownSectionPropsSchema,
'Grid.Dropdown.Item': DropdownItemPropsSchema,
@ -103,8 +109,7 @@ export type ComponentType = keyof Schemas;
export function getTypedProps<T extends ComponentType>(
node: UINode & { type: T }
): z.infer<Schemas[T]> | null {
const schema = componentSchemas[node.type];
const result = schema.safeParse(node.props);
const result = (componentSchemas[node.type] as z.ZodTypeAny).safeParse(node.props);
if (!result.success) {
console.error(
`[Props Validation Error] For node ${node.id} (type: ${node.type}):`,

View file

@ -1,196 +1,367 @@
import { _useBaseView, type BaseViewArgs } from './base.svelte';
import type { GridInset, ViewSectionProps, GridItemProps } from '$lib/props';
import { type BaseViewArgs, type FlatViewItem } from './base.svelte';
import type { GridInset, GridFit, GridSectionProps, GridItemProps, GridProps } from '$lib/props';
import { focusManager } from '$lib/focus.svelte';
import type { VListHandle } from 'virtua/svelte';
import type { UINode } from '$lib/types';
import Fuse from 'fuse.js';
type GridViewItem = FlatViewItem & {
props: GridItemProps;
sectionProps: GridSectionProps & GridProps;
};
export type VirtualGridItem =
| { id: string | number; type: 'header'; props: ViewSectionProps }
| { id: string | number; type: 'header'; props: GridSectionProps }
| {
id: string;
type: 'row';
items: { id: number; type: 'item'; props: GridItemProps; inset?: GridInset }[];
};
items: GridViewItem[];
styling: {
columns: number;
aspectRatio?: string;
fit?: GridFit;
inset?: GridInset;
};
}
| { id: number; type: 'placeholder' };
export function useGridView(args: () => BaseViewArgs & { columns: number; inset?: GridInset }) {
const base = _useBaseView(args, 'Grid.Item');
const { columns, inset: gridInset } = $derived.by(args);
type GridViewArgs = BaseViewArgs & {
gridProps: GridProps | null;
onDispatch: (handlerName: string, args: unknown[]) => void;
};
const processedFlatList = $derived.by(() => {
const list = base.flatList;
const newList: (typeof list)[number][] = [];
let currentSectionInset: GridInset | undefined;
function filterItems(items: GridViewItem[], searchText: string): GridViewItem[] {
if (!searchText.trim()) return items;
const fuse = new Fuse(items, {
keys: ['props.title', 'props.subtitle', 'props.keywords'],
threshold: 0.4,
includeScore: true
});
return fuse.search(searchText).map((result) => result.item);
}
for (const item of list) {
if (item.type === 'header') {
const sectionProps = item.props as ViewSectionProps;
if (item.id === -1) {
currentSectionInset = gridInset;
} else {
currentSectionInset = sectionProps.inset;
export function useGridView(args: () => GridViewArgs) {
const { nodeId, uiTree, onSelect, searchText, gridProps, onDispatch } = $derived.by(args);
const isFilteringEnabled = $derived(
gridProps?.filtering === true ||
(gridProps?.filtering !== false && !gridProps?.onSearchTextChange)
);
const { allItems, emptyViewNodeId } = $derived.by(() => {
const root = uiTree.get(nodeId);
const items: GridViewItem[] = [];
let emptyViewNodeId: number | undefined;
if (!root) return { allItems: items, emptyViewNodeId };
const defaultSectionProps = { ...gridProps, title: undefined, subtitle: undefined };
const processSection = (sectionNode: UINode, sectionPropsOverride?: Partial<GridProps>) => {
const rawSectionProps = {
...defaultSectionProps,
...(sectionNode.props as GridSectionProps),
...sectionPropsOverride
};
const currentSectionItems: GridViewItem[] = [];
for (const itemId of sectionNode.children) {
const itemNode = uiTree.get(itemId);
if (itemNode?.type === 'Grid.Item') {
currentSectionItems.push({
id: itemNode.id,
type: 'item',
props: itemNode.props as GridItemProps,
sectionProps: rawSectionProps
});
}
newList.push(item);
} else {
newList.push({ ...item, inset: currentSectionInset });
}
return { props: rawSectionProps, items: currentSectionItems };
};
const sections: ReturnType<typeof processSection>[] = [];
const topLevelItems: UINode[] = [];
for (const childId of root.children) {
const childNode = uiTree.get(childId);
if (!childNode) continue;
if (childNode.type === 'Grid.Section') {
sections.push(processSection(childNode));
} else if (childNode.type === 'Grid.Item') {
topLevelItems.push(childNode);
} else if (childNode.type === 'Grid.EmptyView') {
emptyViewNodeId = childNode.id;
}
}
return newList;
if (topLevelItems.length > 0) {
sections.unshift(processSection({ ...root, children: topLevelItems.map((i) => i.id) }));
}
for (const section of sections) {
if (isFilteringEnabled) {
const filtered = searchText ? filterItems(section.items, searchText) : section.items;
if (filtered.length > 0) {
items.push(...filtered);
}
} else {
items.push(...section.items);
}
}
return { allItems: items, emptyViewNodeId };
});
let selectedIndex = $state(-1);
const virtualListItems = $derived.by((): VirtualGridItem[] => {
const list: VirtualGridItem[] = [];
let currentRow: (typeof processedFlatList)[number][] = [];
const result: VirtualGridItem[] = [];
if (allItems.length === 0) return result;
for (const item of processedFlatList) {
if (item.type === 'header') {
if (currentRow.length > 0) {
list.push({ id: `row-${list.length}`, type: 'row', items: currentRow });
currentRow = [];
let lastSectionTitle: string | undefined = undefined;
let currentRow: GridViewItem[] = [];
let currentSectionProps: (typeof allItems)[0]['sectionProps'] | undefined;
for (const item of allItems) {
const itemSectionTitle = item.sectionProps.title;
if (itemSectionTitle !== lastSectionTitle) {
if (currentRow.length > 0 && currentSectionProps) {
result.push({
id: `row-${result.length}`,
type: 'row',
items: currentRow,
styling: {
columns: currentSectionProps.columns ?? 6,
aspectRatio: currentSectionProps.aspectRatio,
fit: currentSectionProps.fit,
inset: currentSectionProps.inset
}
});
}
list.push({ id: `header-${item.id}`, type: 'header', props: item.props });
} else if (item.type === 'item') {
currentRow = [];
if (itemSectionTitle) {
result.push({
id: `header-${itemSectionTitle}`,
type: 'header',
props: item.sectionProps
});
}
lastSectionTitle = itemSectionTitle;
currentSectionProps = item.sectionProps;
}
if (currentSectionProps) {
currentRow.push(item);
if (currentRow.length === columns) {
list.push({ id: `row-${list.length}`, type: 'row', items: currentRow });
if (currentRow.length === (currentSectionProps.columns ?? 6)) {
result.push({
id: `row-${result.length}`,
type: 'row',
items: currentRow,
styling: {
columns: currentSectionProps.columns ?? 6,
aspectRatio: currentSectionProps.aspectRatio,
fit: currentSectionProps.fit,
inset: currentSectionProps.inset
}
});
currentRow = [];
}
}
}
if (currentRow.length > 0) {
list.push({ id: `row-${list.length}`, type: 'row', items: currentRow });
}
return list;
});
const flatIndexToVirtualRowIndexMap = $derived.by(() => {
const map = new Map<number, number>();
virtualListItems.forEach((vItem, vIndex) => {
if (vItem.type === 'row') {
vItem.items.forEach((item) => {
const flatIndex = processedFlatList.findIndex((f) => f.id === item.id);
if (flatIndex !== -1) {
map.set(flatIndex, vIndex);
}
});
if (currentRow.length > 0 && currentSectionProps) {
result.push({
id: `row-${result.length}`,
type: 'row',
items: currentRow,
styling: {
columns: currentSectionProps.columns ?? 6,
aspectRatio: currentSectionProps.aspectRatio,
fit: currentSectionProps.fit,
inset: currentSectionProps.inset
}
});
}
if (gridProps?.pagination?.hasMore && gridProps.pagination.pageSize > 0) {
for (let i = 0; i < gridProps.pagination.pageSize; i++) {
result.push({ id: i, type: 'placeholder' });
}
});
return map;
}
return result;
});
let vlistInstance = $state<VListHandle | undefined>();
$effect(() => {
if (base.selectedItemIndex >= 0 && vlistInstance) {
const virtualRowIndex = flatIndexToVirtualRowIndexMap.get(base.selectedItemIndex);
if (virtualRowIndex !== undefined) {
vlistInstance.scrollToIndex(virtualRowIndex, { align: 'nearest' });
}
}
});
const gridMap: {
flatListIndex: number;
sectionIndex: number;
rowIndex: number;
colIndex: number;
}[] = $derived.by(() => {
const newGridMap: {
flatListIndex: number;
sectionIndex: number;
rowIndex: number;
colIndex: number;
}[] = [];
let sectionIndex = -1,
rowIndex = 0,
colIndex = 0;
processedFlatList.forEach((item, index) => {
if (item.type === 'header') {
const newGridMap: (typeof gridMap)[0][] = [];
let sectionIndex = -1;
let lastSectionTitle: string | undefined = undefined;
let rowIndex = -1;
let colIndex = 0;
allItems.forEach((item, index) => {
const itemSectionTitle = item.sectionProps.title;
const columns = item.sectionProps.columns ?? 6;
if (itemSectionTitle !== lastSectionTitle) {
sectionIndex++;
rowIndex = 0;
lastSectionTitle = itemSectionTitle;
rowIndex = -1;
colIndex = 0;
} else if (item.type === 'item') {
if (colIndex === 0 && newGridMap.length > 0) rowIndex++;
newGridMap.push({ flatListIndex: index, sectionIndex, rowIndex, colIndex });
colIndex = (colIndex + 1) % columns;
}
if (colIndex % columns === 0) {
rowIndex++;
colIndex = 0;
}
newGridMap.push({ flatListIndex: index, sectionIndex, rowIndex, colIndex });
colIndex++;
});
return newGridMap;
});
$effect(() => {
if (gridProps?.onSelectionChange) {
onDispatch('onSelectionChange', [allItems[selectedIndex]?.props.id ?? null]);
}
if (allItems.length === 0 && emptyViewNodeId) {
onSelect(emptyViewNodeId);
} else {
onSelect(allItems[selectedIndex]?.id);
}
});
$effect(() => {
// Programmatic selection
const targetId = gridProps?.selectedItemId;
if (targetId) {
const index = allItems.findIndex((item) => item.props.id === targetId);
if (index !== -1 && index !== selectedIndex) {
selectedIndex = index;
}
} else {
// Default selection
if (selectedIndex < 0 && allItems.length > 0) {
selectedIndex = 0;
}
}
});
$effect(() => {
if (selectedIndex >= allItems.length) {
selectedIndex = allItems.length > 0 ? 0 : -1;
}
});
const handleKeydown = (event: KeyboardEvent) => {
if (focusManager.activeScope !== 'main-input') {
if (focusManager.activeScope !== 'main-input' || allItems.length === 0) {
return;
}
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
const currentGridIndex = gridMap.findIndex(
(item) => item.flatListIndex === base.selectedItemIndex
);
const currentGridIndex = gridMap.findIndex((item) => item.flatListIndex === selectedIndex);
if (currentGridIndex === -1) {
if (gridMap.length > 0) base.selectedItemIndex = gridMap[0].flatListIndex;
if (gridMap.length > 0) selectedIndex = gridMap[0].flatListIndex;
return;
}
let newGridIndex = -1;
let newIndex = -1;
const currentPos = gridMap[currentGridIndex];
if (event.key === 'ArrowLeft') {
newGridIndex = Math.max(0, currentGridIndex - 1);
} else if (event.key === 'ArrowRight') {
newGridIndex = Math.min(gridMap.length - 1, currentGridIndex + 1);
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const direction = event.key === 'ArrowDown' ? 1 : -1;
const targetRowIndex = currentPos.rowIndex + direction;
const itemsInSameSection = gridMap.filter(
(item) => item.sectionIndex === currentPos.sectionIndex
);
let itemsInTargetRow = itemsInSameSection.filter((item) => item.rowIndex === targetRowIndex);
switch (event.key) {
case 'ArrowLeft':
newIndex = Math.max(0, selectedIndex - 1);
break;
case 'ArrowRight':
newIndex = Math.min(allItems.length - 1, selectedIndex + 1);
break;
case 'ArrowUp':
case 'ArrowDown': {
const direction = event.key === 'ArrowDown' ? 1 : -1;
let targetRow = currentPos.rowIndex + direction;
let targetSection = currentPos.sectionIndex;
if (itemsInTargetRow.length === 0) {
const targetSectionIndex = currentPos.sectionIndex + direction;
const itemsInTargetSection = gridMap.filter(
(item) => item.sectionIndex === targetSectionIndex
);
if (itemsInTargetSection.length > 0) {
const rows = [...new Set(itemsInTargetSection.map((i) => i.rowIndex))].sort(
(a, b) => a - b
);
itemsInTargetRow = itemsInTargetSection.filter(
(i) => i.rowIndex === (direction === 1 ? rows[0] : rows.at(-1))
while (newIndex === -1) {
const targetRowItems = gridMap.filter(
(p) => p.sectionIndex === targetSection && p.rowIndex === targetRow
);
if (targetRowItems.length > 0) {
const targetItem =
targetRowItems.find((p) => p.colIndex === currentPos.colIndex) ??
targetRowItems.at(-1)!;
newIndex = targetItem.flatListIndex;
break;
}
targetSection += direction;
const sections = [...new Set(gridMap.map((p) => p.sectionIndex))].sort((a, b) => a - b);
if (targetSection < sections[0] || targetSection > sections.at(-1)!) {
break;
}
const rowsInNewSection = [
...new Set(
gridMap.filter((p) => p.sectionIndex === targetSection).map((p) => p.rowIndex)
)
].sort((a, b) => a - b);
targetRow = direction === 1 ? rowsInNewSection[0] : rowsInNewSection.at(-1)!;
}
}
if (itemsInTargetRow.length > 0) {
const targetItem =
itemsInTargetRow.find((item) => item.colIndex === currentPos.colIndex) ??
itemsInTargetRow.at(-1)!;
newGridIndex = gridMap.indexOf(targetItem);
break;
}
}
if (newGridIndex !== -1) {
base.selectedItemIndex = gridMap[newGridIndex].flatListIndex;
if (newIndex !== -1) {
selectedIndex = newIndex;
const virtualRow = virtualListItems.findIndex((v) => {
if (v.type !== 'row') return false;
return v.items.some((i) => i.id === allItems[newIndex].id);
});
if (virtualRow !== -1) {
vlistInstance?.scrollToIndex(virtualRow, { align: 'nearest' });
}
}
};
const onScroll = (offset: number) => {
if (!vlistInstance || !gridProps?.pagination || !gridProps.pagination.hasMore) return;
if (
vlistInstance.getScrollSize() - offset - vlistInstance.getViewportSize() < 300 &&
gridProps.pagination.onLoadMore
) {
onDispatch('onLoadMore', []);
}
};
return {
get flatList() {
return processedFlatList;
get allItems() {
return allItems;
},
get virtualListItems() {
return virtualListItems;
},
get selectedItemIndex() {
return base.selectedItemIndex;
get selectedIndex() {
return selectedIndex;
},
setSelectedItemIndex: (index: number) => {
base.selectedItemIndex = index;
setSelectedIndex(index: number) {
selectedIndex = index;
},
get emptyViewNodeId() {
return emptyViewNodeId;
},
set vlistInstance(instance: VListHandle | undefined) {
vlistInstance = instance;
},
handleKeydown
handleKeydown,
onScroll
};
}