Skip to content

Commit bf779e2

Browse files
smocklecolebemis
authored andcommitted
fix: Split <Item> labels (#1320)
* fix: Separate 'Item' content into 'label' and 'description' * fix: Only add 'aria-describedby' when 'description' exists * fix: Memoize 'id' so 'Item's and labels match * fix: Don’t rely on 'id' which is possibly not globally-unique * fix: Restore semi-full-width 'Item' dividers, without giving up the semantic nesting. By “semantic nesting”, I mean that the 'Item' label and description are now siblings, which is preferable to the previous implementation, where the description node was a child of the label node. As a general principle, we should align DOM hierarchies with information hierarchies. An analogy: If I were using a bulleted list to describe a dog, I would not indent its breed as a second-level bullet beneath the bullet for its name, because a dog’s breed is not dependent/derived data from its name. Similarly, description is not dependent/derived from label, and so should not be nested in DOM. * fix: Reduce styled-components class permutations. https://www.joshwcomeau.com/css/styled-components/
1 parent f499d79 commit bf779e2

File tree

1 file changed

+74
-60
lines changed

1 file changed

+74
-60
lines changed

src/ActionList/Item.tsx

Lines changed: 74 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {CheckIcon, IconProps} from '@primer/octicons-react'
2-
import React, {useCallback} from 'react'
2+
import React, {useCallback, useMemo} from 'react'
33
import {get} from '../constants'
44
import sx, {SxProp} from '../sx'
55
import Truncate from '../Truncate'
@@ -13,6 +13,7 @@ import {
1313
activeDescendantActivatedIndirectly,
1414
isActiveDescendantAttribute
1515
} from '../behaviors/focusZone'
16+
import {uniqueId} from '../utils/uniqueId'
1617

1718
/**
1819
* These colors are not yet in our default theme. Need to remove this once they are added.
@@ -153,11 +154,21 @@ const getItemVariant = (variant = 'default', disabled?: boolean) => {
153154
}
154155
}
155156

156-
const StyledItemContent = styled.div`
157+
const DividedContent = styled.div`
157158
display: flex;
158159
min-width: 0;
159-
flex-grow: 1;
160+
161+
/* Required for dividers */
160162
position: relative;
163+
flex-grow: 1;
164+
`
165+
166+
const MainContent = styled.div`
167+
align-items: baseline;
168+
display: flex;
169+
min-width: 0;
170+
flex-direction: var(--main-content-flex-direction);
171+
flex-grow: 1;
161172
`
162173

163174
const StyledItem = styled.div<
@@ -193,7 +204,7 @@ const StyledItem = styled.div<
193204
:not(:first-of-type):not(${StyledDivider} + &):not(${StyledHeader} + &) {
194205
margin-top: ${({showDivider}) => (showDivider ? `1px` : '0')};
195206
196-
${StyledItemContent}::before {
207+
${DividedContent}::before {
197208
content: ' ';
198209
display: block;
199210
position: absolute;
@@ -207,23 +218,23 @@ const StyledItem = styled.div<
207218
208219
// Item dividers should not be visible:
209220
// - above Hovered
210-
&:hover ${StyledItemContent}::before,
221+
&:hover ${DividedContent}::before,
211222
// - below Hovered
212223
// '*' instead of '&' because '&' maps to separate class names depending on 'variant'
213-
:hover + * ${StyledItemContent}::before {
224+
:hover + * ${DividedContent}::before {
214225
// allow override in case another item in the list is active/focused
215226
border-color: var(--item-hover-divider-border-color-override, transparent) !important;
216227
}
217228
218229
// - above Focused
219-
&:focus ${StyledItemContent}::before,
230+
&:focus ${DividedContent}::before,
220231
// - below Focused
221232
// '*' instead of '&' because '&' maps to separate class names depending on 'variant'
222-
:focus + * ${StyledItemContent}::before,
233+
:focus + * ${DividedContent}::before,
223234
// - above Active Descendent
224-
&[${isActiveDescendantAttribute}] ${StyledItemContent}::before,
235+
&[${isActiveDescendantAttribute}] ${DividedContent}::before,
225236
// - below Active Descendent
226-
[${isActiveDescendantAttribute}] + & ${StyledItemContent}::before {
237+
[${isActiveDescendantAttribute}] + & ${DividedContent}::before {
227238
// '!important' because all the ':not's above give higher specificity
228239
border-color: transparent !important;
229240
}
@@ -248,27 +259,16 @@ const StyledItem = styled.div<
248259
${sx}
249260
`
250261

251-
export const TextContainer = styled.div<{
262+
export const TextContainer = styled.span<{
252263
dangerouslySetInnerHtml?: React.DOMAttributes<HTMLDivElement>['dangerouslySetInnerHTML']
253-
descriptionVariant: ItemProps['descriptionVariant']
254-
}>`
255-
display: flex;
256-
min-width: 0;
257-
flex-grow: 1;
258-
flex-direction: ${({descriptionVariant}) => (descriptionVariant === 'inline' ? 'row' : 'column')};
259-
align-items: baseline;
260-
`
264+
}>``
261265

262266
const BaseVisualContainer = styled.div<{variant?: ItemProps['variant']; disabled?: boolean}>`
263267
// Match visual height to adjacent text line height.
264268
// TODO: When rem-based spacing on a 4px scale lands, replace
265269
// hardcoded '20px' with '${get('space.s20')}'.
266270
height: 20px;
267271
width: ${get('space.3')};
268-
flex-shrink: 0;
269-
display: flex;
270-
flex-direction: column;
271-
justify-content: center;
272272
margin-right: ${get('space.2')};
273273
`
274274

@@ -279,30 +279,33 @@ const ColoredVisualContainer = styled(BaseVisualContainer)`
279279
}
280280
`
281281

282-
const LeadingVisualContainer = styled(ColoredVisualContainer)``
282+
const LeadingVisualContainer = styled(ColoredVisualContainer)`
283+
flex-shrink: 0;
284+
display: flex;
285+
flex-direction: column;
286+
justify-content: center;
287+
`
283288

284-
const TrailingVisualContainer = styled(ColoredVisualContainer)`
289+
const TrailingContent = styled(ColoredVisualContainer)`
285290
color: ${({variant, disabled}) => getItemVariant(variant, disabled).annotationColor}};
286291
margin-left: ${get('space.2')};
287292
margin-right: 0;
293+
width: auto;
288294
div:nth-child(2) {
289295
margin-left: ${get('space.2')};
290296
}
291-
display: flex;
292-
flex-direction: row;
293-
justify-content: flex-end;
294297
`
295298

296-
const DescriptionContainer = styled.span<{descriptionVariant: ItemProps['descriptionVariant']}>`
299+
const DescriptionContainer = styled.span`
297300
color: ${get('colors.text.secondary')};
298301
font-size: ${get('fontSizes.0')};
299302
// TODO: When rem-based spacing on a 4px scale lands, replace
300303
// hardcoded '16px' with '${get('lh-12')}'.
301304
line-height: 16px;
302-
margin-left: ${({descriptionVariant}) => (descriptionVariant === 'inline' ? get('space.2') : 0)};
305+
margin-left: var(--description-container-margin-left);
303306
min-width: 0;
304307
flex-grow: 1;
305-
flex-basis: ${({descriptionVariant}) => (descriptionVariant === 'inline' ? 0 : 'auto')};
308+
flex-basis: var(--description-container-flex-basis);
306309
`
307310

