diff --git a/sonar-project.properties b/sonar-project.properties index 31a784be..118eeb74 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -17,3 +17,23 @@ sonar.test.inclusions=src/**/*.test.ts,src/**/*.test.tsx,src/**/__tests__/**/*.{ # Coverage report sonar.javascript.lcov.reportPaths=coverage/lcov.info + +# Ignore "Refactor this code to not nest functions more than 4 levels deep." in test files, +# because it's mostly raised when describe-functions are used. +# +# Rule: typescript:S2004 +# Name: "Refactor this code to not nest functions more than 4 levels deep." +# URL: https://rules.sonarsource.com/typescript/RSPEC-2004/ +sonar.issue.ignore.multicriteria.e1.ruleKey=typescript:S2004 +sonar.issue.ignore.multicriteria.e1.resourceKey=**/*.test.tsx + +# Ignore "Using pseudorandom number generators (PRNGs) is security-sensitive" in stoyry book files. +# because it is safe in a non-security related Storybook demo file. +# +# Rule: typescript:S2245 +# Name: "Using pseudorandom number generators (PRNGs) is security-sensitive" +# URL: +# - https://rules.sonarsource.com/typescript/RSPEC-2245/ +# - https://sonarcloud.io/organizations/city-of-helsinki/rules?open=typescript%3AS2245&rule_key=typescript%3AS2245 +sonar.issue.ignore.multicriteria.e2.ruleKey=typescript:S2245 +sonar.issue.ignore.multicriteria.e2.resourceKey=**/*.stories.tsx diff --git a/src/common/utils/testImage.ts b/src/common/utils/testImage.ts index ea3d21c6..4dcc7253 100644 --- a/src/common/utils/testImage.ts +++ b/src/common/utils/testImage.ts @@ -1,32 +1,44 @@ /** - * Test that loading image is successful + * Tests if an image at a given URL loads successfully, + * using AbortController for clean event listener management. + * + * @param {string} [url] - The URL of the image to test. + * @return {Promise} - A promise that resolves if the image loads, and rejects otherwise. */ - -/** - * @param {url} url - url of the image to test. - * @return {boolean} - Returns promise. - */ -const testImage = (url?: string): Promise => { +const testImage = (url?: string): Promise => { if (!url) { return Promise.reject(new Error('No image URL given')); } - // Define the promise - const imgPromise = new Promise((resolve, reject) => { - // Create the image - const imgElement = new Image(); + const imgElement = new Image(); + // Create the controller instance + const controller = new AbortController(); + const { signal } = controller; - // When image is loaded, resolve the promise - imgElement.addEventListener('load', () => resolve()); + return new Promise((resolve, reject) => { + // The { signal } option ensures these listeners are grouped for cleanup + imgElement.addEventListener( + 'load', + () => { + controller.abort(); // Triggers removal of ALL listeners attached to this signal + resolve(); + }, + { signal }, + ); - // When there's an error during load, reject the promise - imgElement.addEventListener('error', () => reject()); + imgElement.addEventListener( + 'error', + () => { + controller.abort(); // Triggers removal of ALL listeners attached to this signal + // Reject with a standard Error object + reject(new Error(`Failed to load image from URL: ${url}`)); + }, + { signal }, + ); - // Assign URL imgElement.src = url; }); - - return imgPromise; + // Note: The controller is self-contained and cleaned up when the promise resolves/rejects }; export default testImage; diff --git a/src/core/card/Card.tsx b/src/core/card/Card.tsx index 6b078476..f59715b2 100644 --- a/src/core/card/Card.tsx +++ b/src/core/card/Card.tsx @@ -3,7 +3,6 @@ import { IconArrowRight } from 'hds-react'; import type { MouseEventHandler } from 'react'; import React, { useState } from 'react'; -// eslint-disable-next-line import/no-extraneous-dependencies import classNames from 'classnames'; import styles from './card.module.scss'; @@ -127,11 +126,139 @@ export type CardProps = { flex?: boolean; }; -export function Card({ +const warnOnSSR = () => { + // eslint-disable-next-line no-console + console.warn('Attempted to navigate in a non-browser environment.'); +}; + +function CardTitle({ + children, + direction, + withTitleIcon, + titleIcon, +}: { + readonly children: React.ReactNode; + readonly direction?: CardProps['direction']; + readonly withTitleIcon: CardProps['withTitleIcon']; + readonly titleIcon: CardProps['titleIcon']; +}) { + if (!children) { + return null; + } + + return ( +
+ {children} + {withTitleIcon && titleIcon} +
+ ); +} + +function CardSubTitle({ + children, + backgroundColor, +}: { + readonly children: React.ReactNode; + readonly backgroundColor?: CardProps['backgroundColor']; +}): React.ReactElement | null { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +function CardText({ + children, + clampText, + direction, +}: Pick & { + children: React.ReactNode; +}): React.ReactElement | null { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +function CardLink({ + url, + backgroundColor, + openLinkInNewTab, + ariaLabel, + linkArrowLabel, +}: Pick< + CardProps, + | 'url' + | 'backgroundColor' + | 'openLinkInNewTab' + | 'ariaLabel' + | 'linkArrowLabel' +>) { + if (!url) { + return null; + } + + return ( +
+ } + showExternalIcon={false} + aria-label={ariaLabel} + > + {linkArrowLabel && ( + {linkArrowLabel} + )} + +
+ ); +} + +const getImagePosition = (alignment: CardProps['alignment']) => { + if (alignment === undefined) { + return 'image-left'; + } + return alignment.indexOf('left') === -1 ? 'image-right' : 'image-left'; +}; + +function CardInnerContent({ id, - alignment, ariaLabel, - className, imageUrl, imageLabel, title, @@ -151,60 +278,42 @@ export function Card({ style, backgroundColor, primaryContent = 'text', - flex, -}: CardProps) { - const [isHovered, setIsHovered] = useState(false); - const handleToggleActive: MouseEventHandler | undefined = () => - setIsHovered((val) => !val); - const isDelimited = alignment?.startsWith('delimited'); - const isCentered = alignment?.startsWith('center'); - - const getImagePosition = () => { - if (alignment === undefined) { - return 'image-left'; - } - return alignment.indexOf('left') === -1 ? 'image-right' : 'image-left'; - }; - - const imagePosition = getImagePosition(); - - const { - utils: { redirectToUrl, getIsHrefExternal }, - } = useConfig(); - - const openInNewTab = (): void => { - const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); - if (newWindow) newWindow.opener = null; - }; - - const handleClick = () => { - if (url) { - if (getIsHrefExternal(url)) { - openInNewTab(); - } else { - redirectToUrl(url); - } - } - }; + isHovered, + handleToggleActive, + isDelimited, + isCentered, + imagePosition, + handleClick, +}: Omit & { + isHovered: boolean; + handleToggleActive: MouseEventHandler; + isDelimited: boolean; + isCentered: boolean; + imagePosition: 'image-left' | 'image-right'; + handleClick: () => void; +}) { + const dynamicBackgroundClass = backgroundColor + ? colorStyles[`background${getColor(backgroundColor)}`] + : colorStyles.backgroundDefault; - const content = ( + return (
{title && ( -
{title} - {withTitleIcon && titleIcon} -
- )} - {subTitle && ( -
- {subTitle} -
+ )} + + {subTitle} + {text && ( -
+ {getTextFromHtml(text)} -
+ )} {customContent && (
{customContent}
)}
- {url && hasLink && ( -
- } - showExternalIcon={false} - aria-label={ariaLabel} - > - {linkArrowLabel && ( - {linkArrowLabel} - )} - -
+ {hasLink && ( + )}
); +} + +export function Card(props: CardProps) { + const { id, ariaLabel, className, url, openLinkInNewTab, flex, alignment } = + props; + const [isHovered, setIsHovered] = useState(false); + const handleToggleActive: MouseEventHandler | undefined = () => + setIsHovered((val) => !val); + + const isDelimited = alignment?.startsWith('delimited') ?? false; + const isCentered = alignment?.startsWith('center') ?? false; + const imagePosition = getImagePosition(alignment); + + const { + utils: { redirectToUrl, getIsHrefExternal }, + } = useConfig(); + + const handleClick = () => { + if (!url) return; + + // Ensure we are in a client-side (browser) environment + if (typeof window === 'undefined') { + warnOnSSR(); + return; + } + + // Determine if we should open a new tab: either it's explicitly set, or it's an external URL. + const shouldOpenNewTab = openLinkInNewTab || getIsHrefExternal(url); + + if (shouldOpenNewTab) { + const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); + if (newWindow) newWindow.opener = null; + } else { + redirectToUrl(url); + } + }; + + const content = ( + + ); if (url) { return ( diff --git a/src/core/card/card.module.scss b/src/core/card/card.module.scss index 91acc9f6..4e8d148e 100644 --- a/src/core/card/card.module.scss +++ b/src/core/card/card.module.scss @@ -140,6 +140,15 @@ flex-direction: row; } } + + &.horizontalBorder { + // overriding withborder param + border: none; + + @include breakpoints.respond_below(s) { + border: 1px solid var(--color-black-30); + } + } } & > :first-child { @@ -182,17 +191,6 @@ } } } - - &.responsive-reverse { - &.horizontalBorder { - // overriding withborder param - border: none; - - @include breakpoints.respond_below(s) { - border: 1px solid var(--color-black-30); - } - } - } } .imageWrapper { @@ -292,10 +290,6 @@ .content { flex: 1; - @include breakpoints.respond_above(s) { - // min-height: var(--height-image-desktop); - // height: 100%; - } } .textWrapper { @@ -303,10 +297,6 @@ flex-direction: column; padding: var(--spacing-s) var(--spacing-m); - @include breakpoints.respond_below(s) { - // background-color: var(--color-white); - } - .title { font-size: var(--fontsize-heading-s); line-height: var(--lineheight-m); @@ -341,6 +331,7 @@ display: -webkit-box; &.clamp { -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } @@ -353,6 +344,7 @@ margin-bottom: var(--spacing-2-xs); &.clamp { -webkit-line-clamp: 3; + line-clamp: 3; -webkit-box-orient: vertical; } } diff --git a/src/core/carousel/components/CarouselSlideButton.tsx b/src/core/carousel/components/CarouselSlideButton.tsx index 73643629..17e02318 100644 --- a/src/core/carousel/components/CarouselSlideButton.tsx +++ b/src/core/carousel/components/CarouselSlideButton.tsx @@ -25,10 +25,12 @@ export function CarouselPreviousSlideButton() { handleUpdateSlideProps(targetSlide); }; + const ariaLabelTitle = title ? ` - ${title}` : ''; + return (