fix(auth): stabilize compact auth form scrolling
This commit is contained in:
parent
0c4cfb97a6
commit
e6623b2784
2 changed files with 122 additions and 118 deletions
|
|
@ -77,6 +77,17 @@ function ServerEditDialog({
|
||||||
inputRef.current?.select();
|
inputRef.current?.select();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (evt: KeyboardEvent) => {
|
||||||
|
if (evt.key === 'Escape' && !evt.defaultPrevented && !evt.isComposing) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|
@ -84,12 +95,7 @@ function ServerEditDialog({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={css.ServerDialog} role="dialog" aria-modal="true" tabIndex={-1}>
|
||||||
className={css.ServerDialog}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') onCancel(); }}
|
|
||||||
>
|
|
||||||
<div className={css.ServerDialogBackdrop} onClick={onCancel} aria-hidden="true" />
|
<div className={css.ServerDialogBackdrop} onClick={onCancel} aria-hidden="true" />
|
||||||
<div className={css.ServerDialogCard}>
|
<div className={css.ServerDialogCard}>
|
||||||
<Text size="H3" style={{ color: '#e8e4df', fontWeight: 600 }}>
|
<Text size="H3" style={{ color: '#e8e4df', fontWeight: 600 }}>
|
||||||
|
|
@ -110,13 +116,7 @@ function ServerEditDialog({
|
||||||
placeholder={t('Auth.homeserver_dialog_placeholder')}
|
placeholder={t('Auth.homeserver_dialog_placeholder')}
|
||||||
/>
|
/>
|
||||||
<Box gap="300" justifyContent="End">
|
<Box gap="300" justifyContent="End">
|
||||||
<Button
|
<Button type="button" variant="Secondary" fill="Soft" size="400" onClick={onCancel}>
|
||||||
type="button"
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Soft"
|
|
||||||
size="400"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
<Text size="B400">{t('Auth.homeserver_dialog_cancel')}</Text>
|
<Text size="B400">{t('Auth.homeserver_dialog_cancel')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="Primary" size="400">
|
<Button type="submit" variant="Primary" size="400">
|
||||||
|
|
@ -153,13 +153,19 @@ function calculateModalLayout(input: {
|
||||||
bottomGap: number;
|
bottomGap: number;
|
||||||
}) {
|
}) {
|
||||||
const desiredTop = input.mascotTopOffset + input.mascotHeight * input.anchorRatio;
|
const desiredTop = input.mascotTopOffset + input.mascotHeight * input.anchorRatio;
|
||||||
const maxTop = input.pageHeight - input.footerHeight - input.bottomGap - input.modalHeight;
|
const canFitAboveFooter =
|
||||||
|
input.modalHeight <= input.pageHeight - input.minTop - input.footerHeight - input.bottomGap;
|
||||||
|
const reservedFooterHeight = canFitAboveFooter ? input.footerHeight : 0;
|
||||||
|
const reservedBottomGap = canFitAboveFooter ? input.bottomGap : 0;
|
||||||
|
|
||||||
|
const maxTop = input.pageHeight - reservedFooterHeight - reservedBottomGap - input.modalHeight;
|
||||||
const top = Math.max(input.minTop, Math.min(desiredTop, maxTop));
|
const top = Math.max(input.minTop, Math.min(desiredTop, maxTop));
|
||||||
const maxHeight = Math.max(0, input.pageHeight - input.footerHeight - input.bottomGap - top);
|
const maxHeight = input.pageHeight - reservedFooterHeight - reservedBottomGap - top;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: Math.round(top),
|
top: Math.round(top),
|
||||||
maxHeight: Math.floor(maxHeight),
|
maxHeight: Math.max(0, Math.floor(maxHeight)),
|
||||||
constrained: input.modalHeight > maxHeight,
|
constrained: input.modalHeight > maxHeight + 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,37 +237,39 @@ export function AuthLayout() {
|
||||||
/* ── Refs for JS-driven layout ── */
|
/* ── Refs for JS-driven layout ── */
|
||||||
const pageRef = useRef<HTMLDivElement>(null);
|
const pageRef = useRef<HTMLDivElement>(null);
|
||||||
const mascotRef = useRef<HTMLDivElement>(null);
|
const mascotRef = useRef<HTMLDivElement>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
|
||||||
const footerRef = useRef<HTMLElement>(null);
|
|
||||||
const cardContentRef = useRef<HTMLDivElement>(null);
|
const cardContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const cardBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const footerRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const page = pageRef.current;
|
const page = pageRef.current;
|
||||||
const mascot = mascotRef.current;
|
const mascot = mascotRef.current;
|
||||||
const modal = modalRef.current;
|
const cardContent = cardContentRef.current;
|
||||||
|
const cardBody = cardBodyRef.current;
|
||||||
const footer = footerRef.current;
|
const footer = footerRef.current;
|
||||||
if (!page || !mascot || !modal || !footer) return undefined;
|
if (!page || !mascot || !cardContent || !cardBody || !footer) return undefined;
|
||||||
|
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
const update = (): void => {
|
const update = (): void => {
|
||||||
cancelAnimationFrame(frameId);
|
cancelAnimationFrame(frameId);
|
||||||
frameId = requestAnimationFrame(() => {
|
frameId = requestAnimationFrame(() => {
|
||||||
const s = getComputedStyle(page);
|
const s = getComputedStyle(page);
|
||||||
|
const footerHeight =
|
||||||
|
footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight);
|
||||||
|
const bottomGap = readPx(s, '--vojo-modal-gap');
|
||||||
const layout = calculateModalLayout({
|
const layout = calculateModalLayout({
|
||||||
pageHeight: page.clientHeight,
|
pageHeight: page.clientHeight,
|
||||||
mascotTopOffset: readPx(s, '--vojo-mascot-top'),
|
mascotTopOffset: readPx(s, '--vojo-mascot-top'),
|
||||||
mascotHeight: mascot.offsetHeight,
|
mascotHeight: mascot.offsetHeight,
|
||||||
modalHeight: modal.scrollHeight,
|
modalHeight: cardBody.offsetHeight,
|
||||||
footerHeight: footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight),
|
footerHeight,
|
||||||
anchorRatio: readNum(s, '--vojo-anchor-ratio', 0.58),
|
anchorRatio: readNum(s, '--vojo-anchor-ratio', 0.58),
|
||||||
minTop: readPx(s, '--vojo-modal-min-top'),
|
minTop: readPx(s, '--vojo-modal-min-top'),
|
||||||
bottomGap: readPx(s, '--vojo-modal-gap'),
|
bottomGap,
|
||||||
});
|
});
|
||||||
page.style.setProperty('--vojo-modal-top', `${layout.top}px`);
|
page.style.setProperty('--vojo-modal-top', `${layout.top}px`);
|
||||||
page.style.setProperty('--vojo-modal-max-h', `${layout.maxHeight}px`);
|
page.style.setProperty('--vojo-modal-max-h', `${layout.maxHeight}px`);
|
||||||
if (cardContentRef.current) {
|
cardContent.classList.toggle(css.AuthCardContentScrollable, layout.constrained);
|
||||||
cardContentRef.current.classList.toggle(css.AuthCardContentConstrained, layout.constrained);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -269,6 +277,7 @@ export function AuthLayout() {
|
||||||
const ro = new ResizeObserver(update);
|
const ro = new ResizeObserver(update);
|
||||||
ro.observe(page);
|
ro.observe(page);
|
||||||
ro.observe(mascot);
|
ro.observe(mascot);
|
||||||
|
ro.observe(cardBody);
|
||||||
ro.observe(footer);
|
ro.observe(footer);
|
||||||
window.addEventListener('resize', update);
|
window.addEventListener('resize', update);
|
||||||
|
|
||||||
|
|
@ -285,8 +294,9 @@ export function AuthLayout() {
|
||||||
<AuthMascot mascotRef={mascotRef} />
|
<AuthMascot mascotRef={mascotRef} />
|
||||||
|
|
||||||
<div className={css.AuthModalZone}>
|
<div className={css.AuthModalZone}>
|
||||||
<div className={css.AuthCard} ref={modalRef}>
|
<div className={css.AuthCard}>
|
||||||
<div className={css.AuthCardContent} ref={cardContentRef}>
|
<div className={css.AuthCardContent} ref={cardContentRef}>
|
||||||
|
<div className={css.AuthCardBody} ref={cardBodyRef}>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text size="H2" style={{ color: '#e8e4df', fontWeight: 700 }}>
|
<Text size="H2" style={{ color: '#e8e4df', fontWeight: 700 }}>
|
||||||
{pageTitle}
|
{pageTitle}
|
||||||
|
|
@ -316,7 +326,9 @@ export function AuthLayout() {
|
||||||
)}
|
)}
|
||||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
||||||
<AuthLayoutError
|
<AuthLayoutError
|
||||||
message={t('Auth.error_server_config_invalid', { host: autoDiscoveryError.host })}
|
message={t('Auth.error_server_config_invalid', {
|
||||||
|
host: autoDiscoveryError.host,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
|
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
|
||||||
|
|
@ -329,7 +341,9 @@ export function AuthLayout() {
|
||||||
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
|
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
|
||||||
fallback={() => (
|
fallback={() => (
|
||||||
<AuthLayoutLoading
|
<AuthLayoutLoading
|
||||||
message={t('Auth.loading_connecting', { url: autoDiscoveryInfo['m.homeserver'].base_url })}
|
message={t('Auth.loading_connecting', {
|
||||||
|
url: autoDiscoveryInfo['m.homeserver'].base_url,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
error={() => (
|
error={() => (
|
||||||
|
|
@ -342,9 +356,7 @@ export function AuthLayout() {
|
||||||
fallback={() => (
|
fallback={() => (
|
||||||
<AuthLayoutLoading message={t('Auth.loading_auth_flows')} />
|
<AuthLayoutLoading message={t('Auth.loading_auth_flows')} />
|
||||||
)}
|
)}
|
||||||
error={() => (
|
error={() => <AuthLayoutError message={t('Auth.error_auth_flows')} />}
|
||||||
<AuthLayoutError message={t('Auth.error_auth_flows')} />
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{(authFlows) => (
|
{(authFlows) => (
|
||||||
<AuthFlowsProvider value={authFlows}>
|
<AuthFlowsProvider value={authFlows}>
|
||||||
|
|
@ -362,6 +374,7 @@ export function AuthLayout() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<AuthFooter footerRef={footerRef} />
|
<AuthFooter footerRef={footerRef} />
|
||||||
|
|
||||||
{/* Server edit dialog */}
|
{/* Server edit dialog */}
|
||||||
|
|
|
||||||
|
|
@ -108,14 +108,25 @@ export const AuthCard = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AuthCardContent = style({
|
export const AuthCardContent = style({
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
borderRadius: 'inherit',
|
borderRadius: 'inherit',
|
||||||
backgroundColor: 'rgba(18, 19, 22, 0.55)',
|
backgroundColor: 'rgba(18, 19, 22, 0.55)',
|
||||||
backdropFilter: 'blur(22px) saturate(150%)',
|
backdropFilter: 'blur(22px) saturate(150%)',
|
||||||
WebkitBackdropFilter: 'blur(22px) saturate(150%)',
|
WebkitBackdropFilter: 'blur(22px) saturate(150%)',
|
||||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
width: `min(${toRem(492)}, calc(100vw - 2rem))`,
|
width: `min(${toRem(492)}, calc(100vw - 2rem))`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
maxHeight: 'var(--vojo-modal-max-h)',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthCardContentScrollable = style({
|
||||||
|
overflowY: 'auto',
|
||||||
|
overscrollBehavior: 'contain',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthCardBody = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
padding: `${toRem(32)} ${toRem(40)}`,
|
padding: `${toRem(32)} ${toRem(40)}`,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
gap: toRem(20),
|
gap: toRem(20),
|
||||||
|
|
@ -128,12 +139,6 @@ export const AuthCardContent = style({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AuthCardContentConstrained = style({
|
|
||||||
maxHeight: 'var(--vojo-modal-max-h)',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overscrollBehavior: 'contain',
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── Server info row ── */
|
/* ── Server info row ── */
|
||||||
export const ServerRow = style({
|
export const ServerRow = style({
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
|
|
@ -285,20 +290,6 @@ globalStyle(`${AuthCardContent} input::placeholder`, {
|
||||||
color: 'rgba(232, 228, 223, 0.35)',
|
color: 'rgba(232, 228, 223, 0.35)',
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
* Floor for input boxes when the modal is height-constrained (small screen
|
|
||||||
* or on-screen keyboard). Natural input height is 3rem; floor at 2rem so
|
|
||||||
* the keyboard can still squeeze the form to ~66% before hitting the stop.
|
|
||||||
* Past the floor, flex can't compress further — the form overflows the
|
|
||||||
* AuthLayout (overflow: hidden) and the bottom is covered by the keyboard.
|
|
||||||
*
|
|
||||||
* `:has(> input)` targets the folds Input's outer wrapper (the box that
|
|
||||||
* flex-shrinks) rather than the native <input>. Excludes checkboxes.
|
|
||||||
*/
|
|
||||||
globalStyle(`${AuthCardContent} div:has(> input:not([type="checkbox"]))`, {
|
|
||||||
minHeight: toRem(48),
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Chromium's UA stylesheet paints a saved-credentials overlay on
|
* Chromium's UA stylesheet paints a saved-credentials overlay on
|
||||||
* :-webkit-autofill inputs, forcing a light background and dark text. Without
|
* :-webkit-autofill inputs, forcing a light background and dark text. Without
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue