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 your project.

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"
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}`
}
ui/select.tsx
"use client"

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

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

const Select = SelectPrimitive.Root

function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
  return (
    <SelectPrimitive.Group
      data-slot="select-group"
      className={cn("scroll-my-1 p-1", className)}
      {...props}
    />
  )
}

function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
  return (
    <SelectPrimitive.Value
      data-slot="select-value"
      className={cn("flex flex-1 text-left", className)}
      {...props}
    />
  )
}

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

function SelectContent({
  className,
  children,
  side = "bottom",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
  alignItemWithTrigger = true,
  ...props
}: SelectPrimitive.Popup.Props &
  Pick<
    SelectPrimitive.Positioner.Props,
    "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
  >) {
  return (
    <SelectPrimitive.Portal>
      <SelectPrimitive.Positioner
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        alignItemWithTrigger={alignItemWithTrigger}
      >
        <SelectPrimitive.Popup
          data-slot="select-content"
          data-align-trigger={alignItemWithTrigger}
          className={cn("relative max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className)}
          {...props}
        >
          <SelectScrollUpButton />
          <SelectPrimitive.List>{children}</SelectPrimitive.List>
          <SelectScrollDownButton />
        </SelectPrimitive.Popup>
      </SelectPrimitive.Positioner>
    </SelectPrimitive.Portal>
  )
}

function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
  return (
    <SelectPrimitive.GroupLabel
      data-slot="select-label"
      className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
      {...props}
    />
  )
}

function SelectItem({
  className,
  children,
  indicatorAt = "right",
  ...props
}: SelectPrimitive.Item.Props & { indicatorAt?: indicatorAtT }) {
  return (
    <SelectPrimitive.Item
      data-slot="select-item"
      className={cn(
        "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground 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}
    >
      <SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
        {children}
      </SelectPrimitive.ItemText>

      <SelectPrimitive.ItemIndicator
        className={cn("pointer-events-none absolute flex size-4 items-center justify-center", indicatorAt === "right" ? "right-2" : "left-2")}
      >
        <CheckIcon className="pointer-events-none" />
      </SelectPrimitive.ItemIndicator>
    </SelectPrimitive.Item>
  )
}

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

function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
  return (
    <SelectPrimitive.ScrollUpArrow
      data-slot="select-scroll-up-button"
      className={cn(
        "top-0 z-1 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      <ChevronUpIcon
      />
    </SelectPrimitive.ScrollUpArrow>
  )
}

function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
  return (
    <SelectPrimitive.ScrollDownArrow
      data-slot="select-scroll-down-button"
      className={cn(
        "bottom-0 z-1 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      <ChevronDownIcon
      />
    </SelectPrimitive.ScrollDownArrow>
  )
}

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={cn(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,
}

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