outline-item.js 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. import { useState, useEffect, createRef, useRef } from 'react'
  2. import PropTypes from 'prop-types'
  3. import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
  4. import classNames from 'classnames'
  5. import { useTranslation } from 'react-i18next'
  6. import OutlineList from './outline-list'
  7. import Icon from '../../../shared/components/icon'
  8. function getChildrenLines(children) {
  9. return (children || [])
  10. .map(child => {
  11. return getChildrenLines(child.children).concat(child.line)
  12. })
  13. .flat()
  14. }
  15. function OutlineItem({ outlineItem, jumpToLine, highlightedLine }) {
  16. const { t } = useTranslation()
  17. const [expanded, setExpanded] = useState(true)
  18. const titleElementRef = createRef()
  19. const isHighlightedRef = useRef(false)
  20. const mainItemClasses = classNames('outline-item', {
  21. 'outline-item-no-children': !outlineItem.children,
  22. })
  23. const hasHighlightedChild =
  24. !expanded &&
  25. getChildrenLines(outlineItem.children).includes(highlightedLine)
  26. const isHighlighted =
  27. highlightedLine === outlineItem.line || hasHighlightedChild
  28. const itemLinkClasses = classNames('outline-item-link', {
  29. 'outline-item-link-highlight': isHighlighted,
  30. })
  31. function handleExpandCollapseClick() {
  32. setExpanded(!expanded)
  33. }
  34. function handleOutlineItemLinkClick() {
  35. jumpToLine(outlineItem.line, false)
  36. }
  37. function handleOutlineItemLinkDoubleClick() {
  38. jumpToLine(outlineItem.line, true)
  39. }
  40. useEffect(() => {
  41. const wasHighlighted = isHighlightedRef.current
  42. isHighlightedRef.current = isHighlighted
  43. if (!wasHighlighted && isHighlighted) {
  44. scrollIntoViewIfNeeded(titleElementRef.current, {
  45. scrollMode: 'if-needed',
  46. block: 'center',
  47. })
  48. }
  49. }, [isHighlighted, titleElementRef, isHighlightedRef])
  50. // don't set the aria-expanded attribute when there are no children
  51. const ariaExpandedValue = outlineItem.children ? expanded : undefined
  52. return (
  53. <li
  54. className={mainItemClasses}
  55. aria-expanded={ariaExpandedValue}
  56. role="treeitem"
  57. aria-current={isHighlighted}
  58. aria-label={outlineItem.title}
  59. >
  60. <div className="outline-item-row">
  61. {outlineItem.children ? (
  62. <button
  63. className="outline-item-expand-collapse-btn"
  64. onClick={handleExpandCollapseClick}
  65. aria-label={expanded ? t('collapse') : t('expand')}
  66. >
  67. <Icon
  68. type={expanded ? 'angle-down' : 'angle-right'}
  69. classes={{ icon: 'outline-caret-icon' }}
  70. />
  71. </button>
  72. ) : null}
  73. <button
  74. className={itemLinkClasses}
  75. onClick={handleOutlineItemLinkClick}
  76. onDoubleClick={handleOutlineItemLinkDoubleClick}
  77. ref={titleElementRef}
  78. >
  79. {outlineItem.title}
  80. </button>
  81. </div>
  82. {expanded && outlineItem.children ? (
  83. <OutlineList
  84. outline={outlineItem.children}
  85. jumpToLine={jumpToLine}
  86. isRoot={false}
  87. highlightedLine={highlightedLine}
  88. />
  89. ) : null}
  90. </li>
  91. )
  92. }
  93. OutlineItem.propTypes = {
  94. outlineItem: PropTypes.exact({
  95. line: PropTypes.number.isRequired,
  96. title: PropTypes.string.isRequired,
  97. level: PropTypes.number,
  98. children: PropTypes.array,
  99. }).isRequired,
  100. jumpToLine: PropTypes.func.isRequired,
  101. highlightedLine: PropTypes.number,
  102. }
  103. export default OutlineItem