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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ 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
- if neither `title`, `aria-label` nor `aria-labelledby` is given for an alert, then the `intent` level is used as fallback for `aria-label`, so the alert 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 @@ -44,6 +56,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 All @@ -58,6 +78,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- `show={"print"}` and `hide={"screen"}` content is still accessible by screen readers
- `<Label />`
- `tooltip` content is accessible via keyboard navigation
- `<Card />`
- fix color of first action button in info card
- BOM issue on compressed stylesheet
- first rule `selector` becomes `BOM:selector` that is valid but will never apply
- we fixed this problem by adding a dummy rule as first rule
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
2 changes: 1 addition & 1 deletion src/components/Card/card.scss
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ $eccgui-size-card-spacing: $eccgui-size-typo-base !default;
@extend .#{$ns}-intent-success;
}
&.#{$eccgui}-intent--info > .#{$eccgui}-button:first-child {
@extend .#{$ns}-intent-primary;
@extend .#{$ns}-intent-accent;
}
&.#{$eccgui}-intent--warning > .#{$eccgui}-button:first-child {
@extend .#{$ns}-intent-warning;
Expand Down
7 changes: 4 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 @@ -42,7 +43,7 @@ export const AlertDialog = ({
}

return (
<SimpleDialog size="tiny" preventSimpleClosing={true} intent={intentLevel} {...otherProps}>
<SimpleDialog role="alertdialog" size="tiny" preventSimpleClosing={true} intent={intentLevel} {...otherProps}>
{children}
</SimpleDialog>
);
Expand Down
49 changes: 46 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,10 @@ 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-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 +54,18 @@ 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;
/**
* ID of the element that contains title or label text for this dialog.
* If given then `aria-label` is ignored.
* In v27 it will be enforced that `aria-label` or `aria-labelledby` is set.
*/
"aria-labelledby"?: string;
/**
* Set this if there is no visible title element that is used for `aria-labelledby`.
* Property is ignored if `aria-labelledby` is given.
* In v27 it will be enforced that `aria-label` or `aria-labelledby` is set.
*/
"aria-label"?: string;
}

export type ModalSize = "tiny" | "small" | "regular" | "large" | "xlarge" | "fullscreen";
Expand All @@ -78,6 +92,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 @@ -93,7 +111,14 @@ export const Modal = ({
};
}, []);

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

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

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

// 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": !ariaLabelledby ? ariaLabel : undefined,
"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 +200,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],
);
};
Loading
Loading