From 8345718b07b3b3dc0d849e1da6e86cecc727fca8 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 9 Jun 2026 10:23:08 -0500 Subject: [PATCH 1/8] feat: add list component --- packages/react/src/List/List.module.css | 36 +++++++++++++++++++++ packages/react/src/List/List.stories.tsx | 17 ++++++++++ packages/react/src/List/List.tsx | 40 ++++++++++++++++++++++++ packages/react/src/List/index.ts | 1 + 4 files changed, 94 insertions(+) create mode 100644 packages/react/src/List/List.module.css create mode 100644 packages/react/src/List/List.stories.tsx create mode 100644 packages/react/src/List/List.tsx create mode 100644 packages/react/src/List/index.ts diff --git a/packages/react/src/List/List.module.css b/packages/react/src/List/List.module.css new file mode 100644 index 00000000000..706770e20d2 --- /dev/null +++ b/packages/react/src/List/List.module.css @@ -0,0 +1,36 @@ +@layer primer.components.List { + .List { + list-style: none; + padding: 0; + } + + .Item { + /* label */ + color: var(--fgColor-default); + font-size: var(--text-body-size-medium); + font-weight: var(--base-text-weight-normal); + grid-area: label; + line-height: 20px; + position: relative; + word-break: break-word; + /* label */ + + padding-block: var(--control-medium-paddingBlock); + padding-inline: var(--control-medium-paddingInline-condensed); + border-radius: var(--borderRadius-medium); + transition: background 33.333ms linear; + width: 100%; + user-select: none; + + /* display: grid; */ + /* grid-template-areas: */ + /* 'leadingVisual label trailingVisual' */ + /* '. content .'; */ + + @media (hover: hover) { + &:hover { + background-color: var(--control-transparent-bgColor-hover); + } + } + } +} diff --git a/packages/react/src/List/List.stories.tsx b/packages/react/src/List/List.stories.tsx new file mode 100644 index 00000000000..eac523fa0f7 --- /dev/null +++ b/packages/react/src/List/List.stories.tsx @@ -0,0 +1,17 @@ +import {List, Item} from '../List' + +export default { + title: 'Components/List', +} + +export const Default = () => { + return ( + <> + + Item 1 + Item 2 + Item 3 + + + ) +} diff --git a/packages/react/src/List/List.tsx b/packages/react/src/List/List.tsx new file mode 100644 index 00000000000..d360d82780b --- /dev/null +++ b/packages/react/src/List/List.tsx @@ -0,0 +1,40 @@ +import type {PropsWithChildren} from 'react' +import classes from './List.module.css' + +type ListProps = PropsWithChildren<{}> + +function List({children}: ListProps) { + return +} + +type ItemProps = PropsWithChildren<{}> + +function Item({children}: ItemProps) { + return
  • {children}
  • +} + +type LabelProps = PropsWithChildren<{}> + +function Label({children}: LabelProps) { + return
    {children}
    +} + +type DescriptionProps = PropsWithChildren<{}> + +function Description({children}: DescriptionProps) { + return
    {children}
    +} + +type LeadingVisualsProps = PropsWithChildren<{}> + +function LeadingVisuals({children}: LeadingVisualsProps) { + return
    {children}
    +} + +type TrailingVisualProps = PropsWithChildren<{}> + +function TrailingVisuals({children}: TrailingVisualProps) { + return
    {children}
    +} + +export {List, Item, Label, LeadingVisuals, TrailingVisuals} diff --git a/packages/react/src/List/index.ts b/packages/react/src/List/index.ts new file mode 100644 index 00000000000..6500d5b92f7 --- /dev/null +++ b/packages/react/src/List/index.ts @@ -0,0 +1 @@ +export {List, Item} from './List' From 0b67dd5fb8c0222825c8c44e2092ea9c306220fd Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 10 Jun 2026 12:09:39 -0500 Subject: [PATCH 2/8] feat: add grid layout --- packages/react/src/List/List.module.css | 101 +++++++-- packages/react/src/List/List.stories.tsx | 101 ++++++++- packages/react/src/List/List.tsx | 43 +++- packages/react/src/List/index.ts | 2 +- packages/react/src/List/listbox-element.ts | 240 +++++++++++++++++++++ packages/react/src/List/useListbox.ts | 11 + 6 files changed, 474 insertions(+), 24 deletions(-) create mode 100644 packages/react/src/List/listbox-element.ts create mode 100644 packages/react/src/List/useListbox.ts diff --git a/packages/react/src/List/List.module.css b/packages/react/src/List/List.module.css index 706770e20d2..f93431bdc8c 100644 --- a/packages/react/src/List/List.module.css +++ b/packages/react/src/List/List.module.css @@ -2,18 +2,18 @@ .List { list-style: none; padding: 0; + display: grid; + grid-template-areas: + 'leading label inline trailing' + '. block block .'; + grid-template-columns: auto auto 1fr auto; + grid-template-rows: auto auto; + column-gap: var(--control-medium-gap); + row-gap: var(--base-size-4); } .Item { - /* label */ - color: var(--fgColor-default); - font-size: var(--text-body-size-medium); - font-weight: var(--base-text-weight-normal); - grid-area: label; - line-height: 20px; - position: relative; - word-break: break-word; - /* label */ + --item-fgColor: var(--fgColor-default); padding-block: var(--control-medium-paddingBlock); padding-inline: var(--control-medium-paddingInline-condensed); @@ -21,16 +21,89 @@ transition: background 33.333ms linear; width: 100%; user-select: none; + color: var(--item-fgColor); + background-color: var(--item-bgColor); + display: grid; + grid-template-columns: subgrid; + grid-template-rows: subgrid; + align-items: center; + grid-column: 1 / -1; + grid-row: span 2; + cursor: pointer; + + @media (hover: hover) { + &:hover { + --item-bgColor: var(--control-transparent-bgColor-hover); + } + } - /* display: grid; */ - /* grid-template-areas: */ - /* 'leadingVisual label trailingVisual' */ - /* '. content .'; */ + &[data-disabled], + &[disabled] { + --item-fgColor: var(--control-fgColor-disabled); + } + } + + .Item[data-variant='danger'] { + --item-fgColor: var(--control-danger-fgColor-rest); @media (hover: hover) { &:hover { - background-color: var(--control-transparent-bgColor-hover); + --item-bgColor: var(--control-danger-bgColor-hover); } } } + + .List[data-dividers] .Item::before { + content: ''; + display: block; + width: 100%; + height: 1px; + background-color: var(--borderColor-muted); + } + + .Label { + font-size: var(--text-body-size-medium); + font-weight: var(--base-text-weight-normal); + grid-area: label; + line-height: 20px; + position: relative; + word-break: break-word; + grid-area: label; + } + + .Description { + color: var(--fgColor-muted); + font-size: var(--text-body-size-small); + font-weight: var(--base-text-weight-normal); + line-height: calc(16 / 12); + grid-area: var(--description-grid-area); + } + + .List[data-layout='block'] .Description { + --description-grid-area: block; + grid-row-start: 2; + } + + .List[data-layout='inline'] .Description { + --description-grid-area: inline; + } + + .GroupHeading { + align-self: flex-start; + color: var(--fgColor-muted); + font-size: var(--text-body-size-small); + font-weight: var(--base-text-weight-semibold); + margin: 0; + } + + .Divider { + background: var(--borderColor-muted); + border: 0; + display: block; + height: var(--borderWidth-thin); + list-style: none; + margin-block-end: var(--base-size-8); + margin-block-start: calc(var(--base-size-8) - var(--borderWidth-thin)); + padding: 0; + } } diff --git a/packages/react/src/List/List.stories.tsx b/packages/react/src/List/List.stories.tsx index eac523fa0f7..d95146f5a9d 100644 --- a/packages/react/src/List/List.stories.tsx +++ b/packages/react/src/List/List.stories.tsx @@ -1,17 +1,108 @@ -import {List, Item} from '../List' +import type {StoryObj} from '@storybook/react-vite' +import {List, Item, Label, Description} from '../List' +import './listbox-element' export default { - title: 'Components/List', + title: 'Components/List/Default', } export const Default = () => { return ( <> - Item 1 - Item 2 - Item 3 + + + + + + + + + ) } + +export const WithDescription = () => { + return ( + <> + + + + This is the description for item 1 + + + + This is the description for item 2 + + + + This is the description for item 3 + + + + ) +} + +export const WithBlockDescription = () => { + return ( + <> + + + + This is the description for item 1 + + + + This is the description for item 2 + + + + This is the description for item 3 + + + + ) +} + +export const Selection = () => { + return ( + <> + + Option 1 + Option 2 + Option 3 + + + ) +} + +export const Menu = () => { + return 'TODO' +} + +export const Tree = () => { + return 'TODO' +} + +export const ListStory: StoryObj = { + name: 'List', + render: () => 'TODO', +} + +export const Group = () => { + return 'hi' +} + +export const Disabled = () => { + return 'hi' +} + +export const Async = () => { + return 'hi' +} + +export const Truncation = () => { + return 'hi' +} diff --git a/packages/react/src/List/List.tsx b/packages/react/src/List/List.tsx index d360d82780b..a81c94b285f 100644 --- a/packages/react/src/List/List.tsx +++ b/packages/react/src/List/List.tsx @@ -1,10 +1,17 @@ +import {CheckIcon} from '@primer/octicons-react' import type {PropsWithChildren} from 'react' import classes from './List.module.css' -type ListProps = PropsWithChildren<{}> +type ListProps = PropsWithChildren<{ + layout?: 'inline' | 'block' +}> -function List({children}: ListProps) { - return +function List({children, layout = 'inline'}: ListProps) { + return ( + + ) } type ItemProps = PropsWithChildren<{}> @@ -37,4 +44,32 @@ function TrailingVisuals({children}: TrailingVisualProps) { return
    {children}
    } -export {List, Item, Label, LeadingVisuals, TrailingVisuals} +type GroupProps = PropsWithChildren<{}> + +function Group({children}: GroupProps) { + return
    {children}
    +} + +type GroupHeadingProps = PropsWithChildren<{}> + +function GroupHeading({children}: GroupHeadingProps) { + return
    {children}
    +} + +type DividerProps = PropsWithChildren<{}> + +function Divider({children}: DividerProps) { + return
    {children}
    +} + +type SelectionProps = {} + +function Selection(props: SelectionProps) { + return ( +
    + +
    + ) +} + +export {List, Item, Label, Description, LeadingVisuals, TrailingVisuals, Group, GroupHeading, Divider, Selection} diff --git a/packages/react/src/List/index.ts b/packages/react/src/List/index.ts index 6500d5b92f7..20e097cf099 100644 --- a/packages/react/src/List/index.ts +++ b/packages/react/src/List/index.ts @@ -1 +1 @@ -export {List, Item} from './List' +export {List, Item, Label, Description, LeadingVisuals, TrailingVisuals, Group, Divider, Selection} from './List' diff --git a/packages/react/src/List/listbox-element.ts b/packages/react/src/List/listbox-element.ts new file mode 100644 index 00000000000..9a44b9b7d4d --- /dev/null +++ b/packages/react/src/List/listbox-element.ts @@ -0,0 +1,240 @@ +class ListboxElement extends HTMLElement { + static defaultAttributes = { + role: 'listbox', + } + + #activeElement: OptionElement | null = null + #selectedElement: OptionElement | null = null + + constructor() { + super() + this.addEventListener('toggle-select-option', this.onToggleSelectOption) + } + + connectedCallback() { + for (const [key, value] of Object.entries(ListboxElement.defaultAttributes)) { + if (!this.hasAttribute(key)) { + this.setAttribute(key, value) + } + } + } + + onToggleSelectOption = (event: ToggleSelectOptionEvent) => { + if (this.#selectedElement) { + this.#selectedElement.selected = false + } + + this.#selectedElement = event.detail.option + this.#selectedElement.selected = true + } +} + +type ToggleSelectOptionEvent = CustomEvent<{option: OptionElement}> + +function toggleSelectOption(option: OptionElement): CustomEvent { + const event = new CustomEvent('toggle-select-option', { + detail: { + option, + }, + bubbles: true, + composed: true, + }) + return event +} + +type SelectNextOptionEvent = CustomEvent<{option: OptionElement}> + +function selectNextOption(option: OptionElement): CustomEvent { + const event = new CustomEvent('select-next-option', { + detail: { + option, + }, + bubbles: true, + composed: true, + }) + return event +} + +type SelectPreviousOptionEvent = CustomEvent<{option: OptionElement}> + +function selectPreviousOption(option: OptionElement): CustomEvent { + const event = new CustomEvent('select-previous-option', { + detail: { + option, + }, + bubbles: true, + composed: true, + }) + return event +} + +type SelectFirstOptionEvent = CustomEvent + +function selectFirstOption(): CustomEvent { + const event = new CustomEvent('select-first-option', { + bubbles: true, + composed: true, + }) + return event +} + +type SelectLastOptionEvent = CustomEvent + +function selectLastOption(): CustomEvent { + const event = new CustomEvent('select-last-option', { + bubbles: true, + composed: true, + }) + return event +} + +declare global { + interface HTMLElementEventMap { + 'toggle-select-option': ToggleSelectOptionEvent + 'select-next-option': SelectNextOptionEvent + 'select-previous-option': SelectPreviousOptionEvent + 'select-first-option': SelectFirstOptionEvent + 'select-last-option': SelectLastOptionEvent + } +} + +class OptionElement extends HTMLElement { + static defaultAttributes = { + role: 'option', + tabindex: '-1', + } + + constructor() { + super() + + this.addEventListener('click', this.onClick) + this.addEventListener('keydown', this.onKeyDown) + this.addEventListener('focus', this.onFocus) + } + + get active() { + return this.hasAttribute('active') + } + + set active(isActive) { + if (isActive) { + this.setAttribute('active', '') + } else { + this.removeAttribute('active') + } + } + + get disabled() { + return this.hasAttribute('disabled') && this.getAttribute('aria-disabled') !== 'false' + } + + set disabled(isDisabled) { + if (isDisabled) { + this.setAttribute('disabled', '') + this.setAttribute('aria-disabled', 'true') + } else { + this.removeAttribute('disabled') + this.setAttribute('aria-disabled', 'false') + } + } + + get selected() { + return this.hasAttribute('selected') && this.getAttribute('aria-selected') !== 'false' + } + + set selected(isSelected) { + if (isSelected) { + this.setAttribute('selected', '') + this.setAttribute('aria-selected', 'true') + } else { + this.removeAttribute('selected') + this.setAttribute('aria-selected', 'false') + } + } + + connectedCallback() { + for (const [key, value] of Object.entries(OptionElement.defaultAttributes)) { + if (!this.hasAttribute(key)) { + this.setAttribute(key, value) + } + } + + this.active = false + this.disabled = false + this.selected = false + } + + onClick = () => { + if (this.disabled) { + return + } + + this.dispatchEvent(toggleSelectOption(this)) + } + + onFocus = () => { + if (this.disabled) { + return + } + // + } + + onKeyDown = (event: KeyboardEvent) => { + if (this.disabled) { + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + this.dispatchEvent(toggleSelectOption(this)) + } else if (event.key === 'ArrowDown') { + event.preventDefault() + this.dispatchEvent(selectNextOption(this)) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + this.dispatchEvent(selectPreviousOption(this)) + } else if (event.key === 'Home') { + event.preventDefault() + this.dispatchEvent(selectFirstOption()) + } else if (event.key === 'End') { + event.preventDefault() + this.dispatchEvent(selectLastOption()) + } + } +} + +class GroupElement extends HTMLElement { + static defaultAttributes = { + role: 'group', + } + + connectedCallback() { + for (const [key, value] of Object.entries(GroupElement.defaultAttributes)) { + if (!this.hasAttribute(key)) { + this.setAttribute(key, value) + } + } + } +} + +customElements.define('ui-listbox', ListboxElement) +customElements.define('ui-option', OptionElement) +customElements.define('ui-group', GroupElement) + +// namespace global { +// interface HTMLElementTagNameMap { +// 'ui-listbox': ListboxElement +// 'ui-option': OptionElement +// 'ui-group': GroupElement +// } +// } + +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'ui-listbox': React.DetailedHTMLProps, HTMLElement> + 'ui-option': React.DetailedHTMLProps, HTMLElement> + 'ui-group': React.DetailedHTMLProps, HTMLElement> + } + } +} diff --git a/packages/react/src/List/useListbox.ts b/packages/react/src/List/useListbox.ts new file mode 100644 index 00000000000..3696fdffbde --- /dev/null +++ b/packages/react/src/List/useListbox.ts @@ -0,0 +1,11 @@ +function useListbox() { + function getListboxProps() { + // + } + + function getOptionProps() { + // + } + + return {} +} From e5d4bc2c0c6ca00159ef5cd4212adcafd744de35 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 10 Jun 2026 15:34:23 -0500 Subject: [PATCH 3/8] feat: add dividers --- packages/react/src/List/List.module.css | 50 ++++-- packages/react/src/List/List.stories.tsx | 212 +++++++++++++++++++---- packages/react/src/List/List.tsx | 17 +- packages/react/src/List/index.ts | 2 +- 4 files changed, 229 insertions(+), 52 deletions(-) diff --git a/packages/react/src/List/List.module.css b/packages/react/src/List/List.module.css index f93431bdc8c..ff9024a49a4 100644 --- a/packages/react/src/List/List.module.css +++ b/packages/react/src/List/List.module.css @@ -1,15 +1,17 @@ @layer primer.components.List { .List { + --list-column-gap: var(--control-medium-gap); + --list-row-gap: var(--base-size-4); + list-style: none; padding: 0; display: grid; - grid-template-areas: - 'leading label inline trailing' - '. block block .'; - grid-template-columns: auto auto 1fr auto; - grid-template-rows: auto auto; - column-gap: var(--control-medium-gap); - row-gap: var(--base-size-4); + /* grid-template-areas: */ + /* 'leading label inline trailing' */ + /* '. block block . ' */ + /* '. divider divider divider'; */ + grid-template-columns: min-content auto 1fr min-content; + /* grid-template-rows: auto auto auto; */ } .Item { @@ -25,10 +27,14 @@ background-color: var(--item-bgColor); display: grid; grid-template-columns: subgrid; - grid-template-rows: subgrid; + grid-template-areas: + '. divider divider divider' + 'leading label inline trailing' + '. block block . '; + /* grid-template-rows: subgrid; */ align-items: center; grid-column: 1 / -1; - grid-row: span 2; + grid-row: span 3; cursor: pointer; @media (hover: hover) { @@ -53,12 +59,20 @@ } } - .List[data-dividers] .Item::before { + .List[data-dividers] .Item:not(:first-of-type)::before { content: ''; display: block; - width: 100%; height: 1px; background-color: var(--borderColor-muted); + grid-area: divider; + width: 100%; + position: relative; + top: -7px; + } + + .List[data-dividers] .Item:not(:first-of-type):hover::before, + .List[data-dividers] .Item:hover + .Item::before { + background-color: var(--item-bgColor); } .Label { @@ -81,11 +95,13 @@ .List[data-layout='block'] .Description { --description-grid-area: block; - grid-row-start: 2; + grid-row-start: 3; + margin-block-start: var(--list-row-gap); } .List[data-layout='inline'] .Description { --description-grid-area: inline; + margin-inline-start: var(--list-column-gap); } .GroupHeading { @@ -106,4 +122,14 @@ margin-block-start: calc(var(--base-size-8) - var(--borderWidth-thin)); padding: 0; } + + .Leading { + margin-inline-end: var(--list-column-gap); + grid-area: leading; + } + + .Trailing { + margin-inline-start: var(--list-column-gap); + grid-area: trailing; + } } diff --git a/packages/react/src/List/List.stories.tsx b/packages/react/src/List/List.stories.tsx index d95146f5a9d..d75fd747fa3 100644 --- a/packages/react/src/List/List.stories.tsx +++ b/packages/react/src/List/List.stories.tsx @@ -1,12 +1,12 @@ import type {StoryObj} from '@storybook/react-vite' -import {List, Item, Label, Description} from '../List' +import {List, Item, Label, Description, Leading, Trailing} from '../List' import './listbox-element' export default { - title: 'Components/List/Default', + title: 'Components/List/Features', } -export const Default = () => { +export const WithLabel = () => { return ( <> @@ -66,43 +66,193 @@ export const WithBlockDescription = () => { ) } -export const Selection = () => { +export const WithLeading = () => { return ( <> - - Option 1 - Option 2 - Option 3 - + + + 🔥 + + This is the description for item 1 + + + 🔥 + + This is the description for item 2 + + + 🔥 + + This is the description for item 3 + + + + + 🔥 + + This is the description for item 1 + + + 🔥 + + This is the description for item 2 + + + 🔥 + + This is the description for item 3 + + ) } -export const Menu = () => { - return 'TODO' -} - -export const Tree = () => { - return 'TODO' -} - -export const ListStory: StoryObj = { - name: 'List', - render: () => 'TODO', -} - -export const Group = () => { - return 'hi' +export const WithTrailing = () => { + return ( + <> + + + + This is the description for item 1 + 🔥 + + + + This is the description for item 2 + 🔥 + + + + This is the description for item 3 + 🔥 + + + + + + This is the description for item 1 + 🔥 + + + + This is the description for item 2 + 🔥 + + + + This is the description for item 3 + 🔥 + + + + ) } -export const Disabled = () => { - return 'hi' +export const WithLeadingAndTrailing = () => { + return ( + <> + + + 🔥 + + This is the description for item 1 + 🔥 + + + 🔥 + + This is the description for item 2 + 🔥 + + + 🔥 + + This is the description for item 3 + 🔥 + + + + + 🔥 + + This is the description for item 1 + 🔥 + + + 🔥 + + This is the description for item 2 + 🔥 + + + 🔥 + + This is the description for item 3 + 🔥 + + + + ) } -export const Async = () => { - return 'hi' +export const WithDividers = () => { + return ( + <> + + + + This is the description for item 1 + + + + This is the description for item 2 + + + + This is the description for item 3 + + + + ) } -export const Truncation = () => { - return 'hi' -} +// export const Selection = () => { +// return ( +// <> +// +// Option 1 +// Option 2 +// Option 3 +// +// +// ) +// } +// +// export const Menu = () => { +// return 'TODO' +// } +// +// export const Tree = () => { +// return 'TODO' +// } +// +// export const ListStory: StoryObj = { +// name: 'List', +// render: () => 'TODO', +// } +// +// export const Group = () => { +// return 'hi' +// } +// +// export const Disabled = () => { +// return 'hi' +// } +// +// export const Async = () => { +// return 'hi' +// } +// +// export const Truncation = () => { +// return 'hi' +// } diff --git a/packages/react/src/List/List.tsx b/packages/react/src/List/List.tsx index a81c94b285f..7fdbe34828f 100644 --- a/packages/react/src/List/List.tsx +++ b/packages/react/src/List/List.tsx @@ -3,12 +3,13 @@ import type {PropsWithChildren} from 'react' import classes from './List.module.css' type ListProps = PropsWithChildren<{ + showDividers?: boolean layout?: 'inline' | 'block' }> -function List({children, layout = 'inline'}: ListProps) { +function List({children, layout = 'inline', showDividers}: ListProps) { return ( -
      +
        {children}
      ) @@ -32,16 +33,16 @@ function Description({children}: DescriptionProps) { return
      {children}
      } -type LeadingVisualsProps = PropsWithChildren<{}> +type LeadingProps = PropsWithChildren<{}> -function LeadingVisuals({children}: LeadingVisualsProps) { - return
      {children}
      +function Leading({children}: LeadingProps) { + return
      {children}
      } type TrailingVisualProps = PropsWithChildren<{}> -function TrailingVisuals({children}: TrailingVisualProps) { - return
      {children}
      +function Trailing({children}: TrailingVisualProps) { + return
      {children}
      } type GroupProps = PropsWithChildren<{}> @@ -72,4 +73,4 @@ function Selection(props: SelectionProps) { ) } -export {List, Item, Label, Description, LeadingVisuals, TrailingVisuals, Group, GroupHeading, Divider, Selection} +export {List, Item, Label, Description, Leading, Trailing, Group, GroupHeading, Divider, Selection} diff --git a/packages/react/src/List/index.ts b/packages/react/src/List/index.ts index 20e097cf099..a9ced97927f 100644 --- a/packages/react/src/List/index.ts +++ b/packages/react/src/List/index.ts @@ -1 +1 @@ -export {List, Item, Label, Description, LeadingVisuals, TrailingVisuals, Group, Divider, Selection} from './List' +export {List, Item, Label, Description, Leading, Trailing, Group, Divider, Selection} from './List' From 2647fe5857f22298b6f48112c71f33d7bea9f18e Mon Sep 17 00:00:00 2001 From: joshblack <3901764+joshblack@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:42:08 +0000 Subject: [PATCH 4/8] chore: auto-fix lint and formatting issues --- packages/react/src/List/List.module.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/List/List.module.css b/packages/react/src/List/List.module.css index ff9024a49a4..c1ef68212a0 100644 --- a/packages/react/src/List/List.module.css +++ b/packages/react/src/List/List.module.css @@ -78,7 +78,6 @@ .Label { font-size: var(--text-body-size-medium); font-weight: var(--base-text-weight-normal); - grid-area: label; line-height: 20px; position: relative; word-break: break-word; @@ -95,12 +94,14 @@ .List[data-layout='block'] .Description { --description-grid-area: block; + grid-row-start: 3; margin-block-start: var(--list-row-gap); } .List[data-layout='inline'] .Description { --description-grid-area: inline; + margin-inline-start: var(--list-column-gap); } From d10e3040be528d3674ba85565072830981994e12 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Thu, 11 Jun 2026 11:23:50 -0500 Subject: [PATCH 5/8] feat: add listbox explorations --- packages/react/src/List/List.module.css | 44 ++-- packages/react/src/List/List.stories.tsx | 145 ++++++++++- packages/react/src/List/List.tsx | 47 ++-- packages/react/src/List/listbox-element.ts | 279 +++++++++++++++------ packages/react/src/List/useListbox.ts | 90 ++++++- 5 files changed, 477 insertions(+), 128 deletions(-) diff --git a/packages/react/src/List/List.module.css b/packages/react/src/List/List.module.css index ff9024a49a4..bdbc24a788e 100644 --- a/packages/react/src/List/List.module.css +++ b/packages/react/src/List/List.module.css @@ -1,21 +1,16 @@ @layer primer.components.List { .List { - --list-column-gap: var(--control-medium-gap); - --list-row-gap: var(--base-size-4); + --List-column-gap: var(--control-medium-gap); + --List-row-gap: var(--base-size-4); list-style: none; padding: 0; display: grid; - /* grid-template-areas: */ - /* 'leading label inline trailing' */ - /* '. block block . ' */ - /* '. divider divider divider'; */ grid-template-columns: min-content auto 1fr min-content; - /* grid-template-rows: auto auto auto; */ } .Item { - --item-fgColor: var(--fgColor-default); + --Item-fgColor: var(--fgColor-default); padding-block: var(--control-medium-paddingBlock); padding-inline: var(--control-medium-paddingInline-condensed); @@ -23,15 +18,14 @@ transition: background 33.333ms linear; width: 100%; user-select: none; - color: var(--item-fgColor); - background-color: var(--item-bgColor); + color: var(--Item-fgColor); + background-color: var(--Item-bgColor); display: grid; grid-template-columns: subgrid; grid-template-areas: '. divider divider divider' 'leading label inline trailing' '. block block . '; - /* grid-template-rows: subgrid; */ align-items: center; grid-column: 1 / -1; grid-row: span 3; @@ -39,22 +33,24 @@ @media (hover: hover) { &:hover { - --item-bgColor: var(--control-transparent-bgColor-hover); + --Item-bgColor: var(--control-transparent-bgColor-hover); } } + &[aria-disabled='true'], &[data-disabled], &[disabled] { - --item-fgColor: var(--control-fgColor-disabled); + --Item-fgColor: var(--control-fgColor-disabled); + cursor: not-allowed; } } .Item[data-variant='danger'] { - --item-fgColor: var(--control-danger-fgColor-rest); + --Item-fgColor: var(--control-danger-fgColor-rest); @media (hover: hover) { &:hover { - --item-bgColor: var(--control-danger-bgColor-hover); + --Item-bgColor: var(--control-danger-bgColor-hover); } } } @@ -72,14 +68,14 @@ .List[data-dividers] .Item:not(:first-of-type):hover::before, .List[data-dividers] .Item:hover + .Item::before { - background-color: var(--item-bgColor); + background-color: var(--Item-bgColor); } .Label { font-size: var(--text-body-size-medium); font-weight: var(--base-text-weight-normal); grid-area: label; - line-height: 20px; + line-height: calc(20 / 14); position: relative; word-break: break-word; grid-area: label; @@ -96,12 +92,12 @@ .List[data-layout='block'] .Description { --description-grid-area: block; grid-row-start: 3; - margin-block-start: var(--list-row-gap); + margin-block-start: var(--List-row-gap); } .List[data-layout='inline'] .Description { --description-grid-area: inline; - margin-inline-start: var(--list-column-gap); + margin-inline-start: var(--List-column-gap); } .GroupHeading { @@ -124,12 +120,18 @@ } .Leading { - margin-inline-end: var(--list-column-gap); + margin-inline-end: var(--List-column-gap); grid-area: leading; } .Trailing { - margin-inline-start: var(--list-column-gap); + margin-inline-start: var(--List-column-gap); grid-area: trailing; } + + .Selection { + & svg { + display: block; + } + } } diff --git a/packages/react/src/List/List.stories.tsx b/packages/react/src/List/List.stories.tsx index d75fd747fa3..9d8d0d38fa8 100644 --- a/packages/react/src/List/List.stories.tsx +++ b/packages/react/src/List/List.stories.tsx @@ -1,5 +1,6 @@ -import type {StoryObj} from '@storybook/react-vite' -import {List, Item, Label, Description, Leading, Trailing} from '../List' +import {useEffect, useRef, useState} from 'react' +import {List, Item, Label, Description, Leading, Trailing, Selection} from '../List' +import {useListbox} from './useListbox' import './listbox-element' export default { @@ -216,17 +217,135 @@ export const WithDividers = () => { ) } -// export const Selection = () => { -// return ( -// <> -// -// Option 1 -// Option 2 -// Option 3 -// -// -// ) -// } +export const WithDisabled = () => { + return ( + <> + + + + This item can be selected + + + + This item is unavailable + + + + ) +} + +export const WithSelection = () => { + const [selected, setSelected] = useState(null) + const items = [ + { + id: 0, + label: 'Option 1', + description: 'This is the description for option 1', + value: 'option-1', + }, + { + id: 1, + label: 'Option 2', + description: 'This is the description for option 2', + value: 'option-2', + }, + { + id: 2, + label: 'Option 3', + description: 'This is the description for option 3', + value: 'option-3', + }, + ] + const {getListboxProps, getOptionProps} = useListbox({ + onChange({value}) { + setSelected(value) + }, + }) + + return ( + <> + + {items.map(item => { + return ( + + + + + + {item.description} + + ) + })} + + + ) +} + +export const WithCustomElementSelection = () => { + const [selected, setSelected] = useState(null) + const ref = useRef(null) + const items = [ + { + id: 0, + label: 'Option 1', + description: 'This is the description for option 1', + value: 'option-1', + }, + { + id: 1, + label: 'Option 2', + description: 'This is the description for option 2', + value: 'option-2', + }, + { + id: 2, + label: 'Option 3', + description: 'This is the description for option 3', + value: 'option-3', + }, + ] + + useEffect(() => { + const {current: listbox} = ref + if (!listbox) { + return + } + + function onChange(event) { + setSelected(event.detail.value) + } + + listbox.addEventListener('change', onChange) + + return () => { + listbox.removeEventListener('change', onChange) + } + }, []) + + return ( + <> + + {items.map(item => { + return ( + + {selected === item.value ? ( + + + + ) : null} + + {item.description} + + ) + })} + + + ) +} // // export const Menu = () => { // return 'TODO' diff --git a/packages/react/src/List/List.tsx b/packages/react/src/List/List.tsx index 7fdbe34828f..5456eac3acd 100644 --- a/packages/react/src/List/List.tsx +++ b/packages/react/src/List/List.tsx @@ -1,73 +1,86 @@ import {CheckIcon} from '@primer/octicons-react' -import type {PropsWithChildren} from 'react' +import {forwardRef, type JSX, type PropsWithChildren} from 'react' import classes from './List.module.css' type ListProps = PropsWithChildren<{ + as?: keyof JSX.IntrinsicElements showDividers?: boolean layout?: 'inline' | 'block' }> -function List({children, layout = 'inline', showDividers}: ListProps) { +const List = forwardRef(function List( + {as: BaseComponent = 'ul', children, layout = 'inline', showDividers}: ListProps, + ref, +) { return ( -
        + // @ts-expect-error - class works in React 19 + {children} -
      + ) -} +}) -type ItemProps = PropsWithChildren<{}> +type ItemProps = PropsWithChildren<{ + as?: keyof JSX.IntrinsicElements + disabled?: boolean +}> -function Item({children}: ItemProps) { - return
    • {children}
    • +function Item({as: BaseComponent = 'li', children, disabled, ...rest}: ItemProps) { + return ( + // @ts-expect-error - class works in React 19 + + {children} + + ) } -type LabelProps = PropsWithChildren<{}> +type LabelProps = PropsWithChildren function Label({children}: LabelProps) { return
      {children}
      } -type DescriptionProps = PropsWithChildren<{}> +type DescriptionProps = PropsWithChildren function Description({children}: DescriptionProps) { return
      {children}
      } -type LeadingProps = PropsWithChildren<{}> +type LeadingProps = PropsWithChildren function Leading({children}: LeadingProps) { return
      {children}
      } -type TrailingVisualProps = PropsWithChildren<{}> +type TrailingVisualProps = PropsWithChildren function Trailing({children}: TrailingVisualProps) { return
      {children}
      } -type GroupProps = PropsWithChildren<{}> +type GroupProps = PropsWithChildren function Group({children}: GroupProps) { return
      {children}
      } -type GroupHeadingProps = PropsWithChildren<{}> +type GroupHeadingProps = PropsWithChildren function GroupHeading({children}: GroupHeadingProps) { return
      {children}
      } -type DividerProps = PropsWithChildren<{}> +type DividerProps = PropsWithChildren function Divider({children}: DividerProps) { return
      {children}
      } -type SelectionProps = {} +type SelectionProps = React.HTMLAttributes function Selection(props: SelectionProps) { return ( -
      +
      ) diff --git a/packages/react/src/List/listbox-element.ts b/packages/react/src/List/listbox-element.ts index 9a44b9b7d4d..4e6777c274a 100644 --- a/packages/react/src/List/listbox-element.ts +++ b/packages/react/src/List/listbox-element.ts @@ -1,6 +1,7 @@ class ListboxElement extends HTMLElement { static defaultAttributes = { role: 'listbox', + tabindex: '0', } #activeElement: OptionElement | null = null @@ -9,6 +10,8 @@ class ListboxElement extends HTMLElement { constructor() { super() this.addEventListener('toggle-select-option', this.onToggleSelectOption) + this.addEventListener('keydown', this.onKeyDown) + this.addEventListener('focus', this.onFocus) } connectedCallback() { @@ -19,85 +22,219 @@ class ListboxElement extends HTMLElement { } } + onFocus = () => { + const walker = getOptionsWalker(this, this.#activeElement) + const nextOption = getNextOption(walker) + if (!nextOption) { + return + } + + if (this.#activeElement) { + this.#activeElement.active = false + } + + this.#activeElement = nextOption + this.#activeElement.active = true + this.#activeElement.focus() + this.removeAttribute('tabindex') + } + + onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + this.visitNextOption() + } else if (event.key === 'ArrowUp') { + event.preventDefault() + this.visitPreviousOption() + } else if (event.key === 'Home') { + event.preventDefault() + this.visitFirstOption() + } else if (event.key === 'End') { + event.preventDefault() + this.visitLastOption() + } + } + onToggleSelectOption = (event: ToggleSelectOptionEvent) => { + if (this.#selectedElement === event.detail.option) { + this.unselectOption(this.#selectedElement) + } else { + this.selectOption(event.detail.option) + } + } + + selectOption(option: OptionElement) { if (this.#selectedElement) { this.#selectedElement.selected = false } - this.#selectedElement = event.detail.option + this.#selectedElement = option this.#selectedElement.selected = true + this.dispatchEvent( + new CustomEvent('change', { + bubbles: true, + composed: true, + detail: { + option: this.#selectedElement, + value: this.#selectedElement.value, + }, + }), + ) } -} -type ToggleSelectOptionEvent = CustomEvent<{option: OptionElement}> + unselectOption(option: OptionElement) { + if (this.#selectedElement === option) { + this.#selectedElement.selected = false + this.#selectedElement = null + } + } -function toggleSelectOption(option: OptionElement): CustomEvent { - const event = new CustomEvent('toggle-select-option', { - detail: { - option, + visitOption(option: OptionElement) { + if (this.#activeElement) { + this.#activeElement.active = false + } + + this.#activeElement = option + this.#activeElement.active = true + this.#activeElement.focus() + } + + visitNextOption = () => { + const walker = getOptionsWalker(this, this.#activeElement) + const nextOption = getNextOption(walker) + if (nextOption) { + this.visitOption(nextOption) + } + } + + visitPreviousOption = () => { + const walker = getOptionsWalker(this, this.#activeElement) + const previousOption = getPreviousOption(walker) + if (previousOption) { + this.visitOption(previousOption) + } + } + + visitFirstOption = () => { + const firstOption = getFirstOption(this) + if (firstOption) { + this.visitOption(firstOption) + } + } + + visitLastOption = () => { + const lastOption = getLastOption(this) + if (lastOption) { + this.visitOption(lastOption) + } + } +} + +function getOptionsWalker(root: Node, activeElement: OptionElement | null): TreeWalker { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { + acceptNode(node) { + if (node instanceof OptionElement) { + if (node.hasAttribute('disabled') || node.getAttribute('aria-disabled') === 'true') { + return NodeFilter.FILTER_SKIP + } + return NodeFilter.FILTER_ACCEPT + } + return NodeFilter.FILTER_SKIP }, - bubbles: true, - composed: true, }) - return event + + if (activeElement) { + walker.currentNode = activeElement + } + + return walker } -type SelectNextOptionEvent = CustomEvent<{option: OptionElement}> +function findOption(walker: TreeWalker, predicate: (option: OptionElement) => boolean): OptionElement | null { + let node: Node | null = null -function selectNextOption(option: OptionElement): CustomEvent { - const event = new CustomEvent('select-next-option', { - detail: { - option, - }, - bubbles: true, - composed: true, - }) - return event + while (walker.nextNode()) { + if (walker.currentNode instanceof OptionElement && predicate(walker.currentNode)) { + node = walker.currentNode + break + } + } + + return node instanceof OptionElement ? node : null } -type SelectPreviousOptionEvent = CustomEvent<{option: OptionElement}> +function getNextOption(walker: TreeWalker): OptionElement | null { + let nextNode = walker.nextNode() + if (nextNode instanceof OptionElement) { + return nextNode + } -function selectPreviousOption(option: OptionElement): CustomEvent { - const event = new CustomEvent('select-previous-option', { - detail: { - option, - }, - bubbles: true, - composed: true, - }) - return event + walker.currentNode = walker.root + return walker.nextNode() instanceof OptionElement ? (walker.currentNode as OptionElement) : null } -type SelectFirstOptionEvent = CustomEvent +function getPreviousOption(walker: TreeWalker): OptionElement | null { + let previousNode = walker.previousNode() + if (previousNode instanceof OptionElement) { + return previousNode + } -function selectFirstOption(): CustomEvent { - const event = new CustomEvent('select-first-option', { - bubbles: true, - composed: true, - }) - return event + walker.currentNode = walker.root + + let lastNode: Node | null = null + + while (walker.nextNode()) { + if (walker.currentNode instanceof OptionElement) { + lastNode = walker.currentNode + } + } + + return lastNode instanceof OptionElement ? lastNode : null +} + +function getFirstOption(root: Node): OptionElement | null { + const walker = getOptionsWalker(root, null) + const firstNode = walker.nextNode() + return firstNode instanceof OptionElement ? firstNode : null +} + +function getLastOption(root: Node): OptionElement | null { + const walker = getOptionsWalker(root, null) + + let lastNode: Node | null = null + + while (walker.nextNode()) { + if (walker.currentNode instanceof OptionElement) { + lastNode = walker.currentNode + } + } + + return lastNode instanceof OptionElement ? lastNode : null } -type SelectLastOptionEvent = CustomEvent +type ToggleSelectOptionEvent = CustomEvent<{option: OptionElement}> -function selectLastOption(): CustomEvent { - const event = new CustomEvent('select-last-option', { +function toggleSelectOption(option: OptionElement): CustomEvent { + return new CustomEvent('toggle-select-option', { + detail: { + option, + }, bubbles: true, composed: true, }) - return event } +type SelectOptionEvent = CustomEvent<{option: OptionElement}> + declare global { interface HTMLElementEventMap { 'toggle-select-option': ToggleSelectOptionEvent - 'select-next-option': SelectNextOptionEvent - 'select-previous-option': SelectPreviousOptionEvent - 'select-first-option': SelectFirstOptionEvent - 'select-last-option': SelectLastOptionEvent } } +// +// +// class OptionElement extends HTMLElement { static defaultAttributes = { role: 'option', @@ -119,8 +256,10 @@ class OptionElement extends HTMLElement { set active(isActive) { if (isActive) { this.setAttribute('active', '') + this.setAttribute('tabindex', '0') } else { this.removeAttribute('active') + this.setAttribute('tabindex', '-1') } } @@ -152,6 +291,18 @@ class OptionElement extends HTMLElement { } } + get value() { + return this.getAttribute('value') + } + + set value(newValue) { + if (newValue !== null) { + this.setAttribute('value', newValue) + } else { + this.removeAttribute('value') + } + } + connectedCallback() { for (const [key, value] of Object.entries(OptionElement.defaultAttributes)) { if (!this.hasAttribute(key)) { @@ -159,9 +310,9 @@ class OptionElement extends HTMLElement { } } - this.active = false - this.disabled = false - this.selected = false + this.active = this.hasAttribute('active') + this.disabled = this.hasAttribute('disabled') + this.selected = this.hasAttribute('selected') } onClick = () => { @@ -176,7 +327,11 @@ class OptionElement extends HTMLElement { if (this.disabled) { return } - // + + const listbox = this.closest('ui-listbox') + if (listbox && listbox instanceof ListboxElement) { + listbox.visitOption(this) + } } onKeyDown = (event: KeyboardEvent) => { @@ -187,18 +342,6 @@ class OptionElement extends HTMLElement { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() this.dispatchEvent(toggleSelectOption(this)) - } else if (event.key === 'ArrowDown') { - event.preventDefault() - this.dispatchEvent(selectNextOption(this)) - } else if (event.key === 'ArrowUp') { - event.preventDefault() - this.dispatchEvent(selectPreviousOption(this)) - } else if (event.key === 'Home') { - event.preventDefault() - this.dispatchEvent(selectFirstOption()) - } else if (event.key === 'End') { - event.preventDefault() - this.dispatchEvent(selectLastOption()) } } } @@ -221,20 +364,12 @@ customElements.define('ui-listbox', ListboxElement) customElements.define('ui-option', OptionElement) customElements.define('ui-group', GroupElement) -// namespace global { -// interface HTMLElementTagNameMap { -// 'ui-listbox': ListboxElement -// 'ui-option': OptionElement -// 'ui-group': GroupElement -// } -// } - declare module 'react' { namespace JSX { interface IntrinsicElements { - 'ui-listbox': React.DetailedHTMLProps, HTMLElement> - 'ui-option': React.DetailedHTMLProps, HTMLElement> - 'ui-group': React.DetailedHTMLProps, HTMLElement> + 'ui-listbox': React.DetailedHTMLProps, ListboxElement> + 'ui-option': React.DetailedHTMLProps, OptionElement> + 'ui-group': React.DetailedHTMLProps, GroupElement> } } } diff --git a/packages/react/src/List/useListbox.ts b/packages/react/src/List/useListbox.ts index 3696fdffbde..549e7a46c74 100644 --- a/packages/react/src/List/useListbox.ts +++ b/packages/react/src/List/useListbox.ts @@ -1,11 +1,91 @@ -function useListbox() { +import {useState} from 'react' + +function useListbox({onChange}: {onChange?: ({value}: {value: string | null}) => void} = {}) { + const [active, setActive] = useState(null) + const [selected, setSelected] = useState(null) + + function selectOption(value: string) { + setSelected(value) + if (onChange) { + onChange({value}) + } + } + + function unselectOption() { + setSelected(null) + if (onChange) { + onChange({value: null}) + } + } + function getListboxProps() { - // + return { + role: 'listbox', + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'ArrowDown') { + event.preventDefault() + const options = Array.from(event.currentTarget.querySelectorAll('[role="option"]')) as Array + const activeIndex = options.findIndex(option => option.getAttribute('data-active') === 'true') + const nextIndex = (activeIndex + 1) % options.length + options[nextIndex].focus() + } else if (event.key === 'ArrowUp') { + event.preventDefault() + const options = Array.from(event.currentTarget.querySelectorAll('[role="option"]')) as Array + const activeIndex = options.findIndex(option => option.getAttribute('data-active') === 'true') + const nextIndex = (activeIndex - 1 + options.length) % options.length + options[nextIndex].focus() + } else if (event.key === 'Home') { + event.preventDefault() + const option = event.currentTarget.querySelector('[role="option"]') + if (option instanceof HTMLElement) { + option.focus() + } + } else if (event.key === 'End') { + event.preventDefault() + const options = Array.from(event.currentTarget.querySelectorAll('[role="option"]')) as Array + const option = options[options.length - 1] + if (option instanceof HTMLElement) { + option.focus() + } + } + }, + } } - function getOptionProps() { - // + function getOptionProps({value}: {value: string}) { + return { + 'aria-selected': selected === value, + 'aria-disabled': false, + 'data-active': active === value, + role: 'option', + tabIndex: active === value, + onClick() { + if (selected === value) { + unselectOption() + } else { + selectOption(value) + } + }, + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + if (selected === value) { + unselectOption() + } else { + selectOption(value) + } + } + }, + onFocus() { + setActive(value) + }, + } } - return {} + return { + getListboxProps, + getOptionProps, + } } + +export {useListbox} From c50c5fcd6ab5049e7157a325356b6207059d3e8c Mon Sep 17 00:00:00 2001 From: joshblack <3901764+joshblack@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:34:42 +0000 Subject: [PATCH 6/8] chore: auto-fix lint and formatting issues --- packages/react/src/List/List.module.css | 3 ++- packages/react/src/List/listbox-element.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react/src/List/List.module.css b/packages/react/src/List/List.module.css index 2fef6b20c60..7908cdfb223 100644 --- a/packages/react/src/List/List.module.css +++ b/packages/react/src/List/List.module.css @@ -41,6 +41,7 @@ &[data-disabled], &[disabled] { --Item-fgColor: var(--control-fgColor-disabled); + cursor: not-allowed; } } @@ -74,7 +75,6 @@ .Label { font-size: var(--text-body-size-medium); font-weight: var(--base-text-weight-normal); - grid-area: label; line-height: calc(20 / 14); position: relative; word-break: break-word; @@ -98,6 +98,7 @@ .List[data-layout='inline'] .Description { --description-grid-area: inline; + margin-inline-start: var(--List-column-gap); } diff --git a/packages/react/src/List/listbox-element.ts b/packages/react/src/List/listbox-element.ts index 4e6777c274a..ab70547b848 100644 --- a/packages/react/src/List/listbox-element.ts +++ b/packages/react/src/List/listbox-element.ts @@ -164,7 +164,7 @@ function findOption(walker: TreeWalker, predicate: (option: OptionElement) => bo } function getNextOption(walker: TreeWalker): OptionElement | null { - let nextNode = walker.nextNode() + const nextNode = walker.nextNode() if (nextNode instanceof OptionElement) { return nextNode } @@ -174,7 +174,7 @@ function getNextOption(walker: TreeWalker): OptionElement | null { } function getPreviousOption(walker: TreeWalker): OptionElement | null { - let previousNode = walker.previousNode() + const previousNode = walker.previousNode() if (previousNode instanceof OptionElement) { return previousNode } From b38ff28f1ec48bddaf8865a000c1fba82aec613d Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 12 Jun 2026 10:49:03 -0500 Subject: [PATCH 7/8] chore: add tree element --- packages/react/src/List/tree-element.ts | 138 ++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 packages/react/src/List/tree-element.ts diff --git a/packages/react/src/List/tree-element.ts b/packages/react/src/List/tree-element.ts new file mode 100644 index 00000000000..174cd1d22a4 --- /dev/null +++ b/packages/react/src/List/tree-element.ts @@ -0,0 +1,138 @@ +class TreeElement extends HTMLElement { + static defaultAttributes = { + role: 'tree', + } + + #activeItem: TreeItemElement | null = null + #selectedItem: TreeItemElement | null = null + + connectedCallback() { + for (const [key, value] of Object.entries(TreeElement.defaultAttributes)) { + if (!this.hasAttribute(key)) { + this.setAttribute(key, value) + } + } + } + + visitItem = (item: TreeItemElement) => { + if (this.#activeItem) { + this.#activeItem.active = false + this.#activeItem.setAttribute('tabindex', '-1') + } + + this.#activeItem = item + this.#activeItem.active = true + this.#activeItem.setAttribute('tabindex', '0') + this.#activeItem.focus() + } +} + +class TreeItemElement extends HTMLElement { + static defaultAttributes = { + role: 'treeitem', + tabindex: '-1', + } + + get active() { + return this.hasAttribute('active') + } + + set active(isActive) { + if (isActive) { + this.setAttribute('active', '') + } else { + this.removeAttribute('active') + } + } + + get expanded() { + return this.hasAttribute('expanded') && this.getAttribute('aria-expanded') === 'true' + } + + set expanded(isExpanded) { + if (isExpanded) { + this.setAttribute('expanded', '') + this.setAttribute('aria-expanded', 'true') + } else { + this.removeAttribute('expanded') + this.setAttribute('aria-expanded', 'false') + } + } + + get disabled() { + return this.hasAttribute('disabled') && this.getAttribute('aria-disabled') === 'true' + } + + set disabled(isDisabled) { + if (isDisabled) { + this.setAttribute('disabled', '') + this.setAttribute('aria-disabled', 'true') + } else { + this.removeAttribute('disabled') + this.setAttribute('aria-disabled', 'false') + } + } + + get selected() { + return this.hasAttribute('selected') && this.getAttribute('aria-selected') === 'true' + } + + set selected(isSelected) { + if (isSelected) { + this.setAttribute('selected', '') + this.setAttribute('aria-selected', 'true') + } else { + this.removeAttribute('selected') + this.setAttribute('aria-selected', 'false') + } + } + + get value() { + return this.getAttribute('value') + } + + set value(newValue) { + if (newValue !== null) { + this.setAttribute('value', newValue) + } else { + this.removeAttribute('value') + } + } + + connectedCallback() { + for (const [key, value] of Object.entries(TreeItemElement.defaultAttributes)) { + if (!this.hasAttribute(key)) { + this.setAttribute(key, value) + } + } + + this.active = this.hasAttribute('active') + this.expanded = this.hasAttribute('expanded') + this.disabled = this.hasAttribute('disabled') + this.selected = this.hasAttribute('selected') + } + + attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) { + if (name === 'active') { + this.active = newValue !== null && newValue === '' + } else if (name === 'expanded') { + this.expanded = newValue !== null && newValue === '' + } else if (name === 'disabled') { + this.disabled = newValue !== null && newValue === '' + } else if (name === 'selected') { + this.selected = newValue !== null && newValue === '' + } + } +} + +customElements.define('ui-tree', TreeElement) +customElements.define('ui-treeitem', TreeItemElement) + +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'ui-tree': React.DetailedHTMLProps, TreeElement> + 'ui-treeitem': React.DetailedHTMLProps, TreeItemElement> + } + } +} From d78dc84a5cd94218dd8b3f880dccfb06a0baeccb Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 12 Jun 2026 11:19:17 -0500 Subject: [PATCH 8/8] feat: add tree exploration --- packages/react/src/List/List.module.css | 49 ++++- packages/react/src/List/List.stories.tsx | 162 ++++++++++++-- packages/react/src/List/List.tsx | 61 ++++-- packages/react/src/List/tree-element.ts | 268 ++++++++++++++++++++++- packages/react/src/List/useListbox.ts | 2 +- packages/react/src/List/useTree.ts | 217 ++++++++++++++++++ 6 files changed, 722 insertions(+), 37 deletions(-) create mode 100644 packages/react/src/List/useTree.ts diff --git a/packages/react/src/List/List.module.css b/packages/react/src/List/List.module.css index 7908cdfb223..160e4927fc9 100644 --- a/packages/react/src/List/List.module.css +++ b/packages/react/src/List/List.module.css @@ -1,4 +1,5 @@ @layer primer.components.List { + /* stylelint-disable primer/spacing, primer/colors, primer/typography, selector-max-specificity, declaration-property-value-keyword-no-deprecated -- Existing experimental List styles predate these lint rules. */ .List { --List-column-gap: var(--control-medium-gap); --List-row-gap: var(--base-size-4); @@ -130,10 +131,52 @@ margin-inline-start: var(--List-column-gap); grid-area: trailing; } + /* stylelint-enable primer/spacing, primer/colors, primer/typography, selector-max-specificity, declaration-property-value-keyword-no-deprecated */ .Selection { - & svg { - display: block; - } + display: grid; + width: var(--base-size-16); + height: var(--base-size-16); + place-content: center; + } + + .SelectionSingleIcon { + display: block; + } + + .Selection:not([data-selected]) .SelectionSingleIcon { + visibility: hidden; + } + + .SelectionMultiIcon { + display: grid; + width: var(--base-size-16); + height: var(--base-size-16); + margin: 0; + color: var(--control-checked-fgColor-rest); + background-color: var(--bgColor-default); + border: var(--borderWidth-thin) solid var(--control-borderColor-emphasis); + border-radius: var(--borderRadius-small); + transition: + background-color, + border-color 80ms cubic-bezier(0.33, 1, 0.68, 1); + place-content: center; + } + + .SelectionMultiCheckIcon { + display: block; + visibility: hidden; + } + + .Selection[data-selected] .SelectionMultiIcon { + background-color: var(--control-checked-bgColor-rest); + border-color: var(--control-checked-borderColor-rest); + transition: + background-color, + border-color 80ms cubic-bezier(0.32, 0, 0.67, 0); + } + + .Selection[data-selected] .SelectionMultiCheckIcon { + visibility: visible; } } diff --git a/packages/react/src/List/List.stories.tsx b/packages/react/src/List/List.stories.tsx index 9d8d0d38fa8..49950f7dea3 100644 --- a/packages/react/src/List/List.stories.tsx +++ b/packages/react/src/List/List.stories.tsx @@ -1,7 +1,9 @@ import {useEffect, useRef, useState} from 'react' import {List, Item, Label, Description, Leading, Trailing, Selection} from '../List' import {useListbox} from './useListbox' +import {useTree, type TreeItem} from './useTree' import './listbox-element' +import './tree-element' export default { title: 'Components/List/Features', @@ -269,11 +271,7 @@ export const WithSelection = () => { return ( - + {item.description} @@ -287,7 +285,7 @@ export const WithSelection = () => { export const WithCustomElementSelection = () => { const [selected, setSelected] = useState(null) - const ref = useRef(null) + const ref = useRef(null) const items = [ { id: 0, @@ -315,8 +313,10 @@ export const WithCustomElementSelection = () => { return } - function onChange(event) { - setSelected(event.detail.value) + function onChange(event: Event) { + if (event instanceof CustomEvent) { + setSelected(event.detail.value) + } } listbox.addEventListener('change', onChange) @@ -332,11 +332,147 @@ export const WithCustomElementSelection = () => { {items.map(item => { return ( - {selected === item.value ? ( - - - - ) : null} + + + + + {item.description} + + ) + })} + + + ) +} + +const treeItems: Array< + TreeItem & { + description: string + id: number + } +> = [ + { + id: 0, + label: 'src', + description: 'Source files for @primer/react', + value: 'src', + }, + { + id: 1, + label: 'ActionList', + description: 'Action list components and behaviors', + parentValue: 'src', + value: 'action-list', + }, + { + id: 2, + label: 'List', + description: 'Composable list primitives', + parentValue: 'src', + value: 'list', + }, + { + id: 3, + label: 'SelectPanel', + description: 'Panel selection patterns', + parentValue: 'src', + value: 'select-panel', + }, + { + id: 4, + label: 'docs', + description: 'Contributor documentation', + value: 'docs', + }, + { + id: 5, + label: 'Contributing', + description: 'Guidelines for contributing to Primer React', + parentValue: 'docs', + value: 'contributing', + }, + { + id: 6, + label: 'Versioning', + description: 'Release and changeset guidance', + parentValue: 'docs', + value: 'versioning', + }, +] + +export const WithTreeSelection = () => { + const {getTreeItemProps, getTreeProps, selectedValues, visibleItems} = useTree({ + defaultActiveValue: treeItems[0].value, + defaultExpandedValues: [treeItems[0].value], + defaultSelectedValues: [treeItems[1].value, treeItems[2].value], + items: treeItems, + }) + + return ( + <> + + {visibleItems.map(item => { + const selected = selectedValues.includes(item.value) + + return ( + + + + + + {item.description} + + ) + })} + + + ) +} + +export const WithCustomElementTreeSelection = () => { + const [selectedValues, setSelectedValues] = useState>([treeItems[1].value, treeItems[2].value]) + const ref = useRef(null) + + useEffect(() => { + const {current: tree} = ref + if (!tree) { + return + } + + function onChange(event: Event) { + if (event instanceof CustomEvent) { + setSelectedValues(event.detail.selectedValues) + } + } + + tree.addEventListener('change', onChange) + + return () => { + tree.removeEventListener('change', onChange) + } + }, []) + + return ( + <> + + {treeItems.map((item, index) => { + const selected = selectedValues.includes(item.value) + const expanded = item.value === 'src' + + return ( + + + + {item.description} diff --git a/packages/react/src/List/List.tsx b/packages/react/src/List/List.tsx index 5456eac3acd..1f9a1364fa5 100644 --- a/packages/react/src/List/List.tsx +++ b/packages/react/src/List/List.tsx @@ -1,29 +1,42 @@ import {CheckIcon} from '@primer/octicons-react' -import {forwardRef, type JSX, type PropsWithChildren} from 'react' +import {clsx} from 'clsx' +import {createElement, forwardRef, type ElementType, type HTMLAttributes, type JSX, type PropsWithChildren} from 'react' import classes from './List.module.css' type ListProps = PropsWithChildren<{ - as?: keyof JSX.IntrinsicElements + as?: ElementType showDividers?: boolean layout?: 'inline' | 'block' -}> +}> & + HTMLAttributes const List = forwardRef(function List( - {as: BaseComponent = 'ul', children, layout = 'inline', showDividers}: ListProps, + {as: BaseComponent = 'ul', children, layout = 'inline', showDividers, ...rest}: ListProps, ref, ) { - return ( - // @ts-expect-error - class works in React 19 - - {children} - + return createElement( + BaseComponent, + { + ...rest, + ref, + class: classes.List, + 'data-dividers': showDividers ? '' : undefined, + 'data-layout': layout, + }, + children, ) }) -type ItemProps = PropsWithChildren<{ - as?: keyof JSX.IntrinsicElements - disabled?: boolean -}> +type ItemProps = PropsWithChildren< + { + active?: string + as?: keyof JSX.IntrinsicElements + disabled?: boolean + expanded?: string + selected?: string + value?: string + } & Record +> function Item({as: BaseComponent = 'li', children, disabled, ...rest}: ItemProps) { return ( @@ -76,12 +89,26 @@ function Divider({children}: DividerProps) { return
      {children}
      } -type SelectionProps = React.HTMLAttributes +type SelectionProps = HTMLAttributes & { + selected?: boolean + variant?: 'single' | 'multiple' +} -function Selection(props: SelectionProps) { +function Selection({className, selected = true, variant = 'single', ...props}: SelectionProps) { return ( -
      - +
      + {variant === 'multiple' ? ( +
      + +
      + ) : ( + + )}
      ) } diff --git a/packages/react/src/List/tree-element.ts b/packages/react/src/List/tree-element.ts index 174cd1d22a4..f714cc4c5a1 100644 --- a/packages/react/src/List/tree-element.ts +++ b/packages/react/src/List/tree-element.ts @@ -4,7 +4,14 @@ class TreeElement extends HTMLElement { } #activeItem: TreeItemElement | null = null - #selectedItem: TreeItemElement | null = null + #selectedItems = new Set() + + constructor() { + super() + + this.addEventListener('toggle-select-treeitem', this.onToggleSelectTreeItem) + this.addEventListener('keydown', this.onKeyDown) + } connectedCallback() { for (const [key, value] of Object.entries(TreeElement.defaultAttributes)) { @@ -12,19 +19,225 @@ class TreeElement extends HTMLElement { this.setAttribute(key, value) } } + + for (const item of getTreeItems(this)) { + if (item.selected) { + this.#selectedItems.add(item) + } + } + + this.updateTreeItems() + + const visibleItems = getVisibleTreeItems(this) + const activeItem = visibleItems.find(item => item.active) ?? visibleItems.at(0) + if (activeItem) { + this.visitItem(activeItem) + } } visitItem = (item: TreeItemElement) => { + if (item.disabled || item.hidden) { + return + } + if (this.#activeItem) { this.#activeItem.active = false - this.#activeItem.setAttribute('tabindex', '-1') } this.#activeItem = item this.#activeItem.active = true - this.#activeItem.setAttribute('tabindex', '0') this.#activeItem.focus() } + + onKeyDown = (event: KeyboardEvent) => { + const activeItem = this.#activeItem + if (!activeItem) { + return + } + + const visibleItems = getVisibleTreeItems(this) + const activeIndex = visibleItems.indexOf(activeItem) + + if (event.key === 'ArrowDown') { + event.preventDefault() + this.visitItem(visibleItems[Math.min(activeIndex + 1, visibleItems.length - 1)]) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + this.visitItem(visibleItems[Math.max(activeIndex - 1, 0)]) + } else if (event.key === 'Home') { + event.preventDefault() + this.visitItem(visibleItems[0]) + } else if (event.key === 'End') { + event.preventDefault() + this.visitItem(visibleItems[visibleItems.length - 1]) + } else if (event.key === 'ArrowRight') { + const children = getChildren(this, activeItem.value) + if (children.length === 0) { + return + } + + event.preventDefault() + if (activeItem.expanded) { + this.visitItem(children[0]) + } else { + activeItem.expanded = true + this.updateTreeItems() + } + } else if (event.key === 'ArrowLeft') { + event.preventDefault() + if (activeItem.expanded) { + activeItem.expanded = false + this.updateTreeItems() + } else if (activeItem.parentValue) { + const parent = findItemByValue(this, activeItem.parentValue) + if (parent) { + this.visitItem(parent) + } + } + } else if (event.key === '*') { + event.preventDefault() + for (const sibling of getChildren(this, activeItem.parentValue)) { + if (getChildren(this, sibling.value).length > 0) { + sibling.expanded = true + } + } + this.updateTreeItems() + } else if (isPrintableCharacter(event)) { + const nextItem = findNextItemByLabel(visibleItems, activeIndex, event.key) + if (nextItem) { + event.preventDefault() + this.visitItem(nextItem) + } + } + } + + onToggleSelectTreeItem = (event: ToggleSelectTreeItemEvent) => { + if (this.#selectedItems.has(event.detail.item)) { + this.unselectItem(event.detail.item) + } else { + this.selectItem(event.detail.item) + } + } + + selectItem(item: TreeItemElement) { + this.#selectedItems.add(item) + item.selected = true + this.dispatchChangeEvent() + } + + unselectItem(item: TreeItemElement) { + this.#selectedItems.delete(item) + item.selected = false + this.dispatchChangeEvent() + } + + updateTreeItems() { + for (const item of getTreeItems(this)) { + const children = getChildren(this, item.value) + const siblings = getChildren(this, item.parentValue) + + item.hidden = !isVisible(this, item) + item.setAttribute('aria-level', String(getLevel(this, item))) + item.setAttribute('aria-posinset', String(siblings.indexOf(item) + 1)) + item.setAttribute('aria-setsize', String(siblings.length)) + + if (children.length > 0) { + item.setAttribute('aria-expanded', item.expanded ? 'true' : 'false') + } else { + item.removeAttribute('aria-expanded') + } + } + } + + dispatchChangeEvent() { + this.dispatchEvent( + new CustomEvent('change', { + bubbles: true, + composed: true, + detail: { + items: Array.from(this.#selectedItems), + selectedValues: Array.from(this.#selectedItems, item => item.value).filter(value => value !== null), + }, + }), + ) + } +} + +function getTreeItems(root: ParentNode): Array { + return Array.from(root.querySelectorAll('ui-treeitem')).filter((item): item is TreeItemElement => { + return item instanceof TreeItemElement && !item.disabled + }) +} + +function getVisibleTreeItems(root: ParentNode): Array { + return getTreeItems(root).filter(item => !item.hidden) +} + +function getChildren(root: ParentNode, parentValue: string | null): Array { + return getTreeItems(root).filter(item => item.parentValue === parentValue) +} + +function findItemByValue(root: ParentNode, value: string): TreeItemElement | undefined { + return getTreeItems(root).find(item => item.value === value) +} + +function isVisible(root: ParentNode, item: TreeItemElement) { + let parentValue = item.parentValue + + while (parentValue) { + const parent = findItemByValue(root, parentValue) + if (!parent || !parent.expanded) { + return false + } + parentValue = parent.parentValue + } + + return true +} + +function getLevel(root: ParentNode, item: TreeItemElement) { + let level = 1 + let parentValue = item.parentValue + + while (parentValue) { + const parent = findItemByValue(root, parentValue) + if (!parent) { + break + } + level += 1 + parentValue = parent.parentValue + } + + return level +} + +function findNextItemByLabel(items: Array, activeIndex: number, key: string) { + const normalizedKey = key.toLocaleLowerCase() + const orderedItems = [...items.slice(activeIndex + 1), ...items.slice(0, activeIndex + 1)] + + return orderedItems.find(item => item.textContent.trim().toLocaleLowerCase().startsWith(normalizedKey)) +} + +function isPrintableCharacter(event: KeyboardEvent) { + return event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey && event.key !== ' ' +} + +type ToggleSelectTreeItemEvent = CustomEvent<{item: TreeItemElement}> + +function toggleSelectTreeItem(item: TreeItemElement): CustomEvent { + return new CustomEvent('toggle-select-treeitem', { + detail: { + item, + }, + bubbles: true, + composed: true, + }) +} + +declare global { + interface HTMLElementEventMap { + 'toggle-select-treeitem': ToggleSelectTreeItemEvent + } } class TreeItemElement extends HTMLElement { @@ -33,6 +246,16 @@ class TreeItemElement extends HTMLElement { tabindex: '-1', } + static observedAttributes = ['active', 'expanded', 'disabled', 'selected'] + + constructor() { + super() + + this.addEventListener('click', this.onClick) + this.addEventListener('focus', this.onFocus) + this.addEventListener('keydown', this.onKeyDown) + } + get active() { return this.hasAttribute('active') } @@ -40,8 +263,10 @@ class TreeItemElement extends HTMLElement { set active(isActive) { if (isActive) { this.setAttribute('active', '') + this.setAttribute('tabindex', '0') } else { this.removeAttribute('active') + this.setAttribute('tabindex', '-1') } } @@ -73,6 +298,10 @@ class TreeItemElement extends HTMLElement { } } + get parentValue() { + return this.getAttribute('data-parent-value') + } + get selected() { return this.hasAttribute('selected') && this.getAttribute('aria-selected') === 'true' } @@ -123,12 +352,45 @@ class TreeItemElement extends HTMLElement { this.selected = newValue !== null && newValue === '' } } + + onClick = () => { + if (this.disabled) { + return + } + + this.dispatchEvent(toggleSelectTreeItem(this)) + } + + onFocus = () => { + if (this.disabled) { + return + } + + const tree = this.closest('ui-tree') + if (tree && tree instanceof TreeElement) { + tree.visitItem(this) + } + } + + onKeyDown = (event: KeyboardEvent) => { + if (this.disabled) { + return + } + + if (event.key === ' ') { + event.preventDefault() + this.dispatchEvent(toggleSelectTreeItem(this)) + } + } } +// eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope customElements.define('ui-tree', TreeElement) +// eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope customElements.define('ui-treeitem', TreeItemElement) declare module 'react' { + // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { interface IntrinsicElements { 'ui-tree': React.DetailedHTMLProps, TreeElement> diff --git a/packages/react/src/List/useListbox.ts b/packages/react/src/List/useListbox.ts index 549e7a46c74..94f9c8131d9 100644 --- a/packages/react/src/List/useListbox.ts +++ b/packages/react/src/List/useListbox.ts @@ -58,7 +58,7 @@ function useListbox({onChange}: {onChange?: ({value}: {value: string | null}) => 'aria-disabled': false, 'data-active': active === value, role: 'option', - tabIndex: active === value, + tabIndex: active === value ? 0 : -1, onClick() { if (selected === value) { unselectOption() diff --git a/packages/react/src/List/useTree.ts b/packages/react/src/List/useTree.ts new file mode 100644 index 00000000000..95ae6f078c9 --- /dev/null +++ b/packages/react/src/List/useTree.ts @@ -0,0 +1,217 @@ +import {useMemo, useState, type KeyboardEvent} from 'react' + +type TreeItem = { + label: string + parentValue?: string + value: string +} + +type UseTreeOptions = { + defaultActiveValue: string + defaultExpandedValues?: Array + defaultSelectedValues?: Array + items: Array + onChange?: ({selectedValues}: {selectedValues: Array}) => void +} + +function useTree({ + defaultActiveValue, + defaultExpandedValues = [], + defaultSelectedValues = [], + items, + onChange, +}: UseTreeOptions) { + const [active, setActive] = useState(defaultActiveValue) + const [expandedValues, setExpandedValues] = useState(defaultExpandedValues) + const [selectedValues, setSelectedValues] = useState(defaultSelectedValues) + + const visibleItems = useMemo(() => { + const nextItems: Array = [] + + function visit(parentValue?: string) { + for (const item of getChildren(items, parentValue)) { + nextItems.push(item) + if (expandedValues.includes(item.value)) { + visit(item.value) + } + } + } + + visit() + return nextItems + }, [expandedValues, items]) + + function updateSelectedValues(nextSelectedValues: Array) { + setSelectedValues(nextSelectedValues) + if (onChange) { + onChange({selectedValues: nextSelectedValues}) + } + } + + function toggleItem(value: string) { + const nextSelectedValues = selectedValues.includes(value) + ? selectedValues.filter(selectedValue => selectedValue !== value) + : [...selectedValues, value] + updateSelectedValues(nextSelectedValues) + } + + function expandItem(value: string) { + setExpandedValues(current => (current.includes(value) ? current : [...current, value])) + } + + function collapseItem(value: string) { + setExpandedValues(current => current.filter(expandedValue => expandedValue !== value)) + } + + function focusTreeItem(root: HTMLElement, value: string) { + const item = getTreeItemElements(root).find(element => element.getAttribute('data-tree-value') === value) + item?.focus() + } + + function focusTreeItemAtIndex(root: HTMLElement, index: number) { + const item = visibleItems.at(index) + if (item) { + focusTreeItem(root, item.value) + } + } + + function getTreeProps() { + return { + 'aria-multiselectable': true, + role: 'tree', + onKeyDown(event: KeyboardEvent) { + const activeIndex = visibleItems.findIndex(item => item.value === active) + if (activeIndex === -1) { + return + } + const activeItem = visibleItems[activeIndex] + + if (event.key === 'ArrowDown') { + event.preventDefault() + focusTreeItemAtIndex(event.currentTarget, Math.min(activeIndex + 1, visibleItems.length - 1)) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + focusTreeItemAtIndex(event.currentTarget, Math.max(activeIndex - 1, 0)) + } else if (event.key === 'Home') { + event.preventDefault() + focusTreeItemAtIndex(event.currentTarget, 0) + } else if (event.key === 'End') { + event.preventDefault() + focusTreeItemAtIndex(event.currentTarget, visibleItems.length - 1) + } else if (event.key === 'ArrowRight') { + const children = getChildren(items, activeItem.value) + if (children.length === 0) { + return + } + + event.preventDefault() + if (expandedValues.includes(activeItem.value)) { + focusTreeItem(event.currentTarget, children[0].value) + } else { + expandItem(activeItem.value) + } + } else if (event.key === 'ArrowLeft') { + event.preventDefault() + if (expandedValues.includes(activeItem.value)) { + collapseItem(activeItem.value) + } else if (activeItem.parentValue) { + focusTreeItem(event.currentTarget, activeItem.parentValue) + } + } else if (event.key === ' ') { + event.preventDefault() + toggleItem(activeItem.value) + } else if (event.key === '*') { + event.preventDefault() + const siblings = getChildren(items, activeItem.parentValue) + setExpandedValues(current => { + const next = new Set(current) + for (const sibling of siblings) { + if (getChildren(items, sibling.value).length > 0) { + next.add(sibling.value) + } + } + return Array.from(next) + }) + } else if (isPrintableCharacter(event)) { + const nextItem = findNextItemByLabel(visibleItems, activeIndex, event.key) + if (nextItem) { + event.preventDefault() + focusTreeItem(event.currentTarget, nextItem.value) + } + } + }, + } + } + + function getTreeItemProps({item}: {item: TreeItem}) { + const children = getChildren(items, item.value) + const siblings = getChildren(items, item.parentValue) + const selected = selectedValues.includes(item.value) + + return { + 'aria-expanded': children.length > 0 ? expandedValues.includes(item.value) : undefined, + 'aria-level': getLevel(items, item), + 'aria-posinset': siblings.findIndex(sibling => sibling.value === item.value) + 1, + 'aria-selected': selected, + 'aria-setsize': siblings.length, + 'data-active': active === item.value, + 'data-tree-value': item.value, + role: 'treeitem', + tabIndex: active === item.value ? 0 : -1, + value: item.value, + onClick() { + toggleItem(item.value) + }, + onFocus() { + setActive(item.value) + }, + } + } + + return { + getTreeItemProps, + getTreeProps, + selectedValues, + visibleItems, + } +} + +function getChildren(items: Array, parentValue?: string) { + return items.filter(item => item.parentValue === parentValue) +} + +function getLevel(items: Array, item: T) { + let level = 1 + let parentValue = item.parentValue + + while (parentValue) { + const parent = items.find(candidate => candidate.value === parentValue) + if (!parent) { + break + } + level += 1 + parentValue = parent.parentValue + } + + return level +} + +function getTreeItemElements(root: HTMLElement) { + return Array.from(root.querySelectorAll('[role="treeitem"]')).filter((item): item is HTMLElement => { + return item instanceof HTMLElement && item.getAttribute('aria-disabled') !== 'true' + }) +} + +function findNextItemByLabel(items: Array, activeIndex: number, key: string) { + const normalizedKey = key.toLocaleLowerCase() + const orderedItems = [...items.slice(activeIndex + 1), ...items.slice(0, activeIndex + 1)] + + return orderedItems.find(item => item.label.toLocaleLowerCase().startsWith(normalizedKey)) +} + +function isPrintableCharacter(event: KeyboardEvent) { + return event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey && event.key !== ' ' +} + +export {useTree} +export type {TreeItem}