summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-25 18:46:57 -0600
committerAdam <[email protected]>2025-12-25 18:46:57 -0600
commit603dae562ac78f48895782e2125805116e18a8f3 (patch)
tree1a3a23b1a93851d25b0a35d73c4ca032cc1b82dc
parent650bd76370a48098139898ddecc14abe47ca2c60 (diff)
downloadopencode-603dae562ac78f48895782e2125805116e18a8f3.tar.gz
opencode-603dae562ac78f48895782e2125805116e18a8f3.zip
chore(ui): radio group primitive
-rw-r--r--packages/ui/src/components/radio-group.css160
-rw-r--r--packages/ui/src/components/radio-group.tsx75
-rw-r--r--packages/ui/src/styles/index.css1
3 files changed, 236 insertions, 0 deletions
diff --git a/packages/ui/src/components/radio-group.css b/packages/ui/src/components/radio-group.css
new file mode 100644
index 000000000..38773b819
--- /dev/null
+++ b/packages/ui/src/components/radio-group.css
@@ -0,0 +1,160 @@
+[data-component="radio-group"] {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing) * 2);
+
+ [data-slot="radio-group-wrapper"] {
+ all: unset;
+ background-color: var(--surface-base);
+ border-radius: var(--radius-md);
+ box-shadow: inset 0 0 0 1px var(--border-weak-base);
+ margin: 0;
+ padding: 0;
+ position: relative;
+ width: fit-content;
+ }
+
+ [data-slot="radio-group-items"] {
+ display: inline-flex;
+ list-style: none;
+ flex-direction: row;
+ }
+
+ [data-slot="radio-group-indicator"] {
+ background: var(--button-secondary-base);
+ border-radius: var(--radius-md);
+ box-shadow:
+ var(--shadow-xs),
+ inset 0 0 0 var(--indicator-focus-width, 0px) var(--border-selected),
+ inset 0 0 0 1px var(--border-base);
+ content: "";
+ opacity: var(--indicator-opacity, 1);
+ position: absolute;
+ transition:
+ opacity 300ms ease-in-out,
+ box-shadow 100ms ease-in-out,
+ width 150ms ease,
+ height 150ms ease,
+ transform 150ms ease;
+ }
+
+ [data-slot="radio-group-item"] {
+ position: relative;
+ }
+
+ /* Separator between items */
+ [data-slot="radio-group-item"]:not(:first-of-type)::before {
+ background: var(--border-weak-base);
+ border-radius: var(--radius-xs);
+ content: "";
+ inset: 6px 0;
+ position: absolute;
+ transition: opacity 150ms ease;
+ width: 1px;
+ transform: translateX(-0.5px);
+ }
+
+ /* Hide separator when item or previous item is checked */
+ [data-slot="radio-group-item"]:has([data-slot="radio-group-item-input"][data-checked])::before,
+ [data-slot="radio-group-item"]:has([data-slot="radio-group-item-input"][data-checked])
+ + [data-slot="radio-group-item"]::before {
+ opacity: 0;
+ }
+
+ [data-slot="radio-group-item-label"] {
+ color: var(--text-weak);
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ display: flex;
+ flex-wrap: nowrap;
+ gap: calc(var(--spacing) * 1);
+ line-height: 1;
+ padding: 6px 12px;
+ place-content: center;
+ position: relative;
+ transition-duration: 150ms;
+ transition-property: color, opacity;
+ transition-timing-function: ease-in-out;
+ user-select: none;
+ }
+
+ [data-slot="radio-group-item-input"] {
+ all: unset;
+ }
+
+ /* Checked state */
+ [data-slot="radio-group-item-input"][data-checked] + [data-slot="radio-group-item-label"] {
+ color: var(--text-strong);
+ }
+
+ /* Disabled state */
+ [data-slot="radio-group-item-input"][data-disabled] + [data-slot="radio-group-item-label"] {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+
+ /* Hover state for unchecked, enabled items */
+ [data-slot="radio-group-item-input"]:not([data-checked], [data-disabled]) + [data-slot="radio-group-item-label"] {
+ cursor: pointer;
+ user-select: none;
+ }
+
+ [data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
+ + [data-slot="radio-group-item-label"]:hover {
+ color: var(--text-base);
+ }
+
+ [data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
+ + [data-slot="radio-group-item-label"]:active {
+ opacity: 0.7;
+ }
+
+ /* Focus state */
+ [data-slot="radio-group-wrapper"]:has([data-slot="radio-group-item-input"]:focus-visible)
+ [data-slot="radio-group-indicator"] {
+ --indicator-focus-width: 2px;
+ }
+
+ /* Hide indicator when nothing is checked */
+ [data-slot="radio-group-wrapper"]:not(:has([data-slot="radio-group-item-input"][data-checked]))
+ [data-slot="radio-group-indicator"] {
+ --indicator-opacity: 0;
+ }
+
+ /* Vertical orientation */
+ &[aria-orientation="vertical"] [data-slot="radio-group-items"] {
+ flex-direction: column;
+ }
+
+ &[aria-orientation="vertical"] [data-slot="radio-group-item"]:not(:first-of-type)::before {
+ height: 1px;
+ width: auto;
+ inset: 0 6px;
+ transform: translateY(-0.5px);
+ }
+
+ /* Small size variant */
+ &[data-size="small"] {
+ [data-slot="radio-group-item-label"] {
+ font-size: 12px;
+ padding: 4px 8px;
+ }
+
+ [data-slot="radio-group-item"]:not(:first-of-type)::before {
+ inset: 4px 0;
+ }
+
+ &[aria-orientation="vertical"] [data-slot="radio-group-item"]:not(:first-of-type)::before {
+ inset: 0 4px;
+ }
+ }
+
+ /* Disabled root state */
+ &[data-disabled] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
diff --git a/packages/ui/src/components/radio-group.tsx b/packages/ui/src/components/radio-group.tsx
new file mode 100644
index 000000000..e1812d61a
--- /dev/null
+++ b/packages/ui/src/components/radio-group.tsx
@@ -0,0 +1,75 @@
+import { SegmentedControl as Kobalte } from "@kobalte/core/segmented-control"
+import { For, splitProps } from "solid-js"
+import type { ComponentProps, JSX } from "solid-js"
+
+export type RadioGroupProps<T> = Omit<
+ ComponentProps<typeof Kobalte>,
+ "value" | "defaultValue" | "onChange" | "children"
+> & {
+ options: T[]
+ current?: T
+ defaultValue?: T
+ value?: (x: T) => string
+ label?: (x: T) => JSX.Element | string
+ onSelect?: (value: T | undefined) => void
+ class?: ComponentProps<"div">["class"]
+ classList?: ComponentProps<"div">["classList"]
+ size?: "small" | "medium"
+}
+
+export function RadioGroup<T>(props: RadioGroupProps<T>) {
+ const [local, others] = splitProps(props, [
+ "class",
+ "classList",
+ "options",
+ "current",
+ "defaultValue",
+ "value",
+ "label",
+ "onSelect",
+ "size",
+ ])
+
+ const getValue = (item: T): string => {
+ if (local.value) return local.value(item)
+ return String(item)
+ }
+
+ const getLabel = (item: T): JSX.Element | string => {
+ if (local.label) return local.label(item)
+ return String(item)
+ }
+
+ const findOption = (v: string): T | undefined => {
+ return local.options.find((opt) => getValue(opt) === v)
+ }
+
+ return (
+ <Kobalte
+ {...others}
+ data-component="radio-group"
+ data-size={local.size ?? "medium"}
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ value={local.current ? getValue(local.current) : undefined}
+ defaultValue={local.defaultValue ? getValue(local.defaultValue) : undefined}
+ onChange={(v) => local.onSelect?.(findOption(v))}
+ >
+ <div role="presentation" data-slot="radio-group-wrapper">
+ <Kobalte.Indicator data-slot="radio-group-indicator" />
+ <div role="presentation" data-slot="radio-group-items">
+ <For each={local.options}>
+ {(option) => (
+ <Kobalte.Item value={getValue(option)} data-slot="radio-group-item">
+ <Kobalte.ItemInput data-slot="radio-group-item-input" />
+ <Kobalte.ItemLabel data-slot="radio-group-item-label">{getLabel(option)}</Kobalte.ItemLabel>
+ </Kobalte.Item>
+ )}
+ </For>
+ </div>
+ </div>
+ </Kobalte>
+ )
+}
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index c4302a4d3..5782d2a29 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -29,6 +29,7 @@
@import "../components/message-nav.css" layer(components);
@import "../components/popover.css" layer(components);
@import "../components/progress-circle.css" layer(components);
+@import "../components/radio-group.css" layer(components);
@import "../components/resize-handle.css" layer(components);
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);