mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-12-23 10:11:57 +00:00
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:
parent
ca7c920567
commit
854786f0bb
11 changed files with 522 additions and 181 deletions
|
|
@ -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,
|
||||
|
|
|
|||
BIN
src/lib/assets/no-results-placeholder-400100x78@2x.png
Normal file
BIN
src/lib/assets/no-results-placeholder-400100x78@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
36
src/lib/components/nodes/GridEmptyView.svelte
Normal file
36
src/lib/components/nodes/GridEmptyView.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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}):`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue