Skip to content
Open
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- already set `id` values and connections are never overwritten
- ID references created by the field item are removed again if their part is removed from the field item
- `preventAriaAttribution` property: prevents this automatic connection of the field item parts
- `<Modal />`
- `role`, `aria-label`, `aria-labelledby` and `aria-describedby` properties: they are set on the dialog element inside the modal overlay
- `role` is `dialog` by default, but it is removed again if neither a label nor a description is available; a console warning points this out when the modal is opened
- `aria-modal` is set together with the `role`, so it is left out as well if the `role` was removed
- it is `true` for the modal that was opened last according to the `ModalContext`, otherwise it is `false`
- if no `ModalContext` is provided, then the modals cannot know about each other, so each of them claims modality
- `<SimpleDialog />`
- `role` and the aria attributes are set automatically now if they are not given
- `role` is `alertdialog` if an `intent` state is set that describes an alert (`success`, `warning`, `danger` or `info`), otherwise it is `dialog`
- for those alert intent states the content area gets an `id` and is referred by the dialog via `aria-describedby`
- explicitly given values are never overwritten
- `<AlertDialog />`
- if neither `title`, `aria-label` nor `aria-labelledby` is given, then the alert level is used as fallback for `aria-label`, so the dialog always has an accessible name
- new `utils` methods:
- `truncateMarkdownDisplay`: helper function to iterate over `Markdown` renderings to improve the experienced `cutOff` value
- new icons:
Expand All @@ -40,6 +53,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- the build of the ESM distribution needs a synchronous `import.meta.resolve`, which is only available since this version
- `<FieldItem />`
- the used `Label` element gets the `eccgui-fielditem__label` class now
- `<AlertDialog />`
- always uses `role="alertdialog"` now
- the `role` property is not accepted anymore
- `ModalContext`
- a change of the stack of open modals re-renders the consumers of the context now, this way modals can react on modals that are opened on top of them, e.g. to hand over `aria-modal`
- before only an internal reference was updated, which never triggered any re-render
- the component that provides the context via `useModalContext` is re-rendered on every change of the stack, but not if a change does not affect it, e.g. when a modal is closed that was never registered as open
- `openModalStack()` still returns the current stack synchronously, also directly after `setModalOpen()` was called
- `<StringPreviewContentBlobToggler />`
- `allowedHtmlElementsInPreview` option is set to inline elements on default
- uses now the `Markdown.cutOff` property
Expand Down
9 changes: 3 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,15 @@
},
"lint-staged": {
"*.(json|md)": [
"prettier --write",
"git add"
"prettier --write"
],
"*.(js|ts|tsx)": [
"eslint --fix",
"prettier --write",
"git add"
"prettier --write"
],
"*.(scss)": [
"stylelint --fix",
"prettier --write",
"git add"
"prettier --write"
]
},
"jest": {
Expand Down
2 changes: 1 addition & 1 deletion src/common/utils/truncateMarkdownDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface MarkdownWithCutOffProps extends Omit<MarkdownProps, "cutOff"> {
cutOff: NonNullable<MarkdownProps["cutOff"]>;
}

interface TruncateMarkdownDisplayType {
export interface TruncateMarkdownDisplayType {
(
/**
* Markdown element with mandatory `cutOff` property.
Expand Down
16 changes: 13 additions & 3 deletions src/components/Dialog/AlertDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Definitions as IntentStates, IntentTypes } from "../../common/Intent";

import SimpleDialog, { SimpleDialogProps } from "./SimpleDialog";

export interface AlertDialogProps extends Omit<SimpleDialogProps, "intent"> {
export interface AlertDialogProps extends Omit<SimpleDialogProps, "intent" | "role"> {
/**
* set to true if alert dialog displays a success message
*/
Expand All @@ -21,7 +21,8 @@ export interface AlertDialogProps extends Omit<SimpleDialogProps, "intent"> {

/**
* Special element to display alert notification in modal dialogs.
* Inherits all properties from `SimpleDialog`, except `intent`.
* Inherits all properties from `SimpleDialog`, except `intent` and `role`.
* If `title`, `aria-label` nor a `aria-labelledby` is given then the alert level automatically used as fallback for `aria-label`.
*/
export const AlertDialog = ({
children,
Expand All @@ -41,8 +42,17 @@ export const AlertDialog = ({
intentLevel = IntentStates.DANGER;
}

const labelFallback = !otherProps.title && !otherProps["aria-label"] && !otherProps["aria-labelledby"] ? { "aria-label": intentLevel } : {};

return (
<SimpleDialog size="tiny" preventSimpleClosing={true} intent={intentLevel} {...otherProps}>
<SimpleDialog
role="alertdialog"
size="tiny"
preventSimpleClosing={true}
intent={intentLevel}
{...otherProps}
{...labelFallback}
>
{children}
</SimpleDialog>
);
Expand Down
44 changes: 41 additions & 3 deletions src/components/Dialog/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import {
Classes as BlueprintClassNames,
DialogProps as BlueprintDialogProps,
Overlay2 as BlueprintOverlay,
Overlay2Props as BlueprintOverlayProps,
} from "@blueprintjs/core";
Expand All @@ -11,9 +12,13 @@ import { CLASSPREFIX as eccgui } from "../../configuration/constants";
import { TestableComponent } from "../interfaces";

import { Card } from "./../Card";
import { ModalContext } from "./ModalContext";
import { isModalContextProvided, ModalContext } from "./ModalContext";

export interface ModalProps extends TestableComponent, BlueprintOverlayProps {
export interface ModalProps
extends
TestableComponent,
BlueprintOverlayProps,
Pick<BlueprintDialogProps, "role" | "aria-labelledby" | "aria-describedby"> {
children: React.ReactNode | React.ReactNode[];
/**
* A space-delimited list of class names to pass along to the BlueprintJS `Overlay` element that is used to create the modal.
Expand Down Expand Up @@ -52,6 +57,10 @@ export interface ModalProps extends TestableComponent, BlueprintOverlayProps {
* Prevents that pan and zooming actions of an existing react-flow instance are triggered while this Modal is open.
*/
preventReactFlowEvents?: boolean;
/**
* Set this if there is no visible title element that is used for `aria-labelledby`.
*/
"aria-label"?: string;
}

export type ModalSize = "tiny" | "small" | "regular" | "large" | "xlarge" | "fullscreen";
Expand All @@ -78,6 +87,10 @@ export const Modal = ({
"data-test-id": dataTestId,
"data-testid": dataTestid,
modalId,
role = "dialog",
"aria-labelledby": ariaLabelledby,
"aria-describedby": ariaDescribedby,
"aria-label": ariaLabel,
preventReactFlowEvents = true,
...otherProps
}: ModalProps) => {
Expand All @@ -94,6 +107,10 @@ export const Modal = ({
}, []);

React.useEffect(() => {
if (!(ariaLabel || ariaLabelledby || ariaDescribedby) && role && otherProps.isOpen) {
// eslint-disable-next-line no-console
console.warn(`role=${role} removed from modal because no label or description is available.`);
}
modalContext.setModalOpen(uniqueModalId.current, otherProps.isOpen);
}, [otherProps.isOpen]);

Expand Down Expand Up @@ -140,6 +157,26 @@ export const Modal = ({
}
};

// always remove the role if there is no explanation
const modalRole = ariaLabel || ariaLabelledby || ariaDescribedby ? role : undefined;

// Only the modal that was opened last constrains assistive technologies to its contents.
// Without a provided ModalContext the modals do not know about each other, then each of them
// has to consider itself as the one that constrains.
const openModalStack = modalContext.openModalStack() ?? [];
const isTopMostModal = isModalContextProvided(modalContext)
? openModalStack[openModalStack.length - 1] === uniqueModalId.current
: true;

const modalAriaAttributes = {
role: modalRole,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
"aria-describedby": ariaDescribedby,
// modality can only be expressed together with a dialog role
"aria-modal": modalRole ? isTopMostModal : undefined,
};

return (
<BlueprintOverlay
{...otherProps}
Expand All @@ -158,15 +195,16 @@ export const Modal = ({
className={BlueprintClassNames.DIALOG_CONTAINER}
// this is a workaround because data attribute on SimpleDialog is not correctly routed to the overlay by blueprint js
{...{ "data-test-id": dataTestId ?? "simpleDialogWidget", "data-testid": dataTestid }}
{...focusableProps}
tabIndex={0}
{...focusableProps}
>
<section
className={
`${eccgui}-dialog__wrapper` +
(typeof size === "string" ? ` ${eccgui}-dialog__wrapper--` + size : "") +
(className ? " " + className : "")
}
{...modalAriaAttributes}
>
{alteredChildren}
</section>
Expand Down
89 changes: 58 additions & 31 deletions src/components/Dialog/ModalContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,49 +8,76 @@ export interface ModalContextProps {
openModalStack(): string[] | undefined;
}

/** Can be provided in the application to react to modal related changes. */
export const ModalContext = React.createContext<ModalContextProps>({
/** Used as long as no `ModalContext` is provided by the application, it does not track anything. */
const unprovidedModalContext: ModalContextProps = {
setModalOpen: () => {},
openModalStack: () => [],
});
};

/** Can be provided in the application to react to modal related changes. */
export const ModalContext = React.createContext<ModalContextProps>(unprovidedModalContext);

/** Checks if the given modal context is provided by the application, so it really tracks open modals.
* Without a provided context the modals cannot know about each other.
**/
export const isModalContextProvided = (modalContext: ModalContextProps): boolean =>
modalContext !== unprovidedModalContext;

/** Calculates the stack of open modals after a modal was opened or closed.
* Returns the given stack unchanged if it is not affected.
**/
const updatedOpenModalStack = (stack: string[], modalId: string, isOpen: boolean): string[] => {
if (isOpen) {
// an already registered modal must not be added twice, otherwise closing it would
// consider modals as closed that are still open
return stack.includes(modalId) ? stack : [...stack, modalId];
}

const idx = stack.findIndex((id) => modalId === id);
if (idx === -1) {
// Trying to close modal that has not been registered as open!
return stack;
}

// If a modal in between is closed, then all modals after it are considered as closed, too.
return stack.slice(0, idx);
};

/** Default implementation for modal context props.
* Tracks open modals in a stack representation.
**/
export const useModalContext = (): ModalContextProps => {
// A stack of modal IDs. These should reflect a stacked opening of modals on top of each other.
// It is kept in a ref, so that it can always be read synchronously, even directly after
// `setModalOpen` was called.
const currentOpenModalStack = React.useRef<string[]>([]);

const setOpenModalStack = (stackUpdateFunction: (old: string[]) => string[]) => {
currentOpenModalStack.current = stackUpdateFunction([...currentOpenModalStack.current]);
};
// Counts the changes of the stack. This way a changed stack re-renders all consumers of the
// context, e.g. modals that are not the top most one anymore.
const [stackChangeCount, setStackChangeCount] = React.useState<number>(0);

const setModalOpen = React.useCallback((modalId: string, isOpen: boolean) => {
setOpenModalStack((old) => {
if (isOpen) {
return [...old, modalId];
} else {
const idx = old.findIndex((id) => modalId === id);
switch (idx) {
case -1:
// Trying to close modal that has not been registered as open!
return old;
case old.length - 1:
return old.slice(0, idx);
default:
// Modal in between is closed. Consider all modals after it also as closed.
return old.slice(0, idx);
}
}
});
const updatedStack = updatedOpenModalStack(currentOpenModalStack.current, modalId, isOpen);
if (updatedStack !== currentOpenModalStack.current) {
currentOpenModalStack.current = updatedStack;
setStackChangeCount((count) => count + 1);
}
}, []);

const openModalStack = React.useCallback(() => {
return currentOpenModalStack.current.length ? [...currentOpenModalStack.current] : undefined;
}, []);
const openModalStack = React.useCallback(
() => {
return currentOpenModalStack.current.length ? [...currentOpenModalStack.current] : undefined;
},
// the identity changes with every stack change, so consumers receive a changed context value
[stackChangeCount],
);

return {
openModalStack,
setModalOpen,
};
// the context value only changes when the stack itself changed, so consumers are not
// re-rendered by unrelated re-renders of the providing component
return React.useMemo(
() => ({
openModalStack,
setModalOpen,
}),
[openModalStack, setModalOpen],
);
};
23 changes: 19 additions & 4 deletions src/components/Dialog/SimpleDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface SimpleDialogProps extends ModalProps, TestableComponent {
*/
actions?: React.ReactNode | React.ReactNode[];
/**
* If populated with elements, then a second contant area is included before the action footer.
* If populated with elements, then a second content area is included before the action footer.
* Mainly provided to include `Notification` elements.
*/
notifications?: React.ReactNode | React.ReactNode[];
Expand All @@ -32,7 +32,7 @@ export interface SimpleDialogProps extends ModalProps, TestableComponent {
*/
headerOptions?: null | React.JSX.Element | React.JSX.Element[];
/**
* If enabled neither closing via `esc` key or clicking outside of the component will work, except explicitly specified.
* If enabled neither closing via `esc` key or clicking outside the component will work, except explicitly specified.
*/
preventSimpleClosing?: boolean;
/**
Expand All @@ -51,6 +51,8 @@ export interface SimpleDialogProps extends ModalProps, TestableComponent {

/**
* Simplifies the dialog display by providing a direct `Card` template for the `Modal` element.
* If not given then aria attributes like `role` and `aria-labelledby` are set automatically.
* `aria-describedby` is only automatically when an `intent` state is set.
* Inherits all properties from `Modal`.
*/
export const SimpleDialog = ({
Expand All @@ -68,16 +70,23 @@ export const SimpleDialog = ({
showFullScreenToggler = false,
startInFullScreenMode = false,
size,
role,
"aria-labelledby": ariaLabelledby,
"aria-describedby": ariaDescribedby,
"aria-label": ariaLabel,
actionsProps,
...otherProps
}: SimpleDialogProps) => {
const dialogUniqueId = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [displayFullscreen, setDisplayFullscreen] = React.useState<boolean>(startInFullScreenMode);
const showToggler = startInFullScreenMode || showFullScreenToggler;
const intentClassName = intent ? `${eccgui}-intent--${intent}` : "";
const wrapperDivProps = {
...modalPreventEvents,
...otherProps.wrapperDivProps,
};

const hasSemanticIntent = intent && ["success", "warning", "danger", "info"].includes(intent);
return (
<Modal
enforceFocus={enforceFocus}
Expand All @@ -88,11 +97,17 @@ export const SimpleDialog = ({
canOutsideClickClose={canOutsideClickClose || !preventSimpleClosing}
canEscapeKeyClose={canEscapeKeyClose || !preventSimpleClosing}
size={displayFullscreen ? "fullscreen" : size}
role={role ?? (hasSemanticIntent ? "alertdialog" : "dialog")}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby ?? (title ? `title_${dialogUniqueId}` : undefined)}
aria-describedby={ariaDescribedby ?? (hasSemanticIntent ? `description_${dialogUniqueId}` : undefined)}
>
<Card className={intentClassName}>
{title || headerOptions || showToggler ? (
<CardHeader>
<CardTitle className={intentClassName}>{title}</CardTitle>
<CardTitle className={intentClassName} id={title ? `title_${dialogUniqueId}` : undefined}>
{title}
</CardTitle>
{headerOptions || showToggler ? (
<CardOptions>
{headerOptions}
Expand All @@ -109,7 +124,7 @@ export const SimpleDialog = ({
</CardHeader>
) : null}
{hasBorder && <Divider />}
<CardContent>{children}</CardContent>
<CardContent id={hasSemanticIntent ? `description_${dialogUniqueId}` : undefined}>{children}</CardContent>
{hasBorder && <Divider />}
{!!notifications && (
<CardContent className={`${eccgui}-dialog__notifications`}>{notifications}</CardContent>
Expand Down
Loading
Loading