diff --git a/frontend/src/components/application-loader/loading-screen/loading-animation.tsx b/frontend/src/components/application-loader/loading-screen/loading-animation.tsx index 8843de19f..31da93202 100644 --- a/frontend/src/components/application-loader/loading-screen/loading-animation.tsx +++ b/frontend/src/components/application-loader/loading-screen/loading-animation.tsx @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { concatCssClasses } from '../../../utils/concat-css-classes' import { UiIcon } from '../../common/icons/ui-icon' import { createNumberRangeArray } from '../../common/number-range/number-range' import styles from './animations.module.scss' @@ -24,12 +25,12 @@ export const LoadingAnimation: React.FC = ({ error }) => { const iconRows = useMemo(() => createNumberRangeArray(12).map((index) => ), []) return ( -
+
-
+
diff --git a/frontend/src/components/common/custom-branding/custom-branding.tsx b/frontend/src/components/common/custom-branding/custom-branding.tsx index 2fff8ca66..d2e6fe5e9 100644 --- a/frontend/src/components/common/custom-branding/custom-branding.tsx +++ b/frontend/src/components/common/custom-branding/custom-branding.tsx @@ -21,19 +21,16 @@ export interface BrandingProps { export const CustomBranding: React.FC = ({ inline = false }) => { const branding = useBrandingDetails() + const className = inline ? styles['inline-size'] : styles['regular-size'] + if (!branding) { return null } else if (branding.logo) { return ( /* eslint-disable-next-line @next/next/no-img-element */ - {branding.name} + {branding.name} ) } else { - return {branding.name} + return {branding.name} } } diff --git a/frontend/src/components/common/highlighted-code/highlighted-code.tsx b/frontend/src/components/common/highlighted-code/highlighted-code.tsx index 2b92ad9aa..9333e93a3 100644 --- a/frontend/src/components/common/highlighted-code/highlighted-code.tsx +++ b/frontend/src/components/common/highlighted-code/highlighted-code.tsx @@ -5,13 +5,14 @@ */ import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/async-loading-boundary' import { CopyToClipboardButton } from '../../../components/common/copyable/copy-to-clipboard-button/copy-to-clipboard-button' +import { concatCssClasses } from '../../../utils/concat-css-classes' import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute' import { testId } from '../../../utils/test-id' import styles from './highlighted-code.module.scss' import { useAsyncHighlightJsImport } from './hooks/use-async-highlight-js-import' import { useAttachLineNumbers } from './hooks/use-attach-line-numbers' import { useCodeDom } from './hooks/use-code-dom' -import React from 'react' +import React, { useMemo } from 'react' export interface HighlightedCodeProps { code: string @@ -35,6 +36,13 @@ export const HighlightedCode: React.FC = ({ code, language const codeDom = useCodeDom(code, hljsApi, language) const wrappedDomLines = useAttachLineNumbers(codeDom, startLineNumber) + const className = useMemo(() => { + return concatCssClasses('hljs', { + [styles['showGutter']]: showGutter, + [styles['wrapLines']]: wrapLines + }) + }, [showGutter, wrapLines]) + return (
@@ -43,7 +51,7 @@ export const HighlightedCode: React.FC = ({ code, language {...cypressId('code-highlighter')} {...cypressAttribute('showgutter', showGutter ? 'true' : 'false')} {...cypressAttribute('wraplines', wrapLines ? 'true' : 'false')} - className={`hljs ${showGutter ? styles['showGutter'] : ''} ${wrapLines ? styles['wrapLines'] : ''}`}> + className={className}> {wrappedDomLines}
diff --git a/frontend/src/components/common/icon-button/icon-button.tsx b/frontend/src/components/common/icon-button/icon-button.tsx index 88cd5cc20..046d36c6e 100644 --- a/frontend/src/components/common/icon-button/icon-button.tsx +++ b/frontend/src/components/common/icon-button/icon-button.tsx @@ -3,12 +3,13 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { concatCssClasses } from '../../../utils/concat-css-classes' import type { PropsWithDataTestId } from '../../../utils/test-id' import { testId } from '../../../utils/test-id' import { UiIcon } from '../icons/ui-icon' import { ShowIf } from '../show-if/show-if' import styles from './icon-button.module.scss' -import React from 'react' +import React, { useMemo } from 'react' import type { ButtonProps } from 'react-bootstrap' import { Button } from 'react-bootstrap' import type { Icon } from 'react-bootstrap-icons' @@ -38,13 +39,16 @@ export const IconButton: React.FC = ({ iconSize, ...props }) => { + const finalClassName = useMemo( + () => + concatCssClasses(styles['btn-icon'], 'd-inline-flex align-items-stretch', className, { + [styles['with-border']]: border + }), + [border, className] + ) + return ( - ) } diff --git a/frontend/src/components/editor-page/sidebar/sidebar-menu/sidebar-menu.module.scss b/frontend/src/components/editor-page/sidebar/sidebar-menu/sidebar-menu.module.scss index 825bd1c7e..936fae4b9 100644 --- a/frontend/src/components/editor-page/sidebar/sidebar-menu/sidebar-menu.module.scss +++ b/frontend/src/components/editor-page/sidebar/sidebar-menu/sidebar-menu.module.scss @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -.sidebar-menu { +.menu { transition: height 0.2s, flex-basis 0.2s; display: flex; flex-direction: column; diff --git a/frontend/src/components/editor-page/sidebar/sidebar-menu/sidebar-menu.tsx b/frontend/src/components/editor-page/sidebar/sidebar-menu/sidebar-menu.tsx index 50ed9858d..586ca81d9 100644 --- a/frontend/src/components/editor-page/sidebar/sidebar-menu/sidebar-menu.tsx +++ b/frontend/src/components/editor-page/sidebar/sidebar-menu/sidebar-menu.tsx @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { concatCssClasses } from '../../../../utils/concat-css-classes' import type { SidebarMenuProps } from '../types' import styles from './sidebar-menu.module.scss' import type { PropsWithChildren } from 'react' @@ -16,7 +17,7 @@ import React from 'react' */ export const SidebarMenu: React.FC> = ({ children, expand }) => { return ( -
+
{children}
) diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/pin-note-sidebar-entry/pin-note-sidebar-entry.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/pin-note-sidebar-entry/pin-note-sidebar-entry.tsx index bd3f6c83b..991de5b82 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/pin-note-sidebar-entry/pin-note-sidebar-entry.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/pin-note-sidebar-entry/pin-note-sidebar-entry.tsx @@ -5,6 +5,7 @@ */ import { useApplicationState } from '../../../../../hooks/common/use-application-state' import { toggleHistoryEntryPinning } from '../../../../../redux/history/methods' +import { concatCssClasses } from '../../../../../utils/concat-css-classes' import { useUiNotifications } from '../../../../notifications/ui-notification-boundary' import { SidebarButton } from '../../sidebar-button/sidebar-button' import type { SpecificSidebarEntryProps } from '../../types' @@ -42,7 +43,7 @@ export const PinNoteSidebarEntry: React.FC = ({ class icon={IconPin} hide={hide} onClick={onPinClicked} - className={`${className ?? ''} ${isPinned ? styles['highlighted'] : ''}`}> + className={concatCssClasses(className, { [styles['highlighted']]: isPinned })}> ) diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/online-counter.module.scss b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/online-counter.module.scss index aaa2e144e..345a60ac1 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/online-counter.module.scss +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/online-counter.module.scss @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -.online-entry { +.entry { &:hover { :global(.sidebar-button-icon):after { color: inherit; diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/users-online-sidebar-menu.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/users-online-sidebar-menu.tsx index 430755997..b62196784 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/users-online-sidebar-menu.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/users-online-sidebar-menu/users-online-sidebar-menu.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { useApplicationState } from '../../../../../hooks/common/use-application-state' +import { concatCssClasses } from '../../../../../utils/concat-css-classes' import { SidebarButton } from '../../sidebar-button/sidebar-button' import { SidebarMenu } from '../../sidebar-menu/sidebar-menu' import type { SpecificSidebarMenuProps } from '../../types' @@ -69,7 +70,7 @@ export const UsersOnlineSidebarMenu: React.FC = ({ buttonRef={buttonRef} onClick={onClickHandler} icon={expand ? IconArrowLeft : IconPeople} - className={`${styles['online-entry']} ${className ?? ''}`}> + className={concatCssClasses(styles.entry, className)}> diff --git a/frontend/src/components/editor-page/table-of-contents/table-of-contents.module.scss b/frontend/src/components/editor-page/table-of-contents/table-of-contents.module.scss index 466d8f038..af1989511 100644 --- a/frontend/src/components/editor-page/table-of-contents/table-of-contents.module.scss +++ b/frontend/src/components/editor-page/table-of-contents/table-of-contents.module.scss @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -.markdown-toc { +.toc { width: 100%; max-width: 200px; max-height: 100vh; diff --git a/frontend/src/components/editor-page/table-of-contents/table-of-contents.tsx b/frontend/src/components/editor-page/table-of-contents/table-of-contents.tsx index 60a223a2f..3005bc4bb 100644 --- a/frontend/src/components/editor-page/table-of-contents/table-of-contents.tsx +++ b/frontend/src/components/editor-page/table-of-contents/table-of-contents.tsx @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { concatCssClasses } from '../../../utils/concat-css-classes' import { ShowIf } from '../../common/show-if/show-if' import styles from './table-of-contents.module.scss' import { useBuildReactDomFromTocAst } from './use-build-react-dom-from-toc-ast' @@ -30,7 +31,7 @@ export const TableOfContents: React.FC = ({ ast, maxDepth const tocTree = useBuildReactDomFromTocAst(ast, maxDepth, baseUrl) return ( -
+
diff --git a/frontend/src/utils/concat-css-classes.spec.ts b/frontend/src/utils/concat-css-classes.spec.ts new file mode 100644 index 000000000..799df4a47 --- /dev/null +++ b/frontend/src/utils/concat-css-classes.spec.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { concatCssClasses } from './concat-css-classes' + +describe('concat css classes', () => { + it('works with a map', () => { + expect(concatCssClasses({ a: true, b: false, c: true })).toBe('a c') + }) + + it('works with a string array ', () => { + expect(concatCssClasses('a', 'b', 'c')).toBe('a b c') + }) + + it('works with a string array and map', () => { + expect(concatCssClasses('a', 'b', 'c', { d: true, e: false, f: true })).toBe('a b c d f') + }) + + it("doesn't include undefined and null", () => { + expect(concatCssClasses(undefined, null)).toBe('') + }) + + it("doesn't include duplicates", () => { + expect(concatCssClasses('a', 'a', { a: true })).toBe('a') + }) + + it("doesn't include empty class names", () => { + expect(concatCssClasses('a', '', { '': true })).toBe('a') + }) +}) diff --git a/frontend/src/utils/concat-css-classes.ts b/frontend/src/utils/concat-css-classes.ts new file mode 100644 index 000000000..5584cab1b --- /dev/null +++ b/frontend/src/utils/concat-css-classes.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type ClassMap = Record + +/** + * Generates a css class string from the given arguments. It filters out empty values, as well as null and undefined. + * If one of the arguments is a string to boolean map then only the keys with a true-ish value will be included. + * + * @param values The values that should be included in the class name string + * @return {string} the generates class name string + */ +export const concatCssClasses = (...values: (string | null | undefined | ClassMap)[]): string => { + const strings = generateCssClassStrings(values).filter((value) => !!value) + return Array.from(new Set(strings)).join(' ') +} +const generateCssClassStrings = (values: (string | null | undefined | ClassMap)[]): string[] => { + if (Array.isArray(values)) { + return values.flatMap((value) => { + if (!value) { + return [] + } else if (typeof value === 'string') { + return [value] + } else { + return generateCssClassStringsFromMap(value) + } + }) + } else { + return generateCssClassStringsFromMap(values) + } +} + +const generateCssClassStringsFromMap = (values: ClassMap): string[] => { + return Object.keys(values).filter((value) => values[value]) +}