UI Component Wrappers
Overview
These are opinionated wrappers built on top of Base UI and Shadcn primitives. Directly composing raw primitives throughout an application leads to verbose, repetitive code — these wrappers give you a clean, consistent API while keeping full access to the underlying Base UI or Shadcn UI components when you need advanced control.
Key Benefits
- Reduced Boilerplate: Eliminate repetitive primitive composition patterns
- Type Safety: Built-in TypeScript support for common value types
- Consistent API: Standardized interface across similar components
- Flexibility: Preserve access to underlying Base UI and shadcn primitives for advanced use cases
Prerequest
- shadcn base setup
- add following to your
components.jsonatregistriesblock
{
"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:
type allowedPrimitiveT = string | number | boolean
type itemT = {
label: React.ReactNode
value: allowedPrimitiveT
className?: string
disabled?: boolean
}
type groupT = {
group: string
items: (allowedPrimitiveT | itemT)[]
className?: string
}
type itemsT = (allowedPrimitiveT | itemT | 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
import { isValidElement, type ReactNode } from 'react'
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 itemTypeChecker<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 = itemTypeChecker<itemT>('value')
export const isGroup = itemTypeChecker<groupT>('group')
export const getValue = (item: allowedPrimitiveT | itemT) =>
typeof item === 'object' ? item.value : item
export const getLabel = (item: allowedPrimitiveT | itemT) =>
typeof item === 'object' ? item.label : `${item}`
export function getKey(item: allowedPrimitiveT | itemT, i: number): string {
const val = getValue(item)
if (typeof val === 'boolean') return `key-${val}`
if (val === '---') return `---${i}`
return `${val}`
}
export function extractText(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return ''
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (isValidElement(node)) return extractText((node.props as { children?: ReactNode }).children)
if (Array.isArray(node)) return node.map(extractText).join('')
return ''
}
Items Configuration
Flexible Item Formats
The wrapper system supports multiple item formats to accommodate different use cases:
const items: itemsT = [
// 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 items
{
group: "Group 1",
items: [
"grp 1",
21,
true,
{ value: "banana", label: <><Banana className="mr-2" /> Banana</> }
],
},
// Grouped items with styling
{
group: "Group 2",
items: ["grp 2", 22],
className: "bg-amber-50",
},
]Item 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 items: Organize related items under labeled groups
- Custom styling: Apply Tailwind classes via
classNamefor visual distinction
Value Conversion Pattern
Understanding String Conversion
Controlled components use string values internally. The wrapper system handles conversion between strings and primitive types transparently. Some components directly support type conversion. If a component doesn't support this feature, use the 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
- String State: Simplest approach for string-only values
- Primitive State with Conversion: Use
parseAllowedPrimitiveto maintain type fidelity - Template Literals: Use
`${value}`for type-safe string conversion - Type Annotations: Explicitly type state as
allowedPrimitiveTwhen using conversion
Next Steps
With this foundation in place, you can use the wrapper components for common UI elements. Checkout the wrappers.