diff --git a/.changeset/tame-nails-live.md b/.changeset/tame-nails-live.md
new file mode 100644
index 00000000000..5c39b099be4
--- /dev/null
+++ b/.changeset/tame-nails-live.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": minor
+---
+
+TreeView: Add support for `TreeView.LeadingAction`
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-colorblind-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-colorblind-linux.png
new file mode 100644
index 00000000000..7720ef27c8e
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-colorblind-linux.png differ
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-dimmed-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-dimmed-linux.png
new file mode 100644
index 00000000000..6a6ace273e0
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-dimmed-linux.png differ
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-high-contrast-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-high-contrast-linux.png
new file mode 100644
index 00000000000..5ecd5b32e16
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-high-contrast-linux.png differ
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-linux.png
new file mode 100644
index 00000000000..c695e47df2a
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-linux.png differ
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-tritanopia-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-tritanopia-linux.png
new file mode 100644
index 00000000000..0aaca4b1b03
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-dark-tritanopia-linux.png differ
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-colorblind-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-colorblind-linux.png
new file mode 100644
index 00000000000..1225ebc74f6
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-colorblind-linux.png differ
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-high-contrast-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-high-contrast-linux.png
new file mode 100644
index 00000000000..5b08dd9d293
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-high-contrast-linux.png differ
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-linux.png
new file mode 100644
index 00000000000..9f8888445bf
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-linux.png differ
diff --git a/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-tritanopia-linux.png b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-tritanopia-linux.png
new file mode 100644
index 00000000000..e5428c1f3a9
Binary files /dev/null and b/.playwright/snapshots/components/TreeView.test.ts-snapshots/TreeView-Leading-Action-light-tritanopia-linux.png differ
diff --git a/e2e/components/TreeView.test.ts b/e2e/components/TreeView.test.ts
index 1f665117e85..62d3d4ab11b 100644
--- a/e2e/components/TreeView.test.ts
+++ b/e2e/components/TreeView.test.ts
@@ -138,4 +138,37 @@ test.describe('TreeView', () => {
})
}
})
+
+ test.describe('Leading Action', () => {
+ for (const theme of themes) {
+ test.describe(theme, () => {
+ test('default @vrt', async ({page}) => {
+ await visit(page, {
+ id: 'components-treeview-features--leading-action',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+
+ expect(await page.screenshot()).toMatchSnapshot(`TreeView.Leading Action.${theme}.png`)
+ })
+
+ test('axe @aat', async ({page}) => {
+ await visit(page, {
+ id: 'components-treeview-features--leading-action',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+ await expect(page).toHaveNoViolations({
+ rules: {
+ 'color-contrast': {
+ enabled: theme !== 'dark_dimmed',
+ },
+ },
+ })
+ })
+ })
+ }
+ })
})
diff --git a/packages/react/src/TreeView/TreeView.docs.json b/packages/react/src/TreeView/TreeView.docs.json
index 5f820b0b02a..fb8d83893ef 100644
--- a/packages/react/src/TreeView/TreeView.docs.json
+++ b/packages/react/src/TreeView/TreeView.docs.json
@@ -105,6 +105,16 @@
}
]
},
+ {
+ "name": "TreeView.LeadingAction",
+ "props": [
+ {
+ "name": "children",
+ "required": true,
+ "type": "React.ReactNode"
+ }
+ ]
+ },
{
"name": "TreeView.DirectoryIcon",
"props": []
diff --git a/packages/react/src/TreeView/TreeView.examples.stories.tsx b/packages/react/src/TreeView/TreeView.examples.stories.tsx
new file mode 100644
index 00000000000..47e2b1f291d
--- /dev/null
+++ b/packages/react/src/TreeView/TreeView.examples.stories.tsx
@@ -0,0 +1,76 @@
+import {GrabberIcon} from '@primer/octicons-react'
+import type {Meta, Story} from '@storybook/react'
+import React from 'react'
+import Box from '../Box'
+import {TreeView} from './TreeView'
+import {IconButton} from '../Button'
+
+const meta: Meta = {
+ title: 'Components/TreeView/Examples',
+ component: TreeView,
+ decorators: [
+ Story => {
+ return (
+ // Prevent TreeView from expanding to the full width of the screen
+
+
+
+ )
+ },
+ ],
+}
+
+export const DraggableListItem: Story = () => {
+ return (
+
+
+ Item 1
+
+ Item 2
+
+ sub task 1
+ sub task 2
+
+
+ Item 3
+
+
+ )
+}
+
+const ControlledDraggableItem: React.FC<{id: string; children: React.ReactNode}> = ({id, children}) => {
+ const [expanded, setExpanded] = React.useState(false)
+
+ return (
+ <>
+
+
+ {
+ setExpanded(false)
+ // other drag logic to follow
+ }}
+ />
+
+ {children}
+
+ >
+ )
+}
+
+export default meta
diff --git a/packages/react/src/TreeView/TreeView.features.stories.tsx b/packages/react/src/TreeView/TreeView.features.stories.tsx
index 9470ad91730..73d02f13d75 100644
--- a/packages/react/src/TreeView/TreeView.features.stories.tsx
+++ b/packages/react/src/TreeView/TreeView.features.stories.tsx
@@ -4,7 +4,10 @@ import {
DiffRemovedIcon,
DiffRenamedIcon,
FileIcon,
+ GrabberIcon,
KebabHorizontalIcon,
+ IssueClosedIcon,
+ IssueOpenedIcon,
} from '@primer/octicons-react'
import type {Meta, Story} from '@storybook/react'
import React from 'react'
@@ -989,4 +992,52 @@ export const WithoutIndentation: Story = () => (
)
+export const LeadingAction: Story = () => {
+ return (
+
+
+
+
+
+
+
+
+ Item 1
+
+
+
+
+
+
+
+
+ Item 2
+
+
+
+
+
+ sub task 1
+
+
+
+
+
+ sub task 2
+
+
+
+
+
+
+
+
+
+
+ Item 3
+
+
+ )
+}
+
export default meta
diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx
index 212bb9bf49c..53e86b916f5 100644
--- a/packages/react/src/TreeView/TreeView.tsx
+++ b/packages/react/src/TreeView/TreeView.tsx
@@ -97,6 +97,9 @@ const UlBox = styled.ul`
outline-offset: -2;
}
}
+ &[data-has-leading-action] {
+ --has-leading-action: 1;
+ }
}
.PRIVATE_TreeView-item-container {
@@ -104,8 +107,10 @@ const UlBox = styled.ul`
--toggle-width: 1rem; /* 16px */
position: relative;
display: grid;
- grid-template-columns: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2)) var(--toggle-width) 1fr;
- grid-template-areas: 'spacer toggle content';
+ --leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem);
+ --spacer-width: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2));
+ grid-template-columns: var(--spacer-width) var(--leading-action-width) var(--toggle-width) 1fr;
+ grid-template-areas: 'spacer leadingAction toggle content';
width: 100%;
min-height: 2rem; /* 32px */
font-size: ${get('fontSizes.1')};
@@ -138,7 +143,7 @@ const UlBox = styled.ul`
}
&[data-omit-spacer='true'] .PRIVATE_TreeView-item-container {
- grid-template-columns: 0 0 1fr;
+ grid-template-columns: 0 0 0 1fr;
}
.PRIVATE_TreeView-item[aria-current='true'] > .PRIVATE_TreeView-item-container {
@@ -202,6 +207,12 @@ const UlBox = styled.ul`
color: ${get('colors.fg.muted')};
}
+ .PRIVATE_TreeView-item-leading-action {
+ display: flex;
+ color: ${get('colors.fg.muted')};
+ grid-area: leadingAction;
+ }
+
.PRIVATE_TreeView-item-level-line {
width: 100%;
height: 100%;
@@ -354,11 +365,16 @@ const Item = React.forwardRef(
},
ref,
) => {
- const [slots, rest] = useSlots(children, {leadingVisual: LeadingVisual, trailingVisual: TrailingVisual})
+ const [slots, rest] = useSlots(children, {
+ leadingAction: LeadingAction,
+ leadingVisual: LeadingVisual,
+ trailingVisual: TrailingVisual,
+ })
const {expandedStateCache} = React.useContext(RootContext)
const labelId = useId()
const leadingVisualId = useId()
const trailingVisualId = useId()
+
const [isExpanded, setIsExpanded] = useControllableState({
name: itemId,
// If the item was previously mounted, it's expanded state might be cached.
@@ -449,6 +465,7 @@ const Item = React.forwardRef(
aria-expanded={isSubTreeEmpty ? undefined : isExpanded}
aria-current={isCurrentItem ? 'true' : undefined}
aria-selected={isFocused ? 'true' : 'false'}
+ data-has-leading-action={slots.leadingAction ? true : undefined}
onKeyDown={handleKeyDown}
onFocus={event => {
// Scroll the first child into view when the item receives focus
@@ -488,6 +505,7 @@ const Item = React.forwardRef(
+ {slots.leadingAction}
{hasSubTree ? (
// This lint rule is disabled due to the guidelines in the `TreeView` api docs.
// https://github.com/github/primer/blob/main/apis/tree-view-api.md#the-expandcollapse-chevron-toggle
@@ -829,6 +847,25 @@ const TrailingVisual: React.FC = props => {
TrailingVisual.displayName = 'TreeView.TrailingVisual'
+// ----------------------------------------------------------------------------
+// TreeView.LeadingAction
+
+const LeadingAction: React.FC = props => {
+ const {isExpanded} = React.useContext(ItemContext)
+ const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children
+ return (
+ <>
+
+ {props.label}
+
+
+ {children}
+
+ >
+ )
+}
+
+LeadingAction.displayName = 'TreeView.LeadingAction'
// ----------------------------------------------------------------------------
// TreeView.DirectoryIcon
@@ -898,6 +935,7 @@ ErrorDialog.displayName = 'TreeView.ErrorDialog'
export const TreeView = Object.assign(Root, {
Item,
SubTree,
+ LeadingAction,
LeadingVisual,
TrailingVisual,
DirectoryIcon,