OneJS UI Components
Inspired by Headless UI, OneJS offers a set of unstyled UI components. Example components are also included to demonstrate their usage and styling.
Sample code is available at {ProjectDir}/OneJS/Samples/comps-sample.tsx
. Beyond just demonstrations, the source code of these example components are handy references. You can use them as they are, or you can copy and adjust them to fit your needs.
<gradientrect class="w-full h-full justify-center items-center" colors={[c("#42c873"), c("#06a0bb")]}>
<ExampleTabs class="mb-4" />
<Select class="min-w-[200px] mb-4" items={people} onChange={setSelectedPerson} />
<Toggle class="mb-4" checked={checked} onChange={setChecked} />
<DiamondToggle class="mb-4" checked={checked2} onChange={setChecked2} />
<RadioToggle class="mb-4" items={tiers} onChange={setSelectedTier} />
</gradientrect>
Headless UI Comps
Misc Samples
Toggle
import { Switch, Toggle, DiamondToggle } from "onejs/comps"
Toggle
is the simplest comp here. If you are new with all this headless UI stuff and Render Props, you should just start with checking out the source code for Toggle
. It's located at ScriptLib/onejs/comps/Toggle.tsx
. <Switch />
is the headless component. <Toggle />
and <DiamondToggle />
are example comps using <Switch />
.
Sample <Switch /> Usage
const Toggle = ({ class: classProp, children, checked, onChange, style }) => {
return <Switch class={`w-16 h-8 rounded-[16px] p-[2px] ${checked ? 'bg-[rgba(0_0_0_0.8)]' : 'bg-[rgba(0_0_0_0.5)]'} transition-[background-color] duration-200 ${classProp}`} checked={checked} onChange={onChange} style={style}>
{({ checked }) => (
<div class={`w-[28px] h-[28px] bg-white rounded-full ${checked ? 'translate-x-[32px]' : 'translate-x-0'} transition-[translate] duration-200`} onClick={() => onChange && onChange(!checked)} />
)}
</Switch>
}
const DiamondToggle = ({ class: classProp, children, checked, onChange, style }) => {
return <Switch class={`w-8 h-8 p-[6px] rounded-md border-[1px] ${checked ? 'border-[rgba(255_255_255_0.8)] bg-[rgba(0_0_0_0.5)] rotate-[45deg]' : 'border-[rgba(255_255_255_0.5)] bg-[rgba(0_0_0_0.8)] rotate-0'} transition-[background-color,rotate] duration-200 ${classProp}`} checked={checked} onChange={onChange} style={style}>
{({ checked }) => (
<div class={`w-[18px] h-[18px] bg-white rounded-sm ${checked ? 'flex' : 'hidden'}`} onClick={() => onChange && onChange(!checked)} />
)}
</Switch>
}
Select
import { Listbox, Select } from "onejs/comps"
Select
implements a dropdown using the headless Listbox
comp. The most interesting thing here is how Listbox
works around UI Toolkit's lack of z-index. Check out the source code for Listbox.Options
for that.
Sample <Listbox /> Usage
const Select = ({ class: classProp, items, index, onChange, style }) => {
index = index || 0
const [selectedItem, setSelectedItem] = useState(items[index])
useEffect(() => {
onChange && onChange(selectedItem)
}, [selectedItem])
return <Listbox class={`relative ${classProp}`} items={items} index={index} onChange={setSelectedItem}>
<Listbox.Button class={`default-bg-color active-text-color bold rounded-sm px-[12px] py-[10px] flex-row justify-between`}>
<div class="">{selectedItem.name}</div>
<FAIcon name="down-dir" class="active-text-color translate-y-1" />
</Listbox.Button>
<Listbox.Options class="absolute default-bg-color default-text-color rounded-sm py-2 mt-2">
{items.map((item, i) => (
<Listbox.Option index={i} class={`hover:hover-bg-color hover:active-text-color px-[12px] py-[10px] flex-row justify-between`} item={item}>
{({ selected }) => <Fragment>
<div class={`bold ${selected ? 'active-text-color' : ''}`}>
{item.name}
</div>
{selected ? <FAIcon name="ok" class="active-text-color translate-y-1" /> : null}
</Fragment>}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
}
Radio Group
import { RadioGroup, RadioToggle } from "onejs/comps"
RadioToggle
demonstrates how to use RadioGroup
to design your own multi-choice toggle comp.
Sample <RadioGroup /> Usage
const RadioToggle = ({ class: classProp, items, index, onChange, style }: RadioToggleProps) => {
index = index || 0
function onChangeIndex(index: number) {
onChange && onChange(items[index].value)
}
return <RadioGroup class={`flex flex-row rounded-sm overflow-hidden default-bg-color active-text-color bold ${classProp}`} index={index} onChange={onChangeIndex}>
{items.map((item, i) => (
<RadioGroup.Option class={({ checked }) => `${checked ? "accented-bg-color highlighted-text-color" : "bg-white"} p-3 transition-[background-color] duration-200`} index={i}>
{({ checked }) => <Fragment>{item.label}</Fragment>}
</RadioGroup.Option>
))}
</RadioGroup>
}
Tab
import { Tab, ExampleTabs } from "onejs/comps"
Sample <Tab /> Usage
ExampleTabs
demonstrates Tab.Group
, Tab.List
, and Tab.Panels
usage.
const ExampleTabs = ({ class: classProp, style }: ExampleTabsProps) => {
function onChange(index: number) {
log(`Tabs index changed to ${index}`)
}
return <Tab.Group class={`flex-col w-[500px] ${classProp}`} onChange={onChange} style={style}>
<Tab.List class={`flex-row justify-between rounded-md bg-black/50 p-1 mb-2`}>
{exampleTabs.map((tab, index) =>
<Tab name={tab.label} index={index} class={({ selected }) => (classNames(`flex-row rounded-md text-white/80 items-center bold justify-center p-3 transition-[background-color] duration-200`, selected ? `bg-white active-text-color` : `hover:bg-white/10`))} style={{ width: `${98 / exampleTabs.length}%` }}>{tab.label}</Tab>
)}
</Tab.List>
<Tab.Panels>
{exampleTabs.map((tab, index) =>
<Tab.Panel class={`bg-white rounded-md p-5`}>{tab.content}</Tab.Panel>
)}
</Tab.Panels>
</Tab.Group>
}
Transition
import { Transition } from "onejs/comps"
Using Transition
and Transition.Child
together is a great way to animate multiple components at the same time and make them respond to the same state change. Sample usage code is located at Samples/transition-sample.tsx
.
Sample <Transition /> Usage
const App = () => {
const [show1, setShow1] = useState(true)
const [show2, setShow2] = useState(true)
return <gradientrect class="w-full h-full flex-row justify-center items-center" colors={[c("#42c873"), c("#06a0bb")]}>
<div class="w-64 h-64 justify-center items-center">
<div class="h-32 w-32 mb-8">
<Transition
class="h-full w-full"
show={show1}
appear={true}
enter="transition[opacity,rotate,scale] duration-[400ms]"
enterFrom="opacity-0 rotate-[-120deg] scale-50"
enterTo="opacity-100 rotate-0 scale-100"
leave="transition[opacity,rotate,scale] duration-200 ease-in-out"
leaveFrom="opacity-100 rotate-0 scale-100"
leaveTo="opacity-0 scale-75"
>
<div class="h-full w-full rounded-md bg-orange-200" />
</Transition>
</div>
<button onClick={() => setShow1((show1) => !show1)} text="Toggle" />
</div>
<div class="w-64 h-64 justify-center items-center">
<div class="h-32 w-32 mb-8">
<Transition class="h-full w-full flex-row justify-around" show={show2} appear={true}>
<div class="h-full w-[48%]">
<Transition.Child class="h-full w-full"
enter="transition[translate,opacity] duration-[400ms]"
enterFrom="opacity-0 translate-y-[20%]"
enterTo="opacity-100 translate-y-[0]"
leave="transition[translate,opacity] duration-200 ease-in-out"
leaveFrom="opacity-100 translate-y-[0]"
leaveTo="opacity-0 translate-y-[20%]"
>
<div class="h-full w-full rounded-md bg-orange-200" />
</Transition.Child>
</div>
<div class="h-full w-[48%]">
<Transition.Child class="h-full w-full"
enter="transition[translate,opacity] duration-[400ms]"
enterFrom="opacity-0 translate-y-[-20%]"
enterTo="opacity-100 translate-y-[0]"
leave="transition[translate,opacity] duration-200 ease-in-out"
leaveFrom="opacity-100 translate-y-[0]"
leaveTo="opacity-0 translate-y-[-20%]"
>
<div class="h-full w-full rounded-md bg-orange-200" />
</Transition.Child>
</div>
</Transition>
</div>
<button onClick={() => setShow2((show2) => !show2)} text="Toggle" />
</div>
</gradientrect>
}
Slider Sample
import { Slider } from "onejs/comps"
This is a pure-JS implementation of a Slider component. Free feel to copy and modify according to your own needs.
Slider implementation
const Slider = ({ class: classProp, style, value, onChange, min: _min, max: _max }: SliderProps) => {
const ref = useRef<Dom>()
const progressRef = useRef<Dom>()
const thumbRef = useRef<Dom>()
const [mouseDown, setMouseDown] = useState(false)
const min = _min || 0
const max = _max || 1
let currentValue = value === null || typeof value === "undefined" ? min : value
let currentFraction = (currentValue - min) / (max - min)
useEffect(() => {
document.body.addEventListener("MouseMove", handleMouseMove)
document.body.addEventListener("MouseUp", handleMouseUp)
return () => {
document.body.removeEventListener("MouseMove", handleMouseMove)
document.body.removeEventListener("MouseUp", handleMouseUp)
}
}, [mouseDown])
function calculateFromMouseX(clientX) {
const rect = ref.current.ve.worldBound
const fraction = (clientX - rect.left) / rect.width
const newValue = (min + fraction * (max - min))
return Math.min(Math.max(newValue, min), max)
}
function processValueChange(newValue: number) {
if (newValue != currentValue) {
onChange && onChange(newValue)
currentValue = newValue
currentFraction = (currentValue - min) / (max - min)
const rect = ref.current.ve.worldBound
progressRef.current.style.width = `${currentFraction * 100}%`
thumbRef.current.style.left = currentFraction * rect.width
}
}
function handleMouseDown(e: MouseDownEvent) {
setMouseDown(true)
const newValue = calculateFromMouseX(e.mousePosition.x)
processValueChange(newValue)
}
function handleMouseMove(e: MouseMoveEvent) {
if (!mouseDown) return
const newValue = calculateFromMouseX(e.mousePosition.x)
processValueChange(newValue)
}
function handleMouseUp() {
setMouseDown(false)
}
return <div class={`h-[30px] justify-center ${classProp}`} ref={ref} onMouseDown={handleMouseDown} style={style}>
<div class={`w-full h-[8px] bg-gray-400`} style={{ borderRadius: 4 }}>
<div ref={progressRef} class={`accented-bg-color h-[8px] justify-center`} style={{ width: `${Math.round(currentFraction * 100)}%`, borderRadius: 4 }}>
</div>
</div>
<div ref={thumbRef} class={`w-[24px] h-[24px] default-bg-color absolute rounded-full translate-x-[-10px]`} />
</div>
}
Color Picker Texture Fill
Samples/colorpicker-sample.tsx
This one is a simple example of calling custom C# job from JS.
colorpicker-sample.tsx
import { GradientTextureFillJob } from "OneJS/Utils"
import { Color, Texture2D, TextureFormat } from "UnityEngine"
import { Slider } from "onejs/comps"
import { parseColor as c } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { useEffect } from "preact/hooks"
var texture = new Texture2D(200, 200, TextureFormat.RGBA32, false)
var colors = texture.GetRawDataColor32()
const App = () => {
useEffect(() => {
onChange(0)
}, [])
function onChange(v: number) {
var hueColor = Color.HSVToRGB(v, 1, 1)
GradientTextureFillJob.Run(colors, 200, 200, hueColor)
texture.Apply()
}
return <gradientrect class="w-full h-full justify-center items-center" colors={[c("#42c873"), c("#06a0bb")]}>
<image class="mb-4" image={texture} />
<Slider class="w-[200px]" onChange={onChange} />
</gradientrect>
}
render(<App />, document.body)
GradientTextureFillJob.cs
[BurstCompile]
public struct GradientTextureFillJob : IJobParallelFor {
public NativeArray<Color32> colors;
public int width;
public int height;
public Color32 topRightColor;
// Convenience static method for easy calling from JS for example
public static void Run(NativeArray<Color32> colors, int width, int height, Color32 topRightColor) {
var job = new GradientTextureFillJob {
colors = colors,
width = width,
height = height,
topRightColor = topRightColor
};
job.Schedule(colors.Length, 64).Complete();
}
public void Execute(int index) {
int x = index % width;
int y = index / height;
float fx = (float)x / (float)width;
float fy = (float)y / (float)height;
Color32 leftColor = Color32.Lerp(Color.black, Color.white, fy);
Color32 rightColor = Color32.Lerp(Color.black, topRightColor, fy);
Color32 pixelColor = Color32.Lerp(leftColor, rightColor, fx);
colors[index] = pixelColor;
}
}