diff --git a/src/content/app/genome-browser/components/browser-bar/BrowserBar.module.css b/src/content/app/genome-browser/components/browser-bar/BrowserBar.module.css index 718523c659..73945ddeed 100644 --- a/src/content/app/genome-browser/components/browser-bar/BrowserBar.module.css +++ b/src/content/app/genome-browser/components/browser-bar/BrowserBar.module.css @@ -1,15 +1,18 @@ .browserBar { - display: flex; + display: grid; + grid-template-columns: [browser-reset] auto [feature-summary] 1fr [empty] 20px [location-indicator] auto; /* "empty" column plus two gaps around it should produce 60px of white space */ + column-gap: 20px; width: 100%; align-items: center; position: relative; padding-left: 18px; } -.browserResetWrapper { - margin-right: 20px; +.featureSummaryStrip { + justify-self: start; + max-width: 100%; } -.browserLocationIndicatorWrapper { - margin-left: auto; +.browserLocationIndicator { + grid-column: location-indicator; } diff --git a/src/content/app/genome-browser/components/browser-bar/BrowserBar.tsx b/src/content/app/genome-browser/components/browser-bar/BrowserBar.tsx index 9e871b5f60..c0fccd2a38 100644 --- a/src/content/app/genome-browser/components/browser-bar/BrowserBar.tsx +++ b/src/content/app/genome-browser/components/browser-bar/BrowserBar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useRef } from 'react'; import { useAppSelector } from 'src/store'; @@ -31,6 +31,9 @@ export const BrowserBar = () => { const focusObject = useAppSelector(getBrowserActiveFocusObject); const isDrawerOpened = useAppSelector(getIsDrawerOpened); + const browserBarRef = useRef(null); + const featureSummaryRef = useRef(null); + // return empty div instead of null, so that the dedicated slot in the CSS grid of StandardAppLayout // always contains a child DOM element if (!focusObject) { @@ -38,19 +41,21 @@ export const BrowserBar = () => { } return ( -
-
- -
+
+ {focusObject && ( )} -
- -
+
); }; diff --git a/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.module.css b/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.module.css index a55069a5cf..b4b29b4173 100644 --- a/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.module.css +++ b/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.module.css @@ -3,7 +3,6 @@ align-items: center; white-space: nowrap; flex-wrap: nowrap; - line-height: 1; font-size: 14px; } @@ -22,14 +21,14 @@ align-items: center; } -.chrCode { - background: var(--color-dark-grey); - color: var(--color-white); +.regionNameContainer { display: inline-block; - font-weight: var(--font-weight-bold); - margin: 0 0.5em; - padding: 5px; - text-align: center; + position: relative; + margin-right: 0.5em; +} + +.regionName { + color: var(--color-medium-dark-grey); } .chrSeparator { @@ -40,3 +39,18 @@ color: var(--color-dark-grey); display: inline-block; } + +.probeAnchor { + position: absolute; + top: 0; + right: 0; + height: calc(14px * 1.5); /* font size of the element times the line height */ +} + +.probe { + position: absolute; + top: 0; + right: 0; + color: red; + visibility: hidden; +} diff --git a/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.test.tsx b/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.test.tsx index 31d05d89f8..cd7646cb6d 100644 --- a/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.test.tsx +++ b/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.test.tsx @@ -127,7 +127,7 @@ describe('BrowserLocationIndicator', () => { it('displays chromosome name', async () => { const { container } = renderBrowserLocationIndicator(); await waitFor(() => { - const renderedName = container.querySelector('.chrCode'); + const renderedName = container.querySelector('.regionName'); expect(renderedName?.textContent).toBe(humanChromosomeName); }); }); diff --git a/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.tsx b/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.tsx index 8942e89df2..eb8fee5468 100644 --- a/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.tsx +++ b/src/content/app/genome-browser/components/browser-location-indicator/BrowserLocationIndicator.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState, useEffect, useRef, type RefObject } from 'react'; +import classNames from 'classnames'; import { formatNumber } from 'src/shared/helpers/formatters/numberFormatter'; @@ -26,29 +27,61 @@ import { getActualChrLocation } from 'src/content/app/genome-browser/state/brows import styles from './BrowserLocationIndicator.module.css'; -export const BrowserLocationIndicator = () => { +type Props = { + className?: string; + containerRef?: RefObject; + nonOverlapElementRef?: RefObject; +}; + +export const BrowserLocationIndicator = (props: Props) => { + const shouldCheckProximity = props.containerRef && props.nonOverlapElementRef; + const [shouldShowRegionName, setShouldShowRegionName] = useState( + !shouldCheckProximity + ); // start with false if component needs to figure out how close it is to its neightbor on the left const actualChrLocation = useAppSelector(getActualChrLocation); const activeGenomeId = useAppSelector(getBrowserActiveGenomeId) as string; const { data: genomeKaryotype } = useGenomeKaryotypeQuery(activeGenomeId); - const [chrCode, chrStart, chrEnd] = actualChrLocation || []; + const [regionName, chrStart, chrEnd] = actualChrLocation || []; - if (!chrCode || !chrStart || !chrEnd || !activeGenomeId) { + if (!regionName || !chrStart || !chrEnd || !activeGenomeId) { return null; } const activeChromosome = genomeKaryotype?.find((karyotype) => { - return karyotype.name === chrCode; + return karyotype.name === regionName; }); + const componentClasses = classNames( + styles.browserLocationIndicator, + props.className + ); + + const onRegionNameVisibilityChange = (shouldShowRegionName: boolean) => { + setShouldShowRegionName(shouldShowRegionName); + }; + return ( -
+
{activeChromosome?.is_circular ? ( ) : ( -
{chrCode}
+
+ {shouldShowRegionName && ( + {regionName} + )} + {props.nonOverlapElementRef && props.containerRef && ( + + )} +
)}
{formatNumber(chrStart as number)} @@ -60,6 +93,72 @@ export const BrowserLocationIndicator = () => { ); }; +const ProximitySensor = ({ + regionName, + containerRef, + nonOverlapElementRef, + isRegionNameVisible, + onRegionNameVisibilityChange +}: { + regionName: string; + containerRef: RefObject; + nonOverlapElementRef: RefObject; + isRegionNameVisible: boolean; + onRegionNameVisibilityChange: (x: boolean) => void; +}) => { + const probeRef = useRef(null); + const isRegionNameVisibleRef = useRef(isRegionNameVisible); + const minDistanceToLeft = 60; + + useEffect(() => { + if (!probeRef.current) { + return; + } + + const resizeObserver = new ResizeObserver(onContainerResize); + resizeObserver.observe(containerRef.current as HTMLElement); + + return () => { + resizeObserver.disconnect(); + }; + }, [probeRef.current, nonOverlapElementRef.current]); + + useEffect(() => { + isRegionNameVisibleRef.current = isRegionNameVisible; + }, [isRegionNameVisible]); + + const onContainerResize = () => { + const isTooClose = isTooCloseToLeft(); + const isRegionNameVisible = isRegionNameVisibleRef.current; + if (isTooClose && isRegionNameVisible) { + onRegionNameVisibilityChange(false); + } else if (!isTooClose && !isRegionNameVisible) { + onRegionNameVisibilityChange(true); + } + }; + + const isTooCloseToLeft = () => { + const featureSummaryStrip = nonOverlapElementRef.current as HTMLElement; + const probe = probeRef.current as HTMLElement; + const featureSummaryStripRightCoord = + featureSummaryStrip.getBoundingClientRect().right; + const isfeatureSummaryStripOverflowing = + featureSummaryStrip.scrollWidth > featureSummaryStrip.clientWidth; + const probeLeftCoord = probe.getBoundingClientRect().left; + const distance = probeLeftCoord - featureSummaryStripRightCoord; + + return distance < minDistanceToLeft || isfeatureSummaryStripOverflowing; + }; + + return ( +
+ + {regionName} + +
+ ); +}; + const CircularChromosomeIndicator = () => { return
; }; diff --git a/src/shared/components/feature-summary-strip/FeatureSummaryStrip.module.css b/src/shared/components/feature-summary-strip/FeatureSummaryStrip.module.css index 7c0be2f6c3..00e252cf56 100644 --- a/src/shared/components/feature-summary-strip/FeatureSummaryStrip.module.css +++ b/src/shared/components/feature-summary-strip/FeatureSummaryStrip.module.css @@ -1,19 +1,17 @@ -.featureSummaryStripWrapper { - flex-grow: 1; -} - .featureSummaryStrip { font-size: 14px; - display: flex; - align-items: center; white-space: nowrap; flex-wrap: nowrap; - line-height: 1; overflow: hidden; + text-overflow: ellipsis; +} + +.section { + display: inline; } -.featureSummaryStrip > div:not(:last-child) { - margin-right: 30px; +.section:not(:first-child) { + margin-left: 30px; } .featureSummaryStripGhosted { diff --git a/src/shared/components/feature-summary-strip/FeatureSummaryStrip.test.tsx b/src/shared/components/feature-summary-strip/FeatureSummaryStrip.test.tsx index a54c8a01af..c3865383a1 100644 --- a/src/shared/components/feature-summary-strip/FeatureSummaryStrip.test.tsx +++ b/src/shared/components/feature-summary-strip/FeatureSummaryStrip.test.tsx @@ -24,10 +24,14 @@ import { import { createFocusObject } from 'tests/fixtures/focus-object'; -jest.mock('../feature-summary-strip', () => ({ - GeneSummaryStrip: () =>
Gene Summary Strip
, - LocationSummaryStrip: () =>
Location Summary Strip
-})); +jest.mock('./GeneSummaryStrip', () => { + const { forwardRef } = jest.requireActual('react'); + return forwardRef(() =>
Gene Summary Strip
); +}); +jest.mock('./LocationSummaryStrip', () => { + const { forwardRef } = jest.requireActual('react'); + return forwardRef(() =>
Location Summary Strip
); +}); describe('', () => { const defaultProps = { diff --git a/src/shared/components/feature-summary-strip/FeatureSummaryStrip.tsx b/src/shared/components/feature-summary-strip/FeatureSummaryStrip.tsx index ef85cb48e6..d05c639df1 100644 --- a/src/shared/components/feature-summary-strip/FeatureSummaryStrip.tsx +++ b/src/shared/components/feature-summary-strip/FeatureSummaryStrip.tsx @@ -14,38 +14,57 @@ * limitations under the License. */ -import React from 'react'; +import React, { forwardRef, type ForwardedRef } from 'react'; -import { - GeneSummaryStrip, - LocationSummaryStrip, - VariantSummaryStrip -} from '../feature-summary-strip'; +import GeneSummaryStrip from './GeneSummaryStrip'; +import LocationSummaryStrip from './LocationSummaryStrip'; +import VariantSummaryStrip from './VariantSummaryStrip'; import type { FocusObject } from 'src/shared/types/focus-object/focusObjectTypes'; export type FeatureSummaryStripProps = { focusObject: FocusObject; + className?: string; isGhosted?: boolean; }; -export const FeatureSummaryStrip = (props: FeatureSummaryStripProps) => { +export const FeatureSummaryStrip = ( + props: FeatureSummaryStripProps, + ref: ForwardedRef +) => { const { focusObject, isGhosted } = props; switch (focusObject.type) { case 'gene': - return ; + return ( + + ); case 'location': return ( - + ); case 'variant': return ( - + ); default: return null; } }; -export default FeatureSummaryStrip; +export default forwardRef(FeatureSummaryStrip); diff --git a/src/shared/components/feature-summary-strip/GeneSummaryStrip.tsx b/src/shared/components/feature-summary-strip/GeneSummaryStrip.tsx index d5cfc48d16..ac853cd2a4 100644 --- a/src/shared/components/feature-summary-strip/GeneSummaryStrip.tsx +++ b/src/shared/components/feature-summary-strip/GeneSummaryStrip.tsx @@ -14,11 +14,9 @@ * limitations under the License. */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { forwardRef, type ForwardedRef } from 'react'; import classNames from 'classnames'; -import useResizeObserver from 'src/shared/hooks/useResizeObserver'; - import { getDisplayStableId } from 'src/shared/helpers/focusObjectHelpers'; import { getFormattedLocation } from 'src/shared/helpers/formatters/regionFormatter'; import { getStrandDisplayName } from 'src/shared/helpers/formatters/strandFormatter'; @@ -27,15 +25,6 @@ import { FocusGene } from 'src/shared/types/focus-object/focusObjectTypes'; import styles from './FeatureSummaryStrip.module.css'; -const MEDIUM_WIDTH = 800; -const SMALL_WIDTH = 500; - -enum Display { - FULL = 'full', - COMPACT = 'compact', - MINIMAL = 'minimal' -} - type GeneFields = | 'bio_type' | 'label' @@ -48,68 +37,37 @@ type Gene = Pick; type Props = { gene: Gene; isGhosted?: boolean; + className?: string; }; -type WidthAwareProps = Props & { - display: Display; -}; - -const GeneSummaryWrapper = (props: Props) => { - const [display, setDisplay] = useState(Display.MINIMAL); - const containerRef = useRef(null); - const { width: containerWidth } = useResizeObserver({ ref: containerRef }); +const GeneSummaryStrip = (props: Props, ref: ForwardedRef) => { + const { gene, isGhosted } = props; - useEffect(() => { - if (containerWidth < SMALL_WIDTH) { - display !== Display.MINIMAL && setDisplay(Display.MINIMAL); - } else if (containerWidth < MEDIUM_WIDTH) { - display !== Display.COMPACT && setDisplay(Display.COMPACT); - } else { - display !== Display.FULL && setDisplay(Display.FULL); - } - }, [containerWidth]); + const stripClasses = classNames(styles.featureSummaryStrip, props.className, { + [styles.featureSummaryStripGhosted]: isGhosted + }); return ( -
- +
+ + + {gene.strand && ( +
+ {getStrandDisplayName(gene.strand)} +
+ )} +
+ {getFormattedLocation(gene.location)} +
); }; -const GeneSummaryStrip = ({ gene, isGhosted, display }: WidthAwareProps) => { - const stripClasses = classNames(styles.featureSummaryStrip, { - [styles.featureSummaryStripGhosted]: isGhosted - }); - - let content; - - if (display === Display.MINIMAL) { - content = ; - } else if (display === Display.COMPACT) { - content = ; - } else { - content = ; - } - - return
{content}
; -}; - -const MinimalContent = ({ gene }: { gene: Gene }) => ( - <> - Gene - {gene.label ? ( - {gene.label} - ) : ( - {getDisplayStableId(gene)} - )} - -); - -const CompactContent = ({ gene }: { gene: Gene }) => { +const GeneName = ({ gene }: Props) => { const stableId = getDisplayStableId(gene); return ( -
+
Gene {gene.label && ( {gene.label} @@ -119,18 +77,15 @@ const CompactContent = ({ gene }: { gene: Gene }) => { ); }; -const FullContent = ({ gene }: { gene: Gene }) => ( - <> - - {gene.bio_type && ( -
+const Biotype = ({ gene }: Props) => { + return ( + gene.bio_type && ( +
Biotype {gene.bio_type}
- )} - {gene.strand &&
{getStrandDisplayName(gene.strand)}
} -
{getFormattedLocation(gene.location)}
- -); + ) + ); +}; -export default GeneSummaryWrapper; +export default forwardRef(GeneSummaryStrip); diff --git a/src/shared/components/feature-summary-strip/LocationSummaryStrip.tsx b/src/shared/components/feature-summary-strip/LocationSummaryStrip.tsx index 4099ea7393..d4a5f0cbc7 100644 --- a/src/shared/components/feature-summary-strip/LocationSummaryStrip.tsx +++ b/src/shared/components/feature-summary-strip/LocationSummaryStrip.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { forwardRef, type ForwardedRef } from 'react'; import classNames from 'classnames'; import { getFormattedLocation } from 'src/shared/helpers/formatters/regionFormatter'; @@ -26,14 +26,19 @@ import { FocusLocation } from 'src/shared/types/focus-object/focusObjectTypes'; type Props = { location: FocusLocation; isGhosted?: boolean; + className?: string; }; -const LocationSummaryStrip = ({ location, isGhosted }: Props) => { - const stripClasses = classNames(styles.featureSummaryStrip, { +const LocationSummaryStrip = ( + props: Props, + ref: ForwardedRef +) => { + const { location, isGhosted } = props; + const stripClasses = classNames(styles.featureSummaryStrip, props.className, { [styles.featureSummaryStripGhosted]: isGhosted }); return ( -
+
Location: {getFormattedLocation(location.location)} @@ -42,4 +47,4 @@ const LocationSummaryStrip = ({ location, isGhosted }: Props) => { ); }; -export default LocationSummaryStrip; +export default forwardRef(LocationSummaryStrip); diff --git a/src/shared/components/feature-summary-strip/VariantSummaryStrip.tsx b/src/shared/components/feature-summary-strip/VariantSummaryStrip.tsx index 79b60430ae..c2ac8f9891 100644 --- a/src/shared/components/feature-summary-strip/VariantSummaryStrip.tsx +++ b/src/shared/components/feature-summary-strip/VariantSummaryStrip.tsx @@ -14,11 +14,13 @@ * limitations under the License. */ -import React, { useState, useEffect, useRef, type ComponentProps } from 'react'; +import React, { + forwardRef, + type ComponentProps, + type ForwardedRef +} from 'react'; import classNames from 'classnames'; -import useResizeObserver from 'src/shared/hooks/useResizeObserver'; - import VariantConsequence from 'src/content/app/genome-browser/components/drawer/drawer-views/variant-summary/variant-consequence/VariantConsequence'; import VariantAllelesSequences from 'src/shared/components/variant-alleles-sequences/VariantAllelesSequences'; import VariantLocation from 'src/content/app/genome-browser/components/drawer/drawer-views/variant-summary/variant-location/VariantLocation'; @@ -29,77 +31,42 @@ import type { FocusVariant } from 'src/shared/types/focus-object/focusObjectType import styles from './FeatureSummaryStrip.module.css'; -const MEDIUM_WIDTH = 720; - -enum Display { - FULL = 'full', - MINIMAL = 'minimal' -} - -const VariantSummaryWrapper = (props: { - variant: FocusVariant; - isGhosted?: boolean; -}) => { - const [display, setDisplay] = useState(Display.MINIMAL); - const containerRef = useRef(null); - const { width: containerWidth } = useResizeObserver({ ref: containerRef }); +export type VariantForSummaryStrip = ComponentProps< + typeof VariantConsequence +>['variant'] & + ComponentProps['variant'] & { + name: string; + alleles: ComponentProps['alleles']; + }; - const { genome_id: genomeId, variant_id: variantId } = props.variant; +const VariantSummaryStrip = ( + props: { + variant: FocusVariant; + isGhosted?: boolean; + className?: string; + }, + ref: ForwardedRef +) => { + const { variant, isGhosted } = props; + const { genome_id: genomeId, variant_id: variantId } = variant; const { currentData: variantData } = useGbVariantQuery({ genomeId, variantId }); - useEffect(() => { - if (containerWidth < MEDIUM_WIDTH) { - display !== Display.MINIMAL && setDisplay(Display.MINIMAL); - } else { - display !== Display.FULL && setDisplay(Display.FULL); - } - }, [containerWidth]); - if (!variantData) { return null; } - return ( -
- -
- ); -}; - -export type VariantForSummaryStrip = ComponentProps< - typeof VariantConsequence ->['variant'] & - ComponentProps['variant'] & { - name: string; - alleles: ComponentProps['alleles']; - }; - -const VariantSummaryStrip = (props: { - variant: VariantForSummaryStrip; - isGhosted?: boolean; - display: Display; -}) => { - const { variant, isGhosted, display } = props; - const stripClasses = classNames(styles.featureSummaryStrip, { + const stripClasses = classNames(styles.featureSummaryStrip, props.className, { [styles.featureSummaryStripGhosted]: isGhosted }); - let content; - - if (display === Display.MINIMAL) { - content = ; - } else { - content = ; - } - - return
{content}
; + return ( +
+ +
+ ); }; const MinimalContent = ({ variant }: { variant: VariantForSummaryStrip }) => ( @@ -135,4 +102,4 @@ const FullContent = ({ variant }: { variant: VariantForSummaryStrip }) => { ); }; -export default VariantSummaryWrapper; +export default forwardRef(VariantSummaryStrip);