feat: add concat-css-classes helper method

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-05-19 17:57:15 +02:00
parent d7663e3090
commit 4eb341308a
19 changed files with 129 additions and 41 deletions

View file

@ -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<HedgeDocLogoProps> = ({ error }) => {
const iconRows = useMemo(() => createNumberRangeArray(12).map((index) => <IconRow key={index} />), [])
return (
<div className={`position-relative ${error ? styles.error : ''}`}>
<div className={concatCssClasses('position-relative', { [styles.error]: error })}>
<div className={styles.logo}>
<div>
<UiIcon icon={IconPencilFill} className={styles.background} size={5} />
</div>
<div className={`${styles.overlay}`}>
<div className={styles.overlay}>
<UiIcon icon={IconPencil} size={5} />
</div>
</div>

View file

@ -21,19 +21,16 @@ export interface BrandingProps {
export const CustomBranding: React.FC<BrandingProps> = ({ 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 */
<img
src={branding.logo}
alt={branding.name}
title={branding.name}
className={inline ? styles['inline-size'] : styles['regular-size']}
/>
<img src={branding.logo} alt={branding.name} title={branding.name} className={className} />
)
} else {
return <span className={inline ? styles['inline-size'] : styles['regular-size']}>{branding.name}</span>
return <span className={className}>{branding.name}</span>
}
}

View file

@ -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<HighlightedCodeProps> = ({ 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 (
<AsyncLoadingBoundary loading={loading || !hljsApi} error={!!error} componentName={'highlight.js'}>
<div className={styles['code-highlighter']} {...cypressId('highlighted-code-block')}>
@ -43,7 +51,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ 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}
</code>
<div className={'text-right button-inside'}>

View file

@ -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<IconButtonProps> = ({
iconSize,
...props
}) => {
const finalClassName = useMemo(
() =>
concatCssClasses(styles['btn-icon'], 'd-inline-flex align-items-stretch', className, {
[styles['with-border']]: border
}),
[border, className]
)
return (
<Button
{...props}
className={`${styles['btn-icon']} d-inline-flex align-items-stretch ${border ? styles['with-border'] : ''} ${
className ?? ''
}`}
{...testId('icon-button')}>
<Button {...props} className={finalClassName} {...testId('icon-button')}>
<span className={`${styles['icon-part']}`}>
<UiIcon size={iconSize} icon={icon} className={'icon'} />
</span>

View file

@ -3,6 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { concatCssClasses } from '../../../utils/concat-css-classes'
import styles from './ui-icons.module.scss'
import React, { Fragment, useMemo } from 'react'
import type { Icon } from 'react-bootstrap-icons'
@ -26,9 +27,7 @@ export const UiIcon: React.FC<UiIconProps> = ({ icon, nbsp, className, size, spi
}
}, [size])
const finalClassName = useMemo(() => {
return `${spin ? styles.spin : ''} ${className ?? ''}`
}, [className, spin])
const finalClassName = useMemo(() => concatCssClasses(className, { [styles.spin]: spin }), [className, spin])
if (icon) {
return (

View file

@ -3,6 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { concatCssClasses } from '../../../utils/concat-css-classes'
import type { PropsWithDataCypressId } from '../../../utils/cypress-attribute'
import { cypressId } from '../../../utils/cypress-attribute'
import { testId } from '../../../utils/test-id'
@ -73,7 +74,7 @@ export const CommonModal: React.FC<PropsWithChildren<CommonModalProps>> = ({
onHide={onHide}
animation={true}
{...testId('commonModal')}
dialogClassName={`text-dark ${additionalClasses ?? ''}`}
dialogClassName={concatCssClasses('text-dark', additionalClasses)}
size={modalSize}>
<Modal.Header closeButton={!!showCloseButton}>
<Modal.Title>

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { DarkModePreference } from '../../../redux/dark-mode/types'
import { concatCssClasses } from '../../../utils/concat-css-classes'
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
import { Logger } from '../../../utils/logger'
import { isTestMode } from '../../../utils/test-modes'
@ -168,7 +169,7 @@ export const RendererIframe: React.FC<RendererIframeProps> = ({
allowFullScreen={true}
ref={frameReference}
referrerPolicy={'no-referrer'}
className={`border-0 ${frameClasses ?? ''}`}
className={concatCssClasses('border-0', frameClasses)}
allow={'clipboard-write'}
{...cypressAttribute('renderer-ready', rendererReady ? 'true' : 'false')}
{...cypressAttribute('renderer-type', rendererType)}

View file

@ -3,6 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { concatCssClasses } from '../../../../../utils/concat-css-classes'
import { createCursorCssClass } from './create-cursor-css-class'
import styles from './style.module.scss'
import type { SelectionRange } from '@codemirror/state'
@ -40,9 +41,10 @@ export class RemoteCursorMarker implements LayerMarker {
element.style.setProperty('--name', `"${this.name}"`)
const cursorOnRightSide = this.left > this.viewWidth / 2
const cursorOnDownSide = this.top < 20
element.className = `${styles.cursor} ${createCursorCssClass(this.styleIndex)} ${
cursorOnRightSide ? styles.right : ''
} ${cursorOnDownSide ? styles.down : ''}`
element.className = concatCssClasses(styles.cursor, createCursorCssClass(this.styleIndex), {
[styles.right]: cursorOnRightSide,
[styles.down]: cursorOnDownSide
})
}
eq(other: RemoteCursorMarker): boolean {

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
.sidebar-button {
.button {
height: var(--sidebar-entry-height);
flex: 0 0 var(--sidebar-entry-height);
width: 100%;
@ -25,16 +25,16 @@
height: 0;
border-width: 0;
.sidebar-icon {
.icon {
opacity: 0;
}
.sidebar-text {
.text {
opacity: 0;
}
}
.sidebar-icon {
.icon {
transition: opacity 0.2s;
opacity: 1;
height: var(--sidebar-entry-height);
@ -46,7 +46,7 @@
flex: 0 0 var(--sidebar-entry-height);
}
.sidebar-text {
.text {
height: 100%;
display: flex;
align-items: center;

View file

@ -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 { ShowIf } from '../../../common/show-if/show-if'
import type { SidebarEntryProps } from '../types'
@ -34,15 +35,15 @@ export const SidebarButton: React.FC<PropsWithChildren<SidebarEntryProps>> = ({
return (
<button
ref={buttonRef}
className={`${styles['sidebar-button']} ${hide ? styles['hide'] : ''} ${className ?? ''}`}
className={concatCssClasses(styles.button, className, { [styles.hide]: hide })}
disabled={disabled}
{...props}>
<ShowIf condition={!!icon}>
<span className={`sidebar-button-icon ${styles['sidebar-icon']}`}>
<span className={`sidebar-button-icon ${styles.icon}`}>
<UiIcon icon={icon} />
</span>
</ShowIf>
<span className={styles['sidebar-text']}>{children}</span>
<span className={styles.text}>{children}</span>
</button>
)
}

View file

@ -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;

View file

@ -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<PropsWithChildren<SidebarMenuProps>> = ({ children, expand }) => {
return (
<div className={`${styles['sidebar-menu']} ${expand ? styles['show'] : ''}`}>
<div className={concatCssClasses(styles.menu, { [styles['show']]: expand })}>
<div className={`d-flex flex-column`}>{children}</div>
</div>
)

View file

@ -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<SpecificSidebarEntryProps> = ({ class
icon={IconPin}
hide={hide}
onClick={onPinClicked}
className={`${className ?? ''} ${isPinned ? styles['highlighted'] : ''}`}>
className={concatCssClasses(className, { [styles['highlighted']]: isPinned })}>
<Trans i18nKey={isPinned ? 'editor.documentBar.pinnedToHistory' : 'editor.documentBar.pinNoteToHistory'} />
</SidebarButton>
)

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.online-entry {
.entry {
&:hover {
:global(.sidebar-button-icon):after {
color: inherit;

View file

@ -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<SpecificSidebarMenuProps> = ({
buttonRef={buttonRef}
onClick={onClickHandler}
icon={expand ? IconArrowLeft : IconPeople}
className={`${styles['online-entry']} ${className ?? ''}`}>
className={concatCssClasses(styles.entry, className)}>
<Trans i18nKey={'editor.onlineStatus.online'} />
</SidebarButton>
<SidebarMenu expand={expand}>

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
.markdown-toc {
.toc {
width: 100%;
max-width: 200px;
max-height: 100vh;

View file

@ -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<TableOfContentsProps> = ({ ast, maxDepth
const tocTree = useBuildReactDomFromTocAst(ast, maxDepth, baseUrl)
return (
<div className={`${styles['markdown-toc']} ${className ?? ''}`}>
<div className={concatCssClasses(styles.toc, className)}>
<ShowIf condition={ast.children.length === 0}>
<Trans i18nKey={'editor.infoToc'} />
</ShowIf>

View file

@ -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')
})
})

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
type ClassMap = Record<string, boolean | null | undefined>
/**
* 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])
}