feat(web): add AlphabeticalGrid wrapper with letter dividers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts" generics="T">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
getKey,
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
items: T[];
|
||||||
|
getKey: (it: T) => string;
|
||||||
|
item: Snippet<[T]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Compute the divider letter for each item; if it differs from the
|
||||||
|
// previous item's letter, that index gets a divider above it.
|
||||||
|
const segments = $derived.by(() => {
|
||||||
|
const out: { divider: string | null; item: T }[] = [];
|
||||||
|
let last: string | null = null;
|
||||||
|
for (const it of items) {
|
||||||
|
const k = (getKey(it) || '').charAt(0).toUpperCase();
|
||||||
|
if (k !== last) {
|
||||||
|
out.push({ divider: k, item: it });
|
||||||
|
last = k;
|
||||||
|
} else {
|
||||||
|
out.push({ divider: null, item: it });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="alpha-grid">
|
||||||
|
{#each segments as seg, i (i)}
|
||||||
|
{#if seg.divider}
|
||||||
|
<div class="divider">
|
||||||
|
<span class="letter">{seg.divider}</span>
|
||||||
|
<span class="rule"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="cell">
|
||||||
|
{@render item(seg.item)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.alpha-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.letter {
|
||||||
|
font-family: var(--fs-font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fs-parchment);
|
||||||
|
}
|
||||||
|
.rule {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--fs-pewter);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { createRawSnippet } from 'svelte';
|
||||||
|
import AlphabeticalGrid from './AlphabeticalGrid.svelte';
|
||||||
|
|
||||||
|
type Item = { id: string; key: string; label: string };
|
||||||
|
|
||||||
|
const items: Item[] = [
|
||||||
|
{ id: '1', key: 'A', label: 'Apple' },
|
||||||
|
{ id: '2', key: 'A', label: 'Avocado' },
|
||||||
|
{ id: '3', key: 'B', label: 'Banana' },
|
||||||
|
{ id: '4', key: 'D', label: 'Date' } // skips C — divider must jump to D directly
|
||||||
|
];
|
||||||
|
|
||||||
|
// createRawSnippet<Params> receives getters: each param is () => ParamType
|
||||||
|
const itemSnippet = createRawSnippet<[Item]>((getIt) => ({
|
||||||
|
render: () => `<span>${getIt().label}</span>`
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The generic component's T parameter can't be inferred by testing-library's render,
|
||||||
|
// so we cast props to avoid a spurious unknown-assignability error.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyProps = any;
|
||||||
|
|
||||||
|
describe('AlphabeticalGrid', () => {
|
||||||
|
test('inserts a divider for each new key letter', () => {
|
||||||
|
render(AlphabeticalGrid, {
|
||||||
|
props: {
|
||||||
|
items,
|
||||||
|
getKey: (it: Item) => it.key,
|
||||||
|
item: itemSnippet
|
||||||
|
} as AnyProps
|
||||||
|
});
|
||||||
|
// Three dividers (A, B, D); C is absent because no items use C.
|
||||||
|
expect(screen.getByText('A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('B')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('D')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders items in given order', () => {
|
||||||
|
const { container } = render(AlphabeticalGrid, {
|
||||||
|
props: {
|
||||||
|
items,
|
||||||
|
getKey: (it: Item) => it.key,
|
||||||
|
item: itemSnippet
|
||||||
|
} as AnyProps
|
||||||
|
});
|
||||||
|
expect(container.textContent).toMatch(/A.*Apple.*Avocado.*B.*Banana.*D.*Date/s);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user