mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 01:15:48 -05:00
Redesign: Add navigation scroll fades (#40286)
This commit is contained in:
@@ -6,12 +6,34 @@
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
transparent,
|
||||
var(--color-bg-primary) var(--space-xl)
|
||||
);
|
||||
|
||||
// We're distributing the bottom spacing between padding and margin so that
|
||||
// not too much of it overlaps the navigation links when scrolled down
|
||||
--half-bottom-spacing: calc(var(--space-xl) / 2);
|
||||
|
||||
padding: var(--space-xl) var(--space-lg) var(--half-bottom-spacing);
|
||||
margin-bottom: var(--half-bottom-spacing);
|
||||
background: var(--color-bg-primary);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
inset-inline: 0;
|
||||
height: var(--space-2xl);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--color-bg-primary),
|
||||
transparent
|
||||
);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity linear 200ms;
|
||||
}
|
||||
|
||||
&[data-stuck='true']::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.siteNameLink {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type React from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -9,11 +7,12 @@ import { customAppIcon, domain, title } from '@/mastodon/initial_state';
|
||||
|
||||
import classes from './header.module.scss';
|
||||
|
||||
export const NavigationHeader: React.FC<{ siteName?: string }> = ({
|
||||
siteName,
|
||||
}) => {
|
||||
export const NavigationHeader: React.FC<{
|
||||
siteName?: string;
|
||||
isStuck: boolean;
|
||||
}> = ({ siteName, isStuck }) => {
|
||||
return (
|
||||
<header className={classes.root}>
|
||||
<header className={classes.root} data-stuck={isStuck}>
|
||||
<Link to='/' className={classes.siteNameLink}>
|
||||
{customAppIcon && (
|
||||
<img src={customAppIcon} alt='' className={classes.appIcon} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import FediIcon from '@/images/icons/icon_fediverse.svg?react';
|
||||
import { fetchLists } from '@/mastodon/actions/lists';
|
||||
import { fetchFollowedHashtags } from '@/mastodon/actions/tags_typed';
|
||||
import { FOCUS_TARGET } from '@/mastodon/components/navigation_focus_target';
|
||||
import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import { openNewComposer } from '@/mastodon/reducers/slices/composer';
|
||||
import { getOrderedLists } from '@/mastodon/selectors/lists';
|
||||
@@ -81,12 +82,24 @@ export const RedesignNavigationPanel: React.FC<{ siteName?: string }> = ({
|
||||
const { customFeeds } = useCustomFeeds();
|
||||
const { followedHashtags } = useFollowedHashtags();
|
||||
|
||||
const { sensor: topSensor, isInViewport: isScrolledToTop } = useScrollSensor({
|
||||
placement: 'top',
|
||||
// Only show overlay fade after a bit of scrolling, as the nav header has
|
||||
// a bit of bottom spacing where the fade isn't needed yet
|
||||
tolerance: 36,
|
||||
});
|
||||
const { sensor: bottomSensor, isInViewport: isScrolledToBottom } =
|
||||
useScrollSensor({
|
||||
placement: 'bottom',
|
||||
});
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={classes.root}
|
||||
aria-label={intl.formatMessage(messages.main)}
|
||||
>
|
||||
<NavigationHeader siteName={siteName} />
|
||||
{topSensor}
|
||||
<NavigationHeader siteName={siteName} isStuck={!isScrolledToTop} />
|
||||
{signedIn && (
|
||||
<>
|
||||
<ul className={classes.list}>
|
||||
@@ -186,7 +199,7 @@ export const RedesignNavigationPanel: React.FC<{ siteName?: string }> = ({
|
||||
</ListSection>
|
||||
)}
|
||||
</ul>
|
||||
<footer className={classes.footer}>
|
||||
<footer className={classes.footer} data-stuck={!isScrolledToBottom}>
|
||||
<ul className={classes.footerNav}>
|
||||
<NavigationLink
|
||||
stacked
|
||||
@@ -223,6 +236,7 @@ export const RedesignNavigationPanel: React.FC<{ siteName?: string }> = ({
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
{bottomSensor}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@use '@/styles/mastodon/mixins';
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -18,8 +19,24 @@
|
||||
bottom: 0;
|
||||
margin-top: auto;
|
||||
padding: var(--space-xl);
|
||||
padding-top: 0;
|
||||
padding-top: var(--space-2xs);
|
||||
background-color: var(--color-bg-primary);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
inset-inline: 0;
|
||||
height: var(--space-2xl);
|
||||
background: linear-gradient(to top, var(--color-bg-primary), transparent);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity linear 200ms;
|
||||
}
|
||||
|
||||
&[data-stuck='true']::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.footerNav {
|
||||
|
||||
95
app/javascript/mastodon/hooks/useScrollSensor/index.tsx
Normal file
95
app/javascript/mastodon/hooks/useScrollSensor/index.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
interface ScrollSensorOptions extends ComponentProps<'div'> {
|
||||
placement?: 'top' | 'bottom';
|
||||
tolerance?: number;
|
||||
// Use document scrolling as reference
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `sensor` element and whether it's visible inside of its
|
||||
* nearest scrollable parent element (or the document if `global` is set).
|
||||
*
|
||||
* Useful for detecting whether a scrollable element was scrolled
|
||||
* and whether sticky elements are "stuck" (while we wait for CSS
|
||||
* scroll queries to be more widely supported) without having to rely on
|
||||
* scroll event listeners or resize and mutation observers.
|
||||
*
|
||||
* Place the sensor element directly into the scroll parent as the first
|
||||
* or last element, and ensure that the scroll parent uses a `position`
|
||||
* value other than `static`.
|
||||
*/
|
||||
export const useScrollSensor = ({
|
||||
placement = 'top',
|
||||
tolerance,
|
||||
global,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: ScrollSensorOptions = {}) => {
|
||||
const [isInViewport, setIsInViewport] = useState(true);
|
||||
const sensorRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sensor = sensorRef.current;
|
||||
|
||||
if (!sensor) return;
|
||||
|
||||
// Assign the IntersectionObserver to a ref so we don't
|
||||
// need to re-create it when the effect reruns
|
||||
if (!observerRef.current) {
|
||||
// This could be enhanced with a util to actually walk up
|
||||
// the tree to find the nearest scrollable element. For now,
|
||||
// it assumes the immediate parent as the scroll root.
|
||||
const root = global ? null : sensor.parentElement;
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry) {
|
||||
setIsInViewport(entry.isIntersecting);
|
||||
}
|
||||
},
|
||||
{
|
||||
root,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
observerRef.current.observe(sensor);
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.unobserve(sensor);
|
||||
}
|
||||
};
|
||||
}, [sensorRef, global]);
|
||||
|
||||
const sensorStyle = tolerance
|
||||
? ({
|
||||
...style,
|
||||
'--tolerance': `${tolerance}px`,
|
||||
} as CSSProperties)
|
||||
: style;
|
||||
|
||||
const sensor = (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames(classes.sensor, className)}
|
||||
style={sensorStyle}
|
||||
data-placement={placement}
|
||||
ref={sensorRef}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
sensor,
|
||||
isInViewport,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
.sensor {
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
|
||||
&[data-placement='top'] {
|
||||
position: absolute;
|
||||
top: var(--tolerance, 3px);
|
||||
}
|
||||
|
||||
&[data-placement='bottom'] {
|
||||
position: relative;
|
||||
bottom: var(--tolerance, 3px);
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user