🎉 Add components library (#10675)

* 🎉 Install react-aria-components

* 🎉 Create modal component in TS using react-aria-component

* 🎉 Create modal ds component

* 🎉 Separate header content and footer components

* 🐛 Remove mf/html macros when not needed

* 🎉 Solve little problems

* ♻️ Format files

* ♻️ Remove ModalCloseBtn

* 🐛 Fix CI

* ♻️ Remove unused files

* 🎉 Make close button not dependant on the modal header

* 🎉 Add footer with two slots

* 🎉 Improvements on modal

* 🐛 Fix package imports and remove login example code

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
Eva Marco 2026-07-30 13:05:28 +02:00 committed by GitHub
parent 972353eccb
commit e705b6e12c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 832 additions and 57 deletions

View File

@ -16,7 +16,7 @@
"scripts": {
"build:app:assets": "node ./scripts/build-app-assets.js",
"build:fonts-preview": "node ./scripts/build-fonts-preview.js",
"build:storybook": "pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
"build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
"build:wasm": "../render-wasm/build",
@ -58,7 +58,6 @@
"@penpot/text-editor": "link:text-editor",
"@penpot/tokenscript": "link:packages/tokenscript",
"@penpot/ua-parser": "penpot/ua-parser#1.0.0",
"@penpot/ui": "link:packages/ui",
"@playwright/test": "1.61.1",
"@storybook/addon-docs": "10.4.6",
"@storybook/addon-themes": "10.4.6",
@ -129,5 +128,9 @@
"watcher": "^2.3.1",
"workerpool": "^10.0.3",
"xregexp": "^5.1.2"
},
"dependencies": {
"@penpot/ui": "link:packages/ui",
"react-aria-components": "^1.19.0"
}
}

View File

@ -7,6 +7,10 @@
".": {
"import": "./dist/index.js"
},
"./modal": {
"import": "./dist/modal.js",
"types": "./dist/modal.d.ts"
},
"./style.css": "./dist/style.css"
},
"scripts": {
@ -32,6 +36,9 @@
"storybook": "10.4.6",
"vite-plugin-dts": "^5.0.3"
},
"dependencies": {
"react-aria-components": "^1.19.0"
},
"peerDependencies": {
"react": ">=19.2",
"react-dom": ">=19.2"

View File

@ -1 +1 @@
export * from './lib/example/Example';
export { Modal, useModalClose } from './lib/modal/Modal';

View File

@ -1,5 +0,0 @@
.container {
background-color: #f0f0f0;
padding: 16px;
border: 2px solid #000;
}

View File

@ -1,10 +0,0 @@
import { render } from '@testing-library/react';
import Example from './Example';
describe('Example', () => {
it('should render successfully', () => {
const { baseElement } = render(<Example />);
expect(baseElement).toBeTruthy();
});
});

View File

@ -1,12 +0,0 @@
import { Example } from './Example';
import type { Meta, StoryObj } from '@storybook/react-vite';
const meta = {
title: 'UI/Example',
component: Example,
} satisfies Meta<typeof Example>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};

View File

@ -1,21 +0,0 @@
import { useState } from 'react';
import styles from './Example.module.css';
export function Example() {
const [count, setCount] = useState(0);
return (
<div className={styles.container}>
<h1>Example!</h1>
<div>
<h2>Counter: {count}</h2>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
</div>
);
}
export default Example;

View File

@ -0,0 +1,113 @@
@use "ds/_borders" as *;
@use "ds/_sizes" as *;
@use "ds/_utils" as *;
.overlay {
position: fixed;
inset: 0;
background: var(--color-overlay-default);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-index-set);
&[data-entering] {
animation: overlay-fade-in 0.2s ease-out;
}
&[data-exiting] {
animation: overlay-fade-out 0.15s ease-in;
}
}
.modal {
position: relative;
inline-size: 100%;
margin: var(--sp-l);
background: var(--color-background-primary);
border-radius: $br-8;
box-shadow: var(--el-shadow-dark);
outline: none;
max-block-size: calc(100dvh - var(--sp-xxxl) * 2);
display: flex;
flex-direction: column;
&[data-entering] {
animation: modal-zoom-in 0.2s ease-out;
}
&[data-exiting] {
animation: modal-zoom-out 0.15s ease-in;
}
}
.modalSmall {
max-inline-size: $sz-400;
}
.modalMedium {
max-inline-size: $sz-512;
}
.modalLarge {
max-inline-size: px2rem(640);
}
.modalXlarge {
max-inline-size: px2rem(960);
}
.dialog {
position: relative;
outline: none;
display: flex;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
max-block-size: inherit;
}
@keyframes overlay-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlay-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes modal-zoom-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes modal-zoom-out {
from {
opacity: 1;
transform: scale(0.95);
}
to {
opacity: 0;
transform: scale(1);
}
}

