feat(ui): SidebarPane component

* implement Divider component using Kobalte's Separator
* refine read only state of form components to match the Sidebar Pane design
* introduce a SidebarPane component with sections that can toggle between editing and view states.
This commit is contained in:
Brian McGee
2025-07-04 11:56:17 +01:00
parent a635f9c6fe
commit 1609989734
21 changed files with 400 additions and 61 deletions

View File

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

View File

@@ -1,14 +1,18 @@
import "./Divider.css"; import "./Divider.css";
import cx from "classnames"; import cx from "classnames";
import { Separator, SeparatorRootProps } from "@kobalte/core/separator";
export interface DividerProps { export interface DividerProps extends Pick<SeparatorRootProps, "orientation"> {
inverted?: boolean; inverted?: boolean;
orientation?: "horizontal" | "vertical";
} }
export const Divider = (props: DividerProps) => { export const Divider = (props: DividerProps) => {
const inverted = props.inverted || false; 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] { &[data-invalid] {
@apply border-semantic-error-4; @apply border-semantic-error-4;
} }
&[data-readonly] {
@apply cursor-default bg-inherit border-none;
}
} }
} }
@@ -32,6 +36,10 @@ div.form-field {
&[data-disabled] { &[data-disabled] {
@apply bg-def-4 border-none; @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: { args: {
...Tooltip.args, ...Tooltip.args,
readOnly: true, readOnly: true,

View File

@@ -11,37 +11,64 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
import "./Checkbox.css"; import "./Checkbox.css";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; import { Orienter } from "./Orienter";
import { Show } from "solid-js";
export type CheckboxProps = FieldProps & export type CheckboxProps = FieldProps &
KCheckboxRootProps & { KCheckboxRootProps & {
input?: PolymorphicProps<"input", KCheckboxInputProps<"input">>; input?: PolymorphicProps<"input", KCheckboxInputProps<"input">>;
}; };
export const Checkbox = (props: CheckboxProps) => ( export const Checkbox = (props: CheckboxProps) => {
<KCheckbox const alignment = () =>
class={cx("form-field", "checkbox", props.size, props.orientation, { (props.orientation || "vertical") == "vertical" ? "start" : "center";
inverted: props.inverted,
ghost: props.ghost, const iconChecked = (
})} <Icon
{...props} icon="Checkmark"
> inverted={props.inverted}
<Orienter orientation={props.orientation} align={"start"}> color="secondary"
<Label size="100%"
labelComponent={KCheckbox.Label} />
descriptionComponent={KCheckbox.Description} );
{...props}
/> const iconUnchecked = (
<KCheckbox.Input {...props.input} /> <Icon
<KCheckbox.Control class="checkbox-control"> icon="Close"
<KCheckbox.Indicator> inverted={props.inverted}
<Icon color="secondary"
icon="Checkmark" size="100%"
inverted={props.inverted} />
color="secondary" );
size="100%"
/> return (
</KCheckbox.Indicator> <KCheckbox
</KCheckbox.Control> class={cx("form-field", "checkbox", props.size, props.orientation, {
</Orienter> inverted: props.inverted,
</KCheckbox> 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.form-field.combobox {
div.control { div.control {
@apply flex flex-col w-full gap-2; @apply flex flex-col size-full gap-2;
div.selected-options { 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 { div.input-container {
@@ -44,7 +44,8 @@ div.form-field.combobox {
} }
&[data-readonly] { &[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 { & > input {
@apply px-1.5 py-1; @apply px-1.5 py-1;
font-size: 0.75rem; font-size: 0.75rem;
&[data-readonly] {
@apply p-0;
}
} }
& > button.trigger { & > button.trigger {
@@ -111,6 +116,10 @@ div.form-field.combobox {
&[data-invalid] { &[data-invalid] {
@apply outline-semantic-error-4; @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: { args: {
...Tooltip.args, ...Tooltip.args,
readOnly: true, readOnly: true,
defaultValue: "foo",
}, },
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,13 @@ div.form-field {
} }
&[data-readonly] { &[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 { &.textarea textarea {
@apply px-1.5 py-1; @apply px-1.5 py-1;
font-size: 0.75rem; font-size: 0.75rem;
&[data-readonly] {
@apply p-0;
}
} }
&.text div.input-container { &.text div.input-container {
@@ -114,6 +124,11 @@ div.form-field {
&[data-invalid] { &[data-invalid] {
@apply outline-semantic-error-4; @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} {...props}
/> />
<div class="input-container"> <div class="input-container">
{props.icon && ( {props.icon && !props.readOnly && (
<Icon <Icon
icon={props.icon} icon={props.icon}
inverted={props.inverted} inverted={props.inverted}
@@ -42,7 +42,7 @@ export const TextInput = (props: TextInputProps) => (
)} )}
<TextField.Input <TextField.Input
{...props.input} {...props.input}
classList={{ "has-icon": props.icon }} classList={{ "has-icon": props.icon && !props.readOnly }}
/> />
</div> </div>
</Orienter> </Orienter>

View File

@@ -4,8 +4,8 @@ div.sidebar-header {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
theme(colors.bg.inv.3) 0%, theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.4) 0% theme(colors.bg.inv.3) 0%
); );
& > .dropdown-trigger { & > .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 */ /* Body */
.typography { .typography {
&.weight-light {
font-weight: 300;
}
&.weight-normal { &.weight-normal {
font-weight: 400; 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 Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser"; 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 Family = "regular" | "condensed" | "mono";
export type Transform = "uppercase" | "lowercase" | "capitalize"; export type Transform = "uppercase" | "lowercase" | "capitalize";
@@ -80,9 +80,10 @@ const defaultFamilyMap: Record<Hierarchy, Family> = {
}; };
const weightMap: Record<Weight, string> = { const weightMap: Record<Weight, string> = {
normal: cx("weight-normal"), normal: "weight-normal",
medium: cx("weight-medium"), medium: "weight-medium",
bold: cx("weight-bold"), bold: "weight-bold",
light: "weight-light",
}; };
interface _TypographyProps<H extends Hierarchy> { interface _TypographyProps<H extends Hierarchy> {