Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Combobox / Command not showing Command.Items correctly when source data changes #1562

Open
baronyoung opened this issue Dec 18, 2024 · 5 comments

Comments

@baronyoung
Copy link

baronyoung commented Dec 18, 2024

Describe the bug

Using the demo code for Combobox, if you change the values contained within the 'frameworks' array (by doing a fetch after typing some characters for example), the Command.Items will not display correctly. Oddly, you can see it make space for them but the text won't display even if I try changing the text colors. My actual use case is a stock search which waits for at least one letter to be typed before searching for a list of stock symbols starting with that letter. I can't get anything to display unless the data in the #each block is present when the component mounts.

Reproduction

I tried the above repo link but it builds a Svelte 4 site, which won't work. This should be very easy to replicate, just add a setInterval to add a frameworks object every 5 secs or something.

Logs

No response

System Info

System:
    OS: Linux 6.8 Ubuntu 24.04.1 LTS 24.04.1 LTS (Noble Numbat)
    CPU: (24) x64 12th Gen Intel(R) Core(TM) i9-12900K
    Memory: 45.27 GB / 62.58 GB
    Container: Yes
    Shell: 5.9 - /usr/bin/zsh
  Binaries:
    Node: 22.6.0 - ~/.nvm/versions/node/v22.6.0/bin/node
    npm: 10.8.2 - ~/.nvm/versions/node/v22.6.0/bin/npm
    bun: 1.1.21 - ~/.bun/bin/bun
  Managers:
    Apt: 2.7.14 - /usr/bin/apt
    Cargo: 1.80.1 - ~/.cargo/bin/cargo
    pip3: 24.0 - /usr/bin/pip3
  Utilities:
    CMake: 3.28.3 - /usr/bin/cmake
    Make: 4.3 - /usr/bin/make
    GCC: 13.3.0 - /usr/bin/gcc
    Git: 2.43.0 - /usr/bin/git
    Curl: 8.5.0 - /usr/bin/curl
    OpenSSL: 3.0.13 - /usr/bin/openssl
  Virtualization:
    Docker: 27.3.1 - /usr/local/bin/docker
  IDEs:
    Nano: 7.2 - /usr/bin/nano
  Languages:
    Bash: 5.2.21 - /usr/bin/bash
    Go: 1.22.2 - /usr/bin/go
    Perl: 5.38.2 - /usr/bin/perl
    Python3: 3.12.3 - /usr/bin/python3
    Rust: 1.80.1 - /home/baron/.cargo/bin/rustc
  Browsers:
    Chrome: 130.0.6723.116

Severity

annoyance

@ieedan
Copy link
Contributor

ieedan commented Dec 19, 2024

A reproduction would help I am not able to reproduce this with the information you provided.

Here is what I am trying to work off of but everything works fine.

<script lang="ts">
	import Check from 'lucide-svelte/icons/check';
	import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
	import { tick } from 'svelte';
	import * as Command from '$lib/components/ui/command/index.js';
	import * as Popover from '$lib/components/ui/popover/index.js';
	import { Button } from '$lib/components/ui/button/index.js';
	import { cn } from '$lib/utils.js';

	const frameworks = [
		{
			value: 'sveltekit',
			label: 'SvelteKit'
		},
		{
			value: 'next.js',
			label: 'Next.js'
		},
		{
			value: 'nuxt.js',
			label: 'Nuxt.js'
		},
		{
			value: 'remix',
			label: 'Remix'
		},
		{
			value: 'astro',
			label: 'Astro'
		}
	];

	let open = $state(false);
	let value = $state('');
	let triggerRef = $state<HTMLButtonElement>(null!);

	const selectedValue = $derived(frameworks.find((f) => f.value === value)?.label);

	// We want to refocus the trigger button when the user selects
	// an item from the list so users can continue navigating the
	// rest of the form with the keyboard.
	function closeAndFocusTrigger() {
		open = false;
		tick().then(() => {
			triggerRef.focus();
		});
	}

	const newItem = () => {
		frameworks.push({ label: 'SolidJs', value: 'solid.js' });
	};
</script>

