-
Notifications
You must be signed in to change notification settings - Fork 542
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SelectPanel: Empty State - Explore SelectPanel.Message API and managing visibility of the component under the hood #4988
Changes from all commits
9cee0a4
c7aa5de
40a0c45
344f6ce
fa5f5a6
3cd9ecc
0003e59
713b5ad
6fc7f2b
a246981
e209f47
26cddc1
b1199fb
b669cff
1012662
67dc5c9
c66feb5
4fbb3c5
2d5e2b2
cee7737
9c972c3
17e4ca8
26b6fe6
42c50c0
0d0004f
3ba9bc7
92ab9c2
7b3d5e6
1cf1e09
92303d0
4dc3b41
f673034
3a7d490
ab3ea9c
44ff862
46c94e6
ff8379b
9353d71
0a126ad
91cd5a3
40322c5
f0150e4
f72b9d4
85d0237
2eca9fa
c875223
02a6dc9
25c574a
5dc6adb
85e0bb4
0ee74e1
798008e
beca8db
14fd206
185eb17
318aa99
e9c523c
efd1216
cd9a931
619ff96
e3fe7b8
ebac341
0513474
2afa236
0f38bf4
f72aa55
6e9d706
0b57206
21e14a7
88ef070
7739645
c098cd9
aa9a38a
80e205c
cd6b487
e4b07cc
82be3b4
e903b0d
863c7ec
2abac5f
31c31ee
a7aff04
9264b1a
0d5c1ed
19daa03
38ca715
9895c58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@primer/react': minor | ||
--- | ||
|
||
[SelectPanel] Implement loading states |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import React from 'react' | ||
import Box from '../Box' | ||
import Spinner from '../Spinner' | ||
import {Stack} from '../Stack/Stack' | ||
import {SkeletonBox} from '../experimental/Skeleton/SkeletonBox' | ||
|
||
export class FilteredActionListLoadingType { | ||
public name: string | ||
public appearsInBody: boolean | ||
|
||
constructor(name: string, appearsInBody: boolean) { | ||
this.name = name | ||
this.appearsInBody = appearsInBody | ||
} | ||
} | ||
|
||
export const FilteredActionListLoadingTypes = { | ||
bodySpinner: new FilteredActionListLoadingType('body-spinner', true), | ||
bodySkeleton: new FilteredActionListLoadingType('body-skeleton', true), | ||
input: new FilteredActionListLoadingType('input', false), | ||
} | ||
|
||
export function FilteredActionListBodyLoader({loadingType}: {loadingType: FilteredActionListLoadingType}): JSX.Element { | ||
switch (loadingType) { | ||
case FilteredActionListLoadingTypes.bodySpinner: | ||
return <LoadingSpinner data-testid="filtered-action-list-spinner" /> | ||
case FilteredActionListLoadingTypes.bodySkeleton: | ||
return <LoadingSkeleton data-testid="filtered-action-list-skeleton" rows={10} /> | ||
default: | ||
return <></> | ||
} | ||
} | ||
|
||
function LoadingSpinner({...props}): JSX.Element { | ||
return ( | ||
<Box p={3}> | ||
<Stack direction="horizontal" justify="center"> | ||
<Spinner {...props} /> | ||
</Stack> | ||
</Box> | ||
) | ||
} | ||
|
||
function LoadingSkeleton({rows = 10, ...props}: {rows: number}): JSX.Element { | ||
return ( | ||
<Box p={2}> | ||
<Stack direction="vertical" justify="center" gap="condensed" {...props}> | ||
{Array.from({length: rows}, (_, i) => ( | ||
<Stack key={i} direction="horizontal" gap="condensed" align="center"> | ||
<SkeletonBox width="16px" height="16px" /> | ||
<SkeletonBox height="10px" width={`${Math.random() * 60 + 20}%`} borderRadius={2} /> | ||
</Stack> | ||
))} | ||
</Stack> | ||
</Box> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
import React, {useState, useRef, useMemo} from 'react' | ||
import type {Meta} from '@storybook/react' | ||
import type {Meta, StoryObj} from '@storybook/react' | ||
import Box from '../Box' | ||
import {Button} from '../Button' | ||
import type {ItemInput, GroupedListProps} from '../deprecated/ActionList/List' | ||
|
@@ -14,6 +14,8 @@ import { | |
TypographyIcon, | ||
VersionsIcon, | ||
} from '@primer/octicons-react' | ||
import useSafeTimeout from '../hooks/useSafeTimeout' | ||
import Link from '../Link' | ||
|
||
const meta = { | ||
title: 'Components/SelectPanel/Features', | ||
|
@@ -369,3 +371,123 @@ export const WithGroups = () => { | |
/> | ||
) | ||
} | ||
|
||
export const AsyncFetch: StoryObj<typeof SelectPanel> = { | ||
render: ({initialLoadingType}) => { | ||
const [selected, setSelected] = React.useState<ItemInput[]>([]) | ||
const [filteredItems, setFilteredItems] = React.useState<ItemInput[]>([]) | ||
const [open, setOpen] = useState(false) | ||
const filterTimerId = useRef<number | null>(null) | ||
const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() | ||
const onFilterChange = (value: string) => { | ||
if (filterTimerId.current) { | ||
safeClearTimeout(filterTimerId.current) | ||
} | ||
|
||
filterTimerId.current = safeSetTimeout(() => { | ||
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(value.toLowerCase()))) | ||
}, 2000) as unknown as number | ||
} | ||
|
||
return ( | ||
<SelectPanel | ||
title="Select labels" | ||
subtitle="Use labels to organize issues and pull requests" | ||
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => ( | ||
<Button | ||
trailingAction={TriangleDownIcon} | ||
aria-labelledby={` ${ariaLabelledBy}`} | ||
{...anchorProps} | ||
aria-haspopup="dialog" | ||
> | ||
{children ?? 'Select Labels'} | ||
</Button> | ||
)} | ||
placeholderText="Filter labels" | ||
open={open} | ||
onOpenChange={setOpen} | ||
items={filteredItems} | ||
selected={selected} | ||
onSelectedChange={setSelected} | ||
onFilterChange={onFilterChange} | ||
showItemDividers={true} | ||
initialLoadingType={initialLoadingType} | ||
/> | ||
) | ||
}, | ||
argTypes: { | ||
initialLoadingType: { | ||
control: 'select', | ||
options: ['spinner', 'skeleton'], | ||
defaultValue: 'spinner', | ||
}, | ||
}, | ||
} | ||
|
||
export const NoItems = () => { | ||
const [selected, setSelected] = React.useState<ItemInput[]>([]) | ||
const [filteredItems, setFilteredItems] = React.useState<ItemInput[]>([]) | ||
const [open, setOpen] = useState(true) | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
const onFilterChange = (value: string = '') => { | ||
setTimeout(() => { | ||
// fetch the items | ||
setFilteredItems([]) | ||
}, 0) | ||
} | ||
return ( | ||
<SelectPanel | ||
title="Set projects" | ||
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => ( | ||
<Button trailingAction={TriangleDownIcon} aria-labelledby={` ${ariaLabelledBy}`} {...anchorProps}> | ||
{children ?? 'Select Labels'} | ||
</Button> | ||
)} | ||
open={open} | ||
onOpenChange={setOpen} | ||
items={filteredItems} | ||
selected={selected} | ||
onSelectedChange={setSelected} | ||
onFilterChange={onFilterChange} | ||
overlayProps={{width: 'medium', height: 'large'}} | ||
> | ||
<SelectPanel.Message variant="noitems" title="You haven't created any projects yet"> | ||
<Link href="https://github.com/projects">Start your first project </Link> to organise your issues. | ||
</SelectPanel.Message> | ||
<SelectPanel.Message variant="nomatches" title={`No language found for `}> | ||
Adjust your search term to find other languages | ||
</SelectPanel.Message> | ||
Comment on lines
+454
to
+459
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Spiking about API options for the empty states. After having an initial conversation with @siddharthkp Option 1 (As above code)Pros
Cons
Option 2We could simplify the API and let consumers worry about managing the empty state. Like below
Pros
Cons
Alternatively we can use a prop rather than children for providing the messages. <SelectPanel
message={<SelectPanel.Message variant="empty" title={noItemsState? `No items created yet' : No language found for'`}>
{noItemsState? `Start creating your items here' : Adjust your search term to find other languages}
</SelectPanel.Message>}
</SelectPanel> Curious to hear what you think 🙌🏻 @siddharthkp @camertron There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like option 2 a lot more, it's more intuitive for me to assume what I ever I give in JSX will be rendered.
For me, the way to remove extra work would be be more configuration based and asked for <SelectPanel
message={{
noInitialItems: {title: 'No items created yet', body: <><Button>Start creating items</Button></>},
noFilteredItems: {title: `No items found for ${query}`, body: <>Adjust your search term to find other languages</>}
}}
> There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the classic question that always comes up when designing component APIs: how much freedom should we give consumers? In my experience, the more freedom we give consumers, the greater the chance for incongruous outcomes. This case is even trickier than most because we can almost get away with a props-only approach. Unfortunately the designs show that consumers should be able to add links to the message description, and for error messages it would be nice to eventually be able to add a "Try again" button, etc etc. So I think we have to allow arbitrary content for both error messages and no items/matches. That being said, IMO we can still establish some guardrails. The designs have two bits of text, a title and a description, with specific font colors and sizes. Whatever API we choose, let's make it easy to do the default thing, eg. provide plaintext values for these fields, and style them appropriately. As far as behavior goes, I'm firmly in the camp of having the component manage showing/hiding the messages based on the current filter. Eg. if there is no filter text, show the "no items" message; if there is filter text, show the "no matches" message. I would really prefer to reduce the amount of knowledge a consumer has to have about how select panels are supposed to work in order to use them correctly. Requiring consumers to make decisions about when messages are shown or hidden exacerbates this knowledge gap and will lead to problems. Not only will they have to understand the API, but also implicit rules about how to use it appropriately that they may or may not have read in the documentation. The API should be clear enough to (largely) speak for itself. @broccolinisoup and I brainstormed this during our 1:1 on Monday and came up with an approach where each message has two slots, a title and a description. I imagine it would look something like this: <SelectPanel.Message variant="no-items">
<SelectPanel.Message.Title>
No projects found.
</SelectPanel.Message.Title>
<SelectPanel.Message.Description>
Would you like to <Link href="/projects/create">create one</Link>?
</SelectPanel.Message.Description>
</SelectPanel.Message> That's kind of verbose. Maybe we can simplify it? Hopefully that resonates with you @siddharthkp! Always willing to have my mind changed tho 😄
Yeah, I get that. Hopefully an approach like the one I outlined above will still give consumers the ability to pass in custom JSX and have it be rendered as expected. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you thank you both!! I gathered everything we chatted and wrote down as a discussion #5031 so that we can have a little bit nicer place to continue our discussion and make a decision. |
||
</SelectPanel> | ||
) | ||
} | ||
export const NoMatches = () => { | ||
const [selected, setSelected] = React.useState<ItemInput[]>([]) | ||
const [filter, setFilter] = React.useState<string>('') | ||
const [open, setOpen] = useState(true) | ||
|
||
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) | ||
return ( | ||
<SelectPanel | ||
title="Set projects" | ||
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => ( | ||
<Button trailingAction={TriangleDownIcon} aria-labelledby={` ${ariaLabelledBy}`} {...anchorProps}> | ||
{children ?? 'Select Labels'} | ||
</Button> | ||
)} | ||
open={open} | ||
onOpenChange={setOpen} | ||
items={filteredItems} | ||
selected={selected} | ||
onSelectedChange={setSelected} | ||
onFilterChange={setFilter} | ||
overlayProps={{width: 'medium', height: 'small'}} | ||
> | ||
<SelectPanel.Message variant="noitems" title="You haven't created any projects yet"> | ||
<Link href="https://github.com/projects">Start your first project </Link> to organise your issues. | ||
</SelectPanel.Message> | ||
<SelectPanel.Message variant="nomatches" title={`No language found for ${filter}`}> | ||
Adjust your search term to find other languages | ||
</SelectPanel.Message> | ||
</SelectPanel> | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooh, interesting.
title={'No language found for '}>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consumer don't have to figure out which message to render, we will have defaults but if they need to customise this is how they can do (in this API option)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this seems great! My only suggestion is to add individual slots (children) for the title and description.