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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,13 +11,36 @@ 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) => (
|
||||
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,
|
||||
@@ -25,7 +48,7 @@ export const Checkbox = (props: CheckboxProps) => (
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<Orienter orientation={props.orientation} align={"start"}>
|
||||
<Orienter orientation={props.orientation} align={alignment()}>
|
||||
<Label
|
||||
labelComponent={KCheckbox.Label}
|
||||
descriptionComponent={KCheckbox.Description}
|
||||
@@ -33,15 +56,19 @@ export const Checkbox = (props: CheckboxProps) => (
|
||||
/>
|
||||
<KCheckbox.Input {...props.input} />
|
||||
<KCheckbox.Control class="checkbox-control">
|
||||
<KCheckbox.Indicator>
|
||||
<Icon
|
||||
icon="Checkmark"
|
||||
inverted={props.inverted}
|
||||
color="secondary"
|
||||
size="100%"
|
||||
/>
|
||||
</KCheckbox.Indicator>
|
||||
{props.readOnly && (
|
||||
<Show
|
||||
when={props.checked || props.defaultChecked}
|
||||
fallback={iconUnchecked}
|
||||
>
|
||||
{iconChecked}
|
||||
</Show>
|
||||
)}
|
||||
{!props.readOnly && (
|
||||
<KCheckbox.Indicator>{iconChecked}</KCheckbox.Indicator>
|
||||
)}
|
||||
</KCheckbox.Control>
|
||||
</Orienter>
|
||||
</KCheckbox>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ export const ReadOnly: Story = {
|
||||
args: {
|
||||
...Tooltip.args,
|
||||
readOnly: true,
|
||||
defaultValue: "foo",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -83,14 +83,18 @@ export const DefaultItemControl = <Option,>(
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
{!(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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
33
pkgs/clan-app/ui/src/components/v2/Sidebar/SidebarPane.css
Normal file
33
pkgs/clan-app/ui/src/components/v2/Sidebar/SidebarPane.css
Normal 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%
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
27
pkgs/clan-app/ui/src/components/v2/Sidebar/SidebarPane.tsx
Normal file
27
pkgs/clan-app/ui/src/components/v2/Sidebar/SidebarPane.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
/* Body */
|
||||
.typography {
|
||||
&.weight-light {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
&.weight-normal {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user