DisclosureRow.tsx 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
  2. import clsx from 'clsx'
  3. import { IconChevronDownOutline14 } from './icons/index.tsx'
  4. import css from './DisclosureRow.module.css'
  5. /** Shared 24px disclosure chrome for compact flow rows. */
  6. export interface DisclosureRowProps {
  7. icon: ReactNode
  8. title: string
  9. open: boolean
  10. expandable: boolean
  11. onToggle: () => void
  12. /** Makes the complete title row the disclosure target. */
  13. expandOnRowClick?: boolean | undefined
  14. /** Replaces the collapsed icon with a chevron while the row is hovered. */
  15. previewChevron?: boolean | undefined
  16. /** Keeps `collapsedContent` inline while open. */
  17. keepContentWhenOpen?: boolean | undefined
  18. collapsedContent?: ReactNode
  19. children?: ReactNode
  20. className?: string | undefined
  21. rowClassName?: string | undefined
  22. leadingClassName?: string | undefined
  23. chevronClassName?: string | undefined
  24. titleClassName?: string | undefined
  25. }
  26. /**
  27. * Render one disclosure header and its controlled expanded content.
  28. * @param props - Visual content, controlled state, and interaction policy.
  29. * @returns the disclosure row.
  30. */
  31. export function DisclosureRow({
  32. icon,
  33. title,
  34. open,
  35. expandable,
  36. onToggle,
  37. expandOnRowClick = false,
  38. previewChevron = expandable,
  39. keepContentWhenOpen = false,
  40. collapsedContent,
  41. children,
  42. className,
  43. rowClassName,
  44. leadingClassName,
  45. chevronClassName,
  46. titleClassName,
  47. }: DisclosureRowProps) {
  48. const rowExpands = expandable && expandOnRowClick
  49. const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
  50. event.stopPropagation()
  51. onToggle()
  52. }
  53. const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
  54. if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
  55. event.preventDefault()
  56. onToggle()
  57. }
  58. const collapsedLeading = previewChevron
  59. ? (
  60. <>
  61. <span className={css.iconIdle}>{icon}</span>
  62. <IconChevronDownOutline14 className={clsx(chevronClassName, css.chevronHover)} />
  63. </>
  64. )
  65. : icon
  66. const leading = open
  67. ? <IconChevronDownOutline14 className={chevronClassName} />
  68. : collapsedLeading
  69. return (
  70. <div className={clsx(css.root, className)} data-open={open || undefined}>
  71. <div
  72. className={clsx(css.row, rowClassName)}
  73. data-disclosure-row
  74. data-expandable={rowExpands || undefined}
  75. role={rowExpands ? 'button' : undefined}
  76. tabIndex={rowExpands ? 0 : undefined}
  77. aria-expanded={rowExpands ? open : undefined}
  78. onClick={rowExpands ? onToggle : undefined}
  79. onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
  80. >
  81. {expandable && !rowExpands ? (
  82. <button
  83. type="button"
  84. className={clsx(css.leading, leadingClassName)}
  85. aria-expanded={open}
  86. onClick={toggleFromLeading}
  87. >
  88. {leading}
  89. </button>
  90. ) : (
  91. <span className={clsx(css.leading, leadingClassName)}>
  92. {leading}
  93. </span>
  94. )}
  95. <span className={clsx(css.title, titleClassName)}>{title}</span>
  96. {(keepContentWhenOpen || !open) && collapsedContent}
  97. </div>
  98. {open && children}
  99. </div>
  100. )
  101. }