This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: url search on new card form

+361 -245
+53 -11
src/webapp/features/cards/components/addCardDrawer/AddCardForm.tsx
··· 9 9 Stack, 10 10 Text, 11 11 Textarea, 12 - TextInput, 13 12 ThemeIcon, 14 13 VisuallyHidden, 15 14 Container, ··· 19 18 import { notifications } from '@mantine/notifications'; 20 19 import useAddCard from '../../lib/mutations/useAddCard'; 21 20 import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector'; 22 - import { Suspense, useEffect, useState } from 'react'; 21 + import { Suspense, useEffect, useRef, useState } from 'react'; 23 22 import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 24 23 import { useDisclosure } from '@mantine/hooks'; 25 24 import { BiCollection } from 'react-icons/bi'; ··· 33 32 import { FaSeedling } from 'react-icons/fa6'; 34 33 import { CardSaveSource } from '@/features/analytics/types'; 35 34 import { usePathname } from 'next/navigation'; 35 + import UrlSearchInput from '@/features/connections/components/addConnectionDrawer/UrlSearchInput'; 36 36 37 37 interface Props { 38 38 onClose: () => void; ··· 67 67 pagePath: pathname, 68 68 }); 69 69 70 + const rawUrlInput = useRef(props.initialUrl ?? ''); 71 + 70 72 const form = useForm({ 71 73 initialValues: { 72 74 url: props.initialUrl || '', 73 75 note: '', 74 76 collections: selectedCollections, 75 77 }, 78 + validateInputOnChange: false, 79 + validateInputOnBlur: true, 80 + validate: { 81 + url: (value) => { 82 + if (!value || value.trim() === '') { 83 + return 'URL is required'; 84 + } 85 + try { 86 + new URL(value); 87 + return null; 88 + } catch { 89 + return 'Please enter a valid URL'; 90 + } 91 + }, 92 + }, 76 93 }); 77 94 78 95 const MAX_NOTE_LENGTH = 500; ··· 80 97 useEffect(() => { 81 98 if (props.initialUrl) { 82 99 form.setValues({ url: props.initialUrl }); 100 + rawUrlInput.current = props.initialUrl; 83 101 } 84 102 }, [props.initialUrl]); 85 103 86 104 const handleAddCard = (e: React.FormEvent) => { 87 105 e.preventDefault(); 106 + 107 + // Auto-confirm a valid URL that was typed/pasted but not explicitly selected 108 + if (!form.values.url && rawUrlInput.current) { 109 + try { 110 + new URL(rawUrlInput.current); 111 + form.setFieldValue('url', rawUrlInput.current); 112 + } catch { 113 + // let validation handle it 114 + } 115 + } 116 + 117 + const validation = form.validate(); 118 + if (validation.hasErrors) return; 119 + 88 120 track('add new card'); 89 121 90 122 // Capture values before any state changes ··· 109 141 // Close drawer immediately 110 142 props.onClose(); 111 143 setSelectedCollections(initialCollections); 144 + rawUrlInput.current = ''; 112 145 form.reset(); 113 146 114 147 addCard.mutate({ ...cardData, notificationId }); ··· 118 151 <> 119 152 <form onSubmit={handleAddCard}> 120 153 <Stack gap={'xl'}> 121 - <TextInput 154 + <UrlSearchInput 122 155 id="url" 123 156 label="URL" 124 - type="url" 125 - placeholder="https://www.example.com" 126 - variant="filled" 127 - required 128 - size="md" 129 - leftSection={<IoMdLink size={22} />} 130 - key={form.key('url')} 131 - {...form.getInputProps('url')} 157 + placeholder="Search or paste a link" 158 + value={form.values.url} 159 + error={form.errors.url} 160 + onUrlSelect={(url) => form.setFieldValue('url', url)} 161 + onUrlClear={() => { 162 + rawUrlInput.current = ''; 163 + form.setFieldValue('url', ''); 164 + }} 165 + onInputChange={(raw) => { 166 + rawUrlInput.current = raw; 167 + }} 168 + inputProps={{ 169 + variant: 'filled', 170 + size: 'md', 171 + leftSection: <IoMdLink size={22} />, 172 + 'data-autofocus': true, 173 + }} 132 174 /> 133 175 134 176 <Stack gap={0}>
+54 -11
src/webapp/features/composer/components/Composer.tsx
··· 18 18 VisuallyHidden, 19 19 Scroller, 20 20 } from '@mantine/core'; 21 - import { useState, useEffect, Suspense } from 'react'; 21 + import { useState, useEffect, useRef, Suspense } from 'react'; 22 22 import { Collection, CollectionAccessType } from '@semble/types'; 23 23 import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 24 24 import { useForm } from '@mantine/form'; ··· 30 30 import { useDisclosure } from '@mantine/hooks'; 31 31 import { BiCollection } from 'react-icons/bi'; 32 32 import { IoMdCheckmark, IoMdLink } from 'react-icons/io'; 33 + import UrlSearchInput from '@/features/connections/components/addConnectionDrawer/UrlSearchInput'; 33 34 import { track } from '@vercel/analytics'; 34 35 import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; 35 36 import { isMarginUri, getMarginUrl } from '@/lib/utils/margin'; ··· 84 85 pagePath: pathname, 85 86 }); 86 87 88 + const rawUrlInput = useRef(props.initialUrl ?? ''); 89 + 87 90 const cardForm = useForm({ 88 91 initialValues: { 89 92 url: props.initialUrl || '', 90 93 note: '', 91 94 collections: selectedCollections, 92 95 }, 96 + validateInputOnChange: false, 97 + validateInputOnBlur: true, 98 + validate: { 99 + url: (value) => { 100 + if (!value || value.trim() === '') { 101 + return 'URL is required'; 102 + } 103 + try { 104 + new URL(value); 105 + return null; 106 + } catch { 107 + return 'Please enter a valid URL'; 108 + } 109 + }, 110 + }, 93 111 }); 94 112 95 113 // Collection form state ··· 114 132 useEffect(() => { 115 133 if (props.initialUrl) { 116 134 cardForm.setValues({ url: props.initialUrl }); 135 + rawUrlInput.current = props.initialUrl; 117 136 } 118 137 }, [props.initialUrl]); 119 138 ··· 124 143 collectionForm.reset(); 125 144 setSelectedCollections(initialCollections); 126 145 setMode('card'); 146 + rawUrlInput.current = ''; 127 147 } 128 148 }, [props.isOpen]); 129 149 130 150 const handleAddCard = (e: React.FormEvent) => { 131 151 e.preventDefault(); 152 + 153 + // Auto-confirm a valid URL that was typed/pasted but not explicitly selected 154 + if (!cardForm.values.url && rawUrlInput.current) { 155 + try { 156 + new URL(rawUrlInput.current); 157 + cardForm.setFieldValue('url', rawUrlInput.current); 158 + } catch { 159 + // let validation handle it 160 + } 161 + } 162 + 163 + const validation = cardForm.validate(); 164 + if (validation.hasErrors) return; 165 + 132 166 track('add new card'); 133 167 134 168 // Capture values before any state changes ··· 154 188 props.onClose(); 155 189 setSelectedCollections(initialCollections); 156 190 window.history.replaceState({}, '', window.location.pathname); 191 + rawUrlInput.current = ''; 157 192 cardForm.reset(); 158 193 159 194 addCard.mutate({ ...cardData, notificationId }); ··· 268 303 style={{ flex: 1, display: 'flex', flexDirection: 'column' }} 269 304 > 270 305 <Stack gap={'xl'} style={{ flex: 1 }}> 271 - <TextInput 306 + <UrlSearchInput 272 307 id="url" 273 308 label="URL" 274 - type="url" 275 - placeholder="https://www.example.com" 276 - variant="filled" 277 - required 278 - size="md" 279 - leftSection={<IoMdLink size={22} />} 280 - data-autofocus 281 - key={cardForm.key('url')} 282 - {...cardForm.getInputProps('url')} 309 + placeholder="Search of paste a link" 310 + value={cardForm.values.url} 311 + error={cardForm.errors.url} 312 + onUrlSelect={(url) => cardForm.setFieldValue('url', url)} 313 + onUrlClear={() => { 314 + rawUrlInput.current = ''; 315 + cardForm.setFieldValue('url', ''); 316 + }} 317 + onInputChange={(raw) => { 318 + rawUrlInput.current = raw; 319 + }} 320 + inputProps={{ 321 + variant: 'filled', 322 + size: 'md', 323 + leftSection: <IoMdLink size={22} />, 324 + 'data-autofocus': true, 325 + }} 283 326 /> 284 327 285 328 <Stack gap={0}>
+3 -3
src/webapp/features/connections/components/addConnectionDrawer/SourceCardPreview.tsx
··· 19 19 return ( 20 20 <Card withBorder component="article" p={'xs'} radius={'lg'}> 21 21 <Group gap="xs" wrap="nowrap"> 22 - <Skeleton width={45} height={45} radius={'md'} /> 22 + <Skeleton width={42} height={42} radius={'md'} /> 23 23 <Stack gap={0} style={{ flex: 1 }}> 24 24 <Skeleton height={21.5} width="80%" radius="sm" /> 25 25 <Skeleton height={18.5} width="60%" radius="sm" mt={4} /> ··· 56 56 src={data.metadata.imageUrl} 57 57 alt={`${data.metadata.title} social preview image`} 58 58 radius={'md'} 59 - w={45} 60 - h={45} 59 + w={42} 60 + h={42} 61 61 style={{ flexShrink: 0 }} 62 62 /> 63 63 )}
+251 -220
src/webapp/features/connections/components/addConnectionDrawer/UrlSearchInput.tsx
··· 13 13 Skeleton, 14 14 Stack, 15 15 Text, 16 + TextInput, 17 + type TextInputProps, 16 18 ThemeIcon, 17 19 useCombobox, 18 20 VisuallyHidden, ··· 61 63 onUrlSelect: (url: string) => void; 62 64 onUrlClear?: () => void; 63 65 onInputChange?: (rawValue: string) => void; 66 + /** 67 + * When provided, the input is rendered as a standard Mantine `TextInput` 68 + * (with a visible label) and these props are spread onto it. Use this to 69 + * customize variant, size, leftSection, etc. When omitted, the input keeps 70 + * the legacy card-wrapped unstyled layout used by the connection form. 71 + */ 72 + inputProps?: Omit< 73 + TextInputProps, 74 + | 'id' 75 + | 'label' 76 + | 'placeholder' 77 + | 'value' 78 + | 'onChange' 79 + | 'onFocus' 80 + | 'onBlur' 81 + | 'error' 82 + | 'required' 83 + > & { [K in `data-${string}`]?: unknown }; 64 84 } 65 85 66 86 export default function UrlSearchInput(props: Props) { ··· 216 236 217 237 const currentError = searchFilter === 'cards' ? error : collectionSearchError; 218 238 219 - return ( 220 - <Card padding="xs" radius="lg" withBorder> 221 - <Stack gap={0}> 222 - <Combobox 223 - shadow="sm" 224 - radius={'md'} 225 - store={combobox} 226 - position="bottom-start" 227 - onOptionSubmit={(url) => { 228 - props.onUrlSelect(url); 229 - setInputValue(url); 230 - if (isValidUrl(url)) { 231 - setConfirmedUrl(url); 232 - // Look up metadata from search results or recent cards 233 - const searchMatch = urls.find((u) => u.url === url); 234 - const recentMatch = recentCardsList.find((c) => c.url === url); 235 - setConfirmedMetadata( 236 - searchMatch?.metadata ?? recentMatch?.cardContent, 237 - ); 238 - } 239 - combobox.closeDropdown(); 240 - }} 241 - > 242 - <Combobox.Target> 243 - <Input 244 - id={props.id} 245 - component="input" 246 - type="text" 247 - py={2.5} 248 - placeholder={props.placeholder} 249 - value={inputValue} 250 - onChange={(e) => { 251 - const val = e.currentTarget.value; 252 - setInputValue(val); 253 - props.onInputChange?.(val); 254 - combobox.openDropdown(); 255 - }} 256 - onFocus={() => { 257 - setIsInputFocused(true); 258 - combobox.openDropdown(); 259 - }} 260 - onBlur={() => { 261 - setIsInputFocused(false); 262 - }} 263 - rightSection={null} 264 - variant="unstyled" 265 - size="md" 266 - required 267 - error={props.error} 268 - /> 269 - </Combobox.Target> 239 + const isPlainLayout = !!props.inputProps; 240 + 241 + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { 242 + const val = e.currentTarget.value; 243 + setInputValue(val); 244 + props.onInputChange?.(val); 245 + combobox.openDropdown(); 246 + }; 247 + 248 + const handleFocus = () => { 249 + setIsInputFocused(true); 250 + combobox.openDropdown(); 251 + }; 252 + 253 + const handleBlur = () => { 254 + setIsInputFocused(false); 255 + }; 270 256 271 - <Combobox.Dropdown 272 - hidden={ 273 - (inputValue.trim().length === 0 && 274 - debounced.trim().length === 0 && 275 - !( 276 - isInputFocused && 277 - (isLoadingRecentCards || recentCardsList.length > 0) 278 - )) || 279 - (inputValue.trim().length === 0 && debounced.trim().length > 0) 280 - } 257 + const inputElement = isPlainLayout ? ( 258 + <TextInput 259 + {...props.inputProps} 260 + id={props.id} 261 + label={props.label} 262 + placeholder={props.placeholder} 263 + value={inputValue} 264 + onChange={handleInputChange} 265 + onFocus={handleFocus} 266 + onBlur={handleBlur} 267 + error={props.error} 268 + required 269 + /> 270 + ) : ( 271 + <Input 272 + id={props.id} 273 + component="input" 274 + type="text" 275 + py={2.5} 276 + placeholder={props.placeholder} 277 + value={inputValue} 278 + onChange={handleInputChange} 279 + onFocus={handleFocus} 280 + onBlur={handleBlur} 281 + rightSection={null} 282 + variant="unstyled" 283 + size="md" 284 + required 285 + error={props.error} 286 + /> 287 + ); 288 + 289 + const comboboxBlock = ( 290 + <Combobox 291 + shadow="sm" 292 + radius={'md'} 293 + store={combobox} 294 + position="bottom-start" 295 + onOptionSubmit={(url) => { 296 + props.onUrlSelect(url); 297 + setInputValue(url); 298 + if (isValidUrl(url)) { 299 + setConfirmedUrl(url); 300 + // Look up metadata from search results or recent cards 301 + const searchMatch = urls.find((u) => u.url === url); 302 + const recentMatch = recentCardsList.find((c) => c.url === url); 303 + setConfirmedMetadata( 304 + searchMatch?.metadata ?? recentMatch?.cardContent, 305 + ); 306 + } 307 + combobox.closeDropdown(); 308 + }} 309 + > 310 + <Combobox.Target>{inputElement}</Combobox.Target> 311 + 312 + <Combobox.Dropdown 313 + hidden={ 314 + (inputValue.trim().length === 0 && 315 + debounced.trim().length === 0 && 316 + !( 317 + isInputFocused && 318 + (isLoadingRecentCards || recentCardsList.length > 0) 319 + )) || 320 + (inputValue.trim().length === 0 && debounced.trim().length > 0) 321 + } 322 + > 323 + <Combobox.Options> 324 + <ScrollArea.Autosize 325 + type="scroll" 326 + mah={{ base: 150, xs: 300 }} 327 + offsetScrollbars={'present'} 281 328 > 282 - <Combobox.Options> 283 - <ScrollArea.Autosize 284 - type="scroll" 285 - mah={{ base: 150, xs: 300 }} 286 - offsetScrollbars={'present'} 287 - > 288 - {debounced.trim().length === 0 ? ( 329 + {debounced.trim().length === 0 ? ( 330 + <Fragment> 331 + <Text size="sm" fw={500} c="dimmed" py="xs" px={5}> 332 + Recent cards 333 + </Text> 334 + {isLoadingRecentCards ? ( 335 + <Stack gap={5} p={5}> 336 + {Array.from({ length: 5 }).map((_, i) => ( 337 + <Group key={i} gap="xs" align="center" wrap="nowrap"> 338 + <Skeleton height={35} width={35} radius="sm" /> 339 + <Stack gap={4} style={{ flex: 1 }}> 340 + <Skeleton height={12} width="70%" radius="xl" /> 341 + <Skeleton height={10} width="40%" radius="xl" /> 342 + </Stack> 343 + </Group> 344 + ))} 345 + </Stack> 346 + ) : ( 347 + recentCardsList.map((card) => ( 348 + <Combobox.Option key={card.url} value={card.url} p={5}> 349 + <Group gap={'xs'} align="center" wrap="nowrap"> 350 + {card.cardContent.imageUrl && ( 351 + <Image 352 + src={card.cardContent.imageUrl} 353 + alt={card.cardContent.title || 'URL thumbnail'} 354 + w={35} 355 + h={35} 356 + radius="sm" 357 + fit="cover" 358 + /> 359 + )} 360 + <Stack gap={0}> 361 + <Text fw={500} c={'bright'} lineClamp={1} size="sm"> 362 + {card.cardContent.title || card.url} 363 + </Text> 364 + <Text c={'gray'} lineClamp={1} size="xs"> 365 + {getDomain(card.url)} 366 + </Text> 367 + </Stack> 368 + </Group> 369 + </Combobox.Option> 370 + )) 371 + )} 372 + </Fragment> 373 + ) : ( 374 + <Fragment> 375 + {currentError && ( 376 + <Combobox.Empty>Could not search</Combobox.Empty> 377 + )} 378 + {!currentError && inputValue.trim() && ( 379 + <Fragment> 380 + <Combobox.Option value={inputValue}> 381 + <Group gap="xs" wrap="nowrap" p={0}> 382 + <ThemeIcon 383 + radius={'xl'} 384 + size={'sm'} 385 + variant="light" 386 + color="gray" 387 + > 388 + <BiPlus /> 389 + </ThemeIcon> 390 + <Stack gap={0} style={{ flex: 1 }}> 391 + <Text size="sm" fw={600} c={'bright'}> 392 + Add this link 393 + </Text> 394 + <Text size="xs" c="dimmed" lineClamp={1}> 395 + {inputValue} 396 + </Text> 397 + </Stack> 398 + </Group> 399 + </Combobox.Option> 400 + 401 + {(hasSearchResults || true) && ( 402 + <Fragment> 403 + <Text size="sm" fw={500} c="dimmed" py="xs" px={5}> 404 + Search results 405 + </Text> 406 + <SegmentedControl 407 + value={searchFilter} 408 + onChange={(value) => { 409 + setSearchFilter(value as SearchFilter); 410 + combobox.resetSelectedOption(); 411 + }} 412 + data={[ 413 + { label: 'Cards', value: 'cards' }, 414 + { 415 + label: 'Collections', 416 + value: 'collections', 417 + }, 418 + ]} 419 + size="xs" 420 + mb="xs" 421 + /> 422 + </Fragment> 423 + )} 424 + </Fragment> 425 + )} 426 + {searchFilter === 'cards' && ( 289 427 <Fragment> 290 - <Text size="sm" fw={500} c="dimmed" py="xs" px={5}> 291 - Recent cards 292 - </Text> 293 - {isLoadingRecentCards ? ( 428 + {isFetching ? ( 294 429 <Stack gap={5} p={5}> 295 - {Array.from({ length: 5 }).map((_, i) => ( 430 + {Array.from({ length: 3 }).map((_, i) => ( 296 431 <Group key={i} gap="xs" align="center" wrap="nowrap"> 297 432 <Skeleton height={35} width={35} radius="sm" /> 298 433 <Stack gap={4} style={{ flex: 1 }}> ··· 302 437 </Group> 303 438 ))} 304 439 </Stack> 440 + ) : cardOptions.length > 0 ? ( 441 + <Fragment>{cardOptions}</Fragment> 305 442 ) : ( 306 - recentCardsList.map((card) => ( 307 - <Combobox.Option key={card.url} value={card.url} p={5}> 308 - <Group gap={'xs'} align="center" wrap="nowrap"> 309 - {card.cardContent.imageUrl && ( 310 - <Image 311 - src={card.cardContent.imageUrl} 312 - alt={card.cardContent.title || 'URL thumbnail'} 313 - w={35} 314 - h={35} 315 - radius="sm" 316 - fit="cover" 317 - /> 318 - )} 319 - <Stack gap={0}> 320 - <Text 321 - fw={500} 322 - c={'bright'} 323 - lineClamp={1} 324 - size="sm" 325 - > 326 - {card.cardContent.title || card.url} 327 - </Text> 328 - <Text c={'gray'} lineClamp={1} size="xs"> 329 - {getDomain(card.url)} 330 - </Text> 331 - </Stack> 332 - </Group> 333 - </Combobox.Option> 334 - )) 443 + debounced.trim().length > 0 && ( 444 + <Combobox.Empty>No cards found</Combobox.Empty> 445 + ) 335 446 )} 336 447 </Fragment> 337 - ) : ( 448 + )} 449 + {searchFilter === 'collections' && ( 338 450 <Fragment> 339 - {currentError && ( 340 - <Combobox.Empty>Could not search</Combobox.Empty> 341 - )} 342 - {!currentError && inputValue.trim() && ( 343 - <Fragment> 344 - <Combobox.Option value={inputValue}> 345 - <Group gap="xs" wrap="nowrap" p={0}> 346 - <ThemeIcon 347 - radius={'xl'} 348 - size={'sm'} 349 - variant="light" 350 - color="gray" 351 - > 352 - <BiPlus /> 353 - </ThemeIcon> 354 - <Stack gap={0} style={{ flex: 1 }}> 355 - <Text size="sm" fw={600} c={'bright'}> 356 - Add this link 357 - </Text> 358 - <Text size="xs" c="dimmed" lineClamp={1}> 359 - {inputValue} 360 - </Text> 361 - </Stack> 451 + {isCollectionSearchFetching ? ( 452 + <Stack gap={5} p={5}> 453 + {Array.from({ length: 5 }).map((_, i) => ( 454 + <Group 455 + key={i} 456 + gap="xs" 457 + align="center" 458 + justify="space-between" 459 + wrap="nowrap" 460 + > 461 + <Skeleton height={20} width="70%" radius="xl" /> 462 + <Skeleton height={26} width={26} radius="md" /> 362 463 </Group> 363 - </Combobox.Option> 364 - 365 - {(hasSearchResults || true) && ( 366 - <Fragment> 367 - <Text size="sm" fw={500} c="dimmed" py="xs" px={5}> 368 - Search results 369 - </Text> 370 - <SegmentedControl 371 - value={searchFilter} 372 - onChange={(value) => { 373 - setSearchFilter(value as SearchFilter); 374 - combobox.resetSelectedOption(); 375 - }} 376 - data={[ 377 - { label: 'Cards', value: 'cards' }, 378 - { 379 - label: 'Collections', 380 - value: 'collections', 381 - }, 382 - ]} 383 - size="xs" 384 - mb="xs" 385 - /> 386 - </Fragment> 387 - )} 388 - </Fragment> 389 - )} 390 - {searchFilter === 'cards' && ( 391 - <Fragment> 392 - {isFetching ? ( 393 - <Stack gap={5} p={5}> 394 - {Array.from({ length: 3 }).map((_, i) => ( 395 - <Group 396 - key={i} 397 - gap="xs" 398 - align="center" 399 - wrap="nowrap" 400 - > 401 - <Skeleton height={35} width={35} radius="sm" /> 402 - <Stack gap={4} style={{ flex: 1 }}> 403 - <Skeleton 404 - height={12} 405 - width="70%" 406 - radius="xl" 407 - /> 408 - <Skeleton 409 - height={10} 410 - width="40%" 411 - radius="xl" 412 - /> 413 - </Stack> 414 - </Group> 415 - ))} 416 - </Stack> 417 - ) : cardOptions.length > 0 ? ( 418 - <Fragment>{cardOptions}</Fragment> 419 - ) : ( 420 - debounced.trim().length > 0 && ( 421 - <Combobox.Empty>No cards found</Combobox.Empty> 422 - ) 423 - )} 424 - </Fragment> 425 - )} 426 - {searchFilter === 'collections' && ( 427 - <Fragment> 428 - {isCollectionSearchFetching ? ( 429 - <Stack gap={5} p={5}> 430 - {Array.from({ length: 5 }).map((_, i) => ( 431 - <Group 432 - key={i} 433 - gap="xs" 434 - align="center" 435 - justify="space-between" 436 - wrap="nowrap" 437 - > 438 - <Skeleton height={20} width="70%" radius="xl" /> 439 - <Skeleton height={26} width={26} radius="md" /> 440 - </Group> 441 - ))} 442 - </Stack> 443 - ) : collectionOptions.filter(Boolean).length > 0 ? ( 444 - <Fragment>{collectionOptions}</Fragment> 445 - ) : ( 446 - debounced.trim().length > 0 && ( 447 - <Combobox.Empty> 448 - No collections found 449 - </Combobox.Empty> 450 - ) 451 - )} 452 - </Fragment> 464 + ))} 465 + </Stack> 466 + ) : collectionOptions.filter(Boolean).length > 0 ? ( 467 + <Fragment>{collectionOptions}</Fragment> 468 + ) : ( 469 + debounced.trim().length > 0 && ( 470 + <Combobox.Empty>No collections found</Combobox.Empty> 471 + ) 453 472 )} 454 473 </Fragment> 455 474 )} 456 - </ScrollArea.Autosize> 457 - </Combobox.Options> 458 - </Combobox.Dropdown> 459 - </Combobox> 475 + </Fragment> 476 + )} 477 + </ScrollArea.Autosize> 478 + </Combobox.Options> 479 + </Combobox.Dropdown> 480 + </Combobox> 481 + ); 482 + 483 + if (isPlainLayout) { 484 + return comboboxBlock; 485 + } 486 + 487 + return ( 488 + <Card padding="xs" radius="lg" withBorder> 489 + <Stack gap={0}> 490 + {comboboxBlock} 460 491 <VisuallyHidden> 461 492 <Input.Label htmlFor={props.id} required> 462 493 {props.label}