Select

Find the source code here.

Overview

Select component is actually a dropdown component with select input behaviour. It takes almost all select input props.

The main difference is that select input design depends on browser / OS, it means that, for instance, Mac OS and Windows don't display some web elements same way.

This rule doesn't apply to this Select component. Here below is an example:

Copied !

import { Select } from "$components/select.component";

export function DefaultSelectExample() {
	/**
	 * @type {import("$components/input.component").SelectOptionType[]}
	 */
	const items = [
		{
			label: "Milk",
			value: "milk",
			disabled: true
		},
		{
			label: "Eggs",
			value: "eggs",
			disabled: false
		},
		{
			label: "Cheese",
			value: "cheese",
			disabled: true
		},
		{
			label: "Bread",
			value: "bread",
			disabled: false
		},
		{
			label: "Apples",
			value: "apples",
			disabled: false
		},
		{
			label: "Bananas",
			value: "bananas",
			disabled: false
		},
		{
			label: "Yogurt",
			value: "yogurt",
			disabled: false
		},
		{
			label: "Sugar",
			value: "sugar",
			disabled: false
		},
		{
			label: "Salt",
			value: "salt",
			disabled: false
		},
		{
			label: "Coffee",
			value: "coffee",
			disabled: false
		},
		{
			label: "Tea",
			value: "tea",
			disabled: true
		}
	];
	return (
		<Select
			id="dropdown-select"
			name="dropdown-select"
			items={items}
			placeholder="Select an item"
			class="w-64 border rounded-md shadow-sm border-base-light"
			x-on:select="console.log($event.detail)"
		/>
	);
}

Installation

Notes

To use this component, you need to initialize your project first. If not done yet, run one of the following command:

npx jsxpine init or yarn jsxpine init or pnpm jsxpine init or bunx jsxpine init.

Go to the installation and usage page to learn more.

jsxpine add select
Copied !

Copied !


	--------------------     Components Dependencies   -------------------------

		import { icons } from "@iconify-json/ri/icons.json";
import clsx from "clsx";
import { SVG } from "./svg.component";

/**
 * @typedef {`ri.${keyof typeof import("@iconify-json/ri/icons.json")["icons"]}`} IconName
 */

/**
 * @typedef IconProps
 * @type {{size?: number, name: IconName, color?: string, applyDefsId?: string} & import("./svg.component").SVGProps}
 */

/**
 * Icon component props
 * @param {Omit<IconProps, "viewBox">} props
 */
export function Icon({
	children,
	size = 4,
	name,
	applyDefsId,
	class: className,
	...restProps
}) {
	const iconType = name.split(".")[0];
	const iconName = /** @type {keyof typeof icons} */ (name.split(".")[1]);
	if (!icons[iconName]) {
		console.error(`"${name}" is not an icon from iconify/${iconType}`);
		return "";
	}
	const { body } = icons[iconName];

	// The purpose is to retrieve value from d attribute
	let retrieveDValue = "";
	const bodyMatch = body.match(/d=".+"/g);
	const retrieveDAttribute = bodyMatch ? bodyMatch[0] : "";
	if (retrieveDAttribute) {
		retrieveDValue = retrieveDAttribute.slice(3, -2);
	}

	return (
		<SVG
			viewBox={"0 0 24 24"}
			{...restProps}
			class={clsx(`size-${size}`, className)}
		>
			{children}
			<path
				stroke-linecap="round"
				stroke-linejoin="round"
				fill={applyDefsId ? `url(#${applyDefsId})` : "currentColor"}
				d={retrieveDValue}
			/>
		</SVG>
	);
}


/**
 * @typedef SVGProps
 * @type {{fill?: string, stroke?: string, strokeWidth?: number, viewBox: string } & Omit<JSX.HtmlTag, "className"> & import("../common/props").CLSXClassProps}
 */

/**
 * SVG component props
 * @type {import("../common/props").JSXComponent<SVGProps>}
 */
export function SVG(props) {
    const {
        children,
        class: className,
        fill = "none",
        stroke = "currentColor",
        strokeWidth = 0.5,
        ...restProps
        } = props;
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            fill={fill}
            stroke={stroke}
            class={className}
            style={{ strokeWidth: String(strokeWidth) }}
            {...restProps}
        >
            {children}
        </svg>
    )
}

import { Icon } from "./icon.component";
import clsx from "clsx";

/**
 * @typedef {Object} InputProps
 * @type {Omit<JSX.HtmlInputTag, "className"> & import("../common/props").CLSXClassProps}
 */

