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();
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
|
|
@ -84,12 +95,7 @@ function ServerEditDialog({
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.ServerDialog}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') onCancel(); }}
|
||||
>
|
||||
<div className={css.ServerDialog} role="dialog" aria-modal="true" tabIndex={-1}>
|
||||
<div className={css.ServerDialogBackdrop} onClick={onCancel} aria-hidden="true" />
|
||||
<div className={css.ServerDialogCard}>
|
||||
<Text size="H3" style={{ color: '#e8e4df', fontWeight: 600 }}>
|
||||
|
|
@ -110,13 +116,7 @@ function ServerEditDialog({
|
|||
placeholder={t('Auth.homeserver_dialog_placeholder')}
|
||||
/>
|
||||
<Box gap="300" justifyContent="End">
|
||||
<Button
|
||||
type="button"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Button type="button" variant="Secondary" fill="Soft" size="400" onClick={onCancel}>
|
||||
<Text size="B400">{t('Auth.homeserver_dialog_cancel')}</Text>
|
||||
</Button>
|
||||
<Button type="submit" variant="Primary" size="400">
|
||||
|
|
@ -153,13 +153,19 @@ function calculateModalLayout(input: {
|
|||
bottomGap: number;
|
||||
}) {
|
||||
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 maxHeight = Math.max(0, input.pageHeight - input.footerHeight - input.bottomGap - top);
|
||||
const maxHeight = input.pageHeight - reservedFooterHeight - reservedBottomGap - top;
|
||||
|
||||
return {
|
||||
top: Math.round(top),
|
||||
maxHeight: Math.floor(maxHeight),
|
||||
constrained: input.modalHeight > maxHeight,
|
||||
maxHeight: Math.max(0, Math.floor(maxHeight)),
|
||||
constrained: input.modalHeight > maxHeight + 1,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -231,37 +237,39 @@ export function AuthLayout() {
|
|||
/* ── Refs for JS-driven layout ── */
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const mascotRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const footerRef = useRef<HTMLElement>(null);
|
||||
const cardContentRef = useRef<HTMLDivElement>(null);
|
||||
const cardBodyRef = useRef<HTMLDivElement>(null);
|
||||
const footerRef = useRef<HTMLElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const page = pageRef.current;
|
||||
const mascot = mascotRef.current;
|
||||
const modal = modalRef.current;
|
||||
const cardContent = cardContentRef.current;
|
||||
const cardBody = cardBodyRef.current;
|
||||
const footer = footerRef.current;
|
||||
if (!page || !mascot || !modal || !footer) return undefined;
|
||||
if (!page || !mascot || !cardContent || !cardBody || !footer) return undefined;
|
||||
|
||||
let frameId = 0;
|
||||
const update = (): void => {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(() => {
|
||||
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({
|
||||
pageHeight: page.clientHeight,
|
||||
mascotTopOffset: readPx(s, '--vojo-mascot-top'),
|
||||
mascotHeight: mascot.offsetHeight,
|
||||
modalHeight: modal.scrollHeight,
|
||||
footerHeight: footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight),
|
||||
modalHeight: cardBody.offsetHeight,
|
||||
footerHeight,
|
||||
anchorRatio: readNum(s, '--vojo-anchor-ratio', 0.58),
|
||||
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-max-h', `${layout.maxHeight}px`);
|
||||
if (cardContentRef.current) {
|
||||
cardContentRef.current.classList.toggle(css.AuthCardContentConstrained, layout.constrained);
|
||||
}
|
||||
cardContent.classList.toggle(css.AuthCardContentScrollable, layout.constrained);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -269,6 +277,7 @@ export function AuthLayout() {
|
|||
const ro = new ResizeObserver(update);
|
||||
ro.observe(page);
|
||||
ro.observe(mascot);
|
||||
ro.observe(cardBody);
|
||||
ro.observe(footer);
|
||||
window.addEventListener('resize', update);
|
||||
|
||||
|
|
@ -285,8 +294,9 @@ export function AuthLayout() {
|
|||
<AuthMascot mascotRef={mascotRef} />
|
||||
|
||||
<div className={css.AuthModalZone}>
|
||||
<div className={css.AuthCard} ref={modalRef}>
|
||||
<div className={css.AuthCard}>
|
||||
<div className={css.AuthCardContent} ref={cardContentRef}>
|
||||
<div className={css.AuthCardBody} ref={cardBodyRef}>
|
||||
{/* Title */}
|
||||
<Text size="H2" style={{ color: '#e8e4df', fontWeight: 700 }}>
|
||||
{pageTitle}
|
||||
|
|
@ -316,7 +326,9 @@ export function AuthLayout() {
|
|||
)}
|
||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
||||
<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 && (
|
||||
|
|
@ -329,7 +341,9 @@ export function AuthLayout() {
|
|||
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
|
||||
fallback={() => (
|
||||
<AuthLayoutLoading
|
||||
message={t('Auth.loading_connecting', { url: autoDiscoveryInfo['m.homeserver'].base_url })}
|
||||
message={t('Auth.loading_connecting', {
|
||||
url: autoDiscoveryInfo['m.homeserver'].base_url,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
error={() => (
|
||||
|
|
@ -342,9 +356,7 @@ export function AuthLayout() {
|
|||
fallback={() => (
|
||||
<AuthLayoutLoading message={t('Auth.loading_auth_flows')} />
|
||||
)}
|
||||
error={() => (
|
||||
<AuthLayoutError message={t('Auth.error_auth_flows')} />
|
||||
)}
|
||||
error={() => <AuthLayoutError message={t('Auth.error_auth_flows')} />}
|
||||
>
|
||||
{(authFlows) => (
|
||||
<AuthFlowsProvider value={authFlows}>
|
||||
|
|
@ -362,6 +374,7 @@ export function AuthLayout() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthFooter footerRef={footerRef} />
|
||||
|
||||
{/* Server edit dialog */}
|
||||
|
|
|
|||
|
|
@ -108,14 +108,25 @@ export const AuthCard = style({
|
|||
});
|
||||
|
||||
export const AuthCardContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 'inherit',
|
||||
backgroundColor: 'rgba(18, 19, 22, 0.55)',
|
||||
backdropFilter: 'blur(22px) saturate(150%)',
|
||||
WebkitBackdropFilter: 'blur(22px) saturate(150%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
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)}`,
|
||||
boxSizing: 'border-box',
|
||||
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 ── */
|
||||
export const ServerRow = style({
|
||||
display: 'grid',
|
||||
|
|
@ -285,20 +290,6 @@ globalStyle(`${AuthCardContent} input::placeholder`, {
|
|||
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
|
||||
* :-webkit-autofill inputs, forcing a light background and dark text. Without
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue