add select component with bits-ui

This commit is contained in:
Justin Edmund 2025-09-16 01:33:46 -07:00
parent 7f7661f542
commit 205e1045a6
3 changed files with 373 additions and 0 deletions

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9425 11.8186C11.1844 12.0605 11.5766 12.0605 11.8186 11.8186C12.0605 11.5766 12.0605 11.1844 11.8186 10.9425L7.8761 7L11.8186 3.05755C12.0605 2.81562 12.0605 2.42338 11.8186 2.18145C11.5766 1.93952 11.1844 1.93952 10.9425 2.18145L7 6.1239L3.05755 2.18145C2.81562 1.93952 2.42338 1.93952 2.18145 2.18145C1.93952 2.42338 1.93952 2.81562 2.18145 3.05755L6.1239 7L2.18145 10.9425C1.93952 11.1844 1.93952 11.5766 2.18145 11.8186C2.42338 12.0605 2.81562 12.0605 3.05755 11.8186L7 7.8761L10.9425 11.8186Z" />
</svg>

After

Width:  |  Height:  |  Size: 583 B

View file

@ -0,0 +1,117 @@
<script lang="ts" generics="T extends string | number">
import { Select } from 'bits-ui';
import { ChevronDown, Check } from 'lucide-svelte';
import styles from './select.module.scss';
interface Option {
value: T;
label: string;
disabled?: boolean;
image?: string;
}
interface Props {
options: Option[];
value?: T;
onValueChange?: (value: T | undefined) => void;
placeholder?: string;
disabled?: boolean;
variant?: 'default' | 'bound' | 'small' | 'table';
position?: 'left' | 'right';
fullWidth?: boolean;
hidden?: boolean;
name?: string;
required?: boolean;
class?: string;
}
const {
options = [],
value = $bindable(),
onValueChange,
placeholder = 'Select an option',
disabled = false,
variant = 'default',
position = 'left',
fullWidth = false,
hidden = false,
name,
required = false,
class: className = ''
}: Props = $props();
const selected = $derived(options.find(opt => opt.value === value));
const triggerClasses = [
styles.trigger,
variant === 'bound' && styles.bound,
variant === 'small' && styles.small,
variant === 'table' && styles.table,
position === 'right' && styles.right,
position === 'left' && styles.left,
fullWidth && styles.full,
hidden && styles.hidden,
disabled && styles.disabled,
className
].filter(Boolean).join(' ');
const contentClasses = [
styles.select,
variant === 'bound' && styles.bound
].filter(Boolean).join(' ');
function handleValueChange(newValue: string | undefined) {
if (newValue !== undefined && onValueChange) {
onValueChange(newValue as T);
}
}
</script>
<Select.Root
bind:value
onValueChange={handleValueChange}
{disabled}
{name}
{required}
>
<Select.Trigger
class={triggerClasses}
data-placeholder={!selected}
>
{#if selected?.image}
<img src={selected.image} alt={selected.label} />
{/if}
<span>{selected?.label || placeholder}</span>
<span class={styles.icon}>
<ChevronDown size={16} />
</span>
</Select.Trigger>
<Select.Content class={contentClasses}>
<div class={`${styles.scroll} ${styles.up}`}>
<ChevronDown size={16} />
</div>
<Select.Viewport>
{#each options as option}
<Select.Item
value={option.value}
disabled={option.disabled}
class={styles.item}
>
{#if option.image}
<img src={option.image} alt={option.label} />
{/if}
<span>{option.label}</span>
<Select.ItemIndicator class={styles.indicator}>
<Check size={16} />
</Select.ItemIndicator>
</Select.Item>
{/each}
</Select.Viewport>
<div class={`${styles.scroll} ${styles.down}`}>
<ChevronDown size={16} />
</div>
</Select.Content>
</Select.Root>

View file

@ -0,0 +1,253 @@
@use 'themes/spacing';
@use 'themes/colors';
@use 'themes/typography';
@use 'themes/layout';
@use 'themes/effects';
@use 'themes/mixins';
.trigger {
align-items: center;
background-color: var(--input-bg);
border-radius: layout.$input-corner;
border: 2px solid transparent;
display: flex;
gap: spacing.$unit;
padding: (spacing.$unit * 1.5) spacing.$unit-2x;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.18s ease-out;
&.small {
& > span:not(.icon) {
font-size: typography.$font-small;
margin: 0;
max-width: 200px;
}
@include mixins.breakpoint(tablet) {
&::before {
content: '';
display: block;
width: calc(spacing.$unit-2x - 1px);
}
& > span:not(.icon) {
width: 100%;
max-width: inherit;
text-align: center;
}
}
}
&.grow {
flex-grow: 1;
}
&.left {
flex-grow: 1;
width: 100%;
}
&.right {
flex-grow: 0;
text-align: right;
min-width: 12rem;
}
&.bound {
background-color: var(--select-contained-bg);
&:hover {
background-color: var(--select-contained-bg-hover);
&.disabled {
background-color: var(--select-contained-bg);
}
}
}
&.full {
width: 100%;
}
&.table {
min-width: spacing.$unit * 30;
@include mixins.breakpoint(phone) {
width: 100%;
}
}
&.hidden {
display: none;
}
&:hover {
background-color: var(--input-bg-hover);
span:not(.icon),
&[data-placeholder] > span:not(.icon) {
color: var(--text-primary);
}
.icon svg {
fill: var(--text-primary);
}
}
&.disabled:hover {
background-color: var(--input-bg);
cursor: not-allowed;
}
&[data-placeholder='true'] > span:not(.icon) {
color: var(--text-secondary);
}
& > span:not(.icon) {
color: var(--text-primary);
flex-grow: 1;
font-size: typography.$font-regular;
text-align: left;
}
img {
width: spacing.$unit-4x;
height: auto;
}
.icon {
display: flex;
align-items: center;
svg {
fill: var(--icon-secondary);
}
}
}
.select {
animation: scaleIn effects.$duration-zoom ease-out;
background: var(--dialog-bg);
border-radius: layout.$card-corner;
border: 1px solid rgba(0, 0, 0, 0.24);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
padding: 0 spacing.$unit;
min-width: var(--radix-select-trigger-width);
transform-origin: var(--radix-select-content-transform-origin);
max-height: 40vh;
z-index: 40;
&.bound {
background-color: var(--select-content-contained-bg);
}
.scroll.up,
.scroll.down {
padding: spacing.$unit 0;
text-align: center;
&:hover svg {
fill: var(--icon-secondary-hover);
}
svg {
fill: var(--icon-secondary);
}
}
.scroll.up {
transform: scale(1, -1);
}
@keyframes scaleIn {
0% {
opacity: 0;
transform: scale(0);
}
20% {
opacity: 0.2;
transform: scale(0.4);
}
40% {
opacity: 0.4;
transform: scale(0.8);
}
60% {
opacity: 0.6;
transform: scale(1);
}
65% {
opacity: 0.65;
transform: scale(1.1);
}
70% {
opacity: 0.7;
transform: scale(1);
}
75% {
opacity: 0.75;
transform: scale(0.98);
}
80% {
opacity: 0.8;
transform: scale(1.02);
}
90% {
opacity: 0.9;
transform: scale(0.96);
}
100% {
opacity: 1;
transform: scale(1);
}
}
}
.item {
align-items: center;
border-radius: layout.$item-corner-small;
color: var(--text-primary);
cursor: pointer;
display: flex;
gap: spacing.$unit;
padding: spacing.$unit spacing.$unit-2x;
position: relative;
user-select: none;
transition: background-color 0.12s ease-out;
&:hover {
background-color: var(--option-bg-hover);
}
&[data-disabled] {
color: var(--text-tertiary);
cursor: not-allowed;
opacity: 0.5;
}
&[data-selected] {
background-color: var(--option-bg-hover);
font-weight: typography.$medium;
}
img {
width: spacing.$unit-3x;
height: auto;
}
span {
flex-grow: 1;
}
.indicator {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
svg {
fill: var(--accent-blue);
}
}
}