<main class="flex h-svh flex-col place-items-center justify-center gap-4">
	<Popover.Root bind:open>
		<Popover.Trigger bind:ref={triggerRef}>
			{#snippet child({ props })}
				<Button
					variant="outline"
					class="w-[200px] justify-between"
					{...props}
					role="combobox"
					aria-expanded={open}
				>
					{selectedValue || 'Select a framework...'}
					<ChevronsUpDown class="opacity-50" />
				</Button>
			{/snippet}
		</Popover.Trigger>
		<Popover.Content class="w-[200px] p-0">
			<Command.Root>
				<Command.Input placeholder="Search framework..." />
				<Command.List>
					<Command.Empty>No framework found.</Command.Empty>
					<Command.Group>
						{#each frameworks as framework}
							<Command.Item
								value={framework.value}
								onSelect={() => {
									value = framework.value;
									closeAndFocusTrigger();
								}}
							>
								<Check class={cn(value !== framework.value && 'text-transparent')} />
								{framework.label}
							</Command.Item>
						{/each}
					</Command.Group>
				</Command.List>
			</Command.Root>
		</Popover.Content>
	</Popover.Root>

	<Button onclick={newItem}>Change items</Button>
</main>

@davide-bleggi
Copy link

<script lang="ts">
	import Check from 'lucide-svelte/icons/check';
	import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
	import X from 'lucide-svelte/icons/x';
	import Loader2 from 'lucide-svelte/icons/loader-2';
	import { tick } from 'svelte';
	import * as Command from '$lib/components/ui/command';
	import * as Popover from '$lib/components/ui/popover';
	import { Button } from '$lib/components/ui/button';
	import { cn } from '$lib/utils';

	type ScenarioItem = {
		value: number;
		label: string;
	};

	let {
		noResultsText = 'Nessun risultato trovato',
		path = '/api/frameworks',
		placeholder = 'Cerca...',
		loadingText = 'Caricamento...',
		values = $bindable([] as number[])
	} = $props();

	let open = $state(false);
	let loading = $state(false);
	let options = $state<ScenarioItem[]>([]);
	let searchTerm = $state('');
	let selectedValues = $state<number[]>(values);
	let debounceTimer: number;

	$effect(() => {
		values = selectedValues;
	});

	const selectedLabels = $derived(
		options
			.filter((f) => selectedValues.includes(f.value))
			.map((f) => f.label)
	);

	async function fetchOptions(search: string) {
		const params = new URLSearchParams({ search });
		const response = await fetch(`${path}?${params}`);
		if (!response.ok) throw new Error('Failed to fetch options');
		return response.json();
	}

	async function handleSearch(value: string) {
		searchTerm = value;

		window.clearTimeout(debounceTimer);

		debounceTimer = window.setTimeout(async () => {
			try {
				console.log(searchTerm);
				loading = true;
				options = await fetchOptions(value);
				console.log('options: ', options);
			} catch (error) {
				console.log('error');
				console.error('Error fetching options:', error);
				options = [];
			} finally {
				loading = false;
			}
		}, 300);
	}

	function toggleSelection(option: ScenarioItem) {
		if (selectedValues.includes(option.value)) {
			selectedValues = selectedValues.filter(v => v !== option.value);
		} else {
			selectedValues = [...selectedValues, option.value];

			if (!options.some(o => o.value === option.value)) {
				options = [...options, option];
			}
		}
	}

	function removeValue(valueToRemove: number) {
		selectedValues = selectedValues.filter(v => v !== valueToRemove);
	}

</script>

<Popover.Root bind:open>
	<Popover.Trigger class="w-full">
		<Button
			variant="outline"
			class="w-full h-auto justify-between"
			role="combobox"
			aria-expanded={open}
		>
			<div class="flex flex-wrap gap-1">
				{#if selectedLabels.length > 0}
					{#each selectedLabels as label, i (label)}
						<div class="bg-secondary text-secondary-foreground flex items-center gap-1 rounded px-1 py-0.5">
							<span class="text-sm">{label}</span>
							<Button
								type="button"
								class="hover:bg-secondary/80 rounded-sm"
								onclick={(e) =>{e.stopPropagation();
									const optionToRemove = options.find(f => f.label === label)?.value;
								if(optionToRemove){removeValue(optionToRemove)}}}
							>
								<X class="size-3" />
							</Button>
						</div>
					{/each}
				{:else}
					<span class="text-muted-foreground">{placeholder}</span>
				{/if}
			</div>
			<ChevronsUpDown class="opacity-50 shrink-0" />
		</Button>
	</Popover.Trigger>
	<Popover.Content class="w-auto p-0">
		<Command.Root>
			<Command.Input
				{placeholder}
				oninput={(e)=>{handleSearch(e.currentTarget.value.length>0?e.currentTarget.value:'')}}
				value={searchTerm}
			/>
			<Command.List>
				{JSON.stringify(options)}
				rendering list
				<Command.Group>
					{#each options as option}
						<Command.Item>
							{option.label}
						</Command.Item>

					{/each}
				</Command.Group>

			</Command.List>
		</Command.Root>
	</Popover.Content>
</Popover.Root>

I believe I am experiencing the same issue. In this code, the options are updated based on the handleSearch() call. Although I see the correct result in the JSON.stringify output, the Command.Item component exhibits strange behavior. When I replace Command.Item with a simple <div>, the list renders correctly.

@ieedan
Copy link
Contributor

ieedan commented Jan 13, 2025

@davide-bleggi modifying your example a bit I am able to reproduce.

It seems to be something with the search rendering behavior in the bits-ui primitive going to try and look into it now!

Reproduction:

<script lang="ts">
	import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
	import X from 'lucide-svelte/icons/x';
	import * as Command from '$lib/components/ui/command';
	import * as Popover from '$lib/components/ui/popover';
	import { Button } from '$lib/components/ui/button';

	type ScenarioItem = {
		value: number;
		label: string;
	};

	let { placeholder = 'Search...', values = $bindable([] as number[]) } = $props();

	let open = $state(false);
	let options = $state<ScenarioItem[]>([]);
	let searchTerm = $state('');
	let selectedValues = $state<number[]>(values);
	let debounceTimer: number;

	$effect(() => {
		values = selectedValues;
	});

	const selectedLabels = $derived(
		options.filter((f) => selectedValues.includes(f.value)).map((f) => f.label)
	);

	async function fetchOptions(): Promise<ScenarioItem[]> {
		return [
			{ label: 'test1', value: 1 },
			{ label: 'test2', value: 2 },
			{ label: 'test3', value: 3 }
		];
	}

	async function handleSearch(value: string) {
		searchTerm = value;

		window.clearTimeout(debounceTimer);

		debounceTimer = window.setTimeout(async () => {
			try {
				console.log(searchTerm);
				options = await fetchOptions();
				console.log('options: ', options);
			} catch (error) {
				console.log('error');
				console.error('Error fetching options:', error);
				options = [];
			}
		}, 300);
	}

	function removeValue(valueToRemove: number) {
		selectedValues = selectedValues.filter((v) => v !== valueToRemove);
	}
</script>

<Popover.Root bind:open>
	<Popover.Trigger class="w-full">
		<Button
			variant="outline"
			class="h-auto w-full justify-between"
			role="combobox"
			aria-expanded={open}
		>
			<div class="flex flex-wrap gap-1">
				{#if selectedLabels.length > 0}
					{#each selectedLabels as label, i (label)}
						<div
							class="flex items-center gap-1 rounded bg-secondary px-1 py-0.5 text-secondary-foreground"
						>
							<span class="text-sm">{label}</span>
							<Button
								type="button"
								class="rounded-sm hover:bg-secondary/80"
								onclick={(e) => {
									e.stopPropagation();
									const optionToRemove = options.find((f) => f.label === label)?.value;
									if (optionToRemove) {
										removeValue(optionToRemove);
									}
								}}
							>
								<X class="size-3" />
							</Button>
						</div>
					{/each}
				{:else}
					<span class="text-muted-foreground">{placeholder}</span>
				{/if}
			</div>
			<ChevronsUpDown class="shrink-0 opacity-50" />
		</Button>
	</Popover.Trigger>
	<Popover.Content class="w-auto p-0">
		<Command.Root>
			<Command.Input
				{placeholder}
				oninput={(e) => {
					handleSearch(e.currentTarget.value.length > 0 ? e.currentTarget.value : '');
				}}
				value={searchTerm}
			/>
			<Command.List>
				<Command.Empty>No options found.</Command.Empty>
				{JSON.stringify(options)}
				<Command.Group>
					{#each options as option (option.value)}
						<Command.Item>
							{option.label}
						</Command.Item>
					{/each}
				</Command.Group>
			</Command.List>
		</Command.Root>
	</Popover.Content>
</Popover.Root>

@ieedan
Copy link
Contributor

ieedan commented Jan 13, 2025

For now a workaround for this can be setting shouldFilter on the <Command.Root/> to false and then doing manual filtering.

@davide-bleggi
Copy link

Yeah, this does the trick! Thanks a lot! 😊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants