feat(header): shrink long titles to fit instead of wrapping
The header keeps its size; a title that would overflow scales down to a floor of 55% and ellipsizes only past that. Re-measures on title change, resize, and web-font load.
This commit is contained in:
@@ -1,15 +1,57 @@
|
|||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import { Title, AppShell, Flex } from "@mantine/core";
|
import { Title, AppShell, Flex } from "@mantine/core";
|
||||||
import { useLingui } from "@lingui/react";
|
import { useLingui } from "@lingui/react";
|
||||||
import { HeaderConfig } from "../types/header-config";
|
import { HeaderConfig } from "../types/header-config";
|
||||||
import BackButton from "./back-button";
|
import BackButton from "./back-button";
|
||||||
|
|
||||||
|
const MIN_TITLE_SCALE = 0.55;
|
||||||
|
|
||||||
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
|
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||||
const resolvedTitle =
|
const resolvedTitle =
|
||||||
typeof title === "string" || title === undefined
|
typeof title === "string" || title === undefined
|
||||||
? title
|
? title
|
||||||
: i18n._({ ...title, values: titleValues });
|
: i18n._({ ...title, values: titleValues });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = titleRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const textWidth = () => {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
return range.getBoundingClientRect().width;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
el.style.fontSize = "";
|
||||||
|
const available = el.clientWidth - 1;
|
||||||
|
if (available <= 0) return;
|
||||||
|
let needed = textWidth();
|
||||||
|
if (needed <= available) return;
|
||||||
|
const natural = parseFloat(getComputedStyle(el).fontSize);
|
||||||
|
let scale = Math.max(available / needed, MIN_TITLE_SCALE);
|
||||||
|
el.style.fontSize = `${natural * scale}px`;
|
||||||
|
for (let i = 0; i < 3 && scale > MIN_TITLE_SCALE && textWidth() > available; i++) {
|
||||||
|
scale = Math.max(scale * 0.98, MIN_TITLE_SCALE);
|
||||||
|
el.style.fontSize = `${natural * scale}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fit();
|
||||||
|
document.fonts?.ready.then(() => {
|
||||||
|
if (!cancelled) fit();
|
||||||
|
});
|
||||||
|
const observer = new ResizeObserver(fit);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [resolvedTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
id='app-header'
|
id='app-header'
|
||||||
@@ -20,8 +62,20 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' px='md' mt={8}>
|
<Flex justify='center' px={48} mt={8} w='100%'>
|
||||||
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}>
|
<Title
|
||||||
|
ref={titleRef}
|
||||||
|
order={1}
|
||||||
|
lts='0.08em'
|
||||||
|
style={{
|
||||||
|
userSelect: 'none',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{resolvedTitle?.toLocaleUpperCase()}
|
{resolvedTitle?.toLocaleUpperCase()}
|
||||||
</Title>
|
</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
Reference in New Issue
Block a user