diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a8cf671..e05c49a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,18 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - component for hiding elements in specific media +- `` + - force children to get displayed as inline content +- `` + - `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly` ### Fixed - `` - create more whitespace inside `small` tag - reduce visual impact of border +- `` + - take Markdown rendering into account before testing the maximum preview length ### Changed @@ -30,6 +36,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - `` and `` +### Deprecated + +- `` + - `firstNonEmptyLineOnly` will be removed, is replaced by `useOnly="firstNonEmptyLine"` + ## [25.0.0] - 2025-12-01 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. diff --git a/src/cmem/ContentBlobToggler/ContentBlobToggler.tsx b/src/cmem/ContentBlobToggler/ContentBlobToggler.tsx index 5ba6d1a99..26b214ab1 100644 --- a/src/cmem/ContentBlobToggler/ContentBlobToggler.tsx +++ b/src/cmem/ContentBlobToggler/ContentBlobToggler.tsx @@ -58,7 +58,7 @@ export function ContentBlobToggler({ {previewContent} {enableToggler && ( <> - …{" "} + {" "}…{" "} { /** - The preview content will be cut to this length if it is too long. + * The preview content will be cut to this length if it is too long. */ previewMaxLength?: number; /** - The content string. If it is smaller than previewMaxLength this will be displayed in full, else fullviewContent will be displayed. + * The content string. + * If it is smaller than `previewMaxLength` this will be displayed in full, else `fullviewContent` will be displayed. */ content: string; - /** If only the first non-empty line should be shown in the preview. This will in addition also be shortened according to previewMaxLength. */ - firstNonEmptyLineOnly?: boolean; - /** If enabled the preview is rendered as markdown. */ + /** + * Use only parts of `content` in the preview. + * `firstMarkdownSection` uses the content until the first double line return. + * Currently overwritten by `firstNonEmptyLineOnly`. + */ + useOnly?: "firstNonEmptyLine" | "firstMarkdownSection"; + /** + * If enabled the preview is rendered as Markdown. + */ renderPreviewAsMarkdown?: boolean; - /** White-listing of HTML elements that will be rendered when renderPreviewAsMarkdown is enabled. */ + /** + * White-listing of HTML elements that will be rendered when renderPreviewAsMarkdown is enabled. + */ allowedHtmlElementsInPreview?: string[]; - /** Allows to add non-string elements at the end of the content if the full description is shown, i.e. no toggler is necessary. + /** + * Allows to add non-string elements at the end of the content if the full description is shown, i.e. no toggler is necessary. * This allows to add non-string elements to both the full-view content and the pure string content. */ noTogglerContentSuffix?: JSX.Element; + /** + * If only the first non-empty line should be shown in the preview. + * This will in addition also be shortened according to `previewMaxLength`. + * @deprecated (v26) use `useOnly="firstNonEmptyLine"` instead + */ + firstNonEmptyLineOnly?: boolean; } -/** Version of the content toggler for text only content. */ +/** Version of the content toggler for text centric content. */ export function StringPreviewContentBlobToggler({ className = "", previewMaxLength, @@ -33,14 +49,28 @@ export function StringPreviewContentBlobToggler({ content, fullviewContent, startExtended, - firstNonEmptyLineOnly, + useOnly, renderPreviewAsMarkdown = false, allowedHtmlElementsInPreview, noTogglerContentSuffix, + firstNonEmptyLineOnly, }: StringPreviewContentBlobTogglerProps) { - const previewMaybeFirstLine = firstNonEmptyLineOnly ? firstNonEmptyLine(content) : content; - const previewString = previewMaxLength ? previewMaybeFirstLine.substr(0, previewMaxLength) : previewMaybeFirstLine; - const enableToggler = previewString !== content; + // need to test `firstNonEmptyLineOnly` until property is removed + const useOnlyTest: StringPreviewContentBlobTogglerProps["useOnly"] = firstNonEmptyLineOnly + ? "firstNonEmptyLine" + : useOnly; + + let previewString = content; + switch (useOnlyTest) { + case "firstNonEmptyLine": + previewString = useOnlyPart(content, regexFirstNonEmptyLine); + break; + case "firstMarkdownSection": + previewString = useOnlyPart(content, regexFirstMarkdownSection); + } + + let enableToggler = previewString !== content; + let previewContent = renderPreviewAsMarkdown ? ( {previewString} @@ -48,6 +78,15 @@ export function StringPreviewContentBlobToggler({ ) : ( previewString ); + + if ( + previewMaxLength && + utils.reduceToText(previewContent, { decodeHtmlEntities: true }).length > previewMaxLength + ) { + previewContent = utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength); + enableToggler = true; + } + if (!enableToggler && noTogglerContentSuffix) { previewContent = ( <> @@ -60,7 +99,7 @@ export function StringPreviewContentBlobToggler({ return ( {previewContent}} toggleExtendText={toggleExtendText} toggleReduceText={toggleReduceText} fullviewContent={fullviewContent} @@ -70,17 +109,26 @@ export function StringPreviewContentBlobToggler({ ); } -const newLineRegex = new RegExp("\r|\n"); // eslint-disable-line +const regexFirstNonEmptyLine = new RegExp("\r|\n"); // eslint-disable-line +const regexFirstMarkdownSection = new RegExp("\r\n\r\n|\n\n"); // eslint-disable-line /** * Takes the first non-empty line from a preview string. */ function firstNonEmptyLine(preview: string) { + return useOnlyPart(preview, regexFirstNonEmptyLine); +} + +/** + * Returns only the first part from a preview string. + * Or the full string as fallback. + */ +function useOnlyPart(preview: string, regexTest: RegExp): string { const previewString = preview.trim(); - const result = newLineRegex.exec(previewString); - return result !== null ? previewString.substr(0, result.index) : previewString; + const result = regexTest.exec(previewString); + return result !== null ? result.input.slice(0, result.index) : previewString; } export const stringPreviewContentBlobTogglerUtils = { firstNonEmptyLine, -}; \ No newline at end of file +}; diff --git a/src/cmem/ContentBlobToggler/stories/StringPreviewContentBlobToggler.stories.tsx b/src/cmem/ContentBlobToggler/stories/StringPreviewContentBlobToggler.stories.tsx new file mode 100644 index 000000000..d196bd308 --- /dev/null +++ b/src/cmem/ContentBlobToggler/stories/StringPreviewContentBlobToggler.stories.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Meta, StoryFn } from "@storybook/react"; + +import { Markdown, StringPreviewContentBlobToggler } from "../../../index"; + +const config = { + title: "CMEM/ContentBlobToggler/StringPreview", + component: StringPreviewContentBlobToggler, +} as Meta; +export default config; + +const Template: StoryFn = (args) => ( + +); + +const initialTeststring = + "A library for GUI elements.\nIn order to create graphical user interfaces, please have look at the documentation at [Github](https://github.com/eccenca/gui-elements)."; + +export const Default = Template.bind({}); +Default.args = { + content: initialTeststring, + fullviewContent: {initialTeststring}, + previewMaxLength: 64, + renderPreviewAsMarkdown: true, + toggleExtendText: "show more", + toggleReduceText: "show less", +}; diff --git a/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx b/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx new file mode 100644 index 000000000..f16d9943a --- /dev/null +++ b/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { render, RenderResult } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { + StringPreviewContentBlobToggler, + StringPreviewContentBlobTogglerProps, +} from "../StringPreviewContentBlobToggler"; + +import { Default as StringPreviewContentBlobTogglerStory } from "./../stories/StringPreviewContentBlobToggler.stories"; + +describe("StringPreviewContentBlobToggler", () => { + const textMustExist = (queryByText: RenderResult["queryByText"], text: string) => { + expect(queryByText(text, { exact: false })).not.toBeNull(); + }; + const textMustNotExist = (queryByText: RenderResult["queryByText"], text: string) => { + expect(queryByText(text, { exact: false })).toBeNull(); + }; + it("should cut preview and show toggler to extend", () => { + const { queryByText } = render( + + ); + textMustExist(queryByText, "A library for GUI elements."); + textMustNotExist( + queryByText, + "In order to create graphical user interfaces, please have look at the documentation at" + ); + textMustExist(queryByText, "show more"); + }); + it("should display full view if `startExtended` is enabled, and show toggler to reduce", () => { + const { queryByText } = render( + + ); + textMustExist( + queryByText, + "In order to create graphical user interfaces, please have look at the documentation at" + ); + textMustExist(queryByText, "show less"); + }); + it('should display only first content line on `useOnly={"firstNonEmptyLine"}`', () => { + const { queryByText } = render( + + ); + textMustExist(queryByText, "A library for GUI elements."); + textMustNotExist(queryByText, "In order to create"); + }); + it('should use first Markdown paragraph as preview content on `useOnly={"firstMarkdownSection"}` but shorten it', () => { + const { queryByText } = render( + + ); + textMustExist(queryByText, "A library for GUI elements."); + textMustExist(queryByText, "In order to create"); + textMustNotExist(queryByText, "please have look at the documentation at"); + }); + it("should display full preview and no toggler if content is short enough", () => { + const { queryByText } = render( + + ); + textMustExist(queryByText, "A library for GUI elements."); + textMustExist( + queryByText, + "In order to create graphical user interfaces, please have look at the documentation at" + ); + textMustNotExist(queryByText, "https://github.com/"); // test if Markdown was rendered + textMustNotExist(queryByText, "show more"); + }); + it("should not use Markdown rendering on `renderPreviewAsMarkdown={false}`", () => { + const { queryByText } = render( + + ); + textMustExist(queryByText, "A library for GUI elements."); + textMustExist( + queryByText, + "In order to create graphical user interfaces, please have look at the documentation at" + ); + textMustExist(queryByText, "https://github.com/"); // test if Markdown was rendered + textMustExist(queryByText, "show more"); + }); +}); diff --git a/src/components/Typography/InlineText.tsx b/src/components/Typography/InlineText.tsx new file mode 100644 index 000000000..638ab01fa --- /dev/null +++ b/src/components/Typography/InlineText.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import { CLASSPREFIX as eccgui } from "../../configuration/constants"; +import { TestableComponent } from "../interfaces"; + +export interface InlineTextProps extends React.HTMLAttributes, TestableComponent { + /** + * Additional CSS class name. + */ + className?: string; +} + +/** + * Forces all children to be displayed as inline content. + */ +export const InlineText = ({ className = "", children, ...otherProps }: InlineTextProps) => { + return ( +
+ {children} +
+ ); +}; + +export default InlineText; diff --git a/src/components/Typography/index.ts b/src/components/Typography/index.ts index fba8b8575..b3687231c 100644 --- a/src/components/Typography/index.ts +++ b/src/components/Typography/index.ts @@ -2,3 +2,4 @@ export * from "./Highlighter"; export * from "./HtmlContentBlock"; export * from "./OverflowText"; export * from "./WhiteSpaceContainer"; +export * from "./InlineText"; diff --git a/src/components/Typography/stories/InlineText.stories.tsx b/src/components/Typography/stories/InlineText.stories.tsx new file mode 100644 index 000000000..4bd6af2b6 --- /dev/null +++ b/src/components/Typography/stories/InlineText.stories.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Meta, StoryFn } from "@storybook/react"; + +import { InlineText } from "../InlineText"; + +import overflowTextConfig from "./OverflowText.stories"; + +const config = { + title: "Components/Typography/InlineText", + component: InlineText, + argTypes: { + children: overflowTextConfig.argTypes?.children, + }, +} as Meta; +export default config; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + children: ( +
+
Block line 1.
+
Block line 2.
+
+ ), +}; diff --git a/src/components/Typography/typography.scss b/src/components/Typography/typography.scss index d746b149d..87d20c595 100644 --- a/src/components/Typography/typography.scss +++ b/src/components/Typography/typography.scss @@ -68,6 +68,11 @@ mark { line-height: $eccgui-size-typo-caption-lineheight; } +.#{$eccgui}-typography__contentblock.#{$eccgui}-typography--large { + font-size: $eccgui-size-typo-subtitle; + line-height: $eccgui-size-typo-subtitle-lineheight; +} + h1 { .#{$eccgui}-typography__contentblock &, &.#{$eccgui}-typography__text { @@ -223,9 +228,9 @@ table { max-width: 100%; overflow: hidden; text-overflow: ellipsis; + vertical-align: middle; overflow-wrap: normal; white-space: nowrap; - vertical-align: middle; } .#{$eccgui}-typography__overflowtext--inline { @@ -241,8 +246,8 @@ table { } .#{$eccgui}-typography__overflowtext--ellipsis-reverse { - text-align: left; text-overflow: ellipsis; + text-align: left; direction: rtl; unicode-bidi: embed; @@ -263,6 +268,22 @@ table { } } +// InlineText + +.#{$eccgui}-typography__inlinetext { + display: inline; + + * { + display: inline; + + & + * { + &::before { + content: " "; + } + } + } +} + // helpers .#{$eccgui}-typography--nooverflow {