/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/components/elements/Expander/Expander.tsx
271 строка
8 KB
Lukas Masuch
Exclude collapsed expander content from browser find-in-page (Cmd+F) (#13818)
04 фев 2026, 19:14
Не верифицирован
04 фев 2026, 19:14
b86dad7
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { memo, ReactElement, useEffect, useRef, useState } from "react" import { Block as BlockProto } from "@streamlit/protobuf" import { DynamicIcon } from "~lib/components/shared/Icon" import StreamlitMarkdown from "~lib/components/shared/StreamlitMarkdown" import { notNullOrUndefined } from "~lib/util/utils" import { BORDER_SIZE, StyledDetails, StyledDetailsPanel, StyledExpandableContainer, StyledSummary, StyledSummaryHeading, StyledSummaryLabelWrapper, } from "./styled-components" export interface ExpanderIconProps { icon?: string } /** * Renders an icon for the expander and optionally a user-defined icon. * * If the icon is "spinner", it will render a spinner icon. * If the icon is a valid, user-defined icon, it will render the user-defined icon. * Otherwise, it will render nothing. * * @param {string} icon - The icon to render. * @returns {ReactElement} */ export const ExpanderIcon = (props: ExpanderIconProps): ReactElement => { const { icon } = props const statusIconTestIds: Record<string, string> = { ":material/check:": "stExpanderIconCheck", ":material/error:": "stExpanderIconError", spinner: "stExpanderIconSpinner", } return icon ? ( <DynamicIcon size="lg" iconValue={icon} testid={statusIconTestIds[icon] || "stExpanderIcon"} /> ) : ( <></> ) } export interface ExpanderProps { element: BlockProto.Expandable isStale: boolean } const Expander: React.FC<React.PropsWithChildren<ExpanderProps>> = ({ element, isStale, children, }): ReactElement => { const { label, expanded: initialExpanded } = element const [expanded, setExpanded] = useState<boolean>(initialExpanded || false) const [isHovered, setIsHovered] = useState(false) const detailsRef = useRef<HTMLDetailsElement>(null) const summaryRef = useRef<HTMLElement>(null) const animationRef = useRef<Animation | null>(null) const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const contentRef = useRef<HTMLDivElement>(null) useEffect(() => { // Only apply the expanded state if it was actually set in the proto. if (notNullOrUndefined(initialExpanded)) { // eslint-disable-next-line react-hooks/set-state-in-effect -- TODO: Do not set state in effect setExpanded(initialExpanded) // We manage the open attribute via the detailsRef and not with React state if (detailsRef.current) { detailsRef.current.open = initialExpanded } } // Having `label` in the dependency array here is necessary because // sometimes two distinct expanders look so similar that even the react // diffing algorithm decides that they're the same element with updated // props (this happens when something in the app removes one expander and // replaces it with another in the same position). // // By adding `label` as a dependency, we ensure that we reset the // expander's `expanded` state in this edge case. }, [label, initialExpanded]) const onAnimationFinish = (open: boolean): void => { if (!detailsRef.current) { return } detailsRef.current.open = open animationRef.current = null detailsRef.current.style.height = "" detailsRef.current.style.overflow = "" } const toggleAnimation = ( detailsEl: HTMLDetailsElement, startHeight: number, endHeight: number ): void => { const isOpen = endHeight > startHeight if (animationRef.current) { animationRef.current.cancel() if (timeoutRef.current) { clearTimeout(timeoutRef.current) timeoutRef.current = null } } const animation = detailsEl.animate( { height: [`${startHeight}px`, `${endHeight}px`], }, { duration: 500, easing: "cubic-bezier(0.23, 1, 0.32, 1)", } ) animation.addEventListener("finish", () => onAnimationFinish(isOpen)) animationRef.current = animation } const toggle = (e: React.MouseEvent<HTMLDetailsElement>): void => { e.preventDefault() setExpanded(!expanded) const detailsEl = detailsRef.current if (!detailsEl || !summaryRef.current) { return } detailsEl.style.overflow = "hidden" // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage const detailsHeight = detailsEl.getBoundingClientRect().height // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage const summaryHeight = summaryRef.current.getBoundingClientRect().height if (!expanded) { detailsEl.style.height = `${detailsHeight}px` detailsEl.open = true window.requestAnimationFrame(() => { // For expansion animations, we rely on the rendered width and height // of the children content. However, in Safari, the children are not // rendered because Safari doesn't paint elements that are not visible // (in this case, the details element is not visible because it's // not open). This operation produces inconsistent heights to animate. // To work around this, we force a repaint by animating a tiny bit // and animate the rest of it later. toggleAnimation( detailsEl, detailsHeight, summaryHeight + 2 * BORDER_SIZE + 5 // Arbitrary size of 5px ) timeoutRef.current = setTimeout(() => { if (!contentRef.current) { return } const contentHeight = // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage contentRef.current.getBoundingClientRect().height toggleAnimation( detailsEl, detailsHeight, summaryHeight + contentHeight + 2 * BORDER_SIZE ) }, 100) }) } else { toggleAnimation( detailsEl, detailsHeight, summaryHeight + 2 * BORDER_SIZE ) } } const handleMouseEnter = (): void => { setIsHovered(true) } const handleMouseLeave = (): void => { setIsHovered(false) } // Determine which icon to show const showChevron = !element.icon || isHovered const showUserIcon = element.icon && !isHovered return ( <StyledExpandableContainer className="stExpander" data-testid="stExpander"> <StyledDetails isStale={isStale} ref={detailsRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > <StyledSummary onClick={toggle} ref={summaryRef} isStale={isStale} expanded={expanded} > <StyledSummaryHeading> {showChevron && ( <DynamicIcon iconValue={ expanded ? ":material/keyboard_arrow_down:" : ":material/keyboard_arrow_right:" } size="lg" /> )} {showUserIcon && <ExpanderIcon icon={element.icon} />} <StyledSummaryLabelWrapper> <StreamlitMarkdown source={label} allowHTML={false} isLabel largerLabel /> </StyledSummaryLabelWrapper> </StyledSummaryHeading> </StyledSummary> <StyledDetailsPanel data-testid="stExpanderDetails" ref={contentRef} // Exclude collapsed content from browser find-in-page (Cmd+F) searches. // Using "" instead of true for consistent behavior in jsdom tests. inert={!expanded ? "" : undefined} > {children} </StyledDetailsPanel> </StyledDetails> </StyledExpandableContainer> ) } export default memo(Expander)