diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 063585f2b..fdc3b1308 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -151,6 +151,7 @@ export default defineConfig({
{ text: 'Toggle Group', link: '/components/toggle-group' },
{ text: 'Toolbar', link: '/components/toolbar' },
{ text: 'Tooltip', link: '/components/tooltip' },
+ { text: `Tree ${BadgeHTML('Alpha')}`, link: '/components/tree' },
],
},
{
diff --git a/docs/components/Demos.vue b/docs/components/Demos.vue
index c493f1471..15b1ceacf 100644
--- a/docs/components/Demos.vue
+++ b/docs/components/Demos.vue
@@ -39,6 +39,7 @@ import ToggleDemo from './demo/Toggle/tailwind/index.vue'
import ToggleGroupDemo from './demo/ToggleGroup/tailwind/index.vue'
import ToolbarDemo from './demo/Toolbar/tailwind/index.vue'
import TooltipDemo from './demo/Tooltip/tailwind/index.vue'
+import TreeDemo from './demo/Tree/tailwind/index.vue'
import DemoContainer from './DemoContainer.vue'
@@ -171,5 +172,8 @@ import DemoContainer from './DemoContainer.vue'
+
+
+
diff --git a/docs/components/demo/CalendarYearIncrement/css/index.vue b/docs/components/demo/CalendarYearIncrement/css/index.vue
index 5f5a14d6c..3abf2fce7 100644
--- a/docs/components/demo/CalendarYearIncrement/css/index.vue
+++ b/docs/components/demo/CalendarYearIncrement/css/index.vue
@@ -2,16 +2,16 @@
import { Icon } from '@iconify/vue'
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNext, CalendarPrev, CalendarRoot, type CalendarRootProps } from 'radix-vue'
import './styles.css'
-import type { DateValue } from '@internationalized/date';
+import type { DateValue } from '@internationalized/date'
const isDateUnavailable: CalendarRootProps['isDateUnavailable'] = (date) => {
return date.day === 17 || date.day === 18
}
-const pagingFunc = (date: DateValue, sign: -1 | 1) => {
+function pagingFunc(date: DateValue, sign: -1 | 1) {
if (sign === -1)
- return date.subtract({ years: 1})
- return date.add({ years: 1})
+ return date.subtract({ years: 1 })
+ return date.add({ years: 1 })
}
diff --git a/docs/components/demo/CalendarYearIncrement/tailwind/index.vue b/docs/components/demo/CalendarYearIncrement/tailwind/index.vue
index dc6c26c03..05b6b6f91 100644
--- a/docs/components/demo/CalendarYearIncrement/tailwind/index.vue
+++ b/docs/components/demo/CalendarYearIncrement/tailwind/index.vue
@@ -1,16 +1,16 @@
diff --git a/docs/components/demo/Tree/css/index.vue b/docs/components/demo/Tree/css/index.vue
new file mode 100644
index 000000000..f1e314efb
--- /dev/null
+++ b/docs/components/demo/Tree/css/index.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+ Directory Structure
+
+
+
+
+
+
+
+
+ {{ item.value.title }}
+
+
+
+
diff --git a/docs/components/demo/Tree/tailwind/index.vue b/docs/components/demo/Tree/tailwind/index.vue
new file mode 100644
index 000000000..0adb0d0ea
--- /dev/null
+++ b/docs/components/demo/Tree/tailwind/index.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+ Directory Structure
+
+
+
+
+
+
+
+
+ {{ item.value.title }}
+
+
+
+
diff --git a/docs/components/demo/Tree/tailwind/tailwind.config.js b/docs/components/demo/Tree/tailwind/tailwind.config.js
new file mode 100644
index 000000000..b84aff59e
--- /dev/null
+++ b/docs/components/demo/Tree/tailwind/tailwind.config.js
@@ -0,0 +1,15 @@
+const { green, grass } = require('@radix-ui/colors')
+
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ['./**/*.vue'],
+ theme: {
+ extend: {
+ colors: {
+ ...green,
+ ...grass,
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/docs/content/components/number-field.md b/docs/content/components/number-field.md
index c5002c5d6..c272d9d18 100644
--- a/docs/content/components/number-field.md
+++ b/docs/content/components/number-field.md
@@ -8,6 +8,8 @@ aria: https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton
# Number Field
+Alpha
+
A number field allows a user to enter a number and increment or decrement the value using stepper buttons.
diff --git a/docs/content/components/tree.md b/docs/content/components/tree.md
new file mode 100644
index 000000000..d27220b58
--- /dev/null
+++ b/docs/content/components/tree.md
@@ -0,0 +1,319 @@
+---
+
+title: Tree
+description: A tree view widget displays a hierarchical list of items that can be expanded or collapsed to show or hide their child items, such as in a file system navigator.
+name: tree
+aria: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
+---
+
+# Tree
+
+Alpha
+
+
+A tree view widget displays a hierarchical list of items that can be expanded or collapsed to show or hide their child items, such as in a file system navigator.
+
+
+
+
+## Features
+
+
+
+## Installation
+
+Install the component from your command line.
+
+
+
+## Anatomy
+
+Import all parts and piece them together.
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## API Reference
+
+### Root
+
+Contains all the parts of a tree.
+
+
+
+### Item
+
+The item component.
+
+
+
+
+
+### Virtualizer
+
+Virtual container to achieve list virtualization.
+
+
+
+## Examples
+
+### Selecting multiple items
+
+The `Tree` component allows you to select multiple items. You can enable this by providing an array of values instead of a single value.
+
+```vue line=12,16
+
+
+
+
+ ...
+
+
+```
+
+### Virtual List
+
+Rendering a long list of item can slow down the app, thus using virtualization would significantly improve the performance.
+
+```vue line=9-16
+
+
+
+
+
+
+
+ {{ person.name }}
+
+
+
+
+```
+
+### With Checkbox
+
+Some `Tree` component might want to show `toggled/indeterminate` checkbox. We can change the behavior of the `Tree` component by using a few props and `preventDefault` event.
+
+We set `propagateSelect` to `true` because we want the parent checkbox to select/deselect it's descendants. Then, we add a checkbox that triggers `select` event.
+
+```vue line=10-11,17-25,29-33
+
+
+
+
+ {
+ if (event.detail.originalEvent.type === 'click')
+ event.preventDefault()
+ }"
+ @toggle="(event) => {
+ if (event.detail.originalEvent.type === 'keydown')
+ event.preventDefault()
+ }"
+ >
+
+
+
+
+
+
+
+
+
+ {{ item.value.title }}
+
+
+
+
+```
+
+### Nested Tree Node
+
+The default example shows flatten tree items and nodes, this enables [Virtualization](/components/tree.html#virtual-list) and custom feature such as Drag & Drop easier. However, you can also build it to have nested DOM node.
+
+In `Tree.vue`,
+
+```vue
+
+
+
+
+
+ …
+
+
+
+
+
+```
+
+In `CustomTree.vue`
+
+```vue
+
+
+
+
+
+```
+
+### Draggable/Sortable Tree
+
+For more complex draggable `Tree` component, in this example we will be using [pragmatic-drag-and-drop](https://github.com/atlassian/pragmatic-drag-and-drop), as the core package for handling dnd.
+
+[Stackblitz Demo](https://stackblitz.com/edit/github-8f3fzs?file=src%2FTreeDND.vue)
+
+## Accessibility
+
+Adheres to the [Tree WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).
+
+### Keyboard Interactions
+
+
diff --git a/docs/content/meta/ComboboxRoot.md b/docs/content/meta/ComboboxRoot.md
index b25e8c2eb..ed10617b8 100644
--- a/docs/content/meta/ComboboxRoot.md
+++ b/docs/content/meta/ComboboxRoot.md
@@ -86,6 +86,12 @@
'description': '
The controlled search term of the Combobox. Can be binded-with with v-model:searchTerm.
\n',
'type': 'string',
'required': false
+ },
+ {
+ 'name': 'selectedValue',
+ 'description': 'The current highlighted value of the COmbobox. Can be binded-with v-model:selectedValue
.
\n',
+ 'type': 'AcceptableValue',
+ 'required': false
}
]" />
@@ -104,6 +110,11 @@
'name': 'update:searchTerm',
'description': 'Event handler called when the searchTerm of the combobox changes.
\n',
'type': '[value: string]'
+ },
+ {
+ 'name': 'update:selectedValue',
+ 'description': 'Event handler called when the highlighted value of the combobox changes
\n',
+ 'type': '[value: AcceptableValue]'
}
]" />
diff --git a/docs/content/meta/ListboxRoot.md b/docs/content/meta/ListboxRoot.md
index 46ca8f927..69149737f 100644
--- a/docs/content/meta/ListboxRoot.md
+++ b/docs/content/meta/ListboxRoot.md
@@ -72,7 +72,7 @@
{
'name': 'selectionBehavior',
'description': 'How multiple selection should behave in the collection.
\n',
- 'type': '\'toggle\' | \'replace\'',
+ 'type': '\'replace\' | \'toggle\'',
'required': false,
'default': '\'toggle\''
}
diff --git a/docs/content/meta/ToggleGroupRoot.md b/docs/content/meta/ToggleGroupRoot.md
index 6cde41ee6..f96c34159 100644
--- a/docs/content/meta/ToggleGroupRoot.md
+++ b/docs/content/meta/ToggleGroupRoot.md
@@ -71,7 +71,7 @@
{
'name': 'update:modelValue',
'description': 'Event handler called when the value changes.
\n',
- 'type': '[payload: string]'
+ 'type': '[payload: string | string[]]'
}
]" />
diff --git a/docs/content/meta/ToolbarToggleGroup.md b/docs/content/meta/ToolbarToggleGroup.md
index 62ebc8b2c..589daa2fe 100644
--- a/docs/content/meta/ToolbarToggleGroup.md
+++ b/docs/content/meta/ToolbarToggleGroup.md
@@ -68,6 +68,6 @@
{
'name': 'update:modelValue',
'description': 'Event handler called when the value changes.
\n',
- 'type': '[payload: string]'
+ 'type': '[payload: string | string[]]'
}
]" />
diff --git a/docs/content/meta/TreeItem.md b/docs/content/meta/TreeItem.md
new file mode 100644
index 000000000..323ef97b7
--- /dev/null
+++ b/docs/content/meta/TreeItem.md
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
diff --git a/docs/content/meta/TreeRoot.md b/docs/content/meta/TreeRoot.md
new file mode 100644
index 000000000..729d102c3
--- /dev/null
+++ b/docs/content/meta/TreeRoot.md
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
diff --git a/docs/content/meta/TreeVirtualizer.md b/docs/content/meta/TreeVirtualizer.md
new file mode 100644
index 000000000..9f479e4e0
--- /dev/null
+++ b/docs/content/meta/TreeVirtualizer.md
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/packages/plugins/src/namespaced/index.ts b/packages/plugins/src/namespaced/index.ts
index d286248bf..439698d70 100644
--- a/packages/plugins/src/namespaced/index.ts
+++ b/packages/plugins/src/namespaced/index.ts
@@ -1,4 +1,4 @@
-import { AccordionContent, AccordionHeader, AccordionItem, AccordionRoot, AccordionTrigger, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogOverlay, AlertDialogPortal, AlertDialogRoot, AlertDialogTitle, AlertDialogTrigger, AspectRatio, AvatarFallback, AvatarImage, AvatarRoot, CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNext, CalendarPrev, CalendarRoot, CheckboxIndicator, CheckboxRoot, CollapsibleContent, CollapsibleRoot, CollapsibleTrigger, ComboboxAnchor, ComboboxArrow, ComboboxCancel, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxLabel, ComboboxPortal, ComboboxRoot, ComboboxSeparator, ComboboxTrigger, ComboboxViewport, ContextMenuArrow, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuItemIndicator, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuRoot, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, DateFieldInput, DateFieldRoot, DatePickerAnchor, DatePickerArrow, DatePickerCalendar, DatePickerCell, DatePickerCellTrigger, DatePickerClose, DatePickerContent, DatePickerField, DatePickerGrid, DatePickerGridBody, DatePickerGridHead, DatePickerGridRow, DatePickerHeadCell, DatePickerHeader, DatePickerHeading, DatePickerInput, DatePickerNext, DatePickerPrev, DatePickerRoot, DatePickerTrigger, DateRangeFieldInput, DateRangeFieldRoot, DateRangePickerAnchor, DateRangePickerArrow, DateRangePickerCalendar, DateRangePickerCell, DateRangePickerCellTrigger, DateRangePickerClose, DateRangePickerContent, DateRangePickerField, DateRangePickerGrid, DateRangePickerGridBody, DateRangePickerGridHead, DateRangePickerGridRow, DateRangePickerHeadCell, DateRangePickerHeader, DateRangePickerHeading, DateRangePickerInput, DateRangePickerNext, DateRangePickerPrev, DateRangePickerRoot, DateRangePickerTrigger, DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, DropdownMenuArrow, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemIndicator, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuRoot, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EditableArea, EditableCancelTrigger, EditableEditTrigger, EditableInput, EditablePreview, EditableRoot, EditableSubmitTrigger, HoverCardArrow, HoverCardContent, HoverCardPortal, HoverCardRoot, HoverCardTrigger, Label, ListboxContent, ListboxFilter, ListboxGroup, ListboxGroupLabel, ListboxItem, ListboxItemIndicator, ListboxRoot, ListboxVirtualizer, MenubarArrow, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarItemIndicator, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRadioGroup, MenubarRadioItem, MenubarRoot, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuRoot, NavigationMenuSub, NavigationMenuTrigger, NavigationMenuViewport, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot, PaginationEllipsis, PaginationFirst, PaginationLast, PaginationList, PaginationListItem, PaginationNext, PaginationPrev, PaginationRoot, PinInputInput, PinInputRoot, PopoverAnchor, PopoverArrow, PopoverClose, PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger, ProgressIndicator, ProgressRoot, RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, RangeCalendarCell, RangeCalendarCellTrigger, RangeCalendarGrid, RangeCalendarGridBody, RangeCalendarGridHead, RangeCalendarGridRow, RangeCalendarHeadCell, RangeCalendarHeader, RangeCalendarHeading, RangeCalendarNext, RangeCalendarPrev, RangeCalendarRoot, ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport, SelectArrow, SelectContent, SelectGroup, SelectIcon, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, SelectViewport, Separator, SliderRange, SliderRoot, SliderThumb, SliderTrack, SplitterGroup, SplitterPanel, SplitterResizeHandle, SwitchRoot, SwitchThumb, TabsContent, TabsIndicator, TabsList, TabsRoot, TabsTrigger, TagsInputClear, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot, ToastAction, ToastClose, ToastDescription, ToastProvider, ToastRoot, ToastTitle, ToastViewport, Toggle, ToggleGroupItem, ToggleGroupRoot, ToolbarButton, ToolbarLink, ToolbarRoot, ToolbarSeparator, ToolbarToggleGroup, ToolbarToggleItem, TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger, Viewport } from 'radix-vue'
+import { AccordionContent, AccordionHeader, AccordionItem, AccordionRoot, AccordionTrigger, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogOverlay, AlertDialogPortal, AlertDialogRoot, AlertDialogTitle, AlertDialogTrigger, AspectRatio, AvatarFallback, AvatarImage, AvatarRoot, CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNext, CalendarPrev, CalendarRoot, CheckboxIndicator, CheckboxRoot, CollapsibleContent, CollapsibleRoot, CollapsibleTrigger, ComboboxAnchor, ComboboxArrow, ComboboxCancel, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxLabel, ComboboxPortal, ComboboxRoot, ComboboxSeparator, ComboboxTrigger, ComboboxViewport, ConfigProvider, ContextMenuArrow, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuItemIndicator, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuRoot, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, DateFieldInput, DateFieldRoot, DatePickerAnchor, DatePickerArrow, DatePickerCalendar, DatePickerCell, DatePickerCellTrigger, DatePickerClose, DatePickerContent, DatePickerField, DatePickerGrid, DatePickerGridBody, DatePickerGridHead, DatePickerGridRow, DatePickerHeadCell, DatePickerHeader, DatePickerHeading, DatePickerInput, DatePickerNext, DatePickerPrev, DatePickerRoot, DatePickerTrigger, DateRangeFieldInput, DateRangeFieldRoot, DateRangePickerAnchor, DateRangePickerArrow, DateRangePickerCalendar, DateRangePickerCell, DateRangePickerCellTrigger, DateRangePickerClose, DateRangePickerContent, DateRangePickerField, DateRangePickerGrid, DateRangePickerGridBody, DateRangePickerGridHead, DateRangePickerGridRow, DateRangePickerHeadCell, DateRangePickerHeader, DateRangePickerHeading, DateRangePickerInput, DateRangePickerNext, DateRangePickerPrev, DateRangePickerRoot, DateRangePickerTrigger, DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, DropdownMenuArrow, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemIndicator, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuRoot, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EditableArea, EditableCancelTrigger, EditableEditTrigger, EditableInput, EditablePreview, EditableRoot, EditableSubmitTrigger, HoverCardArrow, HoverCardContent, HoverCardPortal, HoverCardRoot, HoverCardTrigger, Label, ListboxContent, ListboxFilter, ListboxGroup, ListboxGroupLabel, ListboxItem, ListboxItemIndicator, ListboxRoot, ListboxVirtualizer, MenubarArrow, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarItemIndicator, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRadioGroup, MenubarRadioItem, MenubarRoot, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuRoot, NavigationMenuSub, NavigationMenuTrigger, NavigationMenuViewport, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot, PaginationEllipsis, PaginationFirst, PaginationLast, PaginationList, PaginationListItem, PaginationNext, PaginationPrev, PaginationRoot, PinInputInput, PinInputRoot, PopoverAnchor, PopoverArrow, PopoverClose, PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger, Primitive, ProgressIndicator, ProgressRoot, RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, RangeCalendarCell, RangeCalendarCellTrigger, RangeCalendarGrid, RangeCalendarGridBody, RangeCalendarGridHead, RangeCalendarGridRow, RangeCalendarHeadCell, RangeCalendarHeader, RangeCalendarHeading, RangeCalendarNext, RangeCalendarPrev, RangeCalendarRoot, ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport, SelectArrow, SelectContent, SelectGroup, SelectIcon, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, SelectViewport, Separator, SliderRange, SliderRoot, SliderThumb, SliderTrack, Slot, SplitterGroup, SplitterPanel, SplitterResizeHandle, SwitchRoot, SwitchThumb, TabsContent, TabsIndicator, TabsList, TabsRoot, TabsTrigger, TagsInputClear, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot, ToastAction, ToastClose, ToastDescription, ToastProvider, ToastRoot, ToastTitle, ToastViewport, Toggle, ToggleGroupItem, ToggleGroupRoot, ToolbarButton, ToolbarLink, ToolbarRoot, ToolbarSeparator, ToolbarToggleGroup, ToolbarToggleItem, TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger, TreeItem, TreeRoot, TreeVirtualizer, Viewport, VisuallyHidden } from 'radix-vue'
export const Accordion = {
Content: AccordionContent,
@@ -714,4 +714,14 @@ export const Tooltip = {
Provider: typeof TooltipProvider
}
+export const Tree = {
+ Root: TreeRoot,
+ Item: TreeItem,
+ Virtualizer: TreeVirtualizer,
+} as {
+ Root: typeof TreeRoot
+ Item: typeof TreeItem
+ Virtualizer: typeof TreeVirtualizer
+}
+
export { Viewport }
diff --git a/packages/radix-vue/constant/components.ts b/packages/radix-vue/constant/components.ts
index 446b5f64d..d5766cacd 100644
--- a/packages/radix-vue/constant/components.ts
+++ b/packages/radix-vue/constant/components.ts
@@ -408,6 +408,12 @@ export const components = {
'TooltipProvider',
],
+ tree: [
+ 'TreeRoot',
+ 'TreeItem',
+ 'TreeVirtualizer',
+ ],
+
viewport: [
'Viewport',
],
diff --git a/packages/radix-vue/src/Calendar/CalendarNext.vue b/packages/radix-vue/src/Calendar/CalendarNext.vue
index cb7e9fd57..77b3b439c 100644
--- a/packages/radix-vue/src/Calendar/CalendarNext.vue
+++ b/packages/radix-vue/src/Calendar/CalendarNext.vue
@@ -4,11 +4,12 @@ import type { CalendarIncrement } from '@/shared/date'
import type { DateValue } from '@internationalized/date'
export interface CalendarNextProps extends PrimitiveProps {
-/** The calendar unit to go forward
-* @deprecated Use `nextPage` instead
-*/
+/**
+ * The calendar unit to go forward
+ * @deprecated Use `nextPage` instead
+ */
step?: CalendarIncrement
-/** The function to be used for the next page. Overwrites the `nextPage` function set on the `CalendarRoot`. */
+ /** The function to be used for the next page. Overwrites the `nextPage` function set on the `CalendarRoot`. */
nextPage?: (placeholder: DateValue) => DateValue
}
diff --git a/packages/radix-vue/src/Calendar/CalendarPrev.vue b/packages/radix-vue/src/Calendar/CalendarPrev.vue
index bf6f38588..41597c335 100644
--- a/packages/radix-vue/src/Calendar/CalendarPrev.vue
+++ b/packages/radix-vue/src/Calendar/CalendarPrev.vue
@@ -4,11 +4,12 @@ import type { CalendarIncrement } from '@/shared/date'
import type { DateValue } from '@internationalized/date'
export interface CalendarPrevProps extends PrimitiveProps {
-/** The calendar unit to go back
-* @deprecated Use `prevPage` instead
-*/
+/**
+ * The calendar unit to go back
+ * @deprecated Use `prevPage` instead
+ */
step?: CalendarIncrement
-/** The function to be used for the prev page. Overwrites the `prevPage` function set on the `CalendarRoot`. */
+ /** The function to be used for the prev page. Overwrites the `prevPage` function set on the `CalendarRoot`. */
prevPage?: (placeholder: DateValue) => DateValue
}
diff --git a/packages/radix-vue/src/Calendar/story/CalendarChromatic.story.vue b/packages/radix-vue/src/Calendar/story/CalendarChromatic.story.vue
index 6e511ec64..5387aee9b 100644
--- a/packages/radix-vue/src/Calendar/story/CalendarChromatic.story.vue
+++ b/packages/radix-vue/src/Calendar/story/CalendarChromatic.story.vue
@@ -8,7 +8,7 @@ const modelValue = ref(defaultValue) as Ref
const placeholder = ref(new CalendarDate(2024, 4, 1)) as Ref
-const paging = (date: DateValue, sign: -1 | 1) => {
+function paging(date: DateValue, sign: -1 | 1) {
if (sign === -1)
return date.subtract({ years: 1 })
return date.add({ years: 1 })
@@ -79,7 +79,11 @@ const paging = (date: DateValue, sign: -1 | 1) => {
-
+
diff --git a/packages/radix-vue/src/Calendar/story/CalendarYearIncrement.story.vue b/packages/radix-vue/src/Calendar/story/CalendarYearIncrement.story.vue
index e73d888c3..75b0f2737 100644
--- a/packages/radix-vue/src/Calendar/story/CalendarYearIncrement.story.vue
+++ b/packages/radix-vue/src/Calendar/story/CalendarYearIncrement.story.vue
@@ -1,9 +1,9 @@
diff --git a/packages/radix-vue/src/Collection/Collection.ts b/packages/radix-vue/src/Collection/Collection.ts
index faba925b1..8f4255bdc 100644
--- a/packages/radix-vue/src/Collection/Collection.ts
+++ b/packages/radix-vue/src/Collection/Collection.ts
@@ -5,7 +5,7 @@ import { Slot, usePrimitiveElement } from '@/Primitive'
interface CollectionContext {
collectionRef: Ref
- itemMap: Ref>
+ itemMap: Ref>
attrName: string
}
@@ -46,20 +46,21 @@ export const CollectionSlot = defineComponent({
export const CollectionItem = defineComponent({
name: 'CollectionItem',
+ inheritAttrs: false,
setup(_, { slots, attrs }) {
const context = injectCollectionContext()
const { primitiveElement, currentElement } = usePrimitiveElement()
- const vm = getCurrentInstance()
+ const { value, ...restAttrs } = attrs
watchEffect((cleanupFn) => {
if (currentElement.value) {
const key = markRaw(currentElement.value)
- context.itemMap.value.set(key, { ref: currentElement.value!, ...(markRaw(vm?.parent?.props ?? {})) })
+ context.itemMap.value.set(key, { ref: currentElement.value!, value })
cleanupFn(() => context.itemMap.value.delete(key))
}
})
- return () => h(Slot, { ...attrs, [context.attrName]: '', ref: primitiveElement }, slots)
+ return () => h(Slot, { ...restAttrs, [context.attrName]: '', ref: primitiveElement }, slots)
},
})
diff --git a/packages/radix-vue/src/Combobox/ComboboxItem.vue b/packages/radix-vue/src/Combobox/ComboboxItem.vue
index f1935249c..f11bb8b07 100644
--- a/packages/radix-vue/src/Combobox/ComboboxItem.vue
+++ b/packages/radix-vue/src/Combobox/ComboboxItem.vue
@@ -103,7 +103,7 @@ provideComboboxItemContext({
-
+
-
+
-import { createContext, useDirection, useFormControl, useKbd, useTypeahead } from '@/shared'
+import { createContext, findValuesBetween, useDirection, useFormControl, useKbd, useTypeahead } from '@/shared'
import { Primitive } from '..'
import { type PrimitiveProps, usePrimitiveElement } from '@/Primitive'
import type { AcceptableValue, DataOrientation, Direction } from '@/shared/types'
@@ -75,7 +75,7 @@ export type ListboxRootEmits = {
diff --git a/packages/radix-vue/src/RangeCalendar/RangeCalendarPrev.vue b/packages/radix-vue/src/RangeCalendar/RangeCalendarPrev.vue
index bd47075dc..20e5bdc2b 100644
--- a/packages/radix-vue/src/RangeCalendar/RangeCalendarPrev.vue
+++ b/packages/radix-vue/src/RangeCalendar/RangeCalendarPrev.vue
@@ -4,11 +4,12 @@ import type { CalendarIncrement } from '@/shared/date'
import type { DateValue } from '@internationalized/date'
export interface RangeCalendarPrevProps extends PrimitiveProps {
-/** The calendar unit to go forward
-* @deprecated Use `prevPage` instead
-*/
+/**
+ * The calendar unit to go forward
+ * @deprecated Use `prevPage` instead
+ */
step?: CalendarIncrement
-/** The function to be used for the prev page. Overwrites the `prevPage` function set on the `RangeCalendarRoot`. */
+ /** The function to be used for the prev page. Overwrites the `prevPage` function set on the `RangeCalendarRoot`. */
prevPage?: (placeholder: DateValue) => DateValue
}
diff --git a/packages/radix-vue/src/RangeCalendar/story/RangeCalendarChromatic.story.vue b/packages/radix-vue/src/RangeCalendar/story/RangeCalendarChromatic.story.vue
index d11a58241..23b1f114f 100644
--- a/packages/radix-vue/src/RangeCalendar/story/RangeCalendarChromatic.story.vue
+++ b/packages/radix-vue/src/RangeCalendar/story/RangeCalendarChromatic.story.vue
@@ -9,7 +9,7 @@ const modelValue = ref(defaultValue) as Ref<{ start: DateValue, end: DateValue }
const placeholder = ref(new CalendarDate(2024, 4, 1)) as Ref
-const paging = (date: DateValue, sign: -1 | 1) => {
+function paging(date: DateValue, sign: -1 | 1) {
if (sign === -1)
return date.subtract({ years: 1 })
return date.add({ years: 1 })
@@ -81,8 +81,11 @@ const paging = (date: DateValue, sign: -1 | 1) => {
-
+
-
diff --git a/packages/radix-vue/src/RovingFocus/RovingFocusGroup.vue b/packages/radix-vue/src/RovingFocus/RovingFocusGroup.vue
index 606e0c928..b863d23ee 100644
--- a/packages/radix-vue/src/RovingFocus/RovingFocusGroup.vue
+++ b/packages/radix-vue/src/RovingFocus/RovingFocusGroup.vue
@@ -50,8 +50,9 @@ export const [injectRovingFocusGroupContext, provideRovingFocusGroupContext]
-
-
-
+
+
+
+
+
diff --git a/packages/radix-vue/src/RovingFocus/RovingFocusItem.vue b/packages/radix-vue/src/RovingFocus/RovingFocusItem.vue
index 3162cf820..76a3dfe59 100644
--- a/packages/radix-vue/src/RovingFocus/RovingFocusItem.vue
+++ b/packages/radix-vue/src/RovingFocus/RovingFocusItem.vue
@@ -5,6 +5,7 @@ export interface RovingFocusItemProps extends PrimitiveProps {
tabStopId?: string
focusable?: boolean
active?: boolean
+ allowShiftKey?: boolean
}
@@ -13,7 +14,8 @@ import { computed, nextTick, onMounted, onUnmounted } from 'vue'
import { injectRovingFocusGroupContext } from './RovingFocusGroup.vue'
import { Primitive } from '@/Primitive'
import { focusFirst, getFocusIntent, wrapArray } from './utils'
-import { useCollection, useId } from '@/shared'
+import { useId } from '@/shared'
+import { CollectionItem, useCollection } from '@/Collection'
const props = withDefaults(defineProps(), {
focusable: true,
@@ -27,8 +29,7 @@ const isCurrentTabStop = computed(
() => context.currentTabStopId.value === id.value,
)
-const { injectCollection } = useCollection('rovingFocus')
-const collections = injectCollection()
+const { getItems } = useCollection()
onMounted(() => {
if (props.focusable)
@@ -55,10 +56,10 @@ function handleKeydown(event: KeyboardEvent) {
)
if (focusIntent !== undefined) {
- if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
+ if (event.metaKey || event.ctrlKey || event.altKey || (props.allowShiftKey ? false : event.shiftKey))
return
event.preventDefault()
- let candidateNodes = [...collections.value]
+ let candidateNodes = [...getItems().map(i => i.ref).filter(i => i.dataset.disabled !== '')]
if (focusIntent === 'last') {
candidateNodes.reverse()
@@ -81,26 +82,27 @@ function handleKeydown(event: KeyboardEvent) {
- {
- // We prevent focusing non-focusable items on `mousedown`.
- // Even though the item has tabIndex={-1}, that only means take it out of the tab order.
- if (!focusable) event.preventDefault();
- // Safari doesn't focus a button when clicked so we run our logic on mousedown also
- else context.onItemFocus(id);
- }
- "
- @focus="context.onItemFocus(id)"
- @keydown="handleKeydown"
- >
-
-
+
+ {
+ // We prevent focusing non-focusable items on `mousedown`.
+ // Even though the item has tabIndex={-1}, that only means take it out of the tab order.
+ if (!focusable) event.preventDefault();
+ // Safari doesn't focus a button when clicked so we run our logic on mousedown also
+ else context.onItemFocus(id);
+ }
+ "
+ @focus="context.onItemFocus(id)"
+ @keydown="handleKeydown"
+ >
+
+
+
diff --git a/packages/radix-vue/src/Tree/Tree.test.ts b/packages/radix-vue/src/Tree/Tree.test.ts
new file mode 100644
index 000000000..785ed23f7
--- /dev/null
+++ b/packages/radix-vue/src/Tree/Tree.test.ts
@@ -0,0 +1,190 @@
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { axe } from 'vitest-axe'
+import Tree from './story/_Tree.vue'
+import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
+import { mount } from '@vue/test-utils'
+import { useKbd } from '@/shared'
+import { nextTick } from 'vue'
+import { sleep } from '@/test'
+
+const kbd = useKbd()
+
+describe('given default Tree', () => {
+ // const kbd = useKbd()
+ let wrapper: VueWrapper>
+ let content: DOMWrapper
+ let items: DOMWrapper[]
+
+ const updateItems = () => {
+ items = wrapper.findAll('[role=treeitem]')
+ }
+
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ wrapper = mount(Tree, { attachTo: document.body, props: { selectionBehavior: 'toggle' } })
+ content = wrapper.find('[role=tree]')
+ updateItems()
+ })
+
+ it('should pass axe accessibility tests', async () => {
+ expect(await axe(wrapper.element)).toHaveNoViolations()
+ })
+
+ it('should render snapshot', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should select and deselect item', async () => {
+ const item = items[0]
+ await item.trigger('click')
+ expect(item.attributes('aria-selected')).toBe('true')
+ await item.trigger('click')
+ expect(item.attributes('aria-selected')).toBe('false')
+ })
+
+ describe('when expand item by press ArrowRight', async () => {
+ beforeEach(async () => {
+ await items[1].trigger('keydown', { key: kbd.ARROW_RIGHT });
+ (items[1].element as HTMLElement).focus()
+ updateItems()
+ })
+
+ it('should pass axe accessibility tests', async () => {
+ expect(await axe(wrapper.element)).toHaveNoViolations()
+ })
+
+ it('should expand the item, revealing it\'s item', () => {
+ expect(items[2].text()).toBe('tree')
+ })
+
+ it('should close when press ArrowLeft', async () => {
+ await items[1].trigger('keydown', { key: kbd.ARROW_LEFT })
+ updateItems()
+ expect(items[2].text()).toBe('routes')
+ expect(items[2].text()).not.toBe('tree')
+ })
+
+ it('should focus on parent when press ArrowLeft on child item', async () => {
+ await items[2].trigger('keydown', { key: kbd.ARROW_DOWN })
+ await items[3].trigger('keydown', { key: kbd.ARROW_LEFT })
+ expect(document.activeElement).toBe(items[1].element)
+ })
+
+ it('should focus on child item when press ArriwRight', async () => {
+ await items[1].trigger('keydown', { key: kbd.ARROW_RIGHT })
+ expect(document.activeElement).toBe(items[2].element)
+ })
+
+ describe('when expand nested item', async () => {
+ beforeEach(async () => {
+ await items[2].trigger('keydown', { key: kbd.ARROW_RIGHT })
+ updateItems()
+ })
+
+ it('should pass axe accessibility tests', async () => {
+ expect(await axe(wrapper.element)).toHaveNoViolations()
+ })
+
+ it('should expand the nested item, revealing it\'s item ', () => {
+ expect(items[3].text()).toBe('Tree.vue')
+ })
+ })
+ })
+
+ // Test: useTypeAhead
+ describe('when typing letter', async () => {
+ it('should highlight text starting with l', async () => {
+ await content.trigger('keydown', { key: 'l' })
+ const item = items.find(i => i.text().startsWith('l'))
+ expect(document.activeElement).toBe(item?.element)
+ })
+ })
+
+ describe('when selection behavior `replace`', () => {
+ beforeEach(async () => {
+ await wrapper.setProps({ selectionBehavior: 'replace' })
+ await nextTick()
+ })
+
+ it('should not toggle off the selected value', async () => {
+ const item = items[0]
+ await item.trigger('click')
+ await item.trigger('click')
+ expect(item.attributes('aria-selected')).toBe('true')
+ })
+
+ it('should select and replace another item', async () => {
+ const item = items[0]
+ const newItem = items[1]
+ await item.trigger('click')
+ expect(item.attributes('aria-selected')).toBe('true')
+ await newItem.trigger('click')
+ expect(item.attributes('aria-selected')).toBe('false')
+ expect(newItem.attributes('aria-selected')).toBe('true')
+ })
+ })
+})
+
+describe('given multiple `true` Tree', () => {
+ let wrapper: VueWrapper>
+ // let content: DOMWrapper
+ let items: DOMWrapper[]
+
+ beforeEach(async () => {
+ document.body.innerHTML = ''
+ wrapper = mount(Tree, { props: { multiple: true, selectionBehavior: 'toggle' }, attachTo: document.body })
+ await nextTick()
+ // content = wrapper.find('[role=tree]')
+ items = wrapper.findAll('[role=treeitem]')
+ })
+
+ it('should select multiple items', async () => {
+ await items[0].trigger('keydown', { key: kbd.ENTER })
+ await items[0].trigger('keydown', { key: kbd.ARROW_DOWN })
+ await items[1].trigger('keydown', { key: kbd.ARROW_DOWN })
+ await items[2].trigger('keydown', { key: kbd.ENTER })
+
+ expect(items[0].attributes('aria-selected')).toBe('true')
+ expect(items[1].attributes('aria-selected')).toBe('false')
+ expect(items[2].attributes('aria-selected')).toBe('true')
+ })
+
+ describe('when selection behavior `replace`', () => {
+ beforeEach(async () => {
+ wrapper.setProps({ selectionBehavior: 'replace' })
+ await nextTick()
+ await items[0].trigger('click');
+ (items[0].element as HTMLElement).focus()
+ })
+
+ it('should not toggle off the selected value', async () => {
+ const item = items[0]
+ await item.trigger('click')
+ await item.trigger('click')
+ expect(item.attributes('aria-selected')).toBe('true')
+ })
+
+ it('should select and replace another item', async () => {
+ const item = items[0]
+ const newItem = items[1]
+ expect(item.attributes('aria-selected')).toBe('true')
+ await newItem.trigger('click')
+ expect(item.attributes('aria-selected')).toBe('false')
+ expect(newItem.attributes('aria-selected')).toBe('true')
+ })
+
+ describe('when keypress Shift + ArrowDown', () => {
+ it('should select the next item', async () => {
+ await items[0].trigger('keydown.shift', { key: kbd.ARROW_DOWN })
+ expect(items[0].attributes('aria-selected')).toBe('true')
+ expect(items[1].attributes('aria-selected')).toBe('true')
+ expect(items[2].attributes('aria-selected')).toBe('false')
+
+ await items[1].trigger('keydown.shift', { key: kbd.ARROW_DOWN })
+ expect(items[0].attributes('aria-selected')).toBe('true')
+ expect(items[1].attributes('aria-selected')).toBe('true')
+ expect(items[2].attributes('aria-selected')).toBe('true')
+ })
+ })
+ })
+})
diff --git a/packages/radix-vue/src/Tree/TreeItem.vue b/packages/radix-vue/src/Tree/TreeItem.vue
new file mode 100644
index 000000000..b8181ca37
--- /dev/null
+++ b/packages/radix-vue/src/Tree/TreeItem.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+ rootContext.dir.value === 'ltr' ? handleKeydownRight(ev) : handleKeydownLeft(ev)"
+ @keydown.left.prevent="(ev) => rootContext.dir.value === 'ltr' ? handleKeydownLeft(ev) : handleKeydownRight(ev)"
+ @click.stop="(ev) => {
+ handleSelectCustomEvent(ev)
+ handleToggleCustomEvent(ev)
+ }"
+ >
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/TreeRoot.vue b/packages/radix-vue/src/Tree/TreeRoot.vue
new file mode 100644
index 000000000..16539a619
--- /dev/null
+++ b/packages/radix-vue/src/Tree/TreeRoot.vue
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/TreeVirtualizer.vue b/packages/radix-vue/src/Tree/TreeVirtualizer.vue
new file mode 100644
index 000000000..bd54d0fce
--- /dev/null
+++ b/packages/radix-vue/src/Tree/TreeVirtualizer.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/__snapshots__/Tree.test.ts.snap b/packages/radix-vue/src/Tree/__snapshots__/Tree.test.ts.snap
new file mode 100644
index 000000000..b3373f8b8
--- /dev/null
+++ b/packages/radix-vue/src/Tree/__snapshots__/Tree.test.ts.snap
@@ -0,0 +1,16 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`given default Tree > should render snapshot 1`] = `
+"
+
+
+ index.vue
+
+
+ lib
+
+
+ routes
+
+ "
+`;
diff --git a/packages/radix-vue/src/Tree/index.ts b/packages/radix-vue/src/Tree/index.ts
new file mode 100644
index 000000000..c9be0db51
--- /dev/null
+++ b/packages/radix-vue/src/Tree/index.ts
@@ -0,0 +1,16 @@
+export {
+ default as TreeRoot,
+ type TreeRootProps,
+ type TreeRootEmits,
+ type FlattenedItem,
+} from './TreeRoot.vue'
+export {
+ default as TreeItem,
+ type TreeItemProps,
+ type SelectEvent as TreeItemSelectEvent,
+ type ToggleEvent as TreeItemToggleEvent,
+} from './TreeItem.vue'
+export {
+ default as TreeVirtualizer,
+ type TreeVirtualizerProps,
+} from './TreeVirtualizer.vue'
diff --git a/packages/radix-vue/src/Tree/story/TreeAsync.story.vue b/packages/radix-vue/src/Tree/story/TreeAsync.story.vue
new file mode 100644
index 000000000..6f0c232d1
--- /dev/null
+++ b/packages/radix-vue/src/Tree/story/TreeAsync.story.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+ {{ item.value.name }}
+
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/story/TreeBasic.story.vue b/packages/radix-vue/src/Tree/story/TreeBasic.story.vue
new file mode 100644
index 000000000..ffbb7e089
--- /dev/null
+++ b/packages/radix-vue/src/Tree/story/TreeBasic.story.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+ {{ item.value.title }}
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/story/TreeCheckbox.story.vue b/packages/radix-vue/src/Tree/story/TreeCheckbox.story.vue
new file mode 100644
index 000000000..8c1d0640e
--- /dev/null
+++ b/packages/radix-vue/src/Tree/story/TreeCheckbox.story.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+ {
+ if (event.detail.originalEvent.type === 'click')
+ event.preventDefault()
+ }"
+ >
+
+
+
+ {{ item.value.title }}
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/story/TreeNested.story.vue b/packages/radix-vue/src/Tree/story/TreeNested.story.vue
new file mode 100644
index 000000000..99fdb9f8d
--- /dev/null
+++ b/packages/radix-vue/src/Tree/story/TreeNested.story.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/story/TreeVirtual.story.vue b/packages/radix-vue/src/Tree/story/TreeVirtual.story.vue
new file mode 100644
index 000000000..bab13e49b
--- /dev/null
+++ b/packages/radix-vue/src/Tree/story/TreeVirtual.story.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+ {{ item.value.title }}
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/story/_Tree.vue b/packages/radix-vue/src/Tree/story/_Tree.vue
new file mode 100644
index 000000000..c26f0f780
--- /dev/null
+++ b/packages/radix-vue/src/Tree/story/_Tree.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+ {{ item.value.title }}
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/story/_TreeNested.vue b/packages/radix-vue/src/Tree/story/_TreeNested.vue
new file mode 100644
index 000000000..30b902078
--- /dev/null
+++ b/packages/radix-vue/src/Tree/story/_TreeNested.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+ {{ tree.title }}
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/Tree/story/constants.ts b/packages/radix-vue/src/Tree/story/constants.ts
new file mode 100644
index 000000000..30a95a7ce
--- /dev/null
+++ b/packages/radix-vue/src/Tree/story/constants.ts
@@ -0,0 +1,26 @@
+export const items = [
+ {
+ title: 'composables',
+ icon: 'lucide:folder',
+ children: [
+ { title: 'useAuth.ts', icon: 'vscode-icons:file-type-typescript' },
+ { title: 'useUser.ts', icon: 'vscode-icons:file-type-typescript' },
+ ],
+ },
+ {
+ title: 'components',
+ icon: 'lucide:folder',
+ children: [
+ {
+ title: 'Home',
+ icon: 'lucide:folder',
+ children: [
+ { title: 'Card.vue', icon: 'vscode-icons:file-type-vue' },
+ { title: 'Button.vue', icon: 'vscode-icons:file-type-vue' },
+ ],
+ },
+ ],
+ },
+ { title: 'app.vue', icon: 'vscode-icons:file-type-vue' },
+ { title: 'nuxt.config.ts', icon: 'vscode-icons:file-type-nuxt' },
+]
diff --git a/packages/radix-vue/src/Tree/utils.ts b/packages/radix-vue/src/Tree/utils.ts
new file mode 100644
index 000000000..bf1a7ba14
--- /dev/null
+++ b/packages/radix-vue/src/Tree/utils.ts
@@ -0,0 +1,12 @@
+export function flatten(items: T[]): U[] {
+ return items.reduce((acc: any[], item: T) => {
+ acc.push(item)
+
+ if (item.children)
+ acc.push(...flatten(item.children))
+
+ return acc
+ }, [])
+}
+
+// TODO: expose more utility function to handle flattened item
diff --git a/packages/radix-vue/src/index.ts b/packages/radix-vue/src/index.ts
index fa409f1d0..c9c33e6c2 100644
--- a/packages/radix-vue/src/index.ts
+++ b/packages/radix-vue/src/index.ts
@@ -40,6 +40,7 @@ export * from './Toggle'
export * from './ToggleGroup'
export * from './Toolbar'
export * from './Tooltip'
+export * from './Tree'
export * from './Viewport'
// utilities
diff --git a/packages/radix-vue/src/shared/arrays.ts b/packages/radix-vue/src/shared/arrays.ts
index 72de00372..48109d5d1 100644
--- a/packages/radix-vue/src/shared/arrays.ts
+++ b/packages/radix-vue/src/shared/arrays.ts
@@ -1,3 +1,5 @@
+import isEqual from 'fast-deep-equal'
+
/**
* The function `areEqual` compares two arrays and returns true if they are equal in length and have
* the same elements at corresponding indexes.
@@ -37,3 +39,28 @@ export function chunk(arr: T[], size: number): T[][] {
return result
}
+
+/**
+ * The function `findValuesBetween` takes an array and two values, then returns a subarray containing
+ * elements between the first occurrence of the start value and the first occurrence of the end value
+ * in the array.
+ * @param {T[]} array - The `array` parameter is an array of values of type `T`.
+ * @param {T} start - The `start` parameter is the value that marks the beginning of the range you want
+ * to find in the array.
+ * @param {T} end - The `end` parameter in the `findValuesBetween` function represents the end value
+ * that you want to find in the array. This function will return a subarray of values that are between
+ * the `start` and `end` values in the original array.
+ * @returns The `findValuesBetween` function returns an array of values from the input array that are
+ * between the `start` and `end` values (inclusive). If either the `start` or `end` values are not
+ * found in the input array, an empty array is returned.
+ */
+export function findValuesBetween(array: T[], start: T, end: T) {
+ const startIndex = array.findIndex(i => isEqual(i, start))
+ const endIndex = array.findIndex(i => isEqual(i, end))
+ if (startIndex === -1 || endIndex === -1)
+ return []
+
+ const [minIndex, maxIndex] = [startIndex, endIndex].sort((a, b) => a - b)
+
+ return array.slice(minIndex, maxIndex + 1)
+}
diff --git a/packages/radix-vue/src/Listbox/story/constants.ts b/packages/radix-vue/src/shared/constant/countryList.ts
similarity index 100%
rename from packages/radix-vue/src/Listbox/story/constants.ts
rename to packages/radix-vue/src/shared/constant/countryList.ts
diff --git a/packages/radix-vue/src/shared/constant/index.ts b/packages/radix-vue/src/shared/constant/index.ts
new file mode 100644
index 000000000..39c9505b5
--- /dev/null
+++ b/packages/radix-vue/src/shared/constant/index.ts
@@ -0,0 +1 @@
+export * from './countryList'
diff --git a/packages/radix-vue/src/shared/index.ts b/packages/radix-vue/src/shared/index.ts
index c17f37df9..a7dc5936e 100644
--- a/packages/radix-vue/src/shared/index.ts
+++ b/packages/radix-vue/src/shared/index.ts
@@ -25,6 +25,7 @@ export { useForwardRef } from './useForwardRef'
export { useGraceArea } from './useGraceArea'
export { useHideOthers } from './useHideOthers'
export { useId } from './useId'
+export { useSelectionBehavior } from './useSelectionBehavior'
export { useSize } from './useSize'
export { useStateMachine } from './useStateMachine'
export { useTypeahead } from './useTypeahead'
diff --git a/packages/radix-vue/src/shared/useSelectionBehavior.ts b/packages/radix-vue/src/shared/useSelectionBehavior.ts
new file mode 100644
index 000000000..0c064296a
--- /dev/null
+++ b/packages/radix-vue/src/shared/useSelectionBehavior.ts
@@ -0,0 +1,74 @@
+import { type Ref, type UnwrapNestedRefs, ref } from 'vue'
+import { findValuesBetween } from './arrays'
+
+export function useSelectionBehavior(
+ modelValue: Ref,
+ props: UnwrapNestedRefs<{ multiple?: boolean, selectionBehavior?: 'toggle' | 'replace' }>,
+) {
+ const firstValue = ref()
+
+ const onSelectItem = (val: T, condition: (existingValue: T) => boolean) => {
+ // multiple select
+ if (props.multiple && Array.isArray(modelValue.value)) {
+ if (props.selectionBehavior === 'replace') {
+ modelValue.value = [val]
+ firstValue.value = val
+ }
+ else {
+ const index = modelValue.value.findIndex(v => condition(v))
+ if (index !== -1)
+ modelValue.value.splice(index, 1)
+ else
+ modelValue.value.push(val)
+ }
+ }
+ // single select
+ else {
+ if (props.selectionBehavior === 'replace') {
+ modelValue.value = { ...val }
+ }
+ else {
+ if (!Array.isArray(modelValue.value) && condition(modelValue.value))
+ modelValue.value = undefined as any
+ else
+ modelValue.value = { ...val }
+ }
+ }
+ return modelValue.value
+ }
+
+ function handleMultipleReplace(intent: 'first' | 'last' | 'prev' | 'next', currentElement: HTMLElement | Element | null, getItems: () => { ref: HTMLElement, value?: any }[], options: any[]) {
+ if (!firstValue?.value || !props.multiple || !Array.isArray(modelValue.value))
+ return
+
+ const collection = getItems().filter(i => i.ref.dataset.disabled !== '')
+ const lastValue = collection.find(i => i.ref === currentElement)?.value
+ if (!lastValue)
+ return
+
+ let value: T[] | null = null
+ switch (intent) {
+ case 'prev':
+ case 'next': {
+ value = findValuesBetween(options, firstValue.value, lastValue)
+ break
+ }
+ case 'first': {
+ value = findValuesBetween(options, firstValue.value, options?.[0])
+ break
+ }
+ case 'last': {
+ value = findValuesBetween(options, firstValue.value, options?.[options.length - 1])
+ break
+ }
+ }
+
+ modelValue.value = value
+ }
+
+ return {
+ firstValue,
+ onSelectItem,
+ handleMultipleReplace,
+ }
+}