Skip to content

Commit 8e58e4d

Browse files
feat(LabelGroup): render as list by default (#5156)
* feat(LabelGroup): render as list by default * docs(LabelGroup): add as props to docs.json * fix(LabelGroup): lint * fix(LabelGroup): action button inside li * fix(LabelGroup): fix broken tests, small refactor * Create blue-eggs-suffer.md * fix(LabelGroup): wrap children in li instead of using context * fix(Label): revert changes * fix(LabelGroup): use add key to children map
1 parent 7efa934 commit 8e58e4d

File tree

5 files changed

+189
-28
lines changed

5 files changed

+189
-28
lines changed

.changeset/blue-eggs-suffer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
feat(LabelGroup): render as list by default

packages/react/src/LabelGroup/LabelGroup.docs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
"description": "How many tokens to show. `'auto'` truncates the tokens to fit in the parent container. Passing a number will truncate after that number tokens. If this is undefined, tokens will never be truncated.",
1818
"defaultValue": "",
1919
"type": "'auto' | number"
20+
},
21+
{
22+
"name": "as",
23+
"description": "Customize the element type of the rendered container.",
24+
"defaultValue": "ul",
25+
"type": "React.ElementType"
2026
}
2127
],
2228
"subcomponents": []

packages/react/src/LabelGroup/LabelGroup.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export const Playground: StoryFn = ({
6969
</ResizableContainer>
7070
)
7171
}
72+
Playground.args = {
73+
as: 'ul',
74+
}
75+
7276
Playground.argTypes = {
7377
overflowStyle: {
7478
control: {

packages/react/src/LabelGroup/LabelGroup.tsx

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type {SxProp} from '../sx'
1212
import sx from '../sx'
1313

1414
export type LabelGroupProps = {
15+
/** Customize the element type of the rendered container */
16+
as?: React.ElementType
1517
/** How hidden tokens should be shown. `'inline'` shows the hidden tokens after the visible tokens. `'overlay'` shows all tokens in an overlay that appears on top of the visible tokens. */
1618
overflowStyle?: 'inline' | 'overlay'
1719
/** How many tokens to show. `'auto'` truncates the tokens to fit in the parent container. Passing a number will truncate after that number tokens. If this is undefined, tokens will never be truncated. */
@@ -30,6 +32,13 @@ const StyledLabelGroupContainer = styled.div<SxProp>`
3032
flex-wrap: wrap;
3133
}
3234
35+
&[data-list] {
36+
padding-inline-start: 0;
37+
margin-block-start: 0;
38+
margin-block-end: 0;
39+
list-style-type: none;
40+
}
41+
3342
${sx};
3443
`
3544

@@ -54,7 +63,7 @@ const ItemWrapper = styled.div`
5463
// Calculates the width of the overlay to cover the labels/tokens and the expand button.
5564
const getOverlayWidth = (
5665
buttonClientRect: DOMRect,
57-
containerRef: React.RefObject<HTMLDivElement>,
66+
containerRef: React.RefObject<HTMLElement>,
5867
overlayPaddingPx: number,
5968
) => overlayPaddingPx + buttonClientRect.right - (containerRef.current?.getBoundingClientRect().left || 0)
6069

@@ -148,8 +157,9 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
148157
visibleChildCount,
149158
overflowStyle = 'overlay',
150159
sx: sxProp,
160+
as = 'ul',
151161
}) => {
152-
const containerRef = React.useRef<HTMLDivElement>(null)
162+
const containerRef = React.useRef<HTMLElement>(null)
153163
const collapseButtonRef = React.useRef<HTMLButtonElement>(null)
154164
const firstHiddenIndexRef = React.useRef<number | undefined>(undefined)
155165
const [visibilityMap, setVisibilityMap] = React.useState<Record<string, boolean>>({})
@@ -317,50 +327,63 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
317327
}
318328
}, [overflowStyle, isOverflowShown])
319329

330+
const isList = as === 'ul' || as === 'ol'
331+
const ToggleWrapper = isList ? 'li' : React.Fragment
332+
320333
// If truncation is enabled, we need to render based on truncation logic.
321334
return visibleChildCount ? (
322335
<StyledLabelGroupContainer
323336
ref={containerRef}
324337
data-overflow={overflowStyle === 'inline' && isOverflowShown ? 'inline' : undefined}
338+
data-list={isList || undefined}
325339
sx={sxProp}
340+
as={as}
326341
>
327342
{React.Children.map(children, (child, index) => (
328343
<ItemWrapper
329344
// data-index is used as an identifier we can use in the IntersectionObserver
330345
data-index={index}
331346
className={hiddenItemIds.includes(index.toString()) ? 'ItemWrapper--hidden' : undefined}
347+
as={isList ? 'li' : 'span'}
348+
key={index}
332349
>
333350
{child}
334351
</ItemWrapper>
335352
))}
336-
{overflowStyle === 'inline' ? (
337-
<InlineToggle
338-
collapseButtonRef={collapseButtonRef}
339-
collapseInlineExpandedChildren={collapseInlineExpandedChildren}
340-
expandButtonRef={expandButtonRef}
341-
hiddenItemIds={hiddenItemIds}
342-
isOverflowShown={isOverflowShown}
343-
showAllTokensInline={showAllTokensInline}
344-
totalLength={React.Children.toArray(children).length}
345-
/>
346-
) : (
347-
<OverlayToggle
348-
closeOverflowOverlay={closeOverflowOverlay}
349-
expandButtonRef={expandButtonRef}
350-
hiddenItemIds={hiddenItemIds}
351-
isOverflowShown={isOverflowShown}
352-
openOverflowOverlay={openOverflowOverlay}
353-
overlayPaddingPx={overlayPaddingPx}
354-
overlayWidth={overlayWidth}
355-
totalLength={React.Children.toArray(children).length}
356-
>
357-
{children}
358-
</OverlayToggle>
359-
)}
353+
<ToggleWrapper>
354+
{overflowStyle === 'inline' ? (
355+
<InlineToggle
356+
collapseButtonRef={collapseButtonRef}
357+
collapseInlineExpandedChildren={collapseInlineExpandedChildren}
358+
expandButtonRef={expandButtonRef}
359+
hiddenItemIds={hiddenItemIds}
360+
isOverflowShown={isOverflowShown}
361+
showAllTokensInline={showAllTokensInline}
362+
totalLength={React.Children.toArray(children).length}
363+
/>
364+
) : (
365+
<OverlayToggle
366+
closeOverflowOverlay={closeOverflowOverlay}
367+
expandButtonRef={expandButtonRef}
368+
hiddenItemIds={hiddenItemIds}
369+
isOverflowShown={isOverflowShown}
370+
openOverflowOverlay={openOverflowOverlay}
371+
overlayPaddingPx={overlayPaddingPx}
372+
overlayWidth={overlayWidth}
373+
totalLength={React.Children.toArray(children).length}
374+
>
375+
{children}
376+
</OverlayToggle>
377+
)}
378+
</ToggleWrapper>
360379
</StyledLabelGroupContainer>
361380
) : (
362-
<StyledLabelGroupContainer data-overflow="inline" sx={sxProp}>
363-
{children}
381+
<StyledLabelGroupContainer data-overflow="inline" data-list={isList || undefined} sx={sxProp} as={as}>
382+
{isList
383+
? React.Children.map(children, (child, index) => {
384+
return <li key={index}>{child}</li>
385+
})
386+
: children}
364387
</StyledLabelGroupContainer>
365388
)
366389
}

packages/react/src/__tests__/LabelGroup.test.tsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,127 @@ describe('LabelGroup', () => {
177177

178178
expect(document.activeElement).toEqual(getByText('+2').closest('button'))
179179
})
180+
181+
describe('should render as ul by default', () => {
182+
it('without truncation', () => {
183+
const {getByRole} = HTMLRender(
184+
<ThemeAndStyleContainer>
185+
<LabelGroup>
186+
<Label>One</Label>
187+
<Label>Two</Label>
188+
<Label>Three</Label>
189+
<Label>Four</Label>
190+
<Label>Five</Label>
191+
</LabelGroup>
192+
</ThemeAndStyleContainer>,
193+
)
194+
const list = getByRole('list')
195+
expect(list).not.toBeNull()
196+
expect(list.tagName).toBe('UL')
197+
expect(list).toHaveAttribute('data-list', 'true')
198+
expect(list.querySelectorAll('li')).toHaveLength(5)
199+
})
200+
201+
it('with truncation', () => {
202+
const {getByRole} = HTMLRender(
203+
<ThemeAndStyleContainer>
204+
<LabelGroup visibleChildCount={3}>
205+
<Label>One</Label>
206+
<Label>Two</Label>
207+
<Label>Three</Label>
208+
<Label>Four</Label>
209+
<Label>Five</Label>
210+
</LabelGroup>
211+
</ThemeAndStyleContainer>,
212+
)
213+
const list = getByRole('list')
214+
expect(list).not.toBeNull()
215+
expect(list.tagName).toBe('UL')
216+
expect(list).toHaveAttribute('data-list', 'true')
217+
// account for "show more" button
218+
expect(list.querySelectorAll('li')).toHaveLength(6)
219+
})
220+
})
221+
222+
describe('should render as custom element when `as` is provided', () => {
223+
it('without truncation', () => {
224+
const {queryByRole, container} = HTMLRender(
225+
<ThemeAndStyleContainer>
226+
<LabelGroup as="div">
227+
<Label>One</Label>
228+
<Label>Two</Label>
229+
<Label>Three</Label>
230+
<Label>Four</Label>
231+
<Label>Five</Label>
232+
</LabelGroup>
233+
</ThemeAndStyleContainer>,
234+
)
235+
const list = queryByRole('list')
236+
expect(list).toBeNull()
237+
const labelGroupDiv = container.querySelectorAll('div')[1]
238+
expect(labelGroupDiv.querySelectorAll('li')).toHaveLength(0)
239+
expect(labelGroupDiv.querySelectorAll('span')).toHaveLength(5)
240+
expect(labelGroupDiv).not.toHaveAttribute('data-list')
241+
})
242+
243+
it('with truncation', () => {
244+
const {queryByRole, container} = HTMLRender(
245+
<ThemeAndStyleContainer>
246+
<LabelGroup as="div" visibleChildCount={2}>
247+
<Label>One</Label>
248+
<Label>Two</Label>
249+
<Label>Three</Label>
250+
<Label>Four</Label>
251+
<Label>Five</Label>
252+
</LabelGroup>
253+
</ThemeAndStyleContainer>,
254+
)
255+
const list = queryByRole('list')
256+
expect(list).toBeNull()
257+
const labelGroupDiv = container.querySelectorAll('div')[1]
258+
expect(labelGroupDiv.querySelectorAll('li')).toHaveLength(0)
259+
expect(labelGroupDiv.querySelectorAll(':scope > span')).toHaveLength(5)
260+
expect(labelGroupDiv).not.toHaveAttribute('data-list')
261+
})
262+
})
263+
264+
describe('should render children as list items when rendered as ol', () => {
265+
it('without truncation', () => {
266+
const {getByRole} = HTMLRender(
267+
<ThemeAndStyleContainer>
268+
<LabelGroup as={'ol'}>
269+
<Label>One</Label>
270+
<Label>Two</Label>
271+
<Label>Three</Label>
272+
<Label>Four</Label>
273+
<Label>Five</Label>
274+
</LabelGroup>
275+
</ThemeAndStyleContainer>,
276+
)
277+
const list = getByRole('list')
278+
expect(list).not.toBeNull()
279+
expect(list.tagName).toBe('OL')
280+
expect(list).toHaveAttribute('data-list', 'true')
281+
expect(list.querySelectorAll('li')).toHaveLength(5)
282+
})
283+
it('with truncation', () => {
284+
const {getByRole} = HTMLRender(
285+
<ThemeAndStyleContainer>
286+
<LabelGroup as={'ol'} visibleChildCount={1}>
287+
<Label>One</Label>
288+
<Label>Two</Label>
289+
<Label>Three</Label>
290+
<Label>Four</Label>
291+
<Label>Five</Label>
292+
</LabelGroup>
293+
</ThemeAndStyleContainer>,
294+
)
295+
const list = getByRole('list')
296+
expect(list).not.toBeNull()
297+
expect(list.tagName).toBe('OL')
298+
expect(list).toHaveAttribute('data-list', 'true')
299+
// account for "show more" button
300+
expect(list.querySelectorAll('li')).toHaveLength(6)
301+
})
302+
})
180303
})

0 commit comments

Comments
 (0)