diff --git a/.changeset/tender-turtles-serve.md b/.changeset/tender-turtles-serve.md
new file mode 100644
index 00000000000..311317e2152
--- /dev/null
+++ b/.changeset/tender-turtles-serve.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': patch
+---
+
+TreeView: Add support for a skeleton state with the TreeView.SubTree `count` prop
diff --git a/docs/content/TreeView.mdx b/docs/content/TreeView.mdx
index 9d4f116ee1d..d07cf2db27d 100644
--- a/docs/content/TreeView.mdx
+++ b/docs/content/TreeView.mdx
@@ -307,7 +307,12 @@ See [Storybook](https://primer.style/react/storybook?path=/story/components-tree
>
}
/>
- {/* */}
+
+
### TreeView.LeadingVisual
diff --git a/src/TreeView/TreeView.stories.tsx b/src/TreeView/TreeView.stories.tsx
index a153b1dba71..ba9bb1c0a8f 100644
--- a/src/TreeView/TreeView.stories.tsx
+++ b/src/TreeView/TreeView.stories.tsx
@@ -459,6 +459,107 @@ AsyncSuccess.args = {
responseTime: 2000
}
+export const AsyncWithCount: Story = args => {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [asyncItems, setAsyncItems] = React.useState([])
+
+ let state: SubTreeState = 'initial'
+
+ if (isLoading) {
+ state = 'loading'
+ } else if (asyncItems.length > 0) {
+ state = 'done'
+ }
+
+ return (
+
+
+
+ )
+}
+
+AsyncWithCount.args = {
+ responseTime: 2000,
+ count: 3
+}
+
+AsyncWithCount.argTypes = {
+ count: {
+ type: 'number'
+ }
+}
+
async function alwaysFails(responseTime: number) {
await wait(responseTime)
throw new Error('Failed to load items')
diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx
index d2afa327788..09ba38bc75a 100644
--- a/src/TreeView/TreeView.tsx
+++ b/src/TreeView/TreeView.tsx
@@ -6,13 +6,14 @@ import {
} from '@primer/octicons-react'
import {useSSRSafeId} from '@react-aria/ssr'
import React from 'react'
-import styled from 'styled-components'
+import styled, {keyframes} from 'styled-components'
import Box from '../Box'
+import {get} from '../constants'
import {useControllableState} from '../hooks/useControllableState'
import useSafeTimeout from '../hooks/useSafeTimeout'
import Spinner from '../Spinner'
import StyledOcticon from '../StyledOcticon'
-import sx, {SxProp} from '../sx'
+import sx, {SxProp, merge} from '../sx'
import Text from '../Text'
import {Theme} from '../ThemeProvider'
import createSlots from '../utils/create-slots'
@@ -112,12 +113,15 @@ export type TreeViewItemProps = {
expanded?: boolean
onExpandedChange?: (expanded: boolean) => void
onSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void
-}
+} & SxProp
const {Slots, Slot} = createSlots(['LeadingVisual', 'TrailingVisual'])
const Item = React.forwardRef(
- ({current: isCurrentItem = false, defaultExpanded = false, expanded, onExpandedChange, onSelect, children}, ref) => {
+ (
+ {current: isCurrentItem = false, defaultExpanded = false, expanded, onExpandedChange, onSelect, children, sx = {}},
+ ref
+ ) => {
const itemId = useSSRSafeId()
const labelId = useSSRSafeId()
const leadingVisualId = useSSRSafeId()
@@ -219,54 +223,57 @@ const Item = React.forwardRef(
toggle(event)
}
}}
- sx={{
- '--toggle-width': '1rem', // 16px
- position: 'relative',
- display: 'grid',
- gridTemplateColumns: `calc(${level - 1} * (var(--toggle-width) / 2)) var(--toggle-width) 1fr`,
- gridTemplateAreas: `"spacer toggle content"`,
- width: '100%',
- height: '2rem', // 32px
- fontSize: 1,
- color: 'fg.default',
- borderRadius: 2,
- cursor: 'pointer',
- '&:hover': {
- backgroundColor: 'actionListItem.default.hoverBg',
- '@media (forced-colors: active)': {
- outline: '2px solid transparent',
- outlineOffset: -2
- }
- },
- '@media (pointer: coarse)': {
- '--toggle-width': '1.5rem', // 24px
- height: '2.75rem' // 44px
- },
- // WARNING: styled-components v5.2 introduced a bug that changed
- // how it expands `&` in CSS selectors. The following selectors
- // are unnecessarily specific to work around that styled-components bug.
- // Reference issue: https://github.com/styled-components/styled-components/issues/3265
- [`#${itemId}:focus-visible > &:is(div)`]: {
- boxShadow: (theme: Theme) => `inset 0 0 0 2px ${theme.colors.accent.emphasis}`,
- '@media (forced-colors: active)': {
- outline: '2px solid SelectedItem',
- outlineOffset: -2
+ sx={merge.all([
+ {
+ '--toggle-width': '1rem', // 16px
+ position: 'relative',
+ display: 'grid',
+ gridTemplateColumns: `calc(${level - 1} * (var(--toggle-width) / 2)) var(--toggle-width) 1fr`,
+ gridTemplateAreas: `"spacer toggle content"`,
+ width: '100%',
+ minHeight: '2rem', // 32px
+ fontSize: 1,
+ color: 'fg.default',
+ borderRadius: 2,
+ cursor: 'pointer',
+ '&:hover': {
+ backgroundColor: 'actionListItem.default.hoverBg',
+ '@media (forced-colors: active)': {
+ outline: '2px solid transparent',
+ outlineOffset: -2
+ }
+ },
+ '@media (pointer: coarse)': {
+ '--toggle-width': '1.5rem', // 24px
+ minHeight: '2.75rem' // 44px
+ },
+ // WARNING: styled-components v5.2 introduced a bug that changed
+ // how it expands `&` in CSS selectors. The following selectors
+ // are unnecessarily specific to work around that styled-components bug.
+ // Reference issue: https://github.com/styled-components/styled-components/issues/3265
+ [`#${itemId}:focus-visible > &:is(div)`]: {
+ boxShadow: (theme: Theme) => `inset 0 0 0 2px ${theme.colors.accent.emphasis}`,
+ '@media (forced-colors: active)': {
+ outline: '2px solid SelectedItem',
+ outlineOffset: -2
+ }
+ },
+ '[role=treeitem][aria-current=true] > &:is(div)': {
+ bg: 'actionListItem.default.selectedBg',
+ '&::after': {
+ position: 'absolute',
+ top: 'calc(50% - 12px)',
+ left: -2,
+ width: '4px',
+ height: '24px',
+ content: '""',
+ bg: 'accent.fg',
+ borderRadius: 2
+ }
}
},
- '[role=treeitem][aria-current=true] > &:is(div)': {
- bg: 'actionListItem.default.selectedBg',
- '&::after': {
- position: 'absolute',
- top: 'calc(50% - 12px)',
- left: -2,
- width: '4px',
- height: '24px',
- content: '""',
- bg: 'accent.fg',
- borderRadius: 2
- }
- }
- }}
+ sx as SxProp
+ ])}
>
@@ -401,9 +408,13 @@ export type SubTreeState = 'initial' | 'loading' | 'done' | 'error'
export type TreeViewSubTreeProps = {
children?: React.ReactNode
state?: SubTreeState
+ /**
+ * Display a skeleton loading state with the specified count of items
+ */
+ count?: number
}
-const SubTree: React.FC = ({state, children}) => {
+const SubTree: React.FC = ({count, state, children}) => {
const {announceUpdate} = React.useContext(RootContext)
const {itemId, isExpanded} = React.useContext(ItemContext)
const [isLoadingItemVisible, setIsLoadingItemVisible] = React.useState(false)
@@ -469,14 +480,112 @@ const SubTree: React.FC = ({state, children}) => {
margin: 0
}}
>
- {isLoadingItemVisible ? : children}
+ {isLoadingItemVisible ? : children}
)
}
SubTree.displayName = 'TreeView.SubTree'
-const LoadingItem = React.forwardRef((props, ref) => {
+const shimmer = keyframes`
+ from { mask-position: 200%; }
+ to { mask-position: 0%; }
+`
+
+const SkeletonItem = styled.span`
+ display: flex;
+ align-items: center;
+ column-gap: 0.5rem;
+ height: 2rem;
+
+ @media (pointer: coarse) {
+ height: 2.75rem;
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ mask-image: linear-gradient(75deg, #000 30%, rgba(0, 0, 0, 0.65) 80%);
+ mask-size: 200%;
+ animation: ${shimmer};
+ animation-duration: 1s;
+ animation-iteration-count: infinite;
+ }
+
+ &::before {
+ content: '';
+ display: block;
+ width: 1rem;
+ height: 1rem;
+ background-color: ${get('colors.neutral.subtle')};
+ border-radius: 3px;
+ @media (forced-colors: active) {
+ outline: 1px solid transparent;
+ outline-offset: -1px;
+ }
+ }
+
+ &::after {
+ content: '';
+ display: block;
+ width: var(--tree-item-loading-width, 67%);
+ height: 1rem;
+ background-color: ${get('colors.neutral.subtle')};
+ border-radius: 3px;
+ @media (forced-colors: active) {
+ outline: 1px solid transparent;
+ outline-offset: -1px;
+ }
+ }
+
+ &:nth-of-type(5n + 1) {
+ --tree-item-loading-width: 67%;
+ }
+
+ &:nth-of-type(5n + 2) {
+ --tree-item-loading-width: 47%;
+ }
+
+ &:nth-of-type(5n + 3) {
+ --tree-item-loading-width: 73%;
+ }
+
+ &:nth-of-type(5n + 4) {
+ --tree-item-loading-width: 64%;
+ }
+
+ &:nth-of-type(5n + 5) {
+ --tree-item-loading-width: 50%;
+ }
+`
+
+type LoadingItemProps = {
+ count?: number
+}
+
+const LoadingItem = React.forwardRef((props, ref) => {
+ const {count} = props
+
+ if (count) {
+ return (
+ -
+ {Array.from({length: count}).map((_, i) => {
+ return
+ })}
+ Loading {count} items
+
+ )
+ }
+
return (
-