View File

@ -0,0 +1,66 @@
import {
DialogTrigger,
ModalOverlay,
Modal as RACModal,
Dialog,
} from 'react-aria-components';
import {
createContext,
useContext,
type ReactNode,
} from 'react';
import styles from './Modal.module.scss';
const ModalCloseContext = createContext<(() => void) | null>(null);
export function useModalClose(): (() => void) | null {
return useContext(ModalCloseContext);
}
interface ModalProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
children: ReactNode;
trigger?: ReactNode;
isDismissable?: boolean;
size?: 'small' | 'medium' | 'large' | 'xlarge';
className?: string;
}
export function Modal({
isOpen,
onOpenChange,
children,
trigger,
isDismissable = true,
size = 'medium',
className,
}: ModalProps) {
const sizeClass =
size === 'small'
? styles.modalSmall
: size === 'large'
? styles.modalLarge
: size === 'xlarge'
? styles.modalXlarge
: styles.modalMedium;
return (
<DialogTrigger isOpen={isOpen} onOpenChange={onOpenChange}>
{trigger}
<ModalOverlay className={styles.overlay} isDismissable={isDismissable}>
<RACModal
className={`${styles.modal} ${sizeClass} ${className ?? ''}`}
>
<Dialog className={styles.dialog}>
{({ close }) => (
<ModalCloseContext.Provider value={close}>
{children}
</ModalCloseContext.Provider>
)}
</Dialog>
</RACModal>
</ModalOverlay>
</DialogTrigger>
);
}

View File

@ -0,0 +1 @@
export { Modal, useModalClose } from './lib/modal/Modal';

View File

