@jetbrains/ring-ui 7.0.121 → 7.0.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,12 @@
1
1
  import React, { type PropsWithChildren } from 'react';
2
2
  interface Props {
3
+ /**
4
+ * Height of the always-visible preview of the collapsed content.
5
+ * The collapsed subtree is inert until expanded, matching `aria-expanded`.
6
+ * The preview is only a visual clip of that subtree, so it is inert too and
7
+ * assistive technology will not perceive it — if the preview carries essential
8
+ * information, expose an accessible summary outside the collapsed content.
9
+ */
3
10
  minHeight?: number;
4
11
  className?: string;
5
12
  'data-test'?: string | null | undefined;
@@ -10,11 +10,21 @@ const DURATION_FACTOR = 0.5;
10
10
  const DEFAULT_HEIGHT = 0;
11
11
  const VISIBLE = 1;
12
12
  const HIDDEN = 0;
13
+ // margin for the hide fallback timer, so it fires only if transitionend never did
14
+ const HIDE_FALLBACK_EXTRA_DELAY = 100;
15
+ // React 18 renders unknown attributes from strings only, while React 19 treats `inert`
16
+ // as a real boolean and removes the attribute when it gets ''
17
+ // TODO drop the React 18 branch in develop-8.0 — Ring UI 8.0 supports React 19 only
18
+ const REACT_MAJOR_WITH_INERT_SUPPORT = 19;
19
+ const getInertAttributeValue = (reactMajor) => reactMajor >= REACT_MAJOR_WITH_INERT_SUPPORT ? true : '';
20
+ // the cast is confined to the JSX assignment: React 18 actually receives the string form
21
+ const INERT = getInertAttributeValue(Number(React.version.split('.')[0]));
22
+ const isFullyCollapsed = (collapsed, initialContentHeight) => collapsed && initialContentHeight <= DEFAULT_HEIGHT;
13
23
  /**
14
24
  * @name CollapseContent
15
25
  */
16
26
  export const CollapseContent = ({ children, minHeight = DEFAULT_HEIGHT, 'data-test': dataTest, }) => {
17
- const { collapsed, duration, id, disableAnimation } = useContext(CollapseContext);
27
+ const { collapsed, duration, id, disableAnimation, keepMounted } = useContext(CollapseContext);
18
28
  const containerRef = useRef(null);
19
29
  const contentRef = useRef(null);
20
30
  const [initialContentHeight] = useState(minHeight);
@@ -23,20 +33,42 @@ export const CollapseContent = ({ children, minHeight = DEFAULT_HEIGHT, 'data-te
23
33
  const height = toPx(nextHeight);
24
34
  const [shouldHideContent, setShouldHideContent] = useState(collapsed && minHeight <= DEFAULT_HEIGHT);
25
35
  useEffect(() => {
26
- function onTransitionEnd() {
36
+ const container = containerRef.current;
37
+ function finalizeCollapse() {
27
38
  if (initialContentHeight <= DEFAULT_HEIGHT) {
28
39
  setShouldHideContent(collapsed);
29
40
  }
30
41
  }
31
- const container = containerRef.current;
42
+ function onTransitionEnd(event) {
43
+ // transitionend bubbles: only the container's own height transition finalizes the collapse
44
+ if (event.target === container && event.propertyName === 'height') {
45
+ finalizeCollapse();
46
+ }
47
+ }
32
48
  container?.addEventListener('transitionend', onTransitionEnd);
49
+ // fallback for suppressed or cancelled transitions (e.g. `transition: none` overrides),
50
+ // which emit no transitionend and would otherwise leave the content interactive forever.
51
+ // Armed once per collapse toggle from the --duration committed to the DOM — the value
52
+ // the running transition actually uses — so it can neither undercut a transition started
53
+ // from a stale height nor be restarted by content resizes or duration changes mid-collapse
54
+ // TODO merge with global/parse-css-duration when this lands in develop-8.0
55
+ const cssDuration = parseFloat(container?.style.getPropertyValue('--duration') || '') || 0;
56
+ const fallbackTimeout = window.setTimeout(finalizeCollapse, cssDuration + HIDE_FALLBACK_EXTRA_DELAY);
33
57
  return () => {
34
58
  container?.removeEventListener('transitionend', onTransitionEnd);
59
+ window.clearTimeout(fallbackTimeout);
35
60
  };
36
61
  }, [collapsed, initialContentHeight]);
62
+ // render-phase state adjustments (not effects): the React Compiler lint forbids
63
+ // setState-in-effect, and https://react.dev/learn/you-might-not-need-an-effect
64
+ // documents this pattern for resetting state when props change
37
65
  if (!collapsed && shouldHideContent) {
38
66
  setShouldHideContent(false);
39
67
  }
68
+ // without a transition there is no transitionend to wait for, so hide immediately
69
+ if (disableAnimation && !shouldHideContent && isFullyCollapsed(collapsed, initialContentHeight)) {
70
+ setShouldHideContent(true);
71
+ }
40
72
  useEffect(() => {
41
73
  if (contentRef.current) {
42
74
  const observer = new ResizeObserver(() => {
@@ -52,10 +84,20 @@ export const CollapseContent = ({ children, minHeight = DEFAULT_HEIGHT, 'data-te
52
84
  opacity: collapsed && !minHeight ? HIDDEN : VISIBLE,
53
85
  };
54
86
  const fadeShouldBeVisible = Boolean(minHeight && collapsed);
55
- const shouldRenderContent = disableAnimation ? !collapsed : !shouldHideContent;
87
+ const contentVisible = !shouldHideContent;
88
+ const contentHidden = Boolean(keepMounted) && !contentVisible;
89
+ // interaction is blocked as soon as the panel collapses (matching aria-expanded), while
90
+ // visibility waits for the animation to finish so the content stays painted meanwhile.
91
+ // This includes a minHeight preview: content clipped below it must not take focus,
92
+ // and inert cannot be applied any more granularly than to the whole subtree
93
+ const contentInert = collapsed;
56
94
  return (<div ref={containerRef} id={`collapse-content-${id}`} data-test={dataTests(COLLAPSE_CONTENT_CONTAINER_TEST_ID)} className={classNames(styles.container, { [styles.transition]: !disableAnimation })} style={style}>
57
- <div ref={contentRef} data-test={dataTests(COLLAPSE_CONTENT_TEST_ID, dataTest)}>
58
- {shouldRenderContent ? children : null}
95
+ <div ref={contentRef} data-test={dataTests(COLLAPSE_CONTENT_TEST_ID, dataTest)}
96
+ // visibility: hidden removes the fully collapsed content from the tab order and accessibility
97
+ // tree, but descendants can override it with visibility: visible — inert cannot be escaped.
98
+ // Both are rendered in JSX so server-rendered collapsed markup is protected before hydration.
99
+ style={contentHidden ? { visibility: 'hidden' } : undefined} inert={contentInert ? INERT : undefined}>
100
+ {keepMounted || contentVisible ? children : null}
59
101
  </div>
60
102
  {fadeShouldBeVisible && <div className={styles.fade}/>}
61
103
  </div>);
@@ -2,6 +2,7 @@ interface CollapseContextInterface {
2
2
  collapsed: boolean;
3
3
  duration: number;
4
4
  disableAnimation: boolean;
5
+ keepMounted?: boolean;
5
6
  setCollapsed: () => void;
6
7
  id: string;
7
8
  }
@@ -4,6 +4,7 @@ export const CollapseContext = createContext({
4
4
  collapsed: true,
5
5
  duration: BASE_ANIMATION_DURATION,
6
6
  disableAnimation: false,
7
+ keepMounted: false,
7
8
  setCollapsed: () => { },
8
9
  id: '',
9
10
  });
@@ -4,6 +4,15 @@ interface Props {
4
4
  onChange?: (collapsed: boolean) => void;
5
5
  duration?: number;
6
6
  disableAnimation?: boolean;
7
+ /**
8
+ * Keep children mounted while collapsed (hidden via `visibility: hidden`
9
+ * and the `inert` attribute) instead of unmounting them, preserving their state.
10
+ * Note: hidden form controls still participate in form validation
11
+ * and submission — disable them while collapsed if needed.
12
+ * Content rendered through portals (e.g. Popup) escapes the hidden
13
+ * wrapper and is not hidden — close overlays on collapse.
14
+ */
15
+ keepMounted?: boolean;
7
16
  className?: string;
8
17
  defaultCollapsed?: boolean;
9
18
  collapsed?: boolean | null;
@@ -5,7 +5,7 @@ import { BASE_ANIMATION_DURATION } from './consts';
5
5
  /**
6
6
  * @name Collapse
7
7
  */
8
- export const Collapse = ({ children, duration = BASE_ANIMATION_DURATION, disableAnimation = false, className = '', onChange = () => { }, defaultCollapsed = true, collapsed = null, }) => {
8
+ export const Collapse = ({ children, duration = BASE_ANIMATION_DURATION, disableAnimation = false, keepMounted = false, className = '', onChange = () => { }, defaultCollapsed = true, collapsed = null, }) => {
9
9
  const [innerCollapsed, setInnerCollapsed] = useState(defaultCollapsed);
10
10
  const id = useId();
11
11
  const finalCollapsedValue = collapsed ?? innerCollapsed;
@@ -19,6 +19,7 @@ export const Collapse = ({ children, duration = BASE_ANIMATION_DURATION, disable
19
19
  setCollapsed,
20
20
  duration,
21
21
  disableAnimation,
22
+ keepMounted,
22
23
  id,
23
24
  }}>
24
25
  {children}
@@ -37,6 +37,12 @@
37
37
  height: 28px;
38
38
  }
39
39
 
40
+ .heading {
41
+ margin: 0;
42
+
43
+ font: inherit;
44
+ }
45
+
40
46
  .headerButton {
41
47
  display: block;
42
48
 
@@ -10,6 +10,19 @@ export interface CollapsibleGroupProps {
10
10
  onChange?: (expanded: boolean) => void;
11
11
  disableAnimation?: boolean;
12
12
  interactive?: boolean;
13
+ /**
14
+ * Keep children mounted while collapsed (hidden via `visibility: hidden`
15
+ * and the `inert` attribute) instead of unmounting them, preserving their state.
16
+ * Note: hidden form controls still participate in form validation
17
+ * and submission — disable them while collapsed if needed.
18
+ * Content rendered through portals (e.g. Popup) escapes the hidden
19
+ * wrapper and is not hidden — close overlays on collapse.
20
+ */
21
+ keepMounted?: boolean;
22
+ /**
23
+ * Wraps the header in an <h2>–<h6> to keep the document outline.
24
+ */
25
+ headingLevel?: 2 | 3 | 4 | 5 | 6;
13
26
  'data-test'?: string | null | undefined;
14
27
  }
15
28
  declare const CollapsibleGroup: React.ForwardRefExoticComponent<CollapsibleGroupProps & React.RefAttributes<HTMLDivElement>>;
@@ -36,7 +36,7 @@ function CollapsibleGroupHeaderStatic({ avatar, titleContent, subtitle }) {
36
36
  <CollapsibleGroupHeaderContent avatar={avatar} titleContent={titleContent} subtitle={subtitle}/>
37
37
  </span>);
38
38
  }
39
- const CollapsibleGroup = forwardRef(({ avatar, title, subtitle, children, className, defaultExpanded = false, expanded = null, onChange = () => { }, disableAnimation = false, interactive = true, 'data-test': dataTest, }, ref) => {
39
+ const CollapsibleGroup = forwardRef(({ avatar, title, subtitle, children, className, defaultExpanded = false, expanded = null, onChange = () => { }, disableAnimation = false, interactive = true, keepMounted = false, headingLevel, 'data-test': dataTest, }, ref) => {
40
40
  const [innerExpanded, setInnerExpanded] = useState(defaultExpanded);
41
41
  const [hovered, setHovered] = useState(false);
42
42
  const [focused, setFocused] = useState(false);
@@ -60,9 +60,10 @@ const CollapsibleGroup = forwardRef(({ avatar, title, subtitle, children, classN
60
60
  [styles.expanded]: isExpanded,
61
61
  [styles.focused]: focused,
62
62
  });
63
+ const header = interactive ? (<CollapsibleGroupHeader avatar={avatar} titleContent={title} subtitle={subtitle} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={() => setFocused(true)} onBlur={onBlur}/>) : (<CollapsibleGroupHeaderStatic avatar={avatar} titleContent={title} subtitle={subtitle}/>);
63
64
  return (<div ref={ref} className={classes} data-test={dataTest}>
64
- <Collapse defaultCollapsed={!defaultExpanded} collapsed={expanded == null ? null : !expanded} onChange={handleChange} disableAnimation={disableAnimation} className={styles.collapseRoot}>
65
- {interactive ? (<CollapsibleGroupHeader avatar={avatar} titleContent={title} subtitle={subtitle} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={() => setFocused(true)} onBlur={onBlur}/>) : (<CollapsibleGroupHeaderStatic avatar={avatar} titleContent={title} subtitle={subtitle}/>)}
65
+ <Collapse defaultCollapsed={!defaultExpanded} collapsed={expanded == null ? null : !expanded} onChange={handleChange} disableAnimation={disableAnimation} keepMounted={keepMounted} className={styles.collapseRoot}>
66
+ {headingLevel != null ? React.createElement(`h${headingLevel}`, { className: styles.heading }, header) : header}
66
67
  <CollapseContent>
67
68
  <div className={styles.body}>{children}</div>
68
69
  </CollapseContent>
@@ -27,6 +27,10 @@
27
27
  composes: header from '../collapsible-group/collapsible-group.css';
28
28
  }
29
29
 
30
+ .heading {
31
+ composes: heading from '../collapsible-group/collapsible-group.css';
32
+ }
33
+
30
34
  .headerButton {
31
35
  composes: headerButton from '../collapsible-group/collapsible-group.css';
32
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetbrains/ring-ui",
3
- "version": "7.0.121",
3
+ "version": "7.0.122",
4
4
  "description": "JetBrains UI library",
5
5
  "author": {
6
6
  "name": "JetBrains"