Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 71 additions & 44 deletions src/lib/components/modal/Modal.component.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
import { ReactNode, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import { Wrap, spacing } from '../../spacing';
import { zIndex } from '../../style/theme';
import { getThemePropSelector } from '../../utils';
import { Button } from '../buttonv2/Buttonv2.component';
import { Icon } from '../icon/Icon.component';
import { Text } from '../text/Text.component';

type Props = {
type BaseProps = {
isOpen: boolean;
close?: () => void;
/**
* Should be a plain string for correct accessibility (aria-labelledby).
* ReactNode is accepted for backwards compatibility.
*/
title: ReactNode;
footer?: ReactNode;
footer: ReactNode;
children: ReactNode;
subTitle?: ReactNode;
role?: 'dialog' | 'alertdialog';
/**
* When true, the modal sizes to its content (up to 90vw) instead of
* capping body content at 480px. Use for tables, complex forms, or any
* content that needs more horizontal space.
*/
wide?: boolean;
};

type DialogProps = BaseProps & {
role?: 'dialog';
close?: () => void;
};

type AlertDialogProps = BaseProps & {
role: 'alertdialog';
close?: never;
};

type Props = DialogProps | AlertDialogProps;

const ModalContainer = styled.div`
position: fixed;
top: 0;
Expand All @@ -29,30 +50,36 @@ const ModalContainer = styled.div`
background-color: rgba(0, 0, 0, 0.5);
z-index: ${zIndex.modal};
`;

const ModalContent = styled.div`
display: flex;
flex-direction: column;
background-color: ${getThemePropSelector('backgroundLevel1')};
color: ${getThemePropSelector('textPrimary')};
border-radius: 5px;
overflow: hidden;
min-width: 250px;
min-height: 150px;
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
max-height: calc(100vh - ${spacing.r24} - ${spacing.r24});
max-width: 90vw;
`;

const ModalHeader = styled.div`
display: flex;
padding: ${spacing.r16} ${spacing.r16} ${spacing.r16} ${spacing.r32};
background-color: ${(props) => props.theme.backgroundLevel3};
`;

const ModalBody = styled.div`
const ModalBody = styled.div<{ $wide?: boolean }>`
padding: ${spacing.r32};
flex-grow: 1;
min-width: 480px;
box-sizing: border-box;
background-color: ${(props) => props.theme.backgroundLevel4};
overflow-y: auto;
${({ $wide }) => !$wide && css`max-width: 480px;`}
`;

const ModalFooter = styled.div`
padding: ${spacing.r16};
background-color: ${(props) => props.theme.backgroundLevel3};
Expand All @@ -65,6 +92,7 @@ const Modal = ({
children,
footer,
subTitle,
wide,
role = 'dialog',
...rest
}: Props) => {
Expand Down Expand Up @@ -94,45 +122,44 @@ const Modal = ({
};
}
}, [isOpen]);

return isOpen
? createPortal(
<ModalContainer
className="sc-modal"
role={role}
aria-modal="true"
aria-labelledby="dialog_label"
aria-describedby="dialog_desc"
{...rest}
>
<ModalContent className="sc-modal-content">
<ModalHeader className="sc-modal-header">
<Wrap style={{ flex: 1 }}>
<Text variant="Larger" id="dialog_label">
{title}
</Text>
{close ? (
<Button
icon={<Icon name="Close" />}
onClick={close}
tooltip={{
overlay: 'Close modal',
}}
/>
) : (
<>{subTitle}</>
)}
</Wrap>
</ModalHeader>
<ModalBody className="sc-modal-body" id="dialog_desc">
{children}
</ModalBody>
{footer && (
<ModalFooter className="sc-modal-footer">{footer}</ModalFooter>
)}
</ModalContent>
</ModalContainer>,
modalContainer.current,
)
<ModalContainer
className="sc-modal"
role={role}
aria-modal="true"
aria-labelledby="dialog_label"
aria-describedby="dialog_desc"
{...rest}
>
<ModalContent className="sc-modal-content">
<ModalHeader className="sc-modal-header">
<Wrap style={{ flex: 1 }}>
<Text variant="Larger" id="dialog_label">
{title}
</Text>
{close ? (
<Button
icon={<Icon name="Close" />}
onClick={close}
tooltip={{
overlay: 'Close modal',
}}
/>
) : (
<>{subTitle}</>
)}
</Wrap>
</ModalHeader>
<ModalBody className="sc-modal-body" id="dialog_desc" $wide={wide}>
{children}
</ModalBody>
<ModalFooter className="sc-modal-footer">{footer}</ModalFooter>
</ModalContent>
</ModalContainer>,
modalContainer.current,
)
: null;
};

Expand Down
12 changes: 12 additions & 0 deletions src/lib/valalint/index.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import tsParser from "@typescript-eslint/parser";
import modalButtonForbiddenLabel from "./rules/modal-button-forbidden-label.mjs";
import modalFooterRequired from "./rules/modal-footer-required.mjs";
import modalFooterStackAlignment from "./rules/modal-footer-stack-alignment.mjs";
import modalTitleString from "./rules/modal-title-string.mjs";
import modalAlertdialogNoClose from "./rules/modal-alertdialog-no-close.mjs";
import noRawNumberInJsx from "./rules/no-raw-number-in-jsx.mjs";
import technicalSentenceCase from "./rules/technical-sentence-case.mjs";

const rules = {
"technical-sentence-case": technicalSentenceCase,
"modal-button-forbidden-label": modalButtonForbiddenLabel,
"modal-footer-required": modalFooterRequired,
"modal-footer-stack-alignment": modalFooterStackAlignment,
"modal-title-string": modalTitleString,
"modal-alertdialog-no-close": modalAlertdialogNoClose,
"no-raw-number-in-jsx": noRawNumberInJsx,
};

/** Default rule severity for the recommended config. */
const recommendedRules = {
"valalint/technical-sentence-case": "warn",
"valalint/modal-button-forbidden-label": "warn",
"valalint/modal-footer-required": "warn",
"valalint/modal-footer-stack-alignment": "warn",
"valalint/modal-title-string": "warn",
"valalint/modal-alertdialog-no-close": "warn",
"valalint/no-raw-number-in-jsx": "warn",
};

Expand Down
49 changes: 49 additions & 0 deletions src/lib/valalint/rules/modal-alertdialog-no-close.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const modalAlertdialogNoClose = {
meta: {
type: 'problem',
docs: {
description: 'Enforce that <Modal role="alertdialog"> does not receive a close prop',
category: 'Best Practices',
recommended: false,
},
schema: [],
},

create(context) {
return {
JSXElement(node) {
if (node.openingElement.name.name !== 'Modal') return;

const roleAttr = node.openingElement.attributes.find(
attr => attr.type === 'JSXAttribute' && attr.name.name === 'role',
);
if (!roleAttr) return;

let role = null;
if (roleAttr.value?.type === 'Literal') {
role = roleAttr.value.value;
} else if (
roleAttr.value?.type === 'JSXExpressionContainer' &&
roleAttr.value.expression.type === 'Literal'
) {
role = roleAttr.value.expression.value;
}
if (role !== 'alertdialog') return;

const hasClose = node.openingElement.attributes.some(
attr => attr.type === 'JSXAttribute' && attr.name.name === 'close',
);

if (hasClose) {
context.report({
node: node.openingElement,
message:
'<Modal role="alertdialog"> must not have a close prop. Alert dialogs require an explicit user action via footer buttons.',
});
}
},
};
},
};

export default modalAlertdialogNoClose;
49 changes: 49 additions & 0 deletions src/lib/valalint/rules/modal-alertdialog-no-close.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { RuleTester } from 'eslint';
import rule from './modal-alertdialog-no-close.mjs';
import * as tsParser from '@typescript-eslint/parser';

const tester = new RuleTester({
parser: tsParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 2020,
},
});

const ERROR =
'<Modal role="alertdialog"> must not have a close prop. Alert dialogs require an explicit user action via footer buttons.';

tester.run('modal-alertdialog-no-close', rule, {
valid: [
// Standard dialog with close — fine
{ code: '<Modal close={onClose} footer={null}>Content</Modal>' },
// alertdialog without close — correct
{ code: '<Modal role="alertdialog" footer={null}>Content</Modal>' },
// dialog role explicitly set with close — fine
{ code: '<Modal role="dialog" close={onClose} footer={null}>Content</Modal>' },
// Non-Modal elements are not checked
{ code: '<Dialog role="alertdialog" close={onClose}>Content</Dialog>' },
],

invalid: [
{
code: '<Modal role="alertdialog" close={onClose} footer={null}>Content</Modal>',
errors: [{ message: ERROR }],
},
{
code: '<Modal role="alertdialog" close={() => setOpen(false)} footer={null}>Content</Modal>',
errors: [{ message: ERROR }],
},
{
// role as JSX expression container
code: '<Modal role={"alertdialog"} close={onClose} footer={null}>Content</Modal>',
errors: [{ message: ERROR }],
},
],
});

describe('modal-alertdialog-no-close', () => {
it('passes all RuleTester cases', () => {
expect(true).toBe(true);
});
});
32 changes: 32 additions & 0 deletions src/lib/valalint/rules/modal-footer-required.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const modalFooterRequired = {
meta: {
type: 'problem',
docs: {
description: 'Enforce that <Modal> always receives a footer prop',
category: 'Best Practices',
recommended: false,
},
schema: [],
},

create(context) {
return {
JSXElement(node) {
if (node.openingElement.name.name !== 'Modal') return;

const hasFooter = node.openingElement.attributes.some(
attr => attr.type === 'JSXAttribute' && attr.name.name === 'footer',
);

if (!hasFooter) {
context.report({
node: node.openingElement,
message: '<Modal> must have a footer prop.',
});
}
},
};
},
};

export default modalFooterRequired;
49 changes: 49 additions & 0 deletions src/lib/valalint/rules/modal-footer-required.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { RuleTester } from 'eslint';
import rule from './modal-footer-required.mjs';
import * as tsParser from '@typescript-eslint/parser';

const tester = new RuleTester({
parser: tsParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 2020,
},
});

const ERROR = '<Modal> must have a footer prop.';

tester.run('modal-footer-required', rule, {
valid: [
// footer as JSX expression
{ code: '<Modal footer={<Stack><Button>Cancel</Button></Stack>}>Content</Modal>' },
// footer as string (unusual but valid prop)
{ code: '<Modal footer="actions">Content</Modal>' },
// footer with null — consumer's choice
{ code: '<Modal footer={null}>Content</Modal>' },
// Non-Modal elements without footer are fine
{ code: '<Dialog>Content</Dialog>' },
{ code: '<div>Content</div>' },
],

invalid: [
{
code: '<Modal title="Confirm" isOpen={open}>Content</Modal>',
errors: [{ message: ERROR }],
},
{
code: '<Modal title="Confirm" isOpen={open} close={onClose}>Content</Modal>',
errors: [{ message: ERROR }],
},
{
// Self-closing (unusual for Modal but should still be caught)
code: '<Modal title="Confirm" isOpen={open} />',
errors: [{ message: ERROR }],
},
],
});

describe('modal-footer-required', () => {
it('passes all RuleTester cases', () => {
expect(true).toBe(true);
});
});
Loading
Loading