Skip to content

Commit

Permalink
187951404 two line attribute header (#1514)
Browse files Browse the repository at this point in the history
* Splits header into two lines. There is a bug with editing attribute header text.

* Fixes bug where header doesn't update after rename

* Adds logic so that line 1 always shows 1 word

Adds css styling so line doesn't wrap word, and line 2 shows ellipsis on left side.

* fix merge conflict error

* Fixes broken cypress test in table

* Fixes logic for showing attribute units as undefined if there are no units

* Adds padding buffer to button width

Adds logic that if there is only one attribute name and it overflows, don't make it appear in the 2nd line.

* Adds wrap styling for one word headers

* PR fixes

* Adds code to styling of attribute header line 2
  • Loading branch information
eireland authored Oct 1, 2024
1 parent d87c9dd commit 4f8ddcc
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 7 deletions.
3 changes: 2 additions & 1 deletion v3/cypress/e2e/table.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ context("case table ui", () => {
const name = "Tallness",
description = "The average height of the mammal.",
unit = "meters",
newName = "Tallness (meters)",
newName = "Tallness(meters)", // this would appear over 2 lines and extra spaces are trimmed
type = "color",
precision = null,
editable = "No"
Expand All @@ -59,6 +59,7 @@ context("case table ui", () => {
// Verify the attribute has been edited
table.getAttribute(name).should("have.text", newName)


// opening the dialog again should show the updated values
table.openAttributeMenu(name)
table.selectMenuItemFromAttributeMenu("Edit Attribute Properties...")
Expand Down
29 changes: 29 additions & 0 deletions v3/src/components/case-table/case-table.scss
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,37 @@ $table-body-font-size: 8pt;
// chakra menu-button
button {
width: 100%;
height: 100%;
color: #555555 !important;
}

button.codap-attribute-button.allow-two-lines > span {
display: flex;
flex-direction: column;

.one-line-header {
white-space: normal;
}

.two-line-header-line-1 {
display: inline-block;
hyphens: auto;
overflow-wrap: break-word;
overflow: hidden;
}

.two-line-header-line-2.truncated {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
// We reverse the text in code so that the ellipsis is at the beginning of the text
// so text is styled to show right to left, but we override the unicode bi-directionality
// so that text is actually displayed left to right
direction: rtl;
unicode-bidi: bidi-override;
}
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions v3/src/components/case-table/column-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function ColumnHeader(props: TRenderHeaderCellProps) {
}, [cellElt])

return <AttributeHeader attributeId={props.column.key}
allowTwoLines={true}
getDividerBounds={getDividerBounds}
HeaderDivider={AttributeHeaderDivider}
onSetHeaderContentElt={handleSetHeaderContentElt}
Expand Down
33 changes: 27 additions & 6 deletions v3/src/components/case-tile-common/attribute-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Tooltip, Menu, MenuButton, Input, VisuallyHidden, SystemStyleObject } f
import { useDndContext } from "@dnd-kit/core"
import { autorun } from "mobx"
import { observer } from "mobx-react-lite"
import React, { useEffect, useRef, useState } from "react"
import React, { useEffect, useMemo, useRef, useState } from "react"
import { clsx } from "clsx"
import { useDataSetContext } from "../../hooks/use-data-set-context"
import { IUseDraggableAttribute, useDraggableAttribute } from "../../hooks/use-drag-drop"
import { useInstanceIdContext } from "../../hooks/use-instance-id-context"
Expand All @@ -13,6 +14,7 @@ import { AttributeMenuList } from "./attribute-menu/attribute-menu-list"
import { CaseTilePortal } from "./case-tile-portal"
import { GetDividerBoundsFn, IDividerProps, kIndexColumnKey } from "./case-tile-types"
import { useParentChildFocusRedirect } from "./use-parent-child-focus-redirect"
import { useAdjustHeaderForOverflow } from "../../hooks/use-adjust-header-overflow"

interface IProps {
attributeId: string
Expand All @@ -21,6 +23,7 @@ interface IProps {
getDividerBounds?: GetDividerBoundsFn
HeaderDivider?: React.ComponentType<IDividerProps>
showUnits?: boolean
allowTwoLines?: boolean
// returns the draggable parent element for use with DnDKit
onSetHeaderContentElt?: (contentElt: HTMLDivElement | null) => HTMLElement | null
onBeginEdit?: () => void
Expand All @@ -29,7 +32,7 @@ interface IProps {
}

export const AttributeHeader = observer(function AttributeHeader({
attributeId, beforeHeaderDivider, customButtonStyle, getDividerBounds, HeaderDivider,
attributeId, beforeHeaderDivider, customButtonStyle, allowTwoLines, getDividerBounds, HeaderDivider,
showUnits=true, onSetHeaderContentElt, onBeginEdit, onEndEdit, onOpenMenu
}: IProps) {
const { active } = useDndContext()
Expand All @@ -50,7 +53,9 @@ export const AttributeHeader = observer(function AttributeHeader({

const attribute = data?.attrFromID(attributeId)
const attrName = attribute?.name ?? ""

const attrUnits = attribute?.units ? ` (${attribute.units})` : ""
const { line1, line2, isOverflowed, line2Truncated } =
useAdjustHeaderForOverflow(menuButtonRef.current, attrName, attrUnits)
const draggableOptions: IUseDraggableAttribute = {
prefix: instanceId, dataSet: data, attributeId
}
Expand Down Expand Up @@ -172,7 +177,21 @@ export const AttributeHeader = observer(function AttributeHeader({
setIsFocused(true)
}

const units = attribute?.units ? ` (${attribute.units})` : ""
const renderAttributeLabel = useMemo(() => {
if (isOverflowed) {
return (
<>
<span className="two-line-header-line-1">{line1}</span>
<span className={clsx("two-line-header-line-2", {truncated: line2Truncated})}>{line2}</span>
</>
)
} else {
return (
<span className="one-line-header">{line1}</span>
)
}
}, [line1, line2, isOverflowed, line2Truncated])

const description = attribute?.description ? `: ${attribute.description}` : ""
return (
<Menu isLazy>
Expand Down Expand Up @@ -200,13 +219,15 @@ export const AttributeHeader = observer(function AttributeHeader({
/>
: <>
<MenuButton
className="codap-attribute-button" ref={menuButtonRef}
className={clsx("codap-attribute-button", {"allow-two-lines": allowTwoLines})}
ref={menuButtonRef}
disabled={attributeId === kIndexColumnKey}
sx={customButtonStyle}
fontWeight="bold" onKeyDown={handleButtonKeyDown}
data-testid={`codap-attribute-button ${attrName}`}
aria-describedby={`sr-column-header-drag-instructions-${instanceId}`}>
{`${attrName ?? ""}${showUnits ? units : ""}`}
{allowTwoLines ? renderAttributeLabel
: `${attrName ?? ""}${showUnits ? attrUnits : ""}`.trim()}
</MenuButton>
<VisuallyHidden id={`sr-column-header-drag-instructions-${instanceId}`}>
<pre> Press Space to drag the attribute within the table or to a graph.
Expand Down
107 changes: 107 additions & 0 deletions v3/src/hooks/use-adjust-header-overflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react'
import { measureText } from './use-measure-text'

const kPaddingBuffer = 5 // Button width is 5px smaller because of parent padding

// Hook to split headers into 2 rows and elide the 2nd line if it doesn't fit
export function useAdjustHeaderForOverflow(attrbuteHeaderButtonEl: HTMLButtonElement | null,
attrName: string, attrUnits?: string) {
const attributeName = attrName.replace(/_/g, ' ')
const candidateAttributeLabel = `${attributeName}${attrUnits}`.trim()
const [line1, setLine1] = useState('')
const [line2, setLine2] = useState('')
const [isOverflowed, setIsOverflowed] = useState(false)
const [line2Truncated, setLine2Truncated] = useState(false)
const resizeObserverRef = useRef<ResizeObserver | null>(null)

const reverse = (str: string) => {
const rMap: Record<string, string> = {'[': ']', ']': '[', '{': '}', '}': '{', '(': ')', ')': '('}
if (!str) return str
const s = str.split('')
let c = ""
const n = []
for (let ix = s.length - 1; ix >= 0; ix -= 1) {
c = s[ix]
if (rMap[c]) c = rMap[c]
n.push(c)
}
return n.join('')
}

const calculateSplit = () => {
if (!attrbuteHeaderButtonEl) {
setLine1('')
setLine2('')
setIsOverflowed(false)
return
}

const attributeButtonWidth = attrbuteHeaderButtonEl.clientWidth - kPaddingBuffer
const computedStyle = getComputedStyle(attrbuteHeaderButtonEl)
const style = [
`font-style:${computedStyle.fontStyle}`,
`font-variant:${computedStyle.fontVariant}`,
`font-weight:${computedStyle.fontWeight}`,
`font-size:${computedStyle.fontSize}`,
`font-family:${computedStyle.fontFamily}`,
`width:${attrbuteHeaderButtonEl.clientWidth}px`,
`height:${attrbuteHeaderButtonEl.clientHeight}px`
].join('')
const fullTextWidth = measureText(candidateAttributeLabel, style)
const words = candidateAttributeLabel.split(' ')
if (fullTextWidth <= attributeButtonWidth || words.length === 1) {
setLine1(candidateAttributeLabel)
setLine2('')
setIsOverflowed(false)
} else {
let i = 0
let currentLine1 = ''
// Build line1 word by word without exceeding the button width
while (i < words.length && measureText(currentLine1 + words[i], style) < attributeButtonWidth) {
currentLine1 = currentLine1 ? `${currentLine1} ${words[i]}` : words[i]
i++
}
// If line1 ends up with just one word, show the word no matter what the width is
if (currentLine1.split(' ').length === 1) {
currentLine1 = words[0]
}
setLine1(currentLine1)

const remainingWords = words.slice(i).join(' ')
const remainingTextWidth = measureText(remainingWords, style)
if (remainingTextWidth <= attributeButtonWidth) {
// Remaining text fits in line2
setLine2(remainingWords)
setLine2Truncated(false)
} else {
// Remaining text doesn't fit in line2
// We reverse the text so that the ellipsis is at the beginning of the text and
// line2 has style of direction: rtl, text-align: left, unicode-bidi: bidi-override
setLine2Truncated(true)
setLine2(reverse(candidateAttributeLabel))
}
setIsOverflowed(true)
}
}

useEffect(() => {
if (!attrbuteHeaderButtonEl) return
resizeObserverRef.current = new ResizeObserver(() => {
calculateSplit()
})
resizeObserverRef.current.observe(attrbuteHeaderButtonEl)
return () => {
resizeObserverRef.current?.disconnect()
}
// Adding calculateSplit to dependencies causes rerender problems on attribute rename
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidateAttributeLabel, attrbuteHeaderButtonEl])

useEffect(()=> {
calculateSplit()
// Adding calculateSplit to dependencies causes rerender problems on attribute rename
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidateAttributeLabel])

return { line1, line2, isOverflowed, line2Truncated }
}

0 comments on commit 4f8ddcc

Please sign in to comment.