@ -10,7 +10,7 @@ const copyCssPlugin = () => ({
closeBundle: () => {
try {
copyFileSync(
'dist/index.css',
'dist/ui.css',
'../../resources/public/css/ui.css',
);
} catch (e) {
@ -21,6 +21,15 @@ const copyCssPlugin = () => ({
export default defineConfig(() => ({
root: import.meta.dirname,
css: {
preprocessorOptions: {
scss: {
loadPaths: [
path.resolve(import.meta.dirname, '../../src/app/main/ui'),
],
},
},
},
plugins: [
react({
babel: {
@ -42,9 +51,11 @@ export default defineConfig(() => ({
transformMixedEsModules: true,
},
lib: {
entry: 'src/index.ts',
entry: {
index: 'src/index.ts',
modal: 'src/modal.ts',
},
name: 'ui',
fileName: 'index',
formats: ['es' as const],
},
rollupOptions: {

121
frontend/pnpm-lock.yaml generated
View File

@ -25,6 +25,13 @@ patchedDependencies:
importers:
.:
dependencies:
'@penpot/ui':
specifier: link:packages/ui
version: link:packages/ui
react-aria-components:
specifier: ^1.19.0
version: 1.19.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
devDependencies:
'@penpot/draft-js':
specifier: link:packages/draft-js
@ -47,9 +54,6 @@ importers:
'@penpot/ua-parser':
specifier: penpot/ua-parser#1.0.0
version: https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4
'@penpot/ui':
specifier: link:packages/ui
version: link:packages/ui
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
@ -293,6 +297,9 @@ importers:
react:
specifier: '>=19.2'
version: 19.2.7
react-aria-components:
specifier: ^1.19.0
version: 1.19.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-dom:
specifier: '>=19.2'
version: 19.2.7(react@19.2.7)
@ -965,6 +972,15 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@internationalized/date@3.12.2':
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
'@internationalized/number@3.6.7':
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
'@internationalized/string@3.2.9':
resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==}
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
peerDependencies:
@ -1505,6 +1521,11 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@react-types/shared@3.36.0':
resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@resvg/resvg-js-android-arm-eabi@2.6.2':
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
@ -1977,6 +1998,9 @@ packages:
typescript:
optional: true
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@ -2268,6 +2292,10 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
@ -2515,6 +2543,9 @@ packages:
resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==}
engines: {node: '>= 4.0'}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@ -2541,6 +2572,10 @@ packages:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@ -4527,6 +4562,18 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-aria-components@1.19.0:
resolution: {integrity: sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-aria@3.50.0:
resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-compiler-runtime@1.0.0:
resolution: {integrity: sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==}
peerDependencies:
@ -4560,6 +4607,11 @@ packages:
react-lifecycles-compat@3.0.4:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
react-stately@3.48.0:
resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-virtualized@9.22.6:
resolution: {integrity: sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==}
peerDependencies:
@ -6304,6 +6356,18 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@internationalized/date@3.12.2':
dependencies:
'@swc/helpers': 0.5.23
'@internationalized/number@3.6.7':
dependencies:
'@swc/helpers': 0.5.23
'@internationalized/string@3.2.9':
dependencies:
'@swc/helpers': 0.5.23
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))':
dependencies:
glob: 13.0.6
@ -6753,6 +6817,10 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@react-types/shared@3.36.0(react@19.2.7)':
dependencies:
react: 19.2.7
'@resvg/resvg-js-android-arm-eabi@2.6.2':
optional: true
@ -7107,6 +7175,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
@ -7457,6 +7529,10 @@ snapshots:
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
@ -7767,6 +7843,8 @@ snapshots:
dependencies:
source-map: 0.6.1
client-only@0.0.1: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@ -7793,6 +7871,8 @@ snapshots:
clsx@1.2.1: {}
clsx@2.1.1: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@ -9909,6 +9989,31 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-aria-components@1.19.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@internationalized/date': 3.12.2
'@react-types/shared': 3.36.0(react@19.2.7)
'@swc/helpers': 0.5.23
client-only: 0.0.1
react: 19.2.7
react-aria: 3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-dom: 19.2.7(react@19.2.7)
react-stately: 3.48.0(react@19.2.7)
react-aria@3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@internationalized/date': 3.12.2
'@internationalized/number': 3.6.7
'@internationalized/string': 3.2.9
'@react-types/shared': 3.36.0(react@19.2.7)
'@swc/helpers': 0.5.23
aria-hidden: 1.2.6
clsx: 2.1.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-stately: 3.48.0(react@19.2.7)
use-sync-external-store: 1.6.0(react@19.2.7)
react-compiler-runtime@1.0.0(react@19.2.7):
dependencies:
react: 19.2.7
@ -9947,6 +10052,16 @@ snapshots:
react-lifecycles-compat@3.0.4: {}
react-stately@3.48.0(react@19.2.7):
dependencies:
'@internationalized/date': 3.12.2
'@internationalized/number': 3.6.7
'@internationalized/string': 3.2.9
'@react-types/shared': 3.36.0(react@19.2.7)
'@swc/helpers': 0.5.23
react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7)
react-virtualized@9.22.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@babel/runtime': 7.29.7

View File

@ -18,6 +18,7 @@
<meta name="twitter:creator" content="@penpotapp">
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
<link id="theme" href="css/main.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
<link href="css/ui.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
{{#isDebug}}
<link href="css/debug.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
{{/isDebug}}

View File

@ -1,4 +1,5 @@
<link href="./css/ds.css?version={{& version}}" rel="stylesheet" type="text/css" />
<link href="./css/ui.css?version={{& version}}" rel="stylesheet" type="text/css" />
<style>
body {

View File

@ -1,5 +1,8 @@
import * as esbuild from "esbuild";
import { readFile } from "node:fs/promises";
import { execSync } from "node:child_process";
execSync("pnpm run build", { cwd: "packages/ui", stdio: "inherit" });
/**
* esbuild plugin to watch a directory recursively

View File

@ -25,6 +25,7 @@
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.foundations.utilities.token.token-status :refer [token-status-icon*
token-status-list]]
[app.main.ui.ds.layout.modal :refer [modal* modal-header* modal-content* modal-footer*]]
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
[app.main.ui.ds.notifications.actionable :refer [actionable*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
@ -83,6 +84,10 @@
:MilestoneGroup milestone-group*
:Date date*
:PanelTitle panel-title*
:Modal modal*
:ModalHeader modal-header*
:ModalContent modal-content*
:ModalFooter modal-footer*
:set-default-translations
(fn [data]

View File

@ -0,0 +1,120 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.ds.layout.modal
(:require-macros
[app.main.style :as stl])
(:require
["@penpot/ui/modal" :as modal]
[app.common.data :as d]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(def ^:private schema:modal-header
[:map
[:title {:optional true} [:maybe :string]]
[:class {:optional true} [:maybe :string]]])
(mf/defc modal-header*
{::mf/schema schema:modal-header}
[{:keys [title class children] :rest props}]
(let [props (mf/spread-props props
{:class [class (stl/css :modal-header)]})]
[:> :div props
(when title
[:> heading* {:typography t/headline-medium
:level 2
:class (stl/css :modal-header-title)}
title])
children]))
(def ^:private schema:modal-content
[:map
[:class {:optional true} [:maybe :string]]])
(mf/defc modal-content*
{::mf/schema schema:modal-content}
[{:keys [class children] :rest props}]
(let [props (mf/spread-props props
{:class [class (stl/css :modal-content)]})]
[:> :div props
children]))
(def ^:private schema:modal-footer
[:map
[:class {:optional true} [:maybe :string]]
[:start {:optional true} [:maybe :any]]
[:end {:optional true} [:maybe :any]]
[:variant {:optional true} [:maybe [:enum "split" "base"]]]])
(mf/defc modal-footer*
{::mf/schema schema:modal-footer}
[{:keys [class variant children start end] :rest props}]
(let [variant (d/nilv variant "base")
variant-class
(case variant
"split" (stl/css :modal-footer-split)
"base" (stl/css :modal-footer-base)
nil)
props (mf/spread-props props
{:class [class (stl/css :modal-footer) variant-class]})]
(if (= variant "split")
[:> :div props
[:> :div {:class (stl/css :modal-footer-left)}
start]
[:> :div {:class (stl/css :modal-footer-right)}
end]]
[:> :div props
children])))
(def ^:private schema:modal
[:map
[:class {:optional true} [:maybe :string]]
[:is-open {:optional true} [:maybe :boolean]]
[:on-open-change {:optional true} [:maybe fn?]]
[:trigger {:optional true} [:maybe :any]]
[:is-dismissable {:optional true} [:maybe :boolean]]
[:size {:optional true} [:maybe [:enum "small" "medium" "large" "xlarge"]]]
[:hide-close {:optional true} [:maybe :boolean]]
[:show-body {:optional true} [:maybe :boolean]]])
(mf/defc modal-close-button*
[]
(let [close (modal/useModalClose)]
(when close
[:div {:class (stl/css :modal-close)}
[:> icon-button* {:icon "close"
:variant "ghost"
:aria-label (tr "labels.close")
:on-click close}]])))
(mf/defc modal*
{::mf/schema schema:modal}
[{:keys [class is-open on-open-change trigger is-dismissable size hide-close show-body children] :rest props}]
(let [hide-close (d/nilv hide-close false)
is-dismissable (d/nilv is-dismissable true)
size (d/nilv size "medium")
show-body (d/nilv show-body true)
props
(mf/spread-props props
{:class class
:is-open is-open
:on-open-change on-open-change
:trigger trigger
:is-dismissable is-dismissable
:size size})]
[:> modal/Modal props
(when-not hide-close
[:> modal-close-button*])
(if show-body
[:div {:class [class (stl/css :modal-body)]}
children]
children)]))

View File

@ -0,0 +1,163 @@
{ /* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS INC Sucursal en España SL */ }
import { Canvas, Meta } from "@storybook/addon-docs/blocks";
import * as Modal from "./modal.stories";
<Meta title="Layout/Modal" />
# Modal
A modal interrupts the current workflow to display critical information or require a user decision. While open, interaction with the rest of the interface is blocked.
## Example
### Default
<Canvas of={Modal.Default} />
---
# Usage
The modal is composed of optional layout components.
```clojure
[:> modal*
{:is-open open?
:on-open-change #(reset! open* %)}
[:> modal-header*
{:title "Dialog title"}]
[:> modal-content* {}
[:> :p {} "Content"]]
[:> modal-footer* {}
[:> button* {:variant "secondary"} "Cancel"]
[:> button* {:variant "primary"} "Save"]]]
```
The header, content and footer are optional. Use only the sections your dialog requires.
---
## Footer layouts
### Simple
Use when all actions belong together.
```clojure
[:> modal-footer* {}
[:> button* {:variant "secondary"} "Cancel"]
[:> button* {:variant "primary"} "Save"]]
```
Result:
```
. Cancel Save
```
### Split actions
Use when a destructive or secondary action should be visually separated from the primary actions.
```clojure
[:> modal-footer*
{:variant "split"
:start
(mf/html
[:> button* {:variant "destructive"} "Delete"])
:end
(mf/html
[:*
[:> button* {:variant "secondary"} "Cancel"]
[:> button* {:variant "primary"} "Save"]])}]
```
Result:
```
Delete Cancel Save
```
---
# Modal props
## is-open
Controls whether the modal is open.
Type: boolean
## on-open-change
Callback invoked whenever the open state changes.
Type: function
## trigger
Element that opens the modal.
Type: React element
## is-dismissable
Whether the modal can be closed by pressing Escape or clicking the backdrop.
Default: true
## hide-close
Hides the default close button.
Default: false
## size
Controls the modal width.
Options
"small"
"medium" (default)
"large"
"xlarge"
## class
Additional CSS class applied to the modal.
# Accessibility
The modal automatically provides:
- Accessible `dialog` semantics
- Focus trapping
- Focus restoration when closed
- Keyboard support
- Optional dismissal with **Escape** and backdrop click
---
# Best practices
Use a modal for:
- Confirming destructive actions
- Collecting short forms
- Displaying critical information
- Requesting a decision before continuing
Avoid using a modal for:
- Long forms
- Large amounts of content
- Complex navigation
- Non-critical information

View File

@ -0,0 +1,68 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "ds/mixins" as *;
.modal-header {
display: flex;
align-items: center;
flex-shrink: 0;
}
.modal-header-title {
color: var(--color-foreground-primary);
}
.modal-close {
position: absolute;
inset-block-start: var(--sp-m);
inset-inline-end: var(--sp-s);
}
.modal-content {
@include custom-scrollbar;
padding: var(--sp-l);
overflow-block: auto;
color: var(--color-foreground-primary);
}
.modal-body {
display: grid;
grid-template-rows: auto 1fr auto;
gap: var(--sp-xxxl);
min-block-size: 0;
padding: var(--sp-xxxl);
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-s);
flex-shrink: 0;
inline-size: 100%;
}
.modal-footer-base {
justify-content: flex-end;
}
.modal-footer-split {
justify-content: space-between;
}
.modal-footer-left {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.modal-footer-right {
display: flex;
align-items: center;
gap: var(--sp-s);
}

View File

@ -0,0 +1,146 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
import * as React from "react";
import Components from "@target/components";
const { Modal, ModalHeader, ModalContent, ModalFooter, Button, IconButton } =
Components;
const ModalWrapper = ({ children, ...props }) => {
const [open, setOpen] = React.useState(props.isOpen ?? false);
React.useEffect(() => {
setOpen(props.isOpen ?? false);
}, [props.isOpen]);
return (
<>
<Button onClick={() => setOpen(true)}>Open Modal</Button>
<Modal {...props} isOpen={open} onOpenChange={setOpen}>
{children}
</Modal>
</>
);
};
export default {
title: "Layout/Modal",
component: ModalWrapper,
args: {
isOpen: false,
size: "medium",
isDismissable: true,
children: (
<>
<ModalHeader title="Dialog Title" />
<ModalContent>
<p>This is the default modal content.</p>
<p>You can put any React content inside.</p>
</ModalContent>
<ModalFooter>
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Confirm</Button>
</ModalFooter>
</>
),
},
argTypes: {
size: {
control: "select",
options: ["small", "medium", "large", "xlarge"],
},
isDismissable: { control: "boolean" },
},
parameters: {
controls: { exclude: ["isOpen", "onOpenChange", "children"] },
},
render: ({ ...args }) => <ModalWrapper {...args} />,
};
export const Default = {};
export const WithoutTitle = {
args: {
hideClose: true,
children: (
<ModalContent>
<p>This modal has no header or footer.</p>
</ModalContent>
),
},
};
export const DestructiveFooter = {
args: {
children: (
<>
<ModalHeader title="Delete item" />
<ModalContent>
<p>
Are you sure you want to delete this item? This action cannot be
undone.
</p>
</ModalContent>
<ModalFooter
variant="split"
start={<Button variant="destructive">Delete</Button>}
end={
<>
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Accept</Button>
</>
}
/>
</>
),
},
};
export const ScrollableContent = {
args: {
children: (
<>
<ModalHeader title="Terms and Conditions" />
<ModalContent>
{Array.from({ length: 20 }, (_, i) => (
<p key={i}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat.
</p>
))}
</ModalContent>
<ModalFooter>
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Accept</Button>
</ModalFooter>
</>
),
},
};
export const NonDismissable = {
args: {
isDismissable: false,
children: (
<>
<ModalHeader title="Important" />
<ModalContent>
<p>
This modal cannot be closed by clicking the backdrop or pressing
Escape.
</p>
</ModalContent>
<ModalFooter>
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Confirm</Button>
</ModalFooter>
</>
),
},
};