Merge pull request 'feat(ui): SidebarPane component' (#4248) from ui/sidebar-pane into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4248
This commit is contained in:
brianmcgee
2025-07-08 07:37:47 +00:00
21 changed files with 400 additions and 61 deletions

View File

@@ -1,15 +1,15 @@
div.divider {
@apply bg-inv-2;
hr {
@apply border-none outline-none bg-inv-2;
&.inverted {
@apply bg-def-3;
}
&.horizontal {
&[data-orientation="horizontal"] {
@apply w-full h-px;
}
&.vertical {
&[data-orientation="vertical"] {
@apply h-full w-px;
}
}

View File

@@ -1,14 +1,18 @@
import "./Divider.css";
import cx from "classnames";
import { Separator, SeparatorRootProps } from "@kobalte/core/separator";
export interface DividerProps {
export interface DividerProps extends Pick<SeparatorRootProps, "orientation"> {
inverted?: boolean;
orientation?: "horizontal" | "vertical";
}
export const Divider = (props: DividerProps) => {
const inverted = props.inverted || false;
const orientation = () => props.orientation || "horizontal";
return <div class={cx("divider", orientation(), { inverted: inverted })} />;
return (
<Separator
class={cx({ inverted: inverted })}
orientation={props.orientation}
/>
);
};

View File

@@ -16,6 +16,10 @@ div.form-field {
&[data-invalid] {
@apply border-semantic-error-4;
}
&[data-readonly] {
@apply cursor-default bg-inherit border-none;
}
}
}
@@ -32,6 +36,10 @@ div.form-field {
&[data-disabled] {
@apply bg-def-4 border-none;
}
&[data-readonly] {
@apply bg-inherit;
}
}
}
}

View File

@@ -94,7 +94,15 @@ export const Disabled: Story = {
},
};
export const ReadOnly: Story = {
export const ReadOnlyUnchecked: Story = {
args: {
...Tooltip.args,
readOnly: true,
defaultChecked: false,
},
};
export const ReadOnlyChecked: Story = {
args: {
...Tooltip.args,
readOnly: true,

View File

@@ -11,37 +11,64 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
import "./Checkbox.css";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { Show } from "solid-js";
export type CheckboxProps = FieldProps &
KCheckboxRootProps & {
input?: PolymorphicProps<"input", KCheckboxInputProps<"input">>;
};
export const Checkbox = (props: CheckboxProps) => (
<KCheckbox
class={cx("form-field", "checkbox", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
>
<Orienter orientation={props.orientation} align={"start"}>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}
{...props}
/>
<KCheckbox.Input {...props.input} />
<KCheckbox.Control class="checkbox-control">
<KCheckbox.Indicator>
<Icon
icon="Checkmark"
inverted={props.inverted}
color="secondary"
size="100%"
/>
</KCheckbox.Indicator>
</KCheckbox.Control>
</Orienter>
</KCheckbox>
);
export const Checkbox = (props: CheckboxProps) => {
const alignment = () =>
(props.orientation || "vertical") == "vertical" ? "start" : "center";
const iconChecked = (
<Icon
icon="Checkmark"
inverted={props.inverted}
color="secondary"
size="100%"
/>
);
const iconUnchecked = (
<Icon
icon="Close"
inverted={props.inverted}
color="secondary"
size="100%"
/>
);
return (
<KCheckbox
class={cx("form-field", "checkbox", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
>
<Orienter orientation={props.orientation} align={alignment()}>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}
{...props}
/>
<KCheckbox.Input {...props.input} />
<KCheckbox.Control class="checkbox-control">
{props.readOnly && (
<Show
when={props.checked || props.defaultChecked}
fallback={iconUnchecked}
>
{iconChecked}
</Show>
)}
{!props.readOnly && (
<KCheckbox.Indicator>{iconChecked}</KCheckbox.Indicator>
)}
</KCheckbox.Control>
</Orienter>
</KCheckbox>
);
};

View File

@@ -1,9 +1,9 @@
div.form-field.combobox {
div.control {
@apply flex flex-col w-full gap-2;
@apply flex flex-col size-full gap-2;
div.selected-options {
@apply flex flex-wrap gap-1 w-full min-h-5;
@apply flex flex-wrap gap-1 size-full min-h-5;
}
div.input-container {
@@ -44,7 +44,8 @@ div.form-field.combobox {
}
&[data-readonly] {
@apply outline-def-2 cursor-not-allowed;
@apply outline-none border-none bg-inherit;
@apply p-0 resize-none;
}
}
@@ -76,6 +77,10 @@ div.form-field.combobox {
& > input {
@apply px-1.5 py-1;
font-size: 0.75rem;
&[data-readonly] {
@apply p-0;
}
}
& > button.trigger {
@@ -111,6 +116,10 @@ div.form-field.combobox {
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-readonly] {
@apply outline-none border-none bg-inherit cursor-auto;
}
}
}
}

View File

@@ -124,6 +124,7 @@ export const ReadOnly: Story = {
args: {
...Tooltip.args,
readOnly: true,
defaultValue: "foo",
},
};

View File

@@ -83,14 +83,18 @@ export const DefaultItemControl = <Option,>(
</For>
</div>
</Show>
<div class="input-container">
<KCombobox.Input />
<KCombobox.Trigger class="trigger">
<KCombobox.Icon class="icon">
<Icon icon="Expand" inverted={props.inverted} size="100%" />
</KCombobox.Icon>
</KCombobox.Trigger>
</div>
{!(props.readOnly && props.multiple) && (
<div class="input-container">
<KCombobox.Input />
{!props.readOnly && (
<KCombobox.Trigger class="trigger">
<KCombobox.Icon class="icon">
<Icon icon="Expand" inverted={props.inverted} size="100%" />
</KCombobox.Icon>
</KCombobox.Trigger>
)}
</div>
)}
</>
);
@@ -101,7 +105,13 @@ export const Combobox = <Option, OptGroup = never>(
const itemControl = () => props.itemControl || DefaultItemControl;
const itemComponent = () => props.itemComponent || DefaultItemComponent;
const align = () => (props.orientation === "horizontal" ? "start" : "center");
const align = () => {
if (props.readOnly) {
return "center";
} else {
return props.orientation === "horizontal" ? "start" : "center";
}
};
return (
<KCombobox

View File

@@ -12,7 +12,7 @@ div.form-label {
@apply flex items-center gap-1;
}
& > label[data-required] {
& > label[data-required] & not(label[data-readonly]) {
span.typography::after {
@apply fg-def-4 ml-1;

View File

@@ -28,6 +28,7 @@ export interface LabelProps {
tooltip?: string;
icon?: string;
inverted?: boolean;
readOnly?: boolean;
validationState?: "valid" | "invalid";
}
@@ -42,7 +43,7 @@ export const Label = (props: LabelProps) => {
hierarchy="label"
size={props.size || "default"}
color={props.validationState == "invalid" ? "error" : "primary"}
weight="bold"
weight={props.readOnly ? "normal" : "bold"}
inverted={props.inverted}
>
{props.label}

View File

@@ -5,7 +5,7 @@ div.orienter {
}
&.horizontal {
@apply flex-row gap-2 justify-between;
@apply flex-row justify-start;
& > div.form-label {
@apply w-1/2 shrink;

View File

@@ -34,7 +34,13 @@ div.form-field {
}
&[data-readonly] {
@apply outline-def-2 cursor-not-allowed;
@apply outline-none border-none bg-inherit p-0 cursor-auto resize-none;
}
}
&.textarea textarea {
&[data-readonly] {
@apply overflow-y-hidden;
}
}
@@ -72,6 +78,10 @@ div.form-field {
&.textarea textarea {
@apply px-1.5 py-1;
font-size: 0.75rem;
&[data-readonly] {
@apply p-0;
}
}
&.text div.input-container {
@@ -114,6 +124,11 @@ div.form-field {
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-readonly] {
@apply outline-def-2 cursor-auto;
@apply outline-none border-none bg-inherit;
}
}
}

View File

@@ -33,7 +33,7 @@ export const TextInput = (props: TextInputProps) => (
{...props}
/>
<div class="input-container">
{props.icon && (
{props.icon && !props.readOnly && (
<Icon
icon={props.icon}
inverted={props.inverted}
@@ -42,7 +42,7 @@ export const TextInput = (props: TextInputProps) => (
)}
<TextField.Input
{...props.input}
classList={{ "has-icon": props.icon }}
classList={{ "has-icon": props.icon && !props.readOnly }}
/>
</div>
</Orienter>

View File

@@ -4,8 +4,8 @@ div.sidebar-header {
background: linear-gradient(
90deg,
theme(colors.bg.inv.3) 0%,
theme(colors.bg.inv.4) 0%
theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.3) 0%
);
& > .dropdown-trigger {

View File

@@ -0,0 +1,33 @@
div.sidebar-pane {
@apply h-full w-auto max-w-60 border-none;
& > div.header {
@apply flex items-center justify-between px-3 py-2 rounded-t-[0.5rem];
@apply border-t-[1px] border-t-bg-inv-3
border-r-[1px] border-r-bg-inv-3
border-b-2 border-b-bg-inv-4
border-l-[1px] border-l-bg-inv-3;
background: linear-gradient(
90deg,
theme(colors.bg.inv.3) 0%,
theme(colors.bg.inv.4) 100%
);
}
& > div.body {
@apply flex flex-col gap-4 px-2 pt-4 pb-3 w-full h-full;
@apply backdrop-blur-md;
@apply rounded-b-[0.5rem]
border-r-[1px] border-r-bg-inv-3
border-b-2 border-b-bg-inv-4
border-l-[1px] border-l-bg-inv-3;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
linear-gradient(
180deg,
theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.3) 100%
);
}
}

View File

@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import {
SidebarPane,
SidebarPaneProps,
} from "@/src/components/v2/Sidebar/SidebarPane";
import { SidebarSection } from "./SidebarSection";
import { Divider } from "@/src/components/v2/Divider/Divider";
import { TextInput } from "@/src/components/v2/Form/TextInput";
import { TextArea } from "@/src/components/v2/Form/TextArea";
import { Checkbox } from "@/src/components/v2/Form/Checkbox";
import { Combobox } from "../Form/Combobox";
const meta: Meta<SidebarPaneProps> = {
title: "Components/Sidebar/Pane",
component: SidebarPane,
};
export default meta;
type Story = StoryObj<SidebarPaneProps>;
export const Default: Story = {
args: {
title: "Neptune",
onClose: () => {
console.log("closing");
},
children: (
<>
<SidebarSection
title="General"
onSave={async () => {
console.log("saving general");
}}
>
{(editing) => (
<form class="flex flex-col gap-3">
<TextInput
label="First Name"
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
input={{ value: "Ron" }}
/>
<Divider />
<TextInput
label="Last Name"
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
input={{ value: "Burgundy" }}
/>
<Divider />
<TextArea
label="Bio"
size="s"
inverted={true}
readOnly={!editing}
orientation="horizontal"
input={{
value:
"It's actually an optical illusion, it's the pattern on the pants.",
rows: 4,
}}
/>
<Divider />
<Checkbox
size="s"
label="Share Profile"
required={true}
inverted={true}
readOnly={!editing}
checked={true}
orientation="horizontal"
/>
</form>
)}
</SidebarSection>
<SidebarSection
title="Tags"
onSave={async () => {
console.log("saving general");
}}
>
{(editing) => (
<form class="flex flex-col gap-3">
<Combobox
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
multiple={true}
options={["All", "Home Server", "Backup", "Random"]}
defaultValue={["All", "Home Server", "Backup", "Random"]}
/>
</form>
)}
</SidebarSection>
<SidebarSection
title="Advanced Settings"
onSave={async () => {
console.log("saving general");
}}
>
{(editing) => <></>}
</SidebarSection>
</>
),
},
};

View File

@@ -0,0 +1,27 @@
import { JSX } from "solid-js";
import "./SidebarPane.css";
import { Typography } from "@/src/components/v2/Typography/Typography";
import Icon from "../Icon/Icon";
import { Button as KButton } from "@kobalte/core/button";
export interface SidebarPaneProps {
title: string;
onClose: () => void;
children: JSX.Element;
}
export const SidebarPane = (props: SidebarPaneProps) => {
return (
<div class="sidebar-pane">
<div class="header">
<Typography hierarchy="body" size="s" weight="bold" inverted={true}>
{props.title}
</Typography>
<KButton onClick={props.onClose}>
<Icon icon="Close" color="primary" size="0.75rem" inverted={true} />
</KButton>
</div>
<div class="body">{props.children}</div>
</div>
);
};

View File

@@ -0,0 +1,15 @@
div.sidebar-section {
@apply flex flex-col gap-2 w-full h-full;
& > div.header {
@apply flex items-center justify-between px-1.5;
& > div.controls {
@apply flex justify-end gap-2;
}
}
& > div.content {
@apply w-full h-full px-1.5 py-3 rounded-md bg-inv-4;
}
}

View File

@@ -0,0 +1,61 @@
import { createSignal, JSX } from "solid-js";
import "./SidebarSection.css";
import { Typography } from "@/src/components/v2/Typography/Typography";
import { Button as KButton } from "@kobalte/core/button";
import Icon from "../Icon/Icon";
export interface SidebarSectionProps {
title: string;
onSave: () => Promise<void>;
children: (editing: boolean) => JSX.Element;
}
export const SidebarSection = (props: SidebarSectionProps) => {
const [editing, setEditing] = createSignal(false);
const save = async () => {
// todo how do we surface errors?
await props.onSave();
setEditing(false);
};
return (
<div class="sidebar-section">
<div class="header">
<Typography
hierarchy="label"
size="xs"
family="mono"
weight="light"
transform="uppercase"
color="tertiary"
inverted={true}
>
{props.title}
</Typography>
<div class="controls">
{editing() && (
<KButton>
<Icon
icon="Checkmark"
color="tertiary"
size="0.75rem"
inverted={true}
onClick={save}
/>
</KButton>
)}
<KButton onClick={() => setEditing(!editing())}>
<Icon
icon={editing() ? "Close" : "Edit"}
color="tertiary"
size="0.75rem"
inverted={true}
/>
</KButton>
</div>
</div>
<div class="content">{props.children(editing())}</div>
</div>
);
};

View File

@@ -1,5 +1,9 @@
/* Body */
.typography {
&.weight-light {
font-weight: 300;
}
&.weight-normal {
font-weight: 400;
}

View File

@@ -6,7 +6,7 @@ import { Color, fgClass } from "@/src/components/v2/colors";
export type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser";
export type Weight = "normal" | "medium" | "bold";
export type Weight = "normal" | "medium" | "bold" | "light";
export type Family = "regular" | "condensed" | "mono";
export type Transform = "uppercase" | "lowercase" | "capitalize";
@@ -80,9 +80,10 @@ const defaultFamilyMap: Record<Hierarchy, Family> = {
};
const weightMap: Record<Weight, string> = {
normal: cx("weight-normal"),
medium: cx("weight-medium"),
bold: cx("weight-bold"),
normal: "weight-normal",
medium: "weight-medium",
bold: "weight-bold",
light: "weight-light",
};
interface _TypographyProps<H extends Hierarchy> {