Glrk UI

Shadcn Component Wrappers

Overview

Shadcn UI provides an excellent foundation for building design systems, but directly importing and composing individual components throughout your application can lead to verbose, repetitive code. This guide demonstrates a wrapper-based approach that simplifies component usage while maintaining the flexibility to use native Shadcn components when needed for complex UI scenarios.

Key Benefits

  • Reduced Boilerplate: Eliminate repetitive component composition patterns
  • Type Safety: Built-in TypeScript support for common value types
  • Consistent API: Standardized interface across similar components
  • Flexibility: Preserve access to native Shadcn components for advanced use cases

Prerequest

  • Shadcn base setup
  • add following to your components.json at registries block
{
  "registries": {
    "@glrk-ui": "https://ui.glrk.dev/registry/{name}.json"
  }
}

Type Definitions

Core Types

The wrapper system supports three primitive value types that cover the majority of form and selection use cases:

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"

Design Rationale

Primitive Type Restriction: The system intentionally limits allowed values to string, number, and boolean. These types handle the vast majority of form scenarios.

Empty Values: Use empty strings ("") for empty values, or omit the property entirely. Extend the type system as needed for your specific requirements.


Utility Functions

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}`
}

Options Configuration

Flexible Option Formats

The wrapper system supports multiple option formats to accommodate different use cases:

const options: optionsT = [
  // Simple primitive values (label equals value)
  "Data 1",
  false,
  12,
  
  // Visual separator
  "---",
  
  // Object format with custom label
  {
    label: "Obj 1",
    value: "obj-1", 
  },
  
  // Object with custom styling
  {
    label: "Obj 2",
    value: "obj-2",
    className: "bg-red-50",
  },
  
  // Rich content with React nodes
  {
    value: "apple",
    label: <><Apple className="mr-2" /> Apple</>
  },
  
  "---",
  
  // Grouped options
  {
    group: "Group 1",
    options: [
      "grp 1",
      21,
      true,
      { value: "banana", label: <><Banana className="mr-2" /> Banana</> }
    ],
  },
  
  // Grouped options with styling
  {
    group: "Group 2",
    options: ["grp 2", 22],
    className: "bg-amber-50",
  },
]

Option Format Guidelines

  • Primitive values: Use directly when the display label matches the value
  • Separators: Use "---" string for visual separation between items
  • Object format: Use when you need custom labels, styling, or rich content
  • Grouped options: Organize related options under labeled groups
  • Custom styling: Apply Tailwind classes via className for visual distinction

Value Conversion Pattern

Understanding String Conversion

Shadcn's controlled components use string values internally. The wrapper system handles conversion between strings and primitive types transparently. Some components directly supports type conversion. If the component not supported this feature, you can use following guide.

Implementation Patterns

// ❌ Incorrect: Type mismatch
const [value, setValue] = useState(true)

<SelectWrapper
  value={value}
  onValueChange={setValue}
/>

// ✅ Correct: Convert to string
const [value, setValue] = useState("true")

<SelectWrapper
  value={value}
  onValueChange={setValue}
/>

// ✅ Recommended: Preserve primitive types
const [value, setValue] = useState<allowedPrimitiveT>(true)

<SelectWrapper
  value={`${value}`}
  onValueChange={v => setValue(parseAllowedPrimitive(v))}
/>

Best Practices

  1. String State: Simplest approach for string-only values
  2. Primitive State with Conversion: Use parseAllowedPrimitive to maintain type fidelity
  3. Template Literals: Use `${value}` for type-safe string conversion
  4. Type Annotations: Explicitly type state as allowedPrimitiveT when using conversion

Next Steps

With this foundation in place, you can create wrapper components for common Shadcn UI elements. Checkout custom wrappers.