diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05d5d0387..68b1dfa53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
This is a major release, and it might be not compatible with your current usage of our library. Please read about the necessary changes in the section about how to migrate.
+### Migration from v24 to v25
+
+- remove deprecated components, properties and imports from your project, if the info cannot be found here then it was already mentioned in **Deprecated** sections of the past changelogs
+- in case you set your own colors before importing GUI elements you need to update your configuration to the new color palette structure, see `README.md`
+- change `intent="primary"` to `intent="accent"` for ``, `` and ``, if supported you may check if it is better to use `affirmative={true}` or `elevated={true}` instead of `intent`
+
### Added
- ``
@@ -35,24 +41,48 @@ This is a major release, and it might be not compatible with your current usage
- component for React Flow v12, displaying new connection lines
- ``
- component to display a visual tour multi-step tour of the current view
-- new color palette that includes 4 sections with 20+ color tints in 5 weights each
- - indentity, semantic, layout, extra
- - managed via CSS custom properties
- - see `README.md` for inf about usage
+- ``
+ - `accent` value for `intent` was added to align property with other components
+- ``
+ - `accent` value for `intent` was added to align property with other components
+ - `elevated` property can be used to highlight the spinner, currently the `intent="accent"` display is used
+- ``:
+ - Add `ModalContext` to track open/close state of all used application modals.
+ - Add `modalId` property to give a modal a unique ID for tracking purposes.
+ - `preventReactFlowEvents`: adds 'nopan', 'nowheel' and 'nodrag' classes to overlay classes in order to prevent react-flow to react to drag and pan actions in modals.
+ - new `utils` methods
+ - `colorCalculateDistance()`: calculates the difference between 2 colors using the simple CIE76 formula
+ - `textToColorHash()`: calculates a color from a text string
+ - `reduceToText`: shrinks HTML content and React elements to plain text, used for ``
+ - `decodeHtmlEntities`: decode a string of HTML text, map HTML entities back to UTF-8 chars
- SCSS color functions
- `eccgui-color-var`: returns a var of a custom property used for palette color
- `eccgui-color-mix`: mix 2 colors in `srgb`, works with all types of color values and CSS custom properties
- `eccgui-color-rgba`: like `rgba()` but it works also for CSS custom properties
-- `colorCalculateDistance()`
- - function to calculate the difference between 2 colors using the simple CIE76 formula
-- `textToColorHash()`
- - function to calculate a color from a text string
-- new icons
+- Color palette: includes 4 sections with 20+ color tints in 5 weights each
+ - indentity, semantic, layout, extra
+ - managed via CSS custom properties
+ - see `README.md` for more information about usage
+- New icons
- `artefact-task-sqlupdatequeryoperator`
- `artefact-task-customsqlexecution`
+ - `item-legend`
+ - `operation-tour`
+ - `toggler-carettop`
+ - `toggler-caretleft`
+ - `toggler-micon`
+ - `toggler-micoff`
+ - `toggler-radio`
+ - `toggler-radio-checked`
+ - `state-flagged`
+ - `state-progress`
+ - `state-progress-error`
+ - `state-progress-warning`
+ - more icons for build tasks
### Removed
+- support for React Flow v10 was completely removed
- removed direct replacements for legacy components (imported via `@eccenca/gui-elements/src/legacy-replacements` or `LegacyReplacements`)
- ``, ``, ``, ``, ``, ``, ``, ``
- ``, ``, `
`, ``
@@ -65,20 +95,18 @@ This is a major release, and it might be not compatible with your current usage
- `densityHigh` property was removed
- ``
- static fallback for test id `codemirror-wrapper` was removed, add `data-test-id` (or your test id data attribute) always directly to `CodeEditor`.
-- `nodeTypes` and `edgeTypes` exports were removed
- - use ``
- removed `inversePath` property, can be replaced with `arrowDirection: "inversed"` property
- ``
- `description` property was removed because it was defined but not implemented for a very long time, but we plan to add that type of caption later
+- `nodeTypes` and `edgeTypes` exports were removed
+ - use `` with `configuration`, or define it yourself
+- SCSS variables `$eccgui-color-application-text` and `$eccgui-color-application-background` were removed
+ - use `$eccgui-color-workspace-text` and `$eccgui-color-workspace-background`
+- Removed `remark-typograf` plugin from `` rendering to maintain display expectation
### Fixed
-- ``:
- - Add 'nopan', 'nowheel' and 'nodrag' classes to Modal's overlay classes in order to always prevent react-flow to react to drag and pan actions in modals.
- ``:
- In multiline mode, validation errors might be highlighted incorrectly (relative line offset added).
@@ -93,17 +121,22 @@ This is a major release, and it might be not compatible with your current usage
- ``
- beside explicitly specified properties it allows only basic HTML element properties and testing IDs
- overrite the native SCSS `rgba()` function, so it now works for SCSS color values and CSS custom properties
-- `getColorConfiguration()` works with CSS custom properties
- ``
- Always add class 'nodrag' to popover content element to always prevent dragging of react-flow and dnd-kit elements when interacting with the component.
+- `utils.getColorConfiguration()` works with CSS custom properties
+- property names returned by `getColorConfiguration` were changed to kebab case because they are originally defined via CSS custom properties
+ - e.g. `graphNode` is now `eccgui-graph-node` and `graphNodeBright` is `eccgui-graph-node-bright`
+- `` and ``
+ - `intent` display was aligned with other components, `intent="primary"` is now `intent="accent"`, in most cases it may be better to use `affirmative={true}` or `elevated={true}` instead of primary/accent intent
+- ``
+ - `intent` display was aligned with other components, `intent="primary"` is now `intent="accent"`, in most cases it may be better to use `elevated={true}` instead of using intent
+- icons: arrow directions for `list-sortasc` and `list-sortdesc` were switched, up arrow is now used for ascending sort
### Deprecated
- support for React Flow v9 will be removed in v26
- ``
- use `` or build it on single ``
-- property names returned by `getCOlorConfiguration` were changed to kebab case because they are originally defined via CSS custom properties
- - e.g. `graphNode` is now `eccgui-graph-node` and `graphNodeBright` is `eccgui-graph-node-bright`
## [24.4.1] - 2025-08-25
diff --git a/package.json b/package.json
index 37941a867..1a671be96 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@eccenca/gui-elements",
"description": "GUI elements based on other libraries, usable in React application, written in Typescript.",
- "version": "24.4.1",
+ "version": "25.0.0",
"license": "Apache-2.0",
"homepage": "https://github.com/eccenca/gui-elements",
"bugs": "https://github.com/eccenca/gui-elements/issues",
@@ -87,6 +87,7 @@
"codemirror": "^6.0.1",
"color": "^4.2.3",
"compute-scroll-into-view": "^3.1.1",
+ "he": "^1.2.0",
"jshint": "^2.13.6",
"lodash": "^4.17.21",
"n3": "^1.25.1",
@@ -134,6 +135,7 @@
"@testing-library/react": "^12.1.5",
"@types/codemirror": "^5.60.15",
"@types/color": "^3.0.6",
+ "@types/he": "^1.2.3",
"@types/jest": "^29.5.14",
"@types/jshint": "^2.12.4",
"@types/lodash": "^4.17.16",
@@ -150,8 +152,8 @@
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "4",
"identity-obj-proxy": "^3.0.0",
- "jest": "^30.0.5",
- "jest-environment-jsdom": "^30.0.5",
+ "jest": "^30.2.0",
+ "jest-environment-jsdom": "^30.2.0",
"jest-pnp-resolver": "^1.2.3",
"lint-staged": "^15.5.1",
"node-sass-package-importer": "^5.3.3",
diff --git a/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx b/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx
index f9c081265..bb219663c 100644
--- a/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx
+++ b/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx
@@ -83,4 +83,4 @@ function firstNonEmptyLine(preview: string) {
export const stringPreviewContentBlobTogglerUtils = {
firstNonEmptyLine,
-};
+};
\ No newline at end of file
diff --git a/src/cmem/markdown/Markdown.tsx b/src/cmem/markdown/Markdown.tsx
index 2668dbcbd..7544d8f95 100644
--- a/src/cmem/markdown/Markdown.tsx
+++ b/src/cmem/markdown/Markdown.tsx
@@ -2,7 +2,6 @@ import React from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
// @ts-ignore: No declaration file for module (TODO: should be @ts-expect-error but GUI elements is used inside project with `noImplicitAny=false`)
-import remarkTypograf from "@mavrin/remark-typograf";
import rehypeExternalLinks from "rehype-external-links";
import rehypeRaw from "rehype-raw";
import { remarkDefinitionList } from "remark-definition-list";
@@ -55,7 +54,7 @@ const configDefault = {
@see https://github.com/remarkjs/react-markdown#api
*/
// @see https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins
- remarkPlugins: [remarkGfm, remarkTypograf, remarkDefinitionList] as PluggableList,
+ remarkPlugins: [remarkGfm, remarkDefinitionList] as PluggableList,
// @see https://github.com/rehypejs/rehype/blob/main/doc/plugins.md#list-of-plugins
rehypePlugins: [] as PluggableList,
allowedElements: [
diff --git a/src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx b/src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx
index 1492c5cd8..924cb7e51 100644
--- a/src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx
+++ b/src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx
@@ -40,7 +40,7 @@ export interface StickyNoteModalProps {
/**
* Forward other properties to the `SimpleModal` element that is used for this dialog.
*/
- simpleDialogProps?: Omit;
+ simpleDialogProps?: Omit;
/**
* Code editor props
*/
diff --git a/src/cmem/react-flow/_minimap.scss b/src/cmem/react-flow/_minimap.scss
index d40c08ec4..864d932b8 100644
--- a/src/cmem/react-flow/_minimap.scss
+++ b/src/cmem/react-flow/_minimap.scss
@@ -1,3 +1,13 @@
+.#{$eccgui}-graphviz__minimap__node--default {
+ &:not([fill]) {
+ fill: $reactflow-node-border-color;
+ }
+
+ &:not([stroke]) {
+ stroke: $reactflow-node-border-color;
+ }
+}
+
@mixin mapnodestyles($type) {
.#{$eccgui}-graphviz__minimap__node--#{$type} {
@include mapcoloring($type);
diff --git a/src/cmem/react-flow/configuration/_colors-graph.scss b/src/cmem/react-flow/configuration/_colors-graph.scss
index e43165b3a..01693c8c7 100644
--- a/src/cmem/react-flow/configuration/_colors-graph.scss
+++ b/src/cmem/react-flow/configuration/_colors-graph.scss
@@ -1,20 +1,20 @@
.#{$eccgui}-configuration--colors__react-flow-graph {
- --#{$eccgui}-graph-node: #{eccgui-color-var("layout", "purple", 700)};
- --#{$eccgui}-graph-node-bright: #{eccgui-color-var("layout", "purple", 100)};
- --#{$eccgui}-class-node: #{eccgui-color-var("layout", "magenta", 900)};
- --#{$eccgui}-class-node-bright: #{eccgui-color-var("layout", "magenta", 100)};
- --#{$eccgui}-instance-node: #{eccgui-color-var("layout", "magenta", 500)};
- --#{$eccgui}-instance-node-bright: #{eccgui-color-var("layout", "magenta", 100)};
+ --#{$eccgui}-graph-node: #{eccgui-color-var("layout", "magenta", 900)};
+ --#{$eccgui}-graph-node-bright: #{eccgui-color-var("layout", "magenta", 100)};
+ --#{$eccgui}-class-node: #{eccgui-color-var("layout", "purple", 700)};
+ --#{$eccgui}-class-node-bright: #{eccgui-color-var("layout", "purple", 100)};
+ --#{$eccgui}-instance-node: #{eccgui-color-var("layout", "purple", 500)};
+ --#{$eccgui}-instance-node-bright: #{eccgui-color-var("layout", "purple", 100)};
--#{$eccgui}-property-node: #{eccgui-color-var("layout", "teal", 700)};
--#{$eccgui}-property-node-bright: #{eccgui-color-var("layout", "teal", 100)};
--#{$eccgui}-implicit-edge: #{eccgui-color-var("identity", "text", 700)};
--#{$eccgui}-implicit-edge-bright: #{eccgui-color-var("identity", "text", 100)};
- --#{$eccgui}-import-edge: #{eccgui-color-var("layout", "purple", 700)};
- --#{$eccgui}-import-edge-bright: #{eccgui-color-var("layout", "purple", 100)};
- --#{$eccgui}-subclass-edge: #{eccgui-color-var("layout", "magenta", 900)};
- --#{$eccgui}-subclass-edge-bright: #{eccgui-color-var("layout", "magenta", 100)};
+ --#{$eccgui}-import-edge: #{eccgui-color-var("layout", "magenta", 900)};
+ --#{$eccgui}-import-edge-bright: #{eccgui-color-var("layout", "magenta", 100)};
+ --#{$eccgui}-subclass-edge: #{eccgui-color-var("layout", "purple", 700)};
+ --#{$eccgui}-subclass-edge-bright: #{eccgui-color-var("layout", "purple", 100)};
--#{$eccgui}-subproperty-edge: #{eccgui-color-var("layout", "teal", 700)};
--#{$eccgui}-subproperty-edge-bright: #{eccgui-color-var("layout", "teal", 100)};
- --#{$eccgui}-rdftype-edge: #{eccgui-color-var("layout", "magenta", 500)};
- --#{$eccgui}-rdftype-edge-bright: #{eccgui-color-var("layout", "magenta", 100)};
+ --#{$eccgui}-rdftype-edge: #{eccgui-color-var("layout", "purple", 500)};
+ --#{$eccgui}-rdftype-edge-bright: #{eccgui-color-var("layout", "purple", 100)};
}
diff --git a/src/cmem/react-flow/configuration/_colors-linking.scss b/src/cmem/react-flow/configuration/_colors-linking.scss
index 5b5b06360..c9e47d428 100644
--- a/src/cmem/react-flow/configuration/_colors-linking.scss
+++ b/src/cmem/react-flow/configuration/_colors-linking.scss
@@ -1,16 +1,16 @@
.#{eccgui}-configuration--colors__react-flow-linking {
- --#{$eccgui}-sourcepath-node: #{eccgui-color-var("layout", "violet", 700)};
- --#{$eccgui}-sourcepath-node-bright: #{eccgui-color-var("layout", "violet", 100)};
- --#{$eccgui}-targetpath-node: #{eccgui-color-var("layout", "cyan", 900)};
- --#{$eccgui}-targetpath-node-bright: #{eccgui-color-var("layout", "cyan", 100)};
+ --#{$eccgui}-sourcepath-node: #{eccgui-color-var("layout", "purple", 700)};
+ --#{$eccgui}-sourcepath-node-bright: #{eccgui-color-var("layout", "purple", 300)};
+ --#{$eccgui}-targetpath-node: #{eccgui-color-var("layout", "petrol", 700)};
+ --#{$eccgui}-targetpath-node-bright: #{eccgui-color-var("layout", "petrol", 300)};
--#{$eccgui}-transformation-node: #{eccgui-color-var("layout", "pink", 700)};
- --#{$eccgui}-transformation-node-bright: #{eccgui-color-var("layout", "pink", 100)};
+ --#{$eccgui}-transformation-node-bright: #{eccgui-color-var("layout", "pink", 300)};
--#{$eccgui}-comparator-node: #{eccgui-color-var("layout", "teal", 700)};
- --#{$eccgui}-comparator-node-bright: #{eccgui-color-var("layout", "teal", 100)};
+ --#{$eccgui}-comparator-node-bright: #{eccgui-color-var("layout", "teal", 300)};
--#{$eccgui}-aggregator-node: #{eccgui-color-var("layout", "cyan", 700)};
--#{$eccgui}-aggregator-node-bright: #{eccgui-color-var("layout", "cyan", 100)};
--#{$eccgui}-value-edge: #{eccgui-color-var("layout", "grey", 700)};
- --#{$eccgui}-value-edge-bright: #{eccgui-color-var("layout", "grey", 100)};
+ --#{$eccgui}-value-edge-bright: #{eccgui-color-var("layout", "grey", 300)};
--#{$eccgui}-score-edge: #{eccgui-color-var("layout", "cyan", 900)};
- --#{$eccgui}-score-edge-bright: #{eccgui-color-var("layout", "cyan", 100)};
+ --#{$eccgui}-score-edge-bright: #{eccgui-color-var("layout", "cyan", 300)};
}
diff --git a/src/cmem/react-flow/configuration/_colors-workflow.scss b/src/cmem/react-flow/configuration/_colors-workflow.scss
index 5c310a009..52965349e 100644
--- a/src/cmem/react-flow/configuration/_colors-workflow.scss
+++ b/src/cmem/react-flow/configuration/_colors-workflow.scss
@@ -1,16 +1,16 @@
.#{$eccgui}-configuration--colors__react-flow-workflow {
--#{$eccgui}-project-node: #{eccgui-color-var("layout", "magenta", 700)};
- --#{$eccgui}-project-node-bright: #{eccgui-color-var("layout", "magenta", 100)};
- --#{$eccgui}-dataset-node: #{eccgui-color-var("layout", "cyan", 900)};
- --#{$eccgui}-dataset-node-bright: #{eccgui-color-var("layout", "cyan", 100)};
- --#{$eccgui}-linking-node: #{eccgui-color-var("layout", "teal", 900)};
- --#{$eccgui}-linking-node-bright: #{eccgui-color-var("layout", "teal", 100)};
- --#{$eccgui}-transform-node: #{eccgui-color-var("layout", "pink", 700)};
- --#{$eccgui}-transform-node-bright: #{eccgui-color-var("layout", "pink", 100)};
+ --#{$eccgui}-project-node-bright: #{eccgui-color-var("layout", "magenta", 300)};
+ --#{$eccgui}-dataset-node: #{eccgui-color-var("layout", "petrol", 700)};
+ --#{$eccgui}-dataset-node-bright: #{eccgui-color-var("layout", "petrol", 300)};
+ --#{$eccgui}-linking-node: #{eccgui-color-var("layout", "cyan", 700)};
+ --#{$eccgui}-linking-node-bright: #{eccgui-color-var("layout", "cyan", 300)};
+ --#{$eccgui}-transform-node: #{eccgui-color-var("layout", "teal", 700)};
+ --#{$eccgui}-transform-node-bright: #{eccgui-color-var("layout", "teal", 300)};
--#{$eccgui}-task-node: #{eccgui-color-var("layout", "lime", 700)};
- --#{$eccgui}-task-node-bright: #{eccgui-color-var("layout", "lime", 100)};
+ --#{$eccgui}-task-node-bright: #{eccgui-color-var("layout", "lime", 300)};
--#{$eccgui}-workflow-node: #{eccgui-color-var("layout", "purple", 700)};
- --#{$eccgui}-workflow-node-bright: #{eccgui-color-var("layout", "purple", 100)};
- --#{$eccgui}-replaceableInput: #{eccgui-color-var("layout", "amber", 700)};
- --#{$eccgui}-replaceableInput-bright: #{eccgui-color-var("layout", "amber", 100)};
+ --#{$eccgui}-workflow-node-bright: #{eccgui-color-var("layout", "purple", 300)};
+ --#{$eccgui}-replaceable-input: #{eccgui-color-var("layout", "amber", 700)};
+ --#{$eccgui}-replaceable-input-bright: #{eccgui-color-var("layout", "amber", 300)};
}
diff --git a/src/common/index.ts b/src/common/index.ts
index aca74bd28..ab989fa3a 100644
--- a/src/common/index.ts
+++ b/src/common/index.ts
@@ -1,3 +1,5 @@
+import { decode } from "he";
+
import { invisibleZeroWidthCharacters } from "./utils/characters";
import { colorCalculateDistance } from "./utils/colorCalculateDistance";
import decideContrastColorValue from "./utils/colorDecideContrastvalue";
@@ -6,6 +8,8 @@ import getColorConfiguration from "./utils/getColorConfiguration";
import { getScrollParent } from "./utils/getScrollParent";
import { getGlobalVar, setGlobalVar } from "./utils/globalVars";
import { openInNewTab } from "./utils/openInNewTab";
+import { reduceToText } from "./utils/reduceToText";
+export type { DecodeOptions as DecodeHtmlEntitiesOptions } from "he";
export type { IntentTypes as IntentBaseTypes } from "./Intent";
export const utils = {
@@ -19,4 +23,6 @@ export const utils = {
getScrollParent,
getEnabledColorsFromPalette,
textToColorHash,
+ reduceToText,
+ decodeHtmlEntities: decode,
};
diff --git a/src/common/scss/_color-functions.scss b/src/common/scss/_color-functions.scss
index 0f841e6bf..e4d088092 100644
--- a/src/common/scss/_color-functions.scss
+++ b/src/common/scss/_color-functions.scss
@@ -86,6 +86,11 @@
* Created to replace them easily for CSS custom properties.
*/
@function eccgui-color-rgba($color, $alpha) {
+ @if meta.type-of($alpha) != "number" {
+ // in case it is for example a CSS custom property
+ @return eccgui-color-mix($color $alpha, transparent);
+ }
+
@if $alpha > 0 {
@return eccgui-color-mix($color 100% * $alpha, transparent);
} @else {
diff --git a/src/common/utils/reduceToText.tsx b/src/common/utils/reduceToText.tsx
new file mode 100644
index 000000000..d929ff410
--- /dev/null
+++ b/src/common/utils/reduceToText.tsx
@@ -0,0 +1,82 @@
+import React from "react";
+import { renderToString } from "react-dom/server";
+import * as ReactIs from "react-is";
+
+import { TextReducerProps } from "./../../components/TextReducer/TextReducer";
+import { DecodeHtmlEntitiesOptions, utils } from "./../";
+
+export interface ReduceToTextFuncType {
+ (
+ /**
+ * Component or text to reduce HTML markup content to plain text.
+ */
+ input: React.ReactNode | React.ReactNode[] | string,
+ options?: Pick
+ ): string;
+}
+
+export const reduceToText: ReduceToTextFuncType = (input, options) => {
+ const { maxNodes, maxLength, decodeHtmlEntities } = options || {};
+ const content: React.ReactNode | React.ReactNode[] = input;
+ let nodeCount = 0;
+
+ const onlyText = (nodes: React.ReactNode | React.ReactNode[]): string => {
+ if (typeof maxNodes !== "undefined" && nodeCount >= maxNodes) return "";
+
+ return React.Children.toArray(nodes)
+ .slice(0, maxNodes)
+ .map((child) => {
+ if (typeof maxNodes !== "undefined" && nodeCount >= maxNodes) return "";
+
+ if (ReactIs.isFragment(child)) return onlyText(child.props?.children);
+ if (typeof child === "string" || typeof child === "number") {
+ nodeCount++;
+ return child.toString();
+ }
+ if (ReactIs.isElement(child)) {
+ nodeCount++;
+ return renderToString({child});
+ }
+ return "";
+ })
+ .join(" ");
+ };
+
+ let text = typeof content === "string" ? content : onlyText(content);
+
+ // Basic HTML cleanup
+ text = text.replace(/<[^\s][^>]*>/g, "").replace(/\n/g, " ");
+
+ if (decodeHtmlEntities) {
+ const decodeDefaultOptions = {
+ isAttributeValue: true,
+ strict: true,
+ } as DecodeHtmlEntitiesOptions;
+ let decodeErrors = 0;
+ // we decode in pieces to apply some error tolerance even in strict mode
+ text = text
+ .split(" ")
+ .map((value) => {
+ try {
+ return utils.decodeHtmlEntities(value, {
+ ...decodeDefaultOptions,
+ ...options?.decodeHtmlEntitiesOptions,
+ });
+ } catch {
+ decodeErrors++;
+ return value;
+ }
+ })
+ .join(" ");
+ if (decodeErrors > 0) {
+ // eslint-disable-next-line no-console
+ console.warn(`${decodeErrors} parse error(s) for decodeHtmlEntities, return un-decoded text`, text);
+ }
+ }
+
+ if (typeof maxLength === "number") {
+ text = text.slice(0, maxLength);
+ }
+
+ return text.trim();
+};
diff --git a/src/components/Button/Button.stories.tsx b/src/components/Button/Button.stories.tsx
index 72c9fda10..11862224a 100644
--- a/src/components/Button/Button.stories.tsx
+++ b/src/components/Button/Button.stories.tsx
@@ -20,7 +20,7 @@ export default {
},
intent: {
...helpersArgTypes.exampleIntent,
- options: ["UNDEFINED", "primary", "success", "warning", "danger"],
+ options: ["UNDEFINED", "primary", "accent", "success", "warning", "danger"],
},
},
} as Meta;
@@ -62,6 +62,8 @@ const TemplateSemantic: StoryFn = (args) => (
+
+
);
export const ButtonSemantics = TemplateSemantic.bind({});
@@ -74,6 +76,10 @@ const TemplateIntent: StoryFn = (args) => (
+
+
+
+
);
export const ButtonIntent = TemplateIntent.bind({});
diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx
index e15b40968..7110d1c33 100644
--- a/src/components/Button/Button.tsx
+++ b/src/components/Button/Button.tsx
@@ -17,19 +17,23 @@ import Tooltip, { TooltipProps } from "./../Tooltip/Tooltip";
interface AdditionalButtonProps {
/**
* Always use this when the button triggers an affirmative action, e.g. confirm a process.
- * The button is displayed with primary color scheme.
+ * The button is displayed with accent color intent.
*/
affirmative?: boolean;
/**
* Always use this when the button triggers an disruptive action, e.g. delete or remove.
- * The button is displayed with primary color scheme.
+ * The button is displayed with danger color intent.
*/
disruptive?: boolean;
/**
* Use this when a button is important enough to highlight it in a set of other buttons.
- * The button is displayed with primary color scheme.
+ * The button is displayed with accent color intent.
*/
elevated?: boolean;
+ /**
+ * Intent state visualized by color.
+ */
+ intent?: BlueprintIntent | "accent";
/**
* Content displayed in a badge that is attached to the button.
* By default it is displayed `{ size: "small", position: "top-right", maxLength: 2 }` and with the same intent state of the button.
@@ -49,18 +53,21 @@ interface AdditionalButtonProps {
*/
tooltipProps?: Partial>;
/**
- * If an URL is set then the button is included as HTML anchor element instead of a button form element.
+ * Icon displayed on button start.
*/
- //href?: string;
icon?: ValidIconName | JSX.Element;
+ /**
+ * Icon displayed on button end.
+ */
rightIcon?: ValidIconName | JSX.Element;
- //target?: string;
}
-interface ExtendedButtonProps extends AdditionalButtonProps, Omit {}
+interface ExtendedButtonProps
+ extends AdditionalButtonProps,
+ Omit {}
interface ExtendedAnchorButtonProps
extends AdditionalButtonProps,
- Omit {}
+ Omit {}
export type ButtonProps = ExtendedButtonProps & ExtendedAnchorButtonProps;
@@ -86,7 +93,7 @@ export const Button = ({
let intentByFunction;
switch (true) {
case affirmative || elevated:
- intentByFunction = BlueprintIntent.PRIMARY;
+ intentByFunction = "accent";
break;
case disruptive:
intentByFunction = BlueprintIntent.DANGER;
diff --git a/src/components/Button/button.scss b/src/components/Button/button.scss
index 647acf399..766fd4a12 100644
--- a/src/components/Button/button.scss
+++ b/src/components/Button/button.scss
@@ -1,5 +1,6 @@
@use "sass:color";
@use "sass:map";
+@use "sass:list";
$button-height: $pt-button-height;
$button-border-width: 1px; // !default;
@@ -49,64 +50,125 @@ $dark-button-gradient: $button-gradient; // !default;
// $button-outlined-border-disabled-intent-opacity: 0.2 !default;
$button-intents: (
- "primary": (
+ // default - hover - active
+ "primary":
+ (
+ eccgui-color-var("identity", "brand", "900"),
+ eccgui-color-mix(
+ eccgui-color-var("identity", "brand", "900"),
+ eccgui-color-var("identity", "text", "900") 10%
+ ),
+ eccgui-color-mix(
+ eccgui-color-var("identity", "brand", "900"),
+ eccgui-color-var("identity", "text", "900") 20%
+ )
+ ),
+ "accent": (
eccgui-color-var("identity", "accent", "900"),
- eccgui-color-var("identity", "accent", "700"),
- eccgui-color-var("identity", "accent", "500"),
+ eccgui-color-mix(
+ eccgui-color-var("identity", "accent", "900"),
+ eccgui-color-var("identity", "text", "900") 10%
+ ),
+ eccgui-color-mix(eccgui-color-var("identity", "accent", "900"), eccgui-color-var("identity", "text", "900") 20%)
),
"success": (
eccgui-color-var("semantic", "success", "900"),
- eccgui-color-var("semantic", "success", "700"),
- eccgui-color-var("semantic", "success", "500"),
+ eccgui-color-mix(
+ eccgui-color-var("semantic", "success", "900"),
+ eccgui-color-var("identity", "text", "900") 10%
+ ),
+ eccgui-color-mix(
+ eccgui-color-var("semantic", "success", "900"),
+ eccgui-color-var("identity", "text", "900") 20%
+ )
),
"warning": (
eccgui-color-var("semantic", "warning", "900"),
- eccgui-color-var("semantic", "warning", "700"),
- eccgui-color-var("semantic", "warning", "500"),
+ eccgui-color-mix(
+ eccgui-color-var("semantic", "warning", "900"),
+ eccgui-color-var("identity", "text", "900") 10%
+ ),
+ eccgui-color-mix(
+ eccgui-color-var("semantic", "warning", "900"),
+ eccgui-color-var("identity", "text", "900") 20%
+ )
),
"danger": (
eccgui-color-var("semantic", "danger", "900"),
- eccgui-color-var("semantic", "danger", "700"),
- eccgui-color-var("semantic", "danger", "500"),
- ),
+ eccgui-color-mix(
+ eccgui-color-var("semantic", "danger", "900"),
+ eccgui-color-var("identity", "text", "900") 10%
+ ),
+ eccgui-color-mix(eccgui-color-var("semantic", "danger", "900"), eccgui-color-var("identity", "text", "900") 20%)
+ )
);
@import "~@blueprintjs/core/src/components/button/button";
-.#{$ns}-button {
- position: relative;
-
- .#{$ns}-large {
- min-height: mini-units(6);
- }
-
- // special case override: blueprint do not use configured colors here
- &.#{$ns}-intent-warning {
- @include pt-button-intent(map.get($button-intents, "warning")...);
+@mixin eccgui-enhance-blueprint-button-intent($intentvalue) {
+ &.#{$ns}-intent-#{$intentvalue} {
+ @include pt-button-intent(map.get($button-intents, $intentvalue)...);
&:not(.#{$ns}-disabled).#{$ns}-icon > svg {
fill: eccgui-color-rgba($white, 0.7);
}
&:not(.#{$ns}-disabled):not(.#{$ns}-minimal):not(.#{$ns}-outlined) {
- @include pt-button-intent(map.get($button-intents, "warning")...);
+ @include pt-button-intent(map.get($button-intents, $intentvalue)...);
}
&.#{$ns}-minimal,
&.#{$ns}-outlined {
- color: $eccgui-color-warning-text;
+ color: list.nth(map.get($button-intents, $intentvalue), 1);
background: none;
- border-color: $eccgui-color-warning-text;
+ border-color: list.nth(map.get($button-intents, $intentvalue), 1);
box-shadow: none;
&:disabled,
&.#{$ns}-disabled {
- color: eccgui-color-rgba($eccgui-color-warning-text, 0.39);
+ color: eccgui-color-rgba(list.nth(map.get($button-intents, $intentvalue), 1), $eccgui-opacity-disabled);
+ border-color: eccgui-color-rgba(
+ list.nth(map.get($button-intents, $intentvalue), 1),
+ $eccgui-opacity-disabled
+ );
+ }
+
+ &:active:not(:disabled):not(.#{$ns}-disabled),
+ &.#{$ns}-active:not(:disabled):not(.#{$ns}-disabled) {
+ color: list.nth(map.get($button-intents, $intentvalue), 3);
+ background-color: eccgui-color-rgba(
+ list.nth(map.get($button-intents, $intentvalue), 3),
+ $eccgui-opacity-ghostly
+ );
+ border-color: list.nth(map.get($button-intents, $intentvalue), 3);
+ }
+
+ &:focus:not(:disabled):not(.#{$ns}-disabled),
+ &:hover:not(:disabled):not(.#{$ns}-disabled) {
+ color: list.nth(map.get($button-intents, $intentvalue), 2);
+ background-color: eccgui-color-rgba(
+ list.nth(map.get($button-intents, $intentvalue), 2),
+ 0.5 * $eccgui-opacity-ghostly
+ );
+ border-color: list.nth(map.get($button-intents, $intentvalue), 2);
}
}
}
}
+.#{$ns}-button {
+ position: relative;
+
+ .#{$ns}-large {
+ min-height: mini-units(6);
+ }
+
+ // special case override: blueprint do not use configured colors here
+ @include eccgui-enhance-blueprint-button-intent("primary");
+ @include eccgui-enhance-blueprint-button-intent("accent");
+ @include eccgui-enhance-blueprint-button-intent("warning");
+}
+
.#{$ns}-button-text {
min-width: 0;
}
diff --git a/src/components/Chat/stories/ChatField.stories.tsx b/src/components/Chat/stories/ChatField.stories.tsx
index 9adf4fd81..d336f07b6 100644
--- a/src/components/Chat/stories/ChatField.stories.tsx
+++ b/src/components/Chat/stories/ChatField.stories.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { Meta, StoryFn } from "@storybook/react";
-import { ChatField } from "../../../index";
+import { ChatField, ContextMenu, MenuItem } from "../../../index";
export default {
title: "Components/Chat/ChatField",
@@ -15,4 +15,9 @@ const TemplateFull: StoryFn = (args) => alert(value),
+ rightElement: (
+
+
+
+ ),
};
diff --git a/src/components/ContextOverlay/ContextMenu.tsx b/src/components/ContextOverlay/ContextMenu.tsx
index b18c40c88..8e9a5a057 100644
--- a/src/components/ContextOverlay/ContextMenu.tsx
+++ b/src/components/ContextOverlay/ContextMenu.tsx
@@ -66,6 +66,8 @@ export const ContextMenu = ({
so by default we use the title attribute instead of Tooltip. */
tooltipAsTitle = true,
preventPlaceholder = false,
+ "data-test-id": dataTestId,
+ "data-testid": dataTestid,
...restProps
}: ContextMenuProps) => {
const toggleButton =
@@ -76,7 +78,8 @@ export const ContextMenu = ({
text={togglerText}
large={togglerLarge}
disabled={!!disabled}
- data-test-id={restProps["data-test-id"]}
+ data-test-id={dataTestId ?? undefined}
+ data-testid={dataTestid ?? undefined}
/>
) : (
(togglerElement as ReactElement)
diff --git a/src/components/ContextOverlay/ContextOverlay.tsx b/src/components/ContextOverlay/ContextOverlay.tsx
index 1d3991f93..7b3c884ed 100644
--- a/src/components/ContextOverlay/ContextOverlay.tsx
+++ b/src/components/ContextOverlay/ContextOverlay.tsx
@@ -2,6 +2,7 @@ import React from "react";
import {
Classes as BlueprintClasses,
Popover as BlueprintPopover,
+ PopoverInteractionKind as InteractionKind,
PopoverProps as BlueprintPopoverProps,
Utils as BlueprintUtils,
} from "@blueprintjs/core";
@@ -37,8 +38,11 @@ export const ContextOverlay = ({
usePlaceholder = false,
...otherPopoverProps
}: ContextOverlayProps) => {
- const placeholderRef = React.useRef(null);
- const eventMemory = React.useRef(undefined);
+ const placeholderRef = React.useRef(null);
+ const eventMemory = React.useRef(undefined);
+ const swapDelay = React.useRef(null);
+ const interactionKind = React.useRef(otherPopoverProps.interactionKind ?? InteractionKind.CLICK);
+ const swapDelayTime = 15;
const [placeholder, setPlaceholder] = React.useState(
// use placeholder only for "simple" overlays without special states
!otherPopoverProps.disabled &&
@@ -48,30 +52,85 @@ export const ContextOverlay = ({
usePlaceholder
);
+ const swap = (ev: MouseEvent | globalThis.FocusEvent) => {
+ const waitForClick =
+ interactionKind.current === InteractionKind.CLICK ||
+ interactionKind.current === InteractionKind.CLICK_TARGET_ONLY;
+
+ if (swapDelay.current) {
+ clearTimeout(swapDelay.current);
+ }
+
+ const replacePlaceholder = () => {
+ eventMemory.current = ev.type as "mouseenter" | "focusin" | "click";
+ setPlaceholder(false);
+ };
+
+ if (waitForClick) {
+ ev.stopImmediatePropagation();
+ replacePlaceholder();
+ return;
+ }
+
+ swapDelay.current = setTimeout(
+ replacePlaceholder,
+ // we delay the swap for hover/focus to prevent unwanted effects
+ // (e.g. event hickup after replacing elements when it is not really necessary)
+ swapDelayTime
+ );
+ };
+
React.useEffect(() => {
+ interactionKind.current = otherPopoverProps.interactionKind ?? InteractionKind.CLICK;
+ const waitForClick =
+ interactionKind.current === InteractionKind.CLICK ||
+ interactionKind.current === InteractionKind.CLICK_TARGET_ONLY;
+ const removeEvents = () => {
+ if (placeholderRef.current) {
+ placeholderRef.current.removeEventListener("click", swap);
+ placeholderRef.current.removeEventListener("mouseenter", swap);
+ placeholderRef.current.removeEventListener("focusin", swap);
+ }
+ return;
+ };
if (placeholderRef.current) {
- const swap = (ev: MouseEvent | globalThis.FocusEvent) => {
- eventMemory.current = ev.type === "focusin" ? "afterfocus" : "afterhover";
- setPlaceholder(false);
- };
- (placeholderRef.current as HTMLElement).addEventListener("mouseenter", swap);
- (placeholderRef.current as HTMLElement).addEventListener("focusin", swap);
+ removeEvents(); // remove events in case of interaction kind changed during existence
+ if (waitForClick) {
+ placeholderRef.current.addEventListener("click", swap);
+ } else {
+ placeholderRef.current.addEventListener("mouseenter", swap);
+ placeholderRef.current.addEventListener("focusin", swap);
+ }
return () => {
- if (placeholderRef.current) {
- (placeholderRef.current as HTMLElement).removeEventListener("mouseenter", swap);
- (placeholderRef.current as HTMLElement).removeEventListener("focusin", swap);
- }
+ removeEvents();
};
}
return () => {};
- }, [!!placeholderRef.current]);
+ }, [!!placeholderRef.current, otherPopoverProps.interactionKind]);
const refocus = React.useCallback((node) => {
- if (eventMemory.current === "afterfocus" && node) {
- const target = node.targetRef.current.children[0];
- if (target) {
+ const target = node?.targetRef.current.children[0];
+ if (!eventMemory.current || !target) {
+ return;
+ }
+ switch (eventMemory.current) {
+ case "focusin":
target.focus();
- }
+ break;
+ case "click":
+ target.click();
+ break;
+ case "mouseenter":
+ // re-check if the cursor is still over the element after swapping the placeholder before triggering the event to bubble up
+ (target as HTMLElement).addEventListener(
+ "mouseover",
+ () => (target as HTMLElement).dispatchEvent(new MouseEvent("mouseover", { bubbles: true })),
+ {
+ capture: true,
+ once: true,
+ }
+ );
+ break;
}
}, []);
@@ -87,7 +146,7 @@ export const ContextOverlay = ({
PlaceholderElement,
{
...otherPopoverProps?.targetProps,
- className: `${BlueprintClasses.POPOVER_TARGET} ${targetClassName}`,
+ className: `${BlueprintClasses.POPOVER_TARGET} ${targetClassName} ${eccgui}-contextoverlay__wrapper--placeholder`,
ref: placeholderRef,
},
React.cloneElement(childTarget, {
diff --git a/src/components/ContextOverlay/tests/ContextMenu.test.tsx b/src/components/ContextOverlay/tests/ContextMenu.test.tsx
new file mode 100644
index 000000000..e2c909521
--- /dev/null
+++ b/src/components/ContextOverlay/tests/ContextMenu.test.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import "@testing-library/jest-dom";
+
+import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
+
+import ContextMenu from "./../ContextMenu";
+import { Default as ContextMenuStory } from "./../ContextMenu.stories";
+
+const overlayWrapper = `${eccgui}-contextoverlay`;
+const placeholderClass = `${overlayWrapper}__wrapper--placeholder`;
+
+const checkForPlaceholderClass = (container: HTMLElement, tobe: number) => {
+ expect(container.getElementsByClassName(placeholderClass).length).toBe(tobe);
+};
+
+describe("ContextMenu", () => {
+ it("should render placeholder automatically", () => {
+ const { container } = render();
+ checkForPlaceholderClass(container, 1);
+ });
+ it("should not render placeholder when `preventPlaceholder===true`", () => {
+ const { container } = render();
+ checkForPlaceholderClass(container, 0);
+ });
+ it("should render placeholder when `preventPlaceholder===false`", () => {
+ const { container } = render();
+ checkForPlaceholderClass(container, 1);
+ });
+ it("if no placeholder is used the menu should be displayed on click", async () => {
+ const { container } = render();
+ checkForPlaceholderClass(container, 0);
+ fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
+ expect(await screen.findByText("First option")).toBeVisible();
+ });
+ it("if placeholder is used the menu should be displayed on click", async () => {
+ const { container } = render();
+ checkForPlaceholderClass(container, 1);
+ fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
+ expect(await screen.findByText("First option")).toBeVisible();
+ });
+});
diff --git a/src/components/ContextOverlay/tests/ContextOverlay.test.tsx b/src/components/ContextOverlay/tests/ContextOverlay.test.tsx
new file mode 100644
index 000000000..644cb137c
--- /dev/null
+++ b/src/components/ContextOverlay/tests/ContextOverlay.test.tsx
@@ -0,0 +1,71 @@
+import React from "react";
+import { PopoverInteractionKind } from "@blueprintjs/core";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+
+import "@testing-library/jest-dom";
+
+import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
+
+import ContextOverlay from "./../ContextOverlay";
+import { Default as ContextOverlayStory } from "./../ContextOverlay.stories";
+
+const overlayWrapper = `${eccgui}-contextoverlay`;
+const placeholderClass = `${overlayWrapper}__wrapper--placeholder`;
+
+const checkForPlaceholderClass = (container: HTMLElement, tobe: number) => {
+ expect(container.getElementsByClassName(placeholderClass).length).toBe(tobe);
+};
+
+describe("ContextOverlay", () => {
+ it("should not render placeholder automatically", () => {
+ const { container } = render();
+ checkForPlaceholderClass(container, 0);
+ });
+ it("should render placeholder when `usePlaceholder===true`", () => {
+ const { container } = render();
+ checkForPlaceholderClass(container, 1);
+ });
+ it("should render no placeholder when `usePlaceholder===false`", () => {
+ const { container } = render();
+ checkForPlaceholderClass(container, 0);
+ });
+ it("if no placeholder is used the overlay should be displayed on click", async () => {
+ const { container } = render();
+ fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
+ expect(await screen.findByText("Overlay:")).toBeVisible();
+ });
+ it("if no placeholder is used the overlay should be displayed on hover (hover interactionKind)", async () => {
+ const { container } = render(
+
+ );
+ fireEvent.mouseEnter(container.getElementsByClassName(overlayWrapper)[0]);
+ expect(await screen.findByText("Overlay:")).toBeVisible();
+ });
+ it("if placeholder is used the overlay should be displayed on click", async () => {
+ const { container } = render();
+ fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
+ expect(await screen.findByText("Overlay:")).toBeVisible();
+ });
+ it("if placeholder is used the overlay should be displayed on hover (hover interactionKind)", async () => {
+ const { container } = render(
+
+ );
+ checkForPlaceholderClass(container, 1);
+ fireEvent.mouseEnter(container.getElementsByClassName(overlayWrapper)[0]);
+ await waitFor(async () => {
+ expect(screen.queryByDisplayValue("Overlay:")).toBeNull();
+ checkForPlaceholderClass(container, 0);
+ // we need to emulate another mouseover to simulate real user behaviour
+ fireEvent.mouseOver(container.getElementsByClassName(overlayWrapper)[0]);
+ expect(await screen.findByText("Overlay:")).toBeVisible();
+ });
+ });
+});
diff --git a/src/components/Dialog/Modal.tsx b/src/components/Dialog/Modal.tsx
index 480e2be97..e28e97295 100644
--- a/src/components/Dialog/Modal.tsx
+++ b/src/components/Dialog/Modal.tsx
@@ -5,12 +5,13 @@ import {
Overlay2Props as BlueprintOverlayProps,
} from "@blueprintjs/core";
+import { preventReactFlowActionsClasses } from "../../cmem";
import { utils } from "../../common";
import { CLASSPREFIX as eccgui } from "../../configuration/constants";
import { TestableComponent } from "../interfaces";
import { Card } from "./../Card";
-import {preventReactFlowActionsClasses} from "../../cmem";
+import { ModalContext } from "./ModalContext";
export interface ModalProps extends TestableComponent, BlueprintOverlayProps {
children: React.ReactNode | React.ReactNode[];
@@ -43,9 +44,17 @@ export interface ModalProps extends TestableComponent, BlueprintOverlayProps {
* If this option is used inflationary then this could harm the visibility of other overlays.
*/
forceTopPosition?: boolean;
+ /**
+ * Modal ID that should be globally unique. If a ModalContext is provided this can be used to track opening/closing of this modal.
+ */
+ modalId?: string;
+ /**
+ * Prevents that pan and zooming actions of an existing react-flow instance are triggered while this Modal is open.
+ */
+ preventReactFlowEvents?: boolean;
}
-export type ModalSize = "tiny" | "small" | "regular" | "large" | "xlarge" | "fullscreen"
+export type ModalSize = "tiny" | "small" | "regular" | "large" | "xlarge" | "fullscreen";
/**
* Displays contents on top of other elements, used to create dialogs.
@@ -68,8 +77,24 @@ export const Modal = ({
onOpening,
"data-test-id": dataTestId,
"data-testid": dataTestid,
+ modalId,
+ preventReactFlowEvents = true,
...otherProps
}: ModalProps) => {
+ const modalContext = React.useContext(ModalContext)
+ const uniqueModalId = React.useRef(modalId ?? Date.now().toString(36) + Math.random().toString(36).substring(2))
+
+ React.useEffect(() => {
+ return () => {
+ // Make sure to always remove flag when modal is removed
+ modalContext.setModalOpen(uniqueModalId.current, false)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ modalContext.setModalOpen(uniqueModalId.current, otherProps.isOpen)
+ }, [otherProps.isOpen])
+
const backdropProps: React.HTMLProps | undefined =
!canOutsideClickClose && canEscapeKeyClose
? {
@@ -117,7 +142,7 @@ export const Modal = ({
void;
+
+ /** The currently opened modals ordered by when they have been opened. Oldest coming first. */
+ openModalStack(): string[] | undefined;
+}
+
+/** Can be provided in the application to react to modal related changes. */
+export const ModalContext = React.createContext({
+ setModalOpen: () => {},
+ openModalStack: () => [],
+});
+
+/** 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.
+ const currentOpenModalStack = React.useRef([]);
+
+ const setOpenModalStack = ((stackUpdateFunction: (old: string[]) => string[]) => {
+ currentOpenModalStack.current = stackUpdateFunction([...currentOpenModalStack.current])
+ })
+
+ 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 openModalStack = React.useCallback(() => {
+ return currentOpenModalStack.current.length ? [...currentOpenModalStack.current] : undefined
+ }, [])
+
+ return {
+ openModalStack,
+ setModalOpen,
+ };
+};
diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts
index 6db73324f..1f831946a 100644
--- a/src/components/Dialog/index.ts
+++ b/src/components/Dialog/index.ts
@@ -1,3 +1,4 @@
export * from "./Modal";
export * from "./SimpleDialog";
export * from "./AlertDialog";
+export * from "./ModalContext";
diff --git a/src/components/Dialog/stories/Modal.stories.tsx b/src/components/Dialog/stories/Modal.stories.tsx
index 2b5452a5d..c306611c1 100644
--- a/src/components/Dialog/stories/Modal.stories.tsx
+++ b/src/components/Dialog/stories/Modal.stories.tsx
@@ -15,15 +15,18 @@ export default {
control: false,
},
},
+ decorators: [
+ (Story) => (
+
+