/**
 * @typedef TextInputProps
 * @type {InputProps}
 */

/**
 * @typedef PasswordInputProps
 * @type {{ hideIcon?: boolean } & InputProps}
 */

/**
 * @typedef NumberInputProps
 * @type {Omit<InputProps, "placeholder" | "min" | "max"> & { min?: number, max?: number }}
 */

/**
 * @typedef TextareaInputProps
 * @type {{ value?: string, noResize?: boolean} & Omit<JSX.HtmlTextAreaTag, "className"> & import("../common/props").CLSXClassProps}
 */

/**
 * @typedef {Object} DefaultSelectOptionType
 * @property {string} label
 * @property {string | number} [value]
 * @property {boolean} [disabled]
 */

/**
 * @typedef SelectOptionType
 * @type {DefaultSelectOptionType & Record<string, unknown>}
 */

/**
 * @typedef SelectInputProps
 * @type {{ value?: string | number | string[], placeholder?: string, items?: SelectOptionType[], hidePlaceholder?: boolean } & Omit<JSX.HtmlSelectTag, "className"> & import("../common/props").CLSXClassProps}
 */

/**
 * @typedef CheckboxInputProps
 * @type {Omit<InputProps, "placeholder">}
 */

/**
 * @typedef RadioInputProps
 * @type {Omit<InputProps, "placeholder">}
 */

/**
 * Checkbox Input props
 * @type {import("../common/props").JSXComponent<CheckboxInputProps>}
 */
export function CheckboxInput(props) {
	return <input type="checkbox" {...props} />;
}

/**
 * Date Input props
 * @type {import("../common/props").JSXComponent<TextInputProps>}
 */
export function DateInput(props) {
	const { value = "", class: className, ...restProps } = props;
	return (
		<div
			class={clsx("input flex items-center justify-center gap-x-2", className)}
			x-data={`{ value: "${value}" }`}
		>
			<input
				x-ref="dateInput"
				type="date"
				{...restProps}
				x-model="value"
				class="flex-1 px-1"
			/>
			{/* Because calendar-picker-icon doesn't change with theme toggle */}
			<button x-on:click="$refs.dateInput.showPicker()">
				<Icon name="ri.calendar-line" />
			</button>
		</div>
	);
}

/**
 * Email Input props
 * @type {import("../common/props").JSXComponent<TextInputProps>}
 */
export function EmailInput(props) {
	const { value = "", class: className, ...restProps } = props;
	return (
		<div
			class={clsx("input input-primary flex items-center gap-x-2", className)}
			x-data={`{ value: "${value}" }`}
		>
			<input type="email" {...restProps} x-model="value" class="flex-1 px-1" />
			<button
				type="button"
				x-bind:disabled="!value"
				x-on:click="value = ''"
				class="flex items-center justify-center"
				x-bind:class="value ? 'opacity-100' : 'opacity-0'"
			>
				<Icon name="ri.close-line" size={5} />
			</button>
		</div>
	);
}

/**
 * File Input props
 * @type {import("../common/props").JSXComponent<TextInputProps>}
 */
export function FileInput(props) {
	const { value = "", class: className, ...restProps } = props;
	return (
		<div
			class={clsx("input flex items-center justify-center", className)}
			x-data={`{ value: "${value}" }`}
		>
			<input type="file" {...restProps} x-model="value" class="flex-1 px-1" />
		</div>
	);
}

/**
 * Number Input props
 * @type {import("../common/props").JSXComponent<NumberInputProps>}
 */
export function NumberInput(props) {
	const { min = 1, max, value = 1, class: className, ...restProps } = props;
	return (
		<input
			type="number"
			x-data={`{ value: ${value} }`}
			class={clsx("text-center input out-of-range:bg-danger-500", className)}
			{...restProps}
			min={String(min)}
			max={String(max)}
			x-model="value"
			pattern="/\d/g"
		/>
	);
}

/**
 * Password Input props
 * @type {import("../common/props").JSXComponent<PasswordInputProps>}
 */
export function PasswordInput(props) {
	const {
		value = "",
		hideIcon = false,
		class: className,
		...restProps
	} = props;
	return (
		<div
			class={clsx("input input-primary flex items-center gap-x-2", className)}
			x-data={`{ value: "${value}", show: false }`}
		>
			<input
				x-bind:type="show ? 'text' : 'password'"
				{...restProps}
				x-model="value"
				class="flex-1 px-1"
			/>
			{!hideIcon ? (
				<button
					type="button"
					x-bind:disabled="!value"
					x-on:click="show = !show"
					class="flex items-center justify-center"
				>
					<template x-if="!show">
						<Icon name="ri.eye-line" stroke-width="0.5" size={5} />
					</template>
					<template x-if="show">
						<Icon name="ri.eye-off-line" stroke-width="0.5" size={5} />
					</template>
				</button>
			) : null}
		</div>
	);
}

/**
 * Radio Input props
 * @type {import("../common/props").JSXComponent<RadioInputProps>}
 */
export function RadioInput(props) {
	return <input type="radio" {...props} />;
}

/**
 * Select Input props
 * @type {import("../common/props").JSXComponent<SelectInputProps>}
 */
export function SelectInput(props) {
	const {
		items = [],
		placeholder = "Select a value",
		hidePlaceholder = false,
		value,
		class: className,
		...restProps
	} = props;
	return (
		<select
			{...restProps}
			x-data={`{ value: "${value}" }`}
			class={clsx(
				"w-full p-2 text-md border border-base-light rounded cursor-pointer focus:outline-none focus:ring-2 focus:border-primary"
			)}
		>
			<option class={clsx({ hidden: hidePlaceholder })} selected safe>
				{placeholder}
			</option>
			{items.map((item) => {
				return (
					<option value={String(item.value)} safe>
						{item.label}
					</option>
				);
			})}
		</select>
	);
}

/**
 * Text Input props
 * @type {import("../common/props").JSXComponent<TextInputProps>}
 */
export function TextInput(props) {
	const { value = "", class: className, ...restProps } = props;
	return (
		<div
			class={clsx("input input-primary flex items-center gap-x-2", className)}
			x-data={`{ value: "${value}" }`}
		>
			<input type="text" {...restProps} x-model="value" class="flex-1 px-1" />
			<button
				type="button"
				x-bind:disabled="!value"
				x-on:click="value = ''"
				class="flex items-center justify-center"
				x-bind:class="value ? 'opacity-100' : 'opacity-0'"
			>
				<Icon name="ri.close-line" size={5} />
			</button>
		</div>
	);
}

/**
 * Textarea Input props
 * @type {import("../common/props").JSXComponent<TextareaInputProps>}
 */
export function TextareaInput(props) {
	const {
		value = "",
		noResize = false,
		class: className,
		...restProps
	} = props;
	return (
		<textarea
			x-data={`{ value: "${value}" }`}
			x-model="value"
			class={clsx("border rounded-lg h-full p-2", className)}
			{...restProps}
		></textarea>
	);
}

/**
 * Text Input props
 * @type {import("../common/props").JSXComponent<TextInputProps>}
 */
export function TimeInput(props) {
	const { value = "", class: className, ...restProps } = props;
	return (
		<div
			class={clsx("input flex items-center justify-center gap-x-2", className)}
			x-data={`{ value: "${value}" }`}
		>
			<input
				x-ref="timeInput"
				type="time"
				{...restProps}
				x-model="value"
				class="flex-1 px-1"
			/>
			{/* Because calendar-picker-icon doesn't change with theme toggle */}
			<button x-on:click="$refs.timeInput.showPicker()">
				<Icon name="ri.time-line" />
			</button>
		</div>
	);
}


	
	--------------------     Select Component   -------------------------

		import { Icon } from "./icon.component";
import clsx from "clsx";

/**
 * @typedef SelectProps
 * @type {import("./input.component").SelectInputProps & { noInputIcon?: boolean, defaultValue?: import("./input.component").SelectOptionType | null }}
 */

/**
 * Select component props
 * @type {import("../common/props").JSXComponent<SelectProps>}
 */
export function Select(props) {
	const {
		items = [],
		class: className,
		placeholder = "Select Item",
		noInputIcon = false,
		disabled,
		defaultValue,
		...restProps
	} = props;
	return (
		<div
			x-data={`dropdownSelect(${JSON.stringify(items)}, ${defaultValue ? JSON.stringify(defaultValue) : "" })`}
			x-on:keydown="selectKeydown($event);"
			class={clsx("relative", className)}
			{...{
				...restProps,
				"@keydown.escape": `
					if (selectOpen){
						selectOpen = false;
					}
				`,
				"@keydown.down.prevent": `
                    if (selectOpen) {
                        selectableItemActiveNext();
                    } else {
                        selectOpen = true;
                    }
                `,
				"@keydown.up.prevent": `
                    if(selectOpen) {
                        selectableItemActivePrevious();
                    } else {
                        selectOpen=true;
                    }
                `,
				"x-on:keydown.enter": `
					selectedItem = selectableItemActive;
					selectOpen=false;
					$dispatch('select', selectableItemActive);
				`
			}}
		>
			<button
				x-ref="selectButton"
				type="button"
				x-on:click="selectOpen =! selectOpen"
				x-bind:class="{ 'focus:ring-2 focus:ring-offset-2 focus:ring-base' : !selectOpen }"
				class="relative w-full flex items-center justify-between p-1 gap-x-2 text-left hover:cursor-pointer disabled:cursor-not-allowed disabled:text-base/30 focus:outline-none text-sm"
				disabled={disabled}
			>
				<span
					x-text={`selectedItem ? selectedItem.label : "${placeholder}"`}
					class="truncate text-center"
				/>
				{!noInputIcon && (
					<span class="right-0 flex items-center pointer-events-none">
						<Icon name="ri.expand-up-down-line" stroke-width="0.5" />
					</span>
				)}
			</button>

			<ul
				x-show="selectOpen"
				x-ref="selectableItemsList"
				{...{
					"@click.away": "selectOpen = false",
					"x-transition:enter": "transition ease-out duration-50",
					"x-transition:enter-start": "opacity-0 -translate-y-1",
					"x-transition:enter-end": "opacity-100"
				}}
				x-bind:class="{ 'bottom-0 mb-10' : selectDropdownPosition == 'top', 'top-0 mt-10' : selectDropdownPosition == 'bottom' }"
				class="absolute bg-background text-foreground flex flex-col z-[1] w-full grow py-1 mt-1 overflow-y-auto text-sm rounded-md shadow-md max-h-56"
				x-cloak="true"
			>
				<template x-for="item in selectableItems" x-bind:key="item.value">
					<li
						x-on:click="selectedItem = item; selectOpen = false; $refs.selectButton.focus(); $dispatch('select', item)"
						x-bind:id="item.value + '-' + selectId"
						x-bind:data-disabled="item.disabled ?? false"
						x-on:mousemove="selectableItemActive = item"
						class="relative flex items-center h-full py-2 pr-2 pl-8 cursor-pointer select-none hover:bg-base/30 data-[disabled]:bg-base-light data-[disabled]:text-base-dark data-[disabled]:pointer-events-none data-[disabled]:pointer-not-allowed"
					>
						<Icon
							name="ri.check-line"
							x-show="selectedItem?.value == item.value"
							class="absolute left-0 size-4 ml-2 stroke-current text-nmuted-foreground"
						/>
						<span class="block font-medium truncate" x-text="item.label"></span>
					</li>
				</template>
			</ul>
		</div>
	);
}

	
	
	--------------------     Alpine Dependencies   -------------------------

		/**
 * @typedef {Object} DropdownSelectDataOutput
 * @property {boolean} selectOpen
 * @property {import("../../../components/input.component").SelectOptionType | null} selectedItem
 * @property {import("../../../components/input.component").SelectOptionType[]} selectableItems
 * @property {import("../../../components/input.component").SelectOptionType | null} selectableItemActive
 * @property {string} selectId
 * @property {string} selectKeydownValue
 * @property {number} selectKeydownTimeout
 * @property {Timer | null} selectKeydownClearTimeout
 * @property {"top" | "bottom"} selectDropdownPosition
 * @property {(item: import("../../../components/input.component").SelectOptionType) => boolean} selectableItemIsActive
 * @property {Function} selectableItemActiveNext
 * @property {Function} selectableItemActivePrevious
 * @property {(event: KeyboardEvent) => void} selectKeydown
 * @property {() => import("../../../components/input.component").SelectOptionType | null} selectItemsFindBestMatch
 * @property {Function} selectPositionUpdate
 * @property {Function} selectScrollToActiveItem
 */

/**
 * Dropdown-select alpine data
 * @param {import("../../../components/input.component").SelectOptionType[]} selectableItems
 * @param {import("../../../components/input.component").SelectOptionType | null} defaultSelectedItem
 * @returns {import("alpinejs").AlpineComponent<DropdownSelectDataOutput>}
 */
export function dropdownSelectData(
	selectableItems = [],
	defaultSelectedItem = null
) {
	return {
		init() {
			this.$watch("selectOpen", () => {
				if (!this.selectedItem) {
					this.selectableItemActive =
						this.selectableItems.find((item) => !item.disabled) ?? null;
				} else {
					this.selectableItemActive = this.selectedItem;
				}
				setTimeout(() => {
					this.selectScrollToActiveItem();
				}, 10);
				this.selectPositionUpdate();
				window.addEventListener("resize", (event) => {
					this.selectPositionUpdate();
				});
			});
		},
		selectOpen: false,
		selectedItem: defaultSelectedItem,
		selectableItems,
		selectableItemActive: null,
		selectId: this.$id("select"),
		selectKeydownValue: "",
		selectKeydownTimeout: 1000,
		selectKeydownClearTimeout: null,
		selectDropdownPosition: "bottom",
		selectableItemIsActive(item) {
			return this.selectableItemActive?.value == item.value;
		},
		selectableItemActiveNext() {
			let index = this.selectableItems.findIndex(
				(item) => item.value === this.selectableItemActive?.value
			);
			if (index < this.selectableItems.length - 1) {
				if (index + 1 < this.selectableItems.length - 1) {
					if (this.selectableItems[index + 1].disabled) {
						this.selectableItemActive = this.selectableItems[index + 2];
					} else {
						this.selectableItemActive = this.selectableItems[index + 1];
					}
				} else if (!this.selectableItems[index + 1].disabled) {
					this.selectableItemActive = this.selectableItems[index + 1];
				}
				this.selectScrollToActiveItem();
			}
		},
		selectableItemActivePrevious() {
			let index = this.selectableItems.findIndex(
				(item) => item.value === this.selectableItemActive?.value
			);
			if (index > 0) {
				if (index - 1 > 0) {
					if (this.selectableItems[index - 1].disabled) {
						this.selectableItemActive = this.selectableItems[index - 2];
					} else {
						this.selectableItemActive = this.selectableItems[index - 1];
					}
				} else if (!this.selectableItems[index - 1].disabled) {
					this.selectableItemActive = this.selectableItems[index - 1];
				}
				this.selectScrollToActiveItem();
			}
		},
		selectScrollToActiveItem() {
			if (this.selectableItemActive) {
				const activeElement = document.getElementById(
					this.selectableItemActive.value + "-" + this.selectId
				);
				if (activeElement) {
					let newScrollPos =
						activeElement.offsetTop +
						activeElement.offsetHeight -
						this.$refs.selectableItemsList.offsetHeight;
					if (newScrollPos > 0) {
						this.$refs.selectableItemsList.scrollTop = newScrollPos;
					} else {
						this.$refs.selectableItemsList.scrollTop = 0;
					}
				}
			}
		},
		selectKeydown(event) {
			if (Number(event.key) >= 65 && Number(event.key) <= 90) {
				this.selectKeydownValue = event.key;
				const selectedItemBestMatch = this.selectItemsFindBestMatch();
				if (selectedItemBestMatch) {
					if (this.selectOpen) {
						this.selectableItemActive = selectedItemBestMatch;
						this.selectScrollToActiveItem();
					} else {
						this.selectedItem = this.selectableItemActive =
							selectedItemBestMatch;
					}
				}

				if (this.selectKeydownValue != "" && this.selectKeydownClearTimeout) {
					clearTimeout(this.selectKeydownClearTimeout);
					this.selectKeydownClearTimeout = setTimeout(() => {
						this.selectKeydownValue = "";
					}, this.selectKeydownTimeout);
				}
			}
		},
		selectItemsFindBestMatch() {
			const typedValue = this.selectKeydownValue.toLowerCase();
			var bestMatch = null;
			var bestMatchIndex = -1;
			for (var i = 0; i < this.selectableItems.length; i++) {
				var label = this.selectableItems[i].label.toLowerCase();
				var index = label.indexOf(typedValue);
				if (
					index > -1 &&
					(bestMatchIndex == -1 || index < bestMatchIndex) &&
					!this.selectableItems[i].disabled
				) {
					bestMatch = this.selectableItems[i];
					bestMatchIndex = index;
				}
			}
			return bestMatch;
		},
		selectPositionUpdate() {
			const selectDropdownBottomPos =
				this.$refs.selectButton.getBoundingClientRect().top +
				this.$refs.selectButton.offsetHeight +
				parseInt(
					window.getComputedStyle(this.$refs.selectableItemsList).maxHeight
				);
			if (window.innerHeight < selectDropdownBottomPos) {
				this.selectDropdownPosition = "top";
			} else {
				this.selectDropdownPosition = "bottom";
			}
		}
	};
}


	
ComponentsRatings
ComponentsSidebar