Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"dependencies": {
"@maplibre/maplibre-gl-geocoder": "1.9.1",
"@maplibre/maplibre-gl-inspect": "1.7.1",
"fuse.js": "^7.4.2",
"maplibre-gl": "5.20.0",
"svelte-select": "5.8.3"
},
Expand Down
89 changes: 89 additions & 0 deletions components/src/Select/Select.stories.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
await userEvent.click(clearButton);
expect(selectedItem).toEqual(undefined);
});

await step('The default empty message is shown when nothing matches', async () => {
await userEvent.type(select, 'xyz');
expect(canvas.getByText('Keine Treffer')).toBeInTheDocument();
});
}}
>
<StoryTemplate
Expand All @@ -61,6 +66,33 @@
/>
</Story>

<Story
name="Custom empty message"
asChild
play={async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const select = canvas.getByLabelText('Select');

await step('The custom empty message is shown when nothing matches', async () => {
await userEvent.type(select, 'Pizza');
expect(canvas.getByText('Keine passenden Eissorten')).toBeInTheDocument();
});
}}
>
<StoryTemplate
bind:selectedItem
args={{
inputId: 'select',
emptyText: 'Keine passenden Eissorten',
items: [
{ value: 'vanilla', label: 'Vanille' },
{ value: 'chocolate', label: 'Schokolade' },
{ value: 'strawberry', label: 'Erdbeere' }
]
}}
/>
</Story>

<Story name="Grouped" asChild>
<StoryTemplate
bind:selectedItem
Expand Down Expand Up @@ -183,6 +215,63 @@
</StoryTemplate>
</Story>

<Story
name="Fuzzy (custom items)"
asChild
play={async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const select = canvas.getByLabelText('Berufe');

await step('An exact title still selects that item with fuzzy enabled', async () => {
await userEvent.type(select, 'Tierpflege{enter}');
expect(selectedItem?.details.title).toEqual('Tierpflege');
});

await step('A typo in the title still matches the closest item', async () => {
await userEvent.type(select, 'Journlismus{enter}');
expect(selectedItem?.details.title).toEqual('Journalismus');
});

await step('A typo in the add-on still matches the closest item', async () => {
await userEvent.type(select, 'Hundetrener{enter}');
expect(selectedItem?.details.title).toEqual('Tierpflege');
});
}}
>
<StoryTemplate>
{#snippet demoComponent()}
<FormLabel htmlFor="job-select">Berufe</FormLabel>
<Select
bind:value={selectedItem}
inputId="job-select"
placeholder="z.B. Taxifahrer/in"
fuzzy
items={jobsData
.sort((a, b) => a.label.localeCompare(b.label))
.map((item) => ({
value: item.value,
label: `${item.label}: ${item.add_on}`, // used for filtering
details: {
title: item.label, // used for display
addon: item.add_on // used for display
}
}))}
groupHeaderSelectable={false}
>
<div slot="item" let:item class="custom-item">
<h4 class="custom-item-title" data-testid="custom-item-title">
{item.details.title}
</h4>
<p class="custom-item-addon" data-testid="custom-item-addon">{item.details.addon}</p>
</div>
<div slot="selection" let:selection class="selection">
{selection.details.title}
</div>
</Select>
{/snippet}
</StoryTemplate>
</Story>

<style>
.custom-item {
font-size: 0.9rem;
Expand Down
56 changes: 56 additions & 0 deletions components/src/Select/Select.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import Select from 'svelte-select';
import Fuse from 'fuse.js';
import { type SelectItem } from './Select.types';

interface SelectProps {
Expand All @@ -24,6 +25,21 @@
*/
groupHeaderSelectable?: boolean;
clearable?: boolean;
/**
* Enable fuzzy matching of the filter text against item labels (powered by Fuse.js).
* When disabled (default), items are filtered with a plain case-insensitive substring match.
*/
fuzzy?: boolean;
/**
* Fuse.js match tolerance (0 = exact, 1 = match anything). Lower is stricter.
* Tune per dataset: short labels usually want a lower value than long, wordy ones.
* Only applies when `fuzzy` is enabled.
*/
fuzzyThreshold?: number;
/**
* Message shown in the dropdown when no items match the filter.
*/
emptyText?: string;
value: SelectItem | undefined;
}

Expand All @@ -34,10 +50,38 @@
groupBy,
groupHeaderSelectable = false,
clearable = true,
fuzzy = false,
fuzzyThreshold = 0.35,
emptyText = 'Keine Treffer',
value = $bindable(undefined)
}: SelectProps = $props();

const groupByFn = $derived(groupBy || ((item: SelectItem) => item.group as string));

// Index is rebuilt only when the option list or threshold changes, not on every keystroke.
const fuse = $derived(
fuzzy
? new Fuse(items, { keys: ['label'], threshold: fuzzyThreshold, ignoreLocation: true })
: null
);

// Replaces svelte-select's default substring matching with Fuse ranking
// (svelte-select handling of grouping untouched).
function fuzzyFilter({
filterText,
items: filterItems,
groupBy: groupByKey,
filterGroupedItems
}: {
filterText: string;
items: SelectItem[];
groupBy: unknown;
filterGroupedItems: (items: SelectItem[]) => SelectItem[];
}) {
const matched =
filterText && fuse ? fuse.search(filterText).map((result) => result.item) : filterItems;
return groupByKey ? filterGroupedItems(matched) : matched;
}
</script>

<div class="container">
Expand All @@ -48,6 +92,7 @@
{placeholder}
{groupHeaderSelectable}
{clearable}
filter={fuzzy ? fuzzyFilter : undefined}
class="container"
bind:value
>
Expand All @@ -61,6 +106,11 @@
{selection.label}
</slot>
</div>
<div class="empty" slot="empty">
<slot name="empty">
{emptyText}
</slot>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using slot is deprecated in Svelte 5, but I think there's no way around it with svelte-select, because it does not support @render (yet?).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are also using slot already elsewhere, so I guess this is fine!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think we could adjust that everywhere in the future, didn't touch it for now

</Select>
</div>

Expand All @@ -78,4 +128,10 @@
--item-hover-color: var(--color-textPrimary);
--selected-item-color: var(--color-textPrimary);
}

.empty {
text-align: var(--list-empty-text-align, center);
padding: var(--list-empty-padding, 20px 0);
color: var(--list-empty-color, #78848f);
}
</style>
1 change: 1 addition & 0 deletions mock-sveltekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@maplibre/maplibre-gl-geocoder": "1.9.1",
"@maplibre/maplibre-gl-inspect": "1.7.1",
"fuse.js": "7.4.2",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

"maplibre-gl": "5.20.0",
"svelte-select": "5.8.3"
},
Expand Down
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading