Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions packages/react/src/Autocomplete/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,22 +317,29 @@ describe('Autocomplete', () => {
})

it("calls onOpenChange with the menu's open state", async () => {
const user = userEvent.setup()
const onOpenChangeMock = vi.fn()
const {container} = render(
const {getByLabelText} = render(
<LabelledAutocomplete
menuProps={{
items: mockItems,
items: [],
selectedItemIds: [],
onOpenChange: onOpenChangeMock,
['aria-labelledby']: 'autocompleteLabel',
}}
/>,
)
const inputNode = container.querySelector('#autocompleteInput')
const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)

inputNode && (await user.type(inputNode, 'ze'))
expect(onOpenChangeMock).toHaveBeenCalled()
expect(onOpenChangeMock).toHaveBeenNthCalledWith(1, false)

fireEvent.click(inputNode)
fireEvent.keyDown(inputNode, {key: 'ArrowDown'})
await waitFor(() => expect(onOpenChangeMock).toHaveBeenLastCalledWith(true))

// eslint-disable-next-line github/no-blur
fireEvent.blur(inputNode)

await waitFor(() => expect(onOpenChangeMock).toHaveBeenLastCalledWith(false))
})

it('calls onSelectedChange with the data for the selected items', async () => {
Expand Down
28 changes: 14 additions & 14 deletions packages/react/src/Autocomplete/AutocompleteMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
const allItemsToRenderRef = useRef<T[]>([])
const [highlightedItem, setHighlightedItem] = useState<T>()
const [sortedItemIds, setSortedItemIds] = useState<Array<string>>(items.map(({id: itemId}) => itemId))
const [prevShowMenu, setPrevShowMenu] = useState(showMenu)
const generatedUniqueId = useId(id)

const selectableItems = useMemo(
Expand Down Expand Up @@ -324,26 +325,25 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
}
}, [highlightedItem, deferredInputValue, selectedItemIds, setAutocompleteSuggestion])

useEffect(() => {
const itemIdSortResult = [...sortedItemIds].sort(
sortOnCloseFn ? sortOnCloseFn : getDefaultSortFn(itemId => isItemSelected(itemId, selectedItemIds)),
)
const sortResultMatchesState =
itemIdSortResult.length === sortedItemIds.length &&
itemIdSortResult.every((element, index) => element === sortedItemIds[index])
const itemIdSortResult = [...sortedItemIds].sort(
sortOnCloseFn ? sortOnCloseFn : getDefaultSortFn(itemId => isItemSelected(itemId, selectedItemIds)),
)
const sortResultMatchesState =
itemIdSortResult.length === sortedItemIds.length &&
Comment on lines +328 to +332
itemIdSortResult.every((element, index) => element === sortedItemIds[index])

// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
if (showMenu === false && !sortResultMatchesState) {
// Re-sort only when the menu closes. This effect also fires `onOpenChange` below, so it
// stays an effect. (Follow-up: the sort could be derived on the open→closed transition
// while keeping `onOpenChange` in an effect.)
// eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-derived-state
if (prevShowMenu !== showMenu) {
setPrevShowMenu(showMenu)

if (prevShowMenu && showMenu === false && !sortResultMatchesState) {
setSortedItemIds(itemIdSortResult)
}
}

useEffect(() => {
// eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent
onOpenChange && onOpenChange(Boolean(showMenu))
}, [showMenu, onOpenChange, selectedItemIds, sortOnCloseFn, sortedItemIds])
}, [showMenu, onOpenChange])

useEffect(() => {
// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
Expand Down
17 changes: 16 additions & 1 deletion packages/react/src/LabelGroup/LabelGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('LabelGroup', () => {
})

it('should truncate labels to a specified number', () => {
const {getByText} = render(
const {getByText, queryByText, rerender} = render(
<ThemeAndStyleContainer>
<LabelGroup visibleChildCount={3}>
<Label>One</Label>
Expand All @@ -125,6 +125,21 @@ describe('LabelGroup', () => {
const expandButton = getByText('+2')

expect(expandButton).toBeDefined()

rerender(
<ThemeAndStyleContainer>
<LabelGroup visibleChildCount={4}>
<Label>One</Label>
<Label>Two</Label>
<Label>Three</Label>
<Label>Four</Label>
<Label>Five</Label>
</LabelGroup>
</ThemeAndStyleContainer>,
)

expect(queryByText('+2')).toBeNull()
expect(getByText('+1')).toBeDefined()
})

it('should expand all tokens into an overlay when overflowStyle="overlay"', async () => {
Expand Down
67 changes: 44 additions & 23 deletions packages/react/src/LabelGroup/LabelGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ const getOverlayWidth = (
overlayPaddingPx: number,
) => overlayPaddingPx + buttonClientRect.right - (containerRef.current?.getBoundingClientRect().left || 0)

const getVisibilityMapAfterIndex = (childCount: number, truncateAfter: number) => {
const updatedEntries: Record<string, boolean> = {}

for (let index = 0; index < childCount; index++) {
updatedEntries[index.toString()] = index < truncateAfter
}

return updatedEntries
}

const visibilityMapsMatch = (left: Record<string, boolean>, right: Record<string, boolean>) => {
const leftKeys = Object.keys(left)
const rightKeys = Object.keys(right)

return (
leftKeys.length === rightKeys.length &&
leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key])
)
}

const InlineToggle: React.FC<{
collapseButtonRef: React.RefObject<HTMLButtonElement>
collapseInlineExpandedChildren: () => void
Expand Down Expand Up @@ -131,6 +151,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
bottom: 0,
toJSON: () => undefined,
})
const childArray = React.Children.toArray(children)

const overlayPaddingPx = 8 // var(--base-size-8), hardcoded to do some math
const hiddenItemIds = Object.keys(visibilityMap).filter(key => !visibilityMap[key])
Expand Down Expand Up @@ -163,18 +184,12 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
)

// Sets the visibility map to hide children after the given index.
const hideChildrenAfterIndex = React.useCallback((truncateAfter: number) => {
const containerChildren = containerRef.current?.children || []
const updatedEntries: Record<string, boolean> = {}
for (const child of containerChildren) {
const targetId = child.getAttribute('data-index')
if (targetId) {
updatedEntries[targetId] = parseInt(targetId, 10) < truncateAfter
}
}

setVisibilityMap(updatedEntries)
}, [])
const hideChildrenAfterIndex = React.useCallback(
(truncateAfter: number) => {
setVisibilityMap(getVisibilityMapAfterIndex(childArray.length, truncateAfter))
},
[childArray.length],
)

const openOverflowOverlay = React.useCallback(() => setIsOverflowShown(true), [setIsOverflowShown])

Expand Down Expand Up @@ -203,13 +218,26 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
setIsOverflowShown(true)
}, [setVisibilityMap, setIsOverflowShown])

const numericVisibilityMap = React.useMemo(
() =>
typeof visibleChildCount === 'number' && !isOverflowShown
? getVisibilityMapAfterIndex(childArray.length, visibleChildCount)
: undefined,
[childArray.length, isOverflowShown, visibleChildCount],
Comment on lines +221 to +226
)

if (numericVisibilityMap && !visibilityMapsMatch(visibilityMap, numericVisibilityMap)) {
setVisibilityMap(numericVisibilityMap)
}

React.useEffect(() => {
// If we're not truncating, we don't need to run this useEffect.
// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
if (!visibleChildCount || isOverflowShown) {
return
}

// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
if (visibleChildCount === 'auto') {
// Instantiates the IntersectionObserver to track when children fit in the container.
const observer = new IntersectionObserver(
Expand Down Expand Up @@ -245,14 +273,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({

return () => observer.disconnect()
}
// We're not auto truncating, so we need to hide children after the given `visibleChildCount`.
// `hideChildrenAfterIndex` reads the rendered children from the DOM, so it must run in an
// effect. (Follow-up: this branch could derive the visibility map from child indices instead.)
else {
// eslint-disable-next-line react-hooks/set-state-in-effect
hideChildrenAfterIndex(visibleChildCount)
}
}, [buttonClientRect, visibleChildCount, hideChildrenAfterIndex, isOverflowShown])
}, [buttonClientRect, visibleChildCount, isOverflowShown])

// Updates the index of the first hidden child.
// We need to keep track of this so we can focus the first hidden child when the overflow is shown inline.
Expand Down Expand Up @@ -304,7 +325,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
className={clsx(className, classes.Container)}
data-component="LabelGroup"
>
{React.Children.map(children, (child, index) => (
{childArray.map((child, index) => (
<ItemWrapperComponent
// data-index is used as an identifier we can use in the IntersectionObserver
data-index={index}
Expand All @@ -325,7 +346,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
hiddenItemIds={hiddenItemIds}
isOverflowShown={isOverflowShown}
showAllTokensInline={showAllTokensInline}
totalLength={React.Children.toArray(children).length}
totalLength={childArray.length}
/>
) : (
<OverlayToggle
Expand All @@ -336,7 +357,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
openOverflowOverlay={openOverflowOverlay}
overlayPaddingPx={overlayPaddingPx}
overlayWidth={overlayWidth}
totalLength={React.Children.toArray(children).length}
totalLength={childArray.length}
>
{children}
</OverlayToggle>
Expand Down
Loading