add typeahead component
This commit is contained in:
parent
66b03c9108
commit
92e93309bf
1 changed files with 255 additions and 0 deletions
255
src/lib/components/ui/Typeahead.svelte
Normal file
255
src/lib/components/ui/Typeahead.svelte
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<!-- Typeahead Component (Svelecte wrapper) -->
|
||||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts" generics="T">
|
||||
import Svelecte from 'svelecte';
|
||||
import { Label } from 'bits-ui';
|
||||
|
||||
interface Props {
|
||||
/** Array of options to select from */
|
||||
options?: T[];
|
||||
/** Currently selected value(s) */
|
||||
value?: T | T[] | null;
|
||||
/** Callback when value changes */
|
||||
onValueChange?: (value: T | T[] | null) => void;
|
||||
/** Field to use as display label (default: 'label') */
|
||||
labelField?: string;
|
||||
/** Field to use as value (default: 'value') */
|
||||
valueField?: string;
|
||||
/** Enable search/filtering */
|
||||
searchable?: boolean;
|
||||
/** Allow multiple selections */
|
||||
multiple?: boolean;
|
||||
/** Allow creating new options */
|
||||
creatable?: boolean;
|
||||
/** Maximum number of selected items (for multiple mode) */
|
||||
max?: number;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Clear button visible */
|
||||
clearable?: boolean;
|
||||
/** Component size */
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
/** Contained background style */
|
||||
contained?: boolean;
|
||||
/** Full width */
|
||||
fullWidth?: boolean;
|
||||
/** Field label */
|
||||
label?: string;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
/** Required field */
|
||||
required?: boolean;
|
||||
/** Additional CSS class */
|
||||
class?: string;
|
||||
/** Custom filter function */
|
||||
filterFunction?: (item: T, inputValue: string) => boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
options = [],
|
||||
value = $bindable(null),
|
||||
onValueChange,
|
||||
labelField = 'label',
|
||||
valueField = 'value',
|
||||
searchable = true,
|
||||
multiple = false,
|
||||
creatable = false,
|
||||
max,
|
||||
placeholder = 'Select...',
|
||||
disabled = false,
|
||||
clearable = true,
|
||||
size = 'medium',
|
||||
contained = false,
|
||||
fullWidth = false,
|
||||
label,
|
||||
error,
|
||||
required = false,
|
||||
class: className = '',
|
||||
filterFunction
|
||||
}: Props = $props();
|
||||
|
||||
const hasWrapper = $derived(label || error);
|
||||
|
||||
const fieldsetClasses = $derived(
|
||||
['fieldset', fullWidth && 'full', className].filter(Boolean).join(' ')
|
||||
);
|
||||
|
||||
const typeaheadClasses = $derived(
|
||||
['typeahead', size, contained && 'contained', fullWidth && 'full', disabled && 'disabled']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
);
|
||||
|
||||
function handleChange(event: CustomEvent<{ detail: T | T[] | null }>) {
|
||||
const newValue = event.detail;
|
||||
value = newValue;
|
||||
onValueChange?.(newValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasWrapper}
|
||||
<fieldset class={fieldsetClasses}>
|
||||
{#if label}
|
||||
<Label.Root class="label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</Label.Root>
|
||||
{/if}
|
||||
|
||||
<div class={typeaheadClasses}>
|
||||
<Svelecte
|
||||
{options}
|
||||
{value}
|
||||
{labelField}
|
||||
{valueField}
|
||||
{searchable}
|
||||
{multiple}
|
||||
{creatable}
|
||||
{max}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{clearable}
|
||||
on:change={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{:else}
|
||||
<div class={typeaheadClasses}>
|
||||
<Svelecte
|
||||
{options}
|
||||
{value}
|
||||
{labelField}
|
||||
{valueField}
|
||||
{searchable}
|
||||
{multiple}
|
||||
{creatable}
|
||||
{max}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{clearable}
|
||||
on:change={handleChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
@use '$src/themes/typography' as *;
|
||||
@use '$src/themes/layout' as *;
|
||||
@use '$src/themes/mixins' as *;
|
||||
@use '$src/themes/effects' as *;
|
||||
|
||||
// Fieldset wrapper (matching Input component)
|
||||
.fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.label) {
|
||||
color: var(--text-primary);
|
||||
font-size: $font-small;
|
||||
font-weight: $medium;
|
||||
margin-bottom: $unit-half;
|
||||
}
|
||||
|
||||
:global(.label .required) {
|
||||
color: $error;
|
||||
margin-left: $unit-fourth;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error;
|
||||
font-size: $font-small;
|
||||
padding: $unit-half $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
// Typeahead wrapper
|
||||
.typeahead {
|
||||
width: 100%;
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Svelecte overrides - using CSS custom properties
|
||||
.typeahead {
|
||||
// Control (trigger) styling
|
||||
--sv-bg: var(--input-bg);
|
||||
--sv-border-color: transparent;
|
||||
--sv-border: 2px solid var(--sv-border-color);
|
||||
--sv-active-border: 2px solid #{$blue};
|
||||
--sv-active-outline: none;
|
||||
--sv-border-radius: #{$input-corner};
|
||||
--sv-min-height: calc(#{$unit} * 5.5);
|
||||
--sv-placeholder-color: var(--text-tertiary);
|
||||
--sv-color: var(--text-primary);
|
||||
|
||||
// Dropdown styling
|
||||
--sv-dropdown-bg: var(--dialog-bg);
|
||||
--sv-dropdown-border-radius: #{$card-corner};
|
||||
--sv-dropdown-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--sv-dropdown-offset: #{$unit-half};
|
||||
|
||||
// Item styling
|
||||
--sv-item-color: var(--text-primary);
|
||||
--sv-item-active-bg: var(--option-bg-hover);
|
||||
--sv-item-selected-bg: var(--option-bg-hover);
|
||||
|
||||
// Clear/indicator styling
|
||||
--sv-icon-color: var(--text-secondary);
|
||||
--sv-icon-hover-color: var(--text-primary);
|
||||
|
||||
// Selected tag styling (for multiple)
|
||||
--sv-item-wrap-padding: #{$unit-half} #{$unit};
|
||||
--sv-selected-bg: var(--button-bg);
|
||||
--sv-selected-color: var(--text-primary);
|
||||
}
|
||||
|
||||
// Size: Small
|
||||
.typeahead.small {
|
||||
--sv-min-height: calc(#{$unit} * 3.5);
|
||||
--sv-font-size: #{$font-small};
|
||||
}
|
||||
|
||||
// Size: Medium
|
||||
.typeahead.medium {
|
||||
--sv-min-height: calc(#{$unit} * 5.5);
|
||||
--sv-font-size: #{$font-regular};
|
||||
}
|
||||
|
||||
// Size: Large
|
||||
.typeahead.large {
|
||||
--sv-min-height: calc(#{$unit} * 6.5);
|
||||
--sv-font-size: #{$font-large};
|
||||
}
|
||||
|
||||
// Contained variant
|
||||
.typeahead.contained {
|
||||
--sv-bg: var(--input-bound-bg);
|
||||
|
||||
&:hover {
|
||||
--sv-bg: var(--input-bound-bg-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue