Glrk UI

Select

A simplified wrapper for Shadcn Select with flexible option handling and automatic type conversion

Installation

npx shadcn@latest add @glrk-ui/select

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into shadcn select component.

import { cn, getKey, getLabel, getValue, isGroup, isOption, isSeparator } from "@/lib/utils"
ui/select.tsx
type itemProps = {
  option: allowedPrimitiveT | optionT
  className?: string
  indicatorAt?: indicatorAtT
}

function Item({ option, className, indicatorAt }: itemProps) {
  const value = getValue(option)
  const label = getLabel(option)
  const optCls = isOption(option) ? option.className : undefined

  if (isSeparator(value)) return <SelectSeparator className={className} />

  return (
    <SelectItem
      value={`${value}`}
      className={cn(className, optCls)}
      indicatorAt={indicatorAt}
    >
      {label}
    </SelectItem>
  )
}

type selectProps = {
  id?: string
  options: optionsT
  placeholder?: string
  indicatorAt?: indicatorAtT
  triggerCls?: string
  contentCls?: string
  groupCls?: string
  groupLabelCls?: string
  itemCls?: string
} & React.ComponentProps<typeof SelectPrimitive.Root>
function SelectWrapper({
  id, options, placeholder, indicatorAt,
  triggerCls, contentCls, groupCls, itemCls,
  groupLabelCls, ...props
}: selectProps) {
  return (
    <Select {...props}>
      <SelectTrigger id={id} className={cn("w-full", triggerCls)}>
        <SelectValue placeholder={placeholder} />
      </SelectTrigger>

      <SelectContent className={contentCls}>
        {
          options.map((option, i) => {
            if (isGroup(option)) {
              return (
                <SelectGroup key={option.group} className={cn(groupCls, option.className)}>
                  <SelectLabel className={cn("pb-0.5", groupLabelCls)}>{option.group}</SelectLabel>

                  {option.options.map((grOpts, j) => (
                    <Item
                      key={getKey(grOpts, j)}
                      option={grOpts}
                      className={cn("pl-4", itemCls)}
                      indicatorAt={indicatorAt}
                    />
                  ))}
                </SelectGroup>
              )
            }

            return (
              <Item
                key={getKey(option, i)}
                option={option}
                className={itemCls}
                indicatorAt={indicatorAt}
              />
            )
          })
        }
      </SelectContent>
    </Select>
  )
}

Add SelectWrapper at the export block.

export {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectScrollDownButton,
  SelectScrollUpButton,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
  SelectWrapper,
  type selectProps,
}

Installation

npm install @radix-ui/react-select

Copy and paste the following code into your project.

general.d.ts

types/general.d.ts
type readOnlyChildren = Readonly<{
  children: React.ReactNode;
}>

type allowedPrimitiveT = string | number | boolean

type optionT = {
  label: React.ReactNode
  value: allowedPrimitiveT
  className?: string
}

type groupT = {
  group: string
  options: (allowedPrimitiveT | optionT)[]
  className?: string
}

type optionsT = (allowedPrimitiveT | optionT | groupT)[]

type indicatorAtT = "right" | "left"

utils.ts

lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export function isAllowedPrimitive(value: unknown): value is allowedPrimitiveT {
  return ["string", "number", "boolean"].includes(typeof value)
}

export function parseAllowedPrimitive(value: allowedPrimitiveT): allowedPrimitiveT {
  if (typeof value !== "string") return value

  const trimmed = value.trim()

  if (trimmed === "true") return true
  if (trimmed === "false") return false
  if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)

  return trimmed
}

export function optionTypeChecker<T>(key: keyof T) {
  return (option: any): option is T => !!option && typeof option === "object" && key in option
}

export const isSeparator = (item: any) => item === "---"
export const isOption = optionTypeChecker<optionT>("value")
export const isGroup = optionTypeChecker<groupT>("group")

export const getValue = (item: allowedPrimitiveT | optionT) => typeof item === "object" ? item.value : item
export const getLabel = (item: allowedPrimitiveT | optionT) => typeof item === "object" ? item.label : `${item}`

export function getKey(item: allowedPrimitiveT | optionT, i: number): string {
  const val = getValue(item)
  if (typeof val === "boolean") return `key-${val}`
  if (val === "---") return `${i}`
  return `${val}`
}

select.tsx

ui/select.tsx
"use client"

import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"

import { cn, getKey, getLabel, getValue, isGroup, isOption, isSeparator } from "@/lib/utils"

function Select({
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
  return <SelectPrimitive.Root data-slot="select" {...props} />
}

function SelectGroup({
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
  return <SelectPrimitive.Group data-slot="select-group" {...props} />
}

function SelectValue({
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
  return <SelectPrimitive.Value data-slot="select-value" {...props} />
}

function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
  size?: "sm" | "default"
}) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      className={cn(
        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon asChild>
        <ChevronDownIcon className="size-4 opacity-50" />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  )
}

function SelectContent({
  className,
  children,
  position = "popper",
  align = "center",
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
  return (
    <SelectPrimitive.Portal>
      <SelectPrimitive.Content
        data-slot="select-content"
        className={cn(
          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
          position === "popper" &&
          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
          className
        )}
        position={position}
        align={align}
        {...props}
      >
        <SelectScrollUpButton />
        <SelectPrimitive.Viewport
          className={cn(
            "p-1",
            position === "popper" &&
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
          )}
        >
          {children}
        </SelectPrimitive.Viewport>
        <SelectScrollDownButton />
      </SelectPrimitive.Content>
    </SelectPrimitive.Portal>
  )
}

function SelectLabel({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
  return (
    <SelectPrimitive.Label
      data-slot="select-label"
      className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
      {...props}
    />
  )
}

function SelectItem({
  className,
  children,
  indicatorAt = "right",
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Item> & { indicatorAt?: indicatorAtT }) {
  return (
    <SelectPrimitive.Item
      data-slot="select-item"
      className={cn(
        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
        className,
        indicatorAt === "right" ? "pr-8 pl-2" : "pr-2 pl-8",
      )}
      {...props}
    >
      <span className={cn("absolute flex size-3.5 items-center justify-center", indicatorAt === "right" ? "right-2" : "left-2")}>
        <SelectPrimitive.ItemIndicator>
          <CheckIcon className="size-4" />
        </SelectPrimitive.ItemIndicator>
      </span>
      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
    </SelectPrimitive.Item>
  )
}

function SelectSeparator({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
  return (
    <SelectPrimitive.Separator
      data-slot="select-separator"
      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
      {...props}
    />
  )
}

function SelectScrollUpButton({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
  return (
    <SelectPrimitive.ScrollUpButton
      data-slot="select-scroll-up-button"
      className={cn(
        "flex cursor-default items-center justify-center py-1",
        className
      )}
      {...props}
    >
      <ChevronUpIcon className="size-4" />
    </SelectPrimitive.ScrollUpButton>
  )
}

function SelectScrollDownButton({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
  return (
    <SelectPrimitive.ScrollDownButton
      data-slot="select-scroll-down-button"
      className={cn(
        "flex cursor-default items-center justify-center py-1",
        className
      )}
      {...props}
    >
      <ChevronDownIcon className="size-4" />
    </SelectPrimitive.ScrollDownButton>
  )
}

type itemProps = {
  option: allowedPrimitiveT | optionT
  className?: string
  indicatorAt?: indicatorAtT
}

function Item({ option, className, indicatorAt }: itemProps) {
  const value = getValue(option)
  const label = getLabel(option)
  const optCls = isOption(option) ? option.className : undefined

  if (isSeparator(value)) return <SelectSeparator className={className} />

  return (
    <SelectItem
      value={`${value}`}
      className={cn(className, optCls)}
      indicatorAt={indicatorAt}
    >
      {label}
    </SelectItem>
  )
}

type selectProps = {
  id?: string
  options: optionsT
  placeholder?: string
  indicatorAt?: indicatorAtT
  triggerCls?: string
  contentCls?: string
  groupCls?: string
  groupLabelCls?: string
  itemCls?: string
} & React.ComponentProps<typeof SelectPrimitive.Root>
function SelectWrapper({
  id, options, placeholder, indicatorAt,
  triggerCls, contentCls, groupCls, itemCls,
  groupLabelCls, ...props
}: selectProps) {
  return (
    <Select {...props}>
      <SelectTrigger id={id} className={cn("w-full", triggerCls)}>
        <SelectValue placeholder={placeholder} />
      </SelectTrigger>

      <SelectContent className={contentCls}>
        {
          options.map((option, i) => {
            if (isGroup(option)) {
              return (
                <SelectGroup key={option.group} className={cn(groupCls, option.className)}>
                  <SelectLabel className={cn("pb-0.5", groupLabelCls)}>{option.group}</SelectLabel>

                  {option.options.map((grOpts, j) => (
                    <Item
                      key={getKey(grOpts, j)}
                      option={grOpts}
                      className={cn("pl-4", itemCls)}
                      indicatorAt={indicatorAt}
                    />
                  ))}
                </SelectGroup>
              )
            }

            return (
              <Item
                key={getKey(option, i)}
                option={option}
                className={itemCls}
                indicatorAt={indicatorAt}
              />
            )
          })
        }
      </SelectContent>
    </Select>
  )
}

export {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectScrollDownButton,
  SelectScrollUpButton,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
  SelectWrapper,
  type selectProps,
}

Done

You can now use SelectWrapper

Usage

Basic

import { SelectWrapper } from "@/components/ui/select"

export function Basic() {
  return (
    <SelectWrapper
      options={["Option 1", "Option 2", "Option 3"]}
      placeholder="Select an option"
    />
  )
}

Controlled

import { useState } from "react"
import { SelectWrapper } from "@/components/ui/select"
import { parseAllowedPrimitive } from "@/lib/utils"

export function Controlled() {
  const [value, setValue] = useState("apple")

  return (
    <SelectWrapper
      value={value}
      onValueChange={setValue}
      options={["apple", "banana", "orange"]}
    />
  )
}

export function Controlled2() {
  const [value, setValue] = useState<allowedPrimitiveT>("apple")

  return (
    <SelectWrapper
      value={`${value}`}
      onValueChange={(v) => setValue(parseAllowedPrimitive(v))}
      options={["apple", 10, false]}
    />
  )
}

Custom Styling

export function Styled() {
  return (
    <SelectWrapper
      options={["Small", "Medium", "Large"]}
      placeholder="Select size"
      triggerCls="w-[200px] bg-slate-50"
      contentCls="min-w-[200px]"
      itemCls="cursor-pointer hover:bg-slate-100"
      groupCls="py-2"
    />
  )
}

Reference

Prop

Type