Merge pull request 'ui: Modal component' (#4241) from feat/modal into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4241
This commit is contained in:
brianmcgee
2025-07-07 15:16:50 +00:00
7 changed files with 188 additions and 34 deletions

View File

@@ -48,7 +48,7 @@
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0",
"playwright": "~1.53.2",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",
@@ -6189,13 +6189,13 @@
}
},
"node_modules/playwright": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"version": "1.53.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.2.tgz",
"integrity": "sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0"
"playwright-core": "1.53.2"
},
"bin": {
"playwright": "cli.js"
@@ -6208,9 +6208,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"version": "1.53.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.2.tgz",
"integrity": "sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -48,7 +48,7 @@
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0",
"playwright": "~1.53.2",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",

View File

@@ -40,7 +40,7 @@ export type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
legend: "Signup",
fields: (props: FieldProps) => (
children: (props: FieldProps) => (
<>
<TextInput
{...props}
@@ -90,7 +90,7 @@ export const Error: Story = {
args: {
legend: "Signup",
error: "You must enter a First Name",
fields: (props: FieldProps) => (
children: (props: FieldProps) => (
<>
<TextInput
{...props}

View File

@@ -1,41 +1,57 @@
import "./Fieldset.css";
import { JSX } from "solid-js";
import { JSX, splitProps } from "solid-js";
import cx from "classnames";
import { Typography } from "@/src/components/v2/Typography/Typography";
import { FieldProps } from "./Field";
export interface FieldsetProps extends FieldProps {
legend: string;
disabled: boolean;
export type FieldsetFieldProps = Pick<
FieldProps,
"orientation" | "inverted"
> & {
error?: string;
fields: (props: FieldProps) => JSX.Element;
disabled?: boolean;
};
export interface FieldsetProps
extends Pick<FieldProps, "orientation" | "inverted"> {
legend?: string;
disabled?: boolean;
error?: string;
children: (props: FieldsetFieldProps) => JSX.Element;
}
export const Fieldset = (props: FieldsetProps) => {
const orientation = () => props.orientation || "vertical";
const [fieldProps] = splitProps(props, [
"orientation",
"inverted",
"disabled",
"error",
]);
return (
<fieldset
role="group"
class={cx(orientation(), { inverted: props.inverted })}
disabled={props.disabled}
class={cx({ inverted: props.inverted })}
disabled={props.disabled || false}
>
<legend>
<Typography
hierarchy="label"
family="mono"
size="default"
weight="normal"
color="tertiary"
transform="uppercase"
inverted={props.inverted}
>
{props.legend}
</Typography>
</legend>
<div class="fields">
{props.fields({ ...props, orientation: orientation() })}
</div>
{props.legend && (
<legend>
<Typography
hierarchy="label"
family="mono"
size="default"
weight="normal"
color="tertiary"
transform="uppercase"
inverted={props.inverted}
>
{props.legend}
</Typography>
</legend>
)}
<div class="fields">{props.children(fieldProps)}</div>
{props.error && (
<div class="error" role="alert">
<Typography

View File

@@ -0,0 +1,24 @@
div.modal-content {
@apply max-w-[512px];
@apply rounded-md;
/* todo replace with a theme() color */
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
& > div.header {
@apply flex items-center justify-center;
@apply w-full px-2 py-1.5;
@apply bg-def-3;
@apply border border-def-2 rounded-tl-md rounded-tr-md;
@apply border-b-def-3;
& > .title {
@apply mx-auto;
}
}
& > div.body {
@apply p-6 bg-def-1;
@apply border border-def-2 rounded-bl-md rounded-br-md;
}
}

View File

@@ -0,0 +1,74 @@
import { TagProps } from "@/src/components/v2/Tag/Tag";
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { fn } from "storybook/test";
import {
Modal,
ModalContext,
ModalProps,
} from "@/src/components/v2/Modal/Modal";
import { Fieldset } from "@/src/components/v2/Form/Fieldset";
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 { Button } from "../Button/Button";
const meta: Meta<ModalProps> = {
title: "Components/Modal",
component: Modal,
};
export default meta;
type Story = StoryObj<TagProps>;
export const Default: Story = {
args: {
title: "Example Modal",
onClose: fn(),
children: ({ close }: ModalContext) => (
<form class="flex flex-col gap-5">
<Fieldset legend="General">
{(props) => (
<>
<TextInput
{...props}
label="First Name"
size="s"
required={true}
input={{ placeholder: "Ron" }}
/>
<TextInput
{...props}
label="Last Name"
size="s"
required={true}
input={{ placeholder: "Burgundy" }}
/>
<TextArea
{...props}
label="Bio"
size="s"
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
/>
<Checkbox
{...props}
size="s"
label="Accept Terms"
required={true}
/>
</>
)}
</Fieldset>
<div class="flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={close}>
Cancel
</Button>
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
Save
</Button>
</div>
</form>
),
},
};

View File

@@ -0,0 +1,40 @@
import { createSignal, JSX } from "solid-js";
import { Dialog as KDialog } from "@kobalte/core/dialog";
import "./Modal.css";
import { Typography } from "../Typography/Typography";
import Icon from "../Icon/Icon";
export interface ModalContext {
close(): void;
}
export interface ModalProps {
id?: string;
title: string;
onClose: () => void;
children: (ctx: ModalContext) => JSX.Element;
}
export const Modal = (props: ModalProps) => {
const [open, setOpen] = createSignal(true);
return (
<KDialog id={props.id} open={open()} modal={true}>
<KDialog.Portal>
<KDialog.Content class="modal-content">
<div class="header">
<Typography class="title" hierarchy="label" family="mono" size="xs">
{props.title}
</Typography>
<KDialog.CloseButton onClick={() => setOpen(false)}>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</div>
<div class="body">
{props.children({ close: () => setOpen(false) })}
</div>
</KDialog.Content>
</KDialog.Portal>
</KDialog>
);
};