diff --git a/.changeset/huge-cats-say.md b/.changeset/huge-cats-say.md new file mode 100644 index 0000000000..08aab3d769 --- /dev/null +++ b/.changeset/huge-cats-say.md @@ -0,0 +1,8 @@ +--- +'@alfalab/core-components-skeleton': patch +'@alfalab/core-components': patch +--- + +##### hooks/useSkeleton + +- Добавлено дефолтное состояние в `useSkeleton`, чтобы SSR сразу отдавал текстовый скелетон. diff --git a/packages/select-with-tags/src/__image_snapshots__/select-with-tags-dark-preview-snap.png b/packages/select-with-tags/src/__image_snapshots__/select-with-tags-dark-preview-snap.png index 60369aa41a..7148fd8a9b 100644 --- a/packages/select-with-tags/src/__image_snapshots__/select-with-tags-dark-preview-snap.png +++ b/packages/select-with-tags/src/__image_snapshots__/select-with-tags-dark-preview-snap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36970985aedd3f85a53548871a02854d7b0bdb9cdd1f2971832b33d4b6e4910a -size 11475 +oid sha256:cec7be5d63b321b14ca4ca74fd0c7dbd82df60b23e3e592e41915f9ed10442ff +size 8705 diff --git a/packages/skeleton/src/Component.tsx b/packages/skeleton/src/Component.tsx index d6230a56aa..90ff9207a5 100644 --- a/packages/skeleton/src/Component.tsx +++ b/packages/skeleton/src/Component.tsx @@ -12,7 +12,7 @@ const colorStyles = { export type BorderRadiusSize = 0 | 2 | 4 | 6 | 8 | 10 | 12 | 16 | 20 | 24 | 32 | 36 | 64 | 'pill'; -export type SkeletonProps = { +export interface SkeletonProps { /** * Флаг, явно задающий состояние, при котором контент закрывается прелоадером */ @@ -59,7 +59,7 @@ export type SkeletonProps = { * @default 8 */ borderRadius?: BorderRadiusSize; -}; +} export const Skeleton: React.FC = ({ visible, diff --git a/packages/skeleton/src/hooks/use-skeleton/use-skeleton.test.tsx b/packages/skeleton/src/hooks/use-skeleton/use-skeleton.test.tsx index 02f33a8a53..c8e9b5935c 100644 --- a/packages/skeleton/src/hooks/use-skeleton/use-skeleton.test.tsx +++ b/packages/skeleton/src/hooks/use-skeleton/use-skeleton.test.tsx @@ -6,7 +6,7 @@ import { render } from '@testing-library/react'; const Skeleton = (props: TextSkeletonProps) => { const { renderSkeleton, textRef } = useSkeleton(true, props); - const skeleton = renderSkeleton({}); + const skeleton = renderSkeleton({ dataTestId: 'test-skeleton' }); if (skeleton) return skeleton; diff --git a/packages/skeleton/src/hooks/use-skeleton/use-skeleton.tsx b/packages/skeleton/src/hooks/use-skeleton/use-skeleton.tsx index 84503bff5d..68bd4355de 100644 --- a/packages/skeleton/src/hooks/use-skeleton/use-skeleton.tsx +++ b/packages/skeleton/src/hooks/use-skeleton/use-skeleton.tsx @@ -5,64 +5,39 @@ import { useLayoutEffect_SAFE_FOR_SSR } from '@alfalab/hooks'; import { Skeleton } from '../../Component'; import { type TextSkeletonProps } from '../../types/text-skeleton-props'; +import { + getFallbackSkeletonParams, + measureSkeletonParams, + type TextSkeletonParams, +} from '../../utils'; import styles from './use-skeleton.module.css'; -type TextSkeletonParams = { - height: number; - padding: string; - rows: number; -}; - type SkeletonProps = { wrapperClassName?: string; dataTestId?: string; }; export function useSkeleton(showSkeleton?: boolean, skeletonProps?: TextSkeletonProps) { - const [skeletonParams, setSkeletonParams] = useState(); + const [skeletonParams, setSkeletonParams] = useState(() => + showSkeleton ? getFallbackSkeletonParams(skeletonProps) : undefined, + ); const textRef = useRef(null); useLayoutEffect_SAFE_FOR_SSR(() => { - if (showSkeleton && textRef.current) { - const style = getComputedStyle(textRef.current); - - const textHeight = textRef.current.offsetHeight; - const fontSize = parseInt(style.fontSize, 10); - const lineHeight = parseInt(style.lineHeight, 10); - - let padding = - (lineHeight - fontSize) % 2 === 0 - ? (lineHeight - fontSize) / 2 - : (lineHeight - fontSize - 1) / 2; - - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - - /** - * Расчет отступов с учётом размера глифа от базовой линии до верхней границы - * Это позволяет отображать более приближённый размер скелетона к начертанию текста - * @see DS-12535 - */ - if (context && textRef.current.textContent) { - context.font = style.font; - const metrics = context.measureText(textRef.current.textContent); + if (!showSkeleton) { + setSkeletonParams(undefined); - padding = (lineHeight - metrics.actualBoundingBoxAscent) / 2; - } + return; + } - const rows = skeletonProps?.rows - ? skeletonProps?.rows - : Math.ceil(textHeight / lineHeight); + if (textRef.current) { + setSkeletonParams(measureSkeletonParams(textRef.current, skeletonProps)); - setSkeletonParams({ - height: lineHeight - padding * 2, - padding: `${padding}px 0`, - rows, - }); - } else { - setSkeletonParams(undefined); + return; } + + setSkeletonParams((prev) => prev ?? getFallbackSkeletonParams(skeletonProps)); }, [showSkeleton, skeletonProps?.rows]); const renderSkeleton = (props: SkeletonProps) => { diff --git a/packages/skeleton/src/utils.ts b/packages/skeleton/src/utils.ts new file mode 100644 index 0000000000..d4b92caaf7 --- /dev/null +++ b/packages/skeleton/src/utils.ts @@ -0,0 +1,80 @@ +import { type TextSkeletonProps } from './types/text-skeleton-props'; + +type PaddingParams = { + lineHeight: number; + fontSize: number; +}; + +/** + * Возвращает вертикальный padding для выравнивания текста внутри скелетона. + */ +export const getPadding = ({ lineHeight, fontSize }: PaddingParams) => { + if (lineHeight <= fontSize) { + return 0; + } + + const diff = lineHeight - fontSize; + + return diff % 2 === 0 ? diff / 2 : (diff - 1) / 2; +}; + +export type TextSkeletonParams = { + height: number; + padding: string; + rows: number; +}; + +/** + * Возвращает параметры скелетона по умолчанию, когда нет реальных размеров текста. + */ +export const getFallbackSkeletonParams = ( + skeletonProps?: TextSkeletonProps, +): TextSkeletonParams => { + const padding = getPadding({ lineHeight: 24, fontSize: 16 }); + + return { + height: 24 - padding * 2, + padding: `${padding}px 0`, + rows: skeletonProps?.rows ?? 1, + }; +}; + +/** + * Вычисляет параметры скелетона по реальному DOM-узлу. + * + * @param node элемент, по которому считаются размеры текста + * @param skeletonProps пропсы скелетона (для переопределения rows) + * @returns высота, паддинги и количество строк скелетона + */ +export const measureSkeletonParams = ( + node: HTMLElement, + skeletonProps?: TextSkeletonProps, +): TextSkeletonParams => { + const style = getComputedStyle(node); + const textHeight = node.offsetHeight; + const fontSize = parseInt(style.fontSize, 10); + const lineHeight = parseInt(style.lineHeight, 10); + + let padding = getPadding({ lineHeight, fontSize }); + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + /** + * Расчет отступов с учётом размера глифа от базовой линии до верхней границы + * Это позволяет отображать более приближённый размер скелетона к начертанию текста + * @see DS-12535 + */ + if (context && node.textContent) { + context.font = style.font; + const metrics = context.measureText(node.textContent); + + padding = (lineHeight - metrics.actualBoundingBoxAscent) / 2; + } + + return { + height: lineHeight - padding * 2, + padding: `${padding}px 0`, + rows: skeletonProps?.rows ?? Math.ceil(textHeight / lineHeight), + }; +};