Skip to content

Commit

Permalink
UI kit: Radio (#138)
Browse files Browse the repository at this point in the history
  • Loading branch information
bpierre authored Apr 30, 2024
1 parent 4522def commit df31435
Show file tree
Hide file tree
Showing 6 changed files with 435 additions and 0 deletions.
37 changes: 37 additions & 0 deletions frontend/uikit-gallery/src/Radio/1. Default.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { Radio, RadioGroup } from "@liquity2/uikit";
import { useState } from "react";
import type { ReactNode } from "react";

export default function RadioFixture() {
const [selected, setSelected] = useState(0);
const options = ["Option 1", "Option 2", "Option 3"];

return (
<RadioGroup onChange={setSelected} selected={selected}>
{options.map((label, index) => (
<Label key={index}>
<Radio index={index} /> {label}
</Label>
))}
</RadioGroup>
);
}

function Label({ children }: { children: ReactNode }) {
return (
<label
style={{
display: "flex",
alignItems: "center",
height: 32,
gap: 8,
cursor: "pointer",
userSelect: "none",
}}
>
{children}
</label>
);
}
37 changes: 37 additions & 0 deletions frontend/uikit-gallery/src/Radio/2. Disabled.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { Radio, RadioGroup } from "@liquity2/uikit";
import { useState } from "react";
import type { ReactNode } from "react";

export default function RadioFixture() {
const [selected, setSelected] = useState(0);
const options = ["Option 1", "Option 2", "Option 3"];

return (
<RadioGroup onChange={setSelected} selected={selected}>
{options.map((label, index) => (
<Label key={index}>
<Radio index={index} disabled={true} /> {label}
</Label>
))}
</RadioGroup>
);
}

function Label({ children }: { children: ReactNode }) {
return (
<label
style={{
display: "flex",
alignItems: "center",
height: 32,
gap: 8,
cursor: "pointer",
userSelect: "none",
}}
>
{children}
</label>
);
}
202 changes: 202 additions & 0 deletions frontend/uikit/src/Radio/Radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import type { CSSProperties } from "react";

import { a, useTransition } from "@react-spring/web";
import { useEffect, useRef } from "react";
import { css, cx } from "../../styled-system/css";
import { useTheme } from "../Theme/Theme";
import { useRadioGroup } from "./RadioGroup";

export function Radio({
checked: checkedProp,
disabled,
index,
onChange,
tabIndex,
}: {
checked?: boolean;
disabled?: boolean;
index?: number;
onChange?: (checked: boolean) => void;
tabIndex?: number;
}) {
const input = useRef<null | HTMLInputElement>(null);
const radioGroup = useRadioGroup(index);
const inRadioGroup = radioGroup !== null;
const checked = checkedProp ?? (inRadioGroup && index === radioGroup.selected);
const { color } = useTheme();

if (!onChange) {
if (!inRadioGroup || index === undefined) {
throw new Error(
"Radio requires an onChange handler or to be in a RadioGroup with the index prop being set.",
);
}
onChange = (checked) => {
if (checked) radioGroup.select(index);
};
}

const handleChange = () => {
if (onChange) {
onChange(!checked);
}
};

const handleClick = () => {
if (onChange && !disabled) {
onChange(!checked);
}
};

const firstRender = useRef(true);
useEffect(() => {
if (checked && inRadioGroup && !firstRender.current) {
input.current?.focus();
}
firstRender.current = false;
}, [checked, inRadioGroup]);

const checkTransition = useTransition(checked, {
config: {
mass: 1,
tension: 2400,
friction: 100,
},
initial: {
dotColor: color(disabled ? "disabledBorder" : "controlSurface"),
ringColor: color("accent"),
scale: 0.4, // 8px
},
from: {
dotColor: color(disabled ? "disabledBorder" : "controlSurface"),
ringColor: color("accent"),
scale: 0.9, // 18px
},
enter: {
scale: 0.4, // 8px
},
leave: {
dotColor: color(disabled ? "disabledBorder" : "controlSurface"),
ringColor: color("controlBorder"),
scale: 0.9, // 18px
},
});

return (
<div
onClick={handleClick}
className={css({
position: "relative",
display: "inline-block",
width: 20,
height: 20,
})}
>
<input
ref={input}
checked={checked}
disabled={disabled}
onChange={handleChange}
onKeyDown={radioGroup?.onKeyDown}
tabIndex={tabIndex ?? (
radioGroup && (
radioGroup.focusableIndex === undefined
|| index === radioGroup.focusableIndex
)
? 0
: -1
)}
type="radio"
className={cx(
"peer",
css({
zIndex: 1,
position: "absolute",
inset: 0,
opacity: 0,
pointerEvents: "none",
}),
)}
/>
<div
className={css({
position: "absolute",
inset: 0,
background: "controlSurface",
border: "1px solid token(colors.controlBorder)",
borderRadius: "50%",
_peerActive: {
borderColor: "accentActive",
},
_peerDisabled: {
background: "disabledSurface",
borderColor: "disabledBorder!",
},
})}
/>
<div
// focus ring
className={css({
display: "none",
position: "absolute",
inset: 0,
overflow: "hidden",
background: "background",
borderRadius: "50%",
outline: "2px solid token(colors.focused)",
outlineOffset: 3,
_peerFocusVisible: {
display: "block",
},
})}
/>
{checkTransition((style, checked) => (
checked && (
<a.div
className={css({
position: "absolute",
inset: 0,
borderRadius: "50%",
background: "var(--ringColor)",
_peerActive: {
background: "accentActive",
"& > div": {
transform: "scale(0.9)",
},
},
_peerDisabled: {
background: "disabledSurface!",
border: "1px solid token(colors.disabledBorder)!",
"& > div": {
transform: "scale(1)!",
},
},
})}
style={{
"--ringColor": style.ringColor,
} as CSSProperties}
>
<div
className={css({
position: "absolute",
inset: 0,
})}
>
<a.div
className={css({
position: "absolute",
inset: 0,
borderRadius: "50%",
})}
style={{
background: style.dotColor,
scale: style.scale,
}}
/>
</div>
</a.div>
)
))}
</div>
);
}
Loading

0 comments on commit df31435

Please sign in to comment.