Skip to content
Merged
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
40 changes: 24 additions & 16 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,25 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

## [Unreleased]

## [26.2.0] - 2026-10-05

### Added

- `<CodeEditor />`
- documents the built-in Escape then Tab sequence for moving focus out of the editor when Tab is configured to indent
- shows a compact keyboard navigation hint in a bottom CodeMirror panel when Tab is configured to indent
- `keyboardHint` accepts a custom string or element for localized instructions, the default instruction is marked as English
- `<Switch />`
- `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true`
- `noDrag` property: adds the `nodrag` class to the Switch element. Default: `true`
- `<Markdown />`
- Added `cutOff` property to set maximum number of raw Markdown characters to render
- `cutOff` property: sets the maximum number of raw Markdown characters to render
- `<Label />`
- `hidden` property: label is not displayed but stays accessible for screen readers and keyboard navigation
- `<FieldItem />`
- label, input element, helper text and message are connected to each other automatically now
- each part without an own `id` gets one based on a unique ID of the field item
- the label refers to the input element via `for`, or via `aria-labelledby` on the input element if the label is not displayed as `label` element
- an already set `for` is only kept if it refers to the ID of the input element of the field item
- an already set `for` is only kept if it refers to the ID of an input element of the field item, e.g. to a second input element
- helper text and message are referred by the input element via `aria-describedby`
- `input`, `textarea`, `select`, the toggle button of `<Select />` and the editable area of `<CodeEditor />` are supported as input element
- input elements that cannot be referenced by `for`, e.g. the editable area of the code editor, are connected via `aria-labelledby`
Expand All @@ -32,7 +34,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- `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
- `role` is `dialog` by default, but it is removed again if neither `aria-label` nor `aria-labelledby` 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
Expand All @@ -43,7 +45,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- 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
- `truncateMarkdownDisplay`: helper function to iterate over `Markdown` renderings to find a `cutOff` value that matches the visible text length
- `ClassNames`
- `ReactFlow`: helper classes for react-flow, e.g. `preventAllActions` to prevent drag, pan and wheel actions
- new icons:
- `toggler-treenode-closed`
- `toggler-treenode-expanded`
Expand All @@ -54,6 +58,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Carbon, Codemirror, React-Flow
- minimum node version (`engines.node`) is `18.19.0` now
- the build of the ESM distribution needs a synchronous `import.meta.resolve`, which is only available since this version
- `<Switch />`
- the `nodrag` class is added by default now, use `noDrag={false}` to prevent this
- `<FieldItem />`
- the used `Label` element gets the `eccgui-fielditem__label` class now
- `<AlertDialog />`
Expand All @@ -64,22 +70,28 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- 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
- closing a modal only removes this modal from the stack, modals that were opened after it stay in the stack as long as they are open
- before they were considered as closed, too, so the still visible top most modal was not known as the top most one anymore
- `<CodeEditor />`
- `enableTab` overrides the Tab behaviour derived from `mode` and `tabIntentStyle` now, if it is set
- `enableTab={false}` lets Tab always move the focus, even for modes with tab indentation
- if it is not set, Tab is still handled as indentation for modes with `tabIntentStyle="tab"`
- `<AutoSuggestion />`
- Tab is only handled in the editor if `useTabForCompletions` is enabled, as side effect it currently also enables tab indentation
- `<StringPreviewContentBlobToggler />`
- `allowedHtmlElementsInPreview` option is set to inline elements on default
- uses now the `Markdown.cutOff` property
- this enables Markdown rendering even if the preview need to be shortened
- `allowedHtmlElementsInPreview` option is set to inline elements by default
- now uses the `Markdown.cutOff` property
- this enables Markdown rendering even if the preview needs to be shortened
- this may lead to slightly different preview lengths

### Deprecated

- `preventReactFlowActionsClasses`: use `ClassNames.ReactFlow.preventAllActions`, it will be removed in v27

### Fixed

- `<PropertyValuePair />`
- fix description and story to point out that `PropertyValueList` need always to be used as wrapper
- fix description and story to point out that `PropertyValueList` always needs to be used as wrapper
- `<ApplicationViewability />`
- `show={"print"}` and `hide={"screen"}` content is still accessible by screen readers
- `<Label />`
Expand All @@ -88,18 +100,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- 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
- fixed by adding a dummy rule as first rule
- ESM distribution
- the imports of the `@codemirror/legacy-modes` modes were written with an additional `.js` suffix, but the `exports` map of this package only provides the extension-less sub paths, so they were expanded to unresolvable paths like `mode/jinja2.js.js`
- Storybook
- the `<Tab />` story imported the package root directory, this way the `exports` field of our own `package.json` pulled the built `dist/esm/` output into the preview bundle instead of the sources, and the Storybook build failed as soon as `dist/` existed
- the webpack configuration excludes `dist/` from module resolution now, so the sources are always used even if a story references the package root
- Added explicitly `assert` dependency
- the linter of `<CodeEditor />` uses `jshint`, which imports `console-browserify`, and this package requires the node core modules `assert` and `util` without declaring them; bundlers based on webpack 5 do not provide shims for node core modules anymore, so the polyfill (and `util` together with it) is part of the delivery now

### Deprecated

- `preventReactFlowActionsClasses`: use `ClassNames.ReactFlow.preventAllActions`
- Explicitly added `assert` and `util` dependencies
- the linter of `<CodeEditor />` uses `jshint`, which imports `console-browserify`, and this package requires the node core modules `assert` and `util` without declaring them; bundlers based on webpack 5 do not provide shims for node core modules anymore, so both polyfills are part of the delivery now

## [26.1.0] - 2026-08-20

Expand Down
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@eccenca/gui-elements",
"description": "GUI elements based on other libraries, usable in React application, written in Typescript.",
"version": "26.1.0",
"version": "26.2.0",
"license": "Apache-2.0",
"homepage": "https://github.com/eccenca/gui-elements",
"bugs": "https://github.com/eccenca/gui-elements/issues",
Expand Down Expand Up @@ -182,7 +182,16 @@
"**/lodash": "^4.18.1",
"**/minimatch": "^3.1.4",
"**/postcss": "^8.5.18",
"**/serialize-javascript": "^7.0.5"
"**/serialize-javascript": "^7.0.5",
"**/brace-expansion": "^1.1.20",
"jest/**/js-yaml": "^3.15.2",
"babel-jest/**/js-yaml": "^3.15.2",
"stylelint/**/js-yaml": "^4.3.2",
"@storybook/react-webpack5/**/js-yaml": "^4.3.2",
"@eslint/eslintrc/**/js-yaml": "^4.3.2",
"**/fast-uri": "^3.1.6",
"**/browserslist": "^4.28.7",
"@storybook/react-webpack5/**/webpack-dev-middleware": "^7.4.6"
},
"husky": {
"hooks": {
Expand Down
73 changes: 46 additions & 27 deletions src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,35 +63,54 @@ export function StringPreviewContentBlobToggler({
previewString = useOnlyPart(content, regexFirstMarkdownSection);
}

let enableToggler = previewString !== content;
// Measuring and truncating a Markdown preview renders the Markdown multiple times to static markup, so it is only
// re-calculated if one of its inputs changes, and not on every re-render, e.g. of a long list containing togglers.
const { truncatedPreviewContent, isTruncated } = React.useMemo((): {
truncatedPreviewContent: React.JSX.Element | string;
isTruncated: boolean;
} => {
if (!renderPreviewAsMarkdown) {
if (previewMaxLength) {
const previewText = utils.reduceToText(previewString, { decodeHtmlEntities: true });
if (previewText.length > previewMaxLength) {
return { truncatedPreviewContent: previewText.slice(0, previewMaxLength), isTruncated: true };
}
}
return { truncatedPreviewContent: previewString, isTruncated: false };
}

let previewContent = renderPreviewAsMarkdown ? (
<Markdown key="markdown-content" allowedElements={allowedHtmlElementsInPreview}>
{previewString}
</Markdown>
) : (
previewString
);
if (!previewMaxLength) {
return {
truncatedPreviewContent: (
<Markdown key="markdown-content" allowedElements={allowedHtmlElementsInPreview}>
{previewString}
</Markdown>
),
isTruncated: false,
};
}

if (
previewMaxLength &&
utils.reduceToText(previewContent, { decodeHtmlEntities: true }).length > previewMaxLength
) {
previewContent = renderPreviewAsMarkdown
? utils.truncateMarkdownDisplay(
<Markdown
key="markdown-content"
allowedElements={allowedHtmlElementsInPreview}
cutOff={previewMaxLength}
cutOffSuffix={""}
>
{previewString}
</Markdown>,
{ decodeHtmlEntities: true },
)
: utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength);
enableToggler = true;
}
// `truncateMarkdownDisplay` already measures the complete Markdown display first and returns it without
// `cutOff` if it is short enough, so it is not measured here a second time.
const markdownPreview = utils.truncateMarkdownDisplay(
<Markdown
key="markdown-content"
allowedElements={allowedHtmlElementsInPreview}
cutOff={previewMaxLength}
cutOffSuffix={""}
>
{previewString}
</Markdown>,
{ decodeHtmlEntities: true },
);
return {
truncatedPreviewContent: markdownPreview,
isTruncated: markdownPreview.props.cutOff !== undefined,
};
}, [previewString, previewMaxLength, renderPreviewAsMarkdown, allowedHtmlElementsInPreview]);

const enableToggler = previewString !== content || isTruncated;
let previewContent = truncatedPreviewContent;

if (!enableToggler && noTogglerContentSuffix) {
previewContent = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, RenderResult } from "@testing-library/react";

import "@testing-library/jest-dom";

import { utils } from "../../../common";
import {
StringPreviewContentBlobToggler,
StringPreviewContentBlobTogglerProps,
Expand Down Expand Up @@ -72,6 +73,22 @@ describe("StringPreviewContentBlobToggler", () => {
textMustNotExist(queryByText, "https://github.com/"); // test if Markdown was rendered
textMustNotExist(queryByText, "show more");
});
it("should not truncate the Markdown preview again when re-rendered with unchanged content", () => {
const truncateSpy = jest.spyOn(utils, "truncateMarkdownDisplay");
const props = StringPreviewContentBlobTogglerStory.args as StringPreviewContentBlobTogglerProps;
try {
const { rerender, queryByText } = render(<StringPreviewContentBlobToggler {...props} />);
rerender(<StringPreviewContentBlobToggler {...props} />);
expect(truncateSpy).toHaveBeenCalledTimes(1);
textMustExist(queryByText, "show more");

rerender(<StringPreviewContentBlobToggler {...props} previewMaxLength={144} />);
expect(truncateSpy).toHaveBeenCalledTimes(2);
textMustNotExist(queryByText, "show more");
} finally {
truncateSpy.mockRestore();
}
});
it("should not use Markdown rendering on `renderPreviewAsMarkdown={false}`", () => {
const { queryByText } = render(
<StringPreviewContentBlobToggler
Expand Down
1 change: 0 additions & 1 deletion src/cmem/markdown/Markdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,3 @@ CutOffWithBoldText.args = {
children: "Some intro text and then **an important warning** and more text afterwards here.",
cutOff: 35,
};

34 changes: 34 additions & 0 deletions src/cmem/markdown/truncateMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ describe("truncateMarkdown", () => {
expect(result).toBe("```\nsome code\n```\n\n...");
});

it("keeps the opening fence line complete when cutOff falls inside its info string", () => {
const content = "```javascript\nconst a = 1;\nconst b = 2;\n```";
expect(truncateMarkdown(content, 5, "...")).toBe("```javascript\nconst\n```\n\n...");
});

it("keeps the opening fence marker complete when cutOff falls inside it", () => {
const content = "~~~~\nsome code line here\n~~~~";
expect(truncateMarkdown(content, 2, "...")).toBe("~~~~\nsome\n~~~~\n\n...");
});

it("keeps an empty fence complete when cutOff falls inside its opening line", () => {
const content = "```javascript\n\n```\n\nSome text after the empty code block.";
expect(truncateMarkdown(content, 5, "...")).toBe("```javascript\n```\n\n...");
});

it("cuts a table only at complete rows", () => {
const content = ["| Name | Value |", "| --- | --- |", "| first | row |", "| second | row |"].join("\n");
const result = truncateMarkdown(content, content.indexOf("second") + 3, "...");
Expand Down Expand Up @@ -233,6 +248,25 @@ describe("truncateMarkdown", () => {
expect(truncateMarkdown(content, 32, "...")).toBe("Some intro words and\n\n...");
});

it("keeps a leading bold span complete when it is followed by punctuation instead of a space", () => {
const content = "**Important note**: this is a long text that goes on and on.";
expect(truncateMarkdown(content, 5, "...")).toBe("**Important note**\n\n...");
});

it("does not fall back to a word boundary inside a span that ends right before the cutOff", () => {
const content = "Some intro text and then **an important warning**, and more text afterwards here.";
expect(truncateMarkdown(content, content.indexOf(","), "...")).toBe(
"Some intro text and then **an important warning**\n\n...",
);
});

it("does not fall back to a word boundary inside a link that ends right before the cutOff", () => {
const content = "Some text (see [the docs](https://example.com)), and more text afterwards here.";
expect(truncateMarkdown(content, content.indexOf(","), "...")).toBe(
"Some text (see [the docs](https://example.com)\n\n...",
);
});

it("does not treat intra-word underscores as emphasis", () => {
const content = "Some intro words and snake_case_name_here plus more words to cut here somewhere.";
expect(truncateMarkdown(content, 30, "...")).toBe("Some intro words and\n\n...");
Expand Down
Loading
Loading