308311
const MultiSelectInput = styled.input`
@@ -333,6 +336,9 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
333336
...props
334337
} = itemProps
335338

339+
const labelId = useMemo(() => uniqueId(), [])
340+
const descriptionId = useMemo(() => uniqueId(), [])
341+
336342
const keyPressHandler = useCallback(
337343
event => {
338344
if (disabled) {
@@ -377,6 +383,8 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
377383
variant={variant}
378384
showDivider={showDivider}
379385
aria-selected={selected}
386+
aria-labelledby={text ? labelId : undefined}
387+
aria-describedby={description ? descriptionId : undefined}
380388
{...props}
381389
data-id={id}
382390
onKeyPress={keyPressHandler}
@@ -412,35 +420,41 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
412420
<LeadingVisual />
413421
</LeadingVisualContainer>
414422
)}
415-
<StyledItemContent>
416-
{children}
417-
{(text || description) && (
418-
<TextContainer descriptionVariant={descriptionVariant}>
419-
{text && <div>{text}</div>}
420-
{description && (
421-
<DescriptionContainer descriptionVariant={descriptionVariant}>
422-
{descriptionVariant === 'block' ? (
423-
description
424-
) : (
425-
<Truncate title={description} inline={true} maxWidth="100%">
426-
{description}
427-
</Truncate>
428-
)}
429-
</DescriptionContainer>
430-
)}
431-
</TextContainer>
432-
)}
433-
{(TrailingIcon || trailingText) && (
434-
<TrailingVisualContainer variant={variant} disabled={disabled}>
435-
{trailingText && <div>{trailingText}</div>}
436-
{TrailingIcon && (
437-
<div>
438-
<TrailingIcon />
439-
</div>
440-
)}
441-
</TrailingVisualContainer>
442-
)}
443-
</StyledItemContent>
423+
<DividedContent>
424+
<MainContent
425+
style={
426+
{'--main-content-flex-direction': descriptionVariant === 'inline' ? 'row' : 'column'} as React.CSSProperties
427+
}
428+
>
429+
{children}
430+
{text ? <TextContainer id={labelId}>{text}</TextContainer> : null}
431+
{description ? (
432+
<DescriptionContainer
433+
id={descriptionId}
434+
style={
435+
{
436+
'--description-container-margin-left': descriptionVariant === 'inline' ? get('space.2')(theme) : 0,
437+
'--description-container-flex-basis': descriptionVariant === 'inline' ? 0 : 'auto'
438+
} as React.CSSProperties
439+
}
440+
>
441+
{descriptionVariant === 'block' ? (
442+
description
443+
) : (
444+
<Truncate title={description} inline={true} maxWidth="100%">
445+
{description}
446+
</Truncate>
447+
)}
448+
</DescriptionContainer>
449+
) : null}
450+
</MainContent>
451+
{TrailingIcon || trailingText ? (
452+
<TrailingContent variant={variant} disabled={disabled}>
453+
{trailingText}
454+
{TrailingIcon && <TrailingIcon />}
455+
</TrailingContent>
456+
) : null}
457+
</DividedContent>
444458
</StyledItem>
445459
)
446460
}

0 commit comments

Comments
 (0)