usecollection wip
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# `useCollectionNavigation`
|
||||
|
||||
The `useCollectionNavigation` composable handles the navigation across a collection (i.e., changing the active item).
|
||||
|
||||
It is mainly used to navigate between items in a collection with a keyboard.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
const definition = defineCollection(/* ... */)
|
||||
|
||||
const { items, activeItem } = useCollection(definitions)
|
||||
|
||||
const { moveUp, moveDown, moveLeft, moveRight, handleKeydown } = useCollectionNavigation(items, activeItem)
|
||||
```
|
||||
|
||||
## `moveUp`
|
||||
|
||||
The `moveUp` function set the `activeItem` to the previous one, if any, or `undefined` otherwise.
|
||||
|
||||
If the previous item is a `Group` and it is expanded, the `moveUp` function will set the `activeItem` to the last item
|
||||
of that group.
|
||||
|
||||
## `moveDown`
|
||||
|
||||
The `moveDown` function set the `activeItem` to the next one, if any, or `undefined` otherwise.
|
||||
|
||||
If the current `activeItem` is a `Group` and it is expanded, the `moveDown` function will set the `activeItem` to the
|
||||
first item of that group.
|
||||
|
||||
## `moveLeft`
|
||||
|
||||
If the current `activeItem` is a `Group` and it is expanded, the `moveLeft` function will collapse the group.
|
||||
|
||||
In any other case, the `moveLeft` function will set the `activeItem` to the parent `Group`, if any, or will do nothing
|
||||
otherwise.
|
||||
|
||||
## `moveRight`
|
||||
|
||||
If the current `activeItem` is a `Group` and it is collapsed, the `moveRight` function will expand the group.
|
||||
|
||||
In any other case, the `moveRight` function will act the same as `moveDown`.
|
||||
|
||||
## `handleKeydown`
|
||||
|
||||
The `handleKeydown` function is a helper function that can be used with the `@keydown` event binding.
|
||||
|
||||
```html
|
||||
<div @keydown="handleKeydown" tabindex="0">...</div>
|
||||
|
||||
<!-- Is equivalent to -->
|
||||
<div
|
||||
@keydown.left.prevent="moveLeft"
|
||||
@keydown.right.prevent="moveRight"
|
||||
@keydown.up.prevent="moveUp"
|
||||
@keydown.down.prevent="moveDown"
|
||||
tabindex="0"
|
||||
>
|
||||
...
|
||||
</div>
|
||||
```
|
||||
@@ -30,11 +30,8 @@ useCollection(definitions, options)
|
||||
| | Type | |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `items` | `(Leaf \| Group)[]` | Array of visible `Leaf` and `Group` instances (See Item Visibility below) |
|
||||
| `activeId` | `ComputedRef<string \| undefined>` | The id of the active item |
|
||||
| `activeItem` | `ComputedRef<Leaf \| Group \| undefined>` | The active item instance |
|
||||
| `selectedIds` | `ComputedRef<string[]>` | Array of selected item ids |
|
||||
| `selectedItems` | `ComputedRef<(Leaf \| Group)[]>` | Array of selected item instances |
|
||||
| `expandedIds` | `ComputedRef<string[]>` | Array of expanded group ids |
|
||||
| `expandedItems` | `ComputedRef<Group[]>` | Array of expanded group instances |
|
||||
|
||||
## `LeafDefinition`
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Group } from '@core/composables/collection/group'
|
||||
import type { Item } from '@core/composables/collection/types'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
export function useCollectionNavigation<TItem extends Item>(
|
||||
items: ComputedRef<TItem[]>,
|
||||
activeItem: ComputedRef<TItem | undefined>
|
||||
) {
|
||||
const flatItems = computed(() => {
|
||||
const result = [] as any[]
|
||||
|
||||
function add(item: Item) {
|
||||
result.push(item)
|
||||
|
||||
if (item instanceof Group) {
|
||||
item.children.forEach(child => add(child))
|
||||
}
|
||||
}
|
||||
|
||||
items.value.forEach(item => add(item))
|
||||
|
||||
return result
|
||||
}) as ComputedRef<TItem[]>
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
const id = activeItem.value?.id
|
||||
|
||||
return id === undefined ? -1 : flatItems.value.findIndex(item => item.id === id)
|
||||
})
|
||||
|
||||
const moveDown = () => {
|
||||
flatItems.value[activeIndex.value === -1 ? 0 : activeIndex.value + 1]?.activate()
|
||||
}
|
||||
|
||||
const moveUp = () => {
|
||||
flatItems.value[activeIndex.value - 1]?.activate()
|
||||
}
|
||||
|
||||
const moveLeft = () => {
|
||||
if (activeItem.value instanceof Group && activeItem.value.isExpanded) {
|
||||
return activeItem.value.toggleExpand(false, true)
|
||||
}
|
||||
|
||||
activeItem.value?.parent?.activate()
|
||||
}
|
||||
|
||||
const moveRight = () => {
|
||||
if (activeItem.value instanceof Group && !activeItem.value.isExpanded) {
|
||||
return activeItem.value.toggleExpand(true)
|
||||
}
|
||||
|
||||
moveDown()
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
return moveDown()
|
||||
case 'ArrowUp':
|
||||
return moveUp()
|
||||
case 'ArrowLeft':
|
||||
return moveLeft()
|
||||
case 'ArrowRight':
|
||||
return moveRight()
|
||||
}
|
||||
}
|
||||
|
||||
return { moveUp, moveDown, moveLeft, moveRight, handleKeydown }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { buildCollection } from '@core/composables/collection/build-collection'
|
||||
import type { CollectionContext, Definition, Item } from '@core/composables/collection/types'
|
||||
import { computed, type MaybeRefOrGetter, reactive, type Ref, ref, toValue } from 'vue'
|
||||
|
||||
export function useCollection<TDefinition extends Definition>(
|
||||
definitions: MaybeRefOrGetter<TDefinition[]>,
|
||||
options?: { allowMultiSelect?: boolean; expand?: boolean }
|
||||
) {
|
||||
const selected = ref(new Map()) as Ref<Map<string, Item>>
|
||||
const expanded = ref(new Map()) as Ref<Map<string, Item>>
|
||||
const active = ref() as Ref<Item | undefined>
|
||||
|
||||
const context = reactive({
|
||||
allowMultiSelect: options?.allowMultiSelect ?? false,
|
||||
selected,
|
||||
expanded,
|
||||
active,
|
||||
}) as CollectionContext
|
||||
|
||||
const rawItems = computed(() => buildCollection(toValue(definitions), context))
|
||||
const items = computed(() => rawItems.value.filter(item => item.isVisible))
|
||||
|
||||
if (options?.expand !== false) {
|
||||
items.value.forEach(item => item.isGroup && item.toggleExpand(true, true))
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
activeItem: computed(() => context.active),
|
||||
selectedItems: computed(() => Array.from(context.selected.values())),
|
||||
expandedItems: computed(() => Array.from(context.expanded.values())),
|
||||
}
|
||||
}
|
||||
69
@xen-orchestra/web-core/lib/composables/collection/base.ts
Normal file
69
@xen-orchestra/web-core/lib/composables/collection/base.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Group } from '@core/composables/collection/group'
|
||||
import type { CollectionContext, ItemOptions } from '@core/composables/collection/types'
|
||||
|
||||
export abstract class Base<T = any, TDiscriminator = any> {
|
||||
abstract readonly isGroup: boolean
|
||||
abstract passesFilterDownwards: boolean
|
||||
abstract isVisible: boolean
|
||||
abstract labelClasses: Record<string, boolean>
|
||||
|
||||
readonly id: string
|
||||
readonly data: T
|
||||
readonly depth: number
|
||||
readonly discriminator: TDiscriminator | undefined
|
||||
|
||||
readonly parent: Group | undefined
|
||||
readonly context: CollectionContext
|
||||
readonly predicate: undefined | ((data: T) => boolean | undefined)
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
data: T,
|
||||
parent: Group | undefined,
|
||||
context: CollectionContext,
|
||||
depth: number,
|
||||
options?: ItemOptions<T, TDiscriminator>
|
||||
) {
|
||||
this.id = id
|
||||
this.data = data
|
||||
this.parent = parent
|
||||
this.context = context
|
||||
this.depth = depth
|
||||
this.discriminator = options?.discriminator
|
||||
this.predicate = options?.predicate
|
||||
}
|
||||
|
||||
get passesFilter() {
|
||||
return this.predicate?.(this.data)
|
||||
}
|
||||
|
||||
get isSelected() {
|
||||
return this.context.selected.has(this.id)
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this.context.active?.id === this.id
|
||||
}
|
||||
|
||||
get passesFilterUpwards(): boolean {
|
||||
return this.passesFilter || (this.parent?.passesFilterUpwards ?? false)
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.context.active = this
|
||||
}
|
||||
|
||||
toggleSelect(force?: boolean) {
|
||||
const shouldSelect = force ?? !this.isSelected
|
||||
|
||||
if (shouldSelect) {
|
||||
if (!this.context.allowMultiSelect) {
|
||||
this.context.selected.clear()
|
||||
}
|
||||
|
||||
this.context.selected.set(this.id, this)
|
||||
} else {
|
||||
this.context.selected.delete(this.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Group } from '@core/composables/collection/group'
|
||||
import { GroupDefinition } from '@core/composables/collection/group-definition'
|
||||
import { Leaf } from '@core/composables/collection/leaf'
|
||||
import type { CollectionContext, Definition, DefinitionToItem, Item } from '@core/composables/collection/types'
|
||||
|
||||
export function buildCollection<TDefinition extends Definition>(
|
||||
definitions: TDefinition[],
|
||||
context: CollectionContext
|
||||
): DefinitionToItem<TDefinition>[] {
|
||||
function create(definitions: Definition[], parent: Group | undefined, depth: number): Item[] {
|
||||
return definitions.map(definition =>
|
||||
definition instanceof GroupDefinition
|
||||
? new Group(definition.id, definition.data, parent, context, depth, definition.options, thisGroup =>
|
||||
create(definition.children, thisGroup, depth + 1)
|
||||
)
|
||||
: new Leaf(definition.id, definition.data, parent, context, depth, definition.options)
|
||||
)
|
||||
}
|
||||
|
||||
return create(definitions, undefined, 0) as DefinitionToItem<TDefinition>[]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { GroupDefinition } from '@core/composables/collection/group-definition'
|
||||
import { LeafDefinition } from '@core/composables/collection/leaf-definition'
|
||||
import type { DefineCollectionOptions, Definition } from '@core/composables/collection/types'
|
||||
|
||||
// Overload 1: Leaf with no options
|
||||
export function defineCollection<T, const TDiscriminator>(entries: T[]): LeafDefinition<T, TDiscriminator>[]
|
||||
|
||||
// Overload 2: Leaf with options
|
||||
export function defineCollection<T, const TDiscriminator>(
|
||||
entries: T[],
|
||||
options: DefineCollectionOptions<T, TDiscriminator>
|
||||
): LeafDefinition<T, TDiscriminator>[]
|
||||
|
||||
// Overload 3: Group with no options
|
||||
export function defineCollection<T, TChildDefinition extends Definition, const TDiscriminator>(
|
||||
entries: T[],
|
||||
getChildren: (data: T) => TChildDefinition[]
|
||||
): GroupDefinition<T, TChildDefinition, TDiscriminator>[]
|
||||
|
||||
// Overload 4: Group with options
|
||||
export function defineCollection<T, TChildDefinition extends Definition, const TDiscriminator>(
|
||||
entries: T[],
|
||||
options: DefineCollectionOptions<T, TDiscriminator>,
|
||||
getChildren: (data: T) => TChildDefinition[]
|
||||
): GroupDefinition<T, TChildDefinition, TDiscriminator>[]
|
||||
|
||||
// Implementation
|
||||
export function defineCollection<T, TChildDefinition extends Definition, const TDiscriminator>(
|
||||
entries: T[],
|
||||
optionsOrGetChildren?: DefineCollectionOptions<T, TDiscriminator> | ((data: T) => TChildDefinition[]),
|
||||
getChildren?: (data: T) => TChildDefinition[]
|
||||
) {
|
||||
const options = typeof optionsOrGetChildren === 'function' ? {} : optionsOrGetChildren ?? {}
|
||||
const getChildrenFn = typeof optionsOrGetChildren === 'function' ? optionsOrGetChildren : getChildren
|
||||
|
||||
const { idField = 'id' as keyof T, ...otherOptions } = options
|
||||
|
||||
if (getChildrenFn !== undefined) {
|
||||
return entries.map(data => new GroupDefinition(data[idField] as string, data, otherOptions, getChildrenFn(data)))
|
||||
}
|
||||
|
||||
return entries.map(data => new LeafDefinition(data[idField] as string, data, options))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ItemOptions } from '@core/composables/collection/types'
|
||||
|
||||
export abstract class DefinitionBase<T, TDiscriminator> {
|
||||
id: string
|
||||
data: T
|
||||
options: ItemOptions<T, TDiscriminator>
|
||||
|
||||
constructor(id: string, data: T, options: ItemOptions<T, TDiscriminator> = {}) {
|
||||
this.data = data
|
||||
this.options = options
|
||||
this.id = id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DefinitionBase } from '@core/composables/collection/definition-base'
|
||||
import type { Definition, ItemOptions } from '@core/composables/collection/types'
|
||||
|
||||
export class GroupDefinition<
|
||||
T = any,
|
||||
TChildDefinition extends Definition = Definition,
|
||||
const TDiscriminator = any,
|
||||
> extends DefinitionBase<T, TDiscriminator> {
|
||||
children: TChildDefinition[]
|
||||
|
||||
constructor(id: string, data: T, children: TChildDefinition[])
|
||||
constructor(id: string, data: T, options: ItemOptions<T, TDiscriminator>, children: TChildDefinition[])
|
||||
constructor(
|
||||
id: string,
|
||||
data: T,
|
||||
optionsOrChildren: ItemOptions<T, TDiscriminator> | TChildDefinition[],
|
||||
children?: TChildDefinition[]
|
||||
) {
|
||||
super(id, data, Array.isArray(optionsOrChildren) ? {} : optionsOrChildren)
|
||||
|
||||
this.children = Array.isArray(optionsOrChildren) ? optionsOrChildren : children!
|
||||
}
|
||||
}
|
||||
106
@xen-orchestra/web-core/lib/composables/collection/group.ts
Normal file
106
@xen-orchestra/web-core/lib/composables/collection/group.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Base } from '@core/composables/collection/base'
|
||||
import type { CollectionContext, Item, ItemOptions } from '@core/composables/collection/types'
|
||||
|
||||
export class Group<T = any, TChild extends Item = Item, const TDiscriminator = any> extends Base<T, TDiscriminator> {
|
||||
readonly isGroup = true
|
||||
readonly rawChildren: TChild[]
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
data: T,
|
||||
parent: Group | undefined,
|
||||
context: CollectionContext,
|
||||
depth: number,
|
||||
options: ItemOptions<T, TDiscriminator> | undefined,
|
||||
getChildren: (thisGroup: Group<T, TChild, TDiscriminator>) => TChild[]
|
||||
) {
|
||||
super(id, data, parent, context, depth, options)
|
||||
this.rawChildren = getChildren(this)
|
||||
}
|
||||
|
||||
get children() {
|
||||
return this.rawChildren.filter(child => child.isVisible)
|
||||
}
|
||||
|
||||
get passesFilterDownwards(): boolean {
|
||||
return this.passesFilter || this.rawChildren.some(child => child.passesFilterDownwards)
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
if (this.passesFilterUpwards || this.passesFilterDownwards) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.passesFilter === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.parent?.isExpanded ?? true
|
||||
}
|
||||
|
||||
get isExpanded() {
|
||||
return this.context.expanded.has(this.id) || this.passesFilterDownwards || this.passesFilterUpwards
|
||||
}
|
||||
|
||||
get areChildrenFullySelected(): boolean {
|
||||
if (!this.context.allowMultiSelect) {
|
||||
throw new Error('allowMultiSelect must be enabled to use areChildrenFullySelected')
|
||||
}
|
||||
|
||||
return this.rawChildren.every(child => (child.isGroup ? child.areChildrenFullySelected : child.isSelected))
|
||||
}
|
||||
|
||||
get areChildrenPartiallySelected(): boolean {
|
||||
if (!this.context.allowMultiSelect) {
|
||||
throw new Error('allowMultiSelect must be enabled to use areChildrenPartiallySelected')
|
||||
}
|
||||
|
||||
if (this.areChildrenFullySelected) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.rawChildren.some(child => (child.isGroup ? child.areChildrenPartiallySelected : child.isSelected))
|
||||
}
|
||||
|
||||
get labelClasses() {
|
||||
return {
|
||||
active: this.isActive,
|
||||
selected: this.isSelected,
|
||||
matches: this.passesFilter === true,
|
||||
'selected-partial': this.context.allowMultiSelect && this.areChildrenPartiallySelected,
|
||||
'selected-full': this.context.allowMultiSelect && this.areChildrenFullySelected,
|
||||
expanded: this.isExpanded,
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpand(force?: boolean, recursive?: boolean) {
|
||||
const shouldExpand = force ?? !this.isExpanded
|
||||
|
||||
if (shouldExpand) {
|
||||
this.context.expanded.set(this.id, this)
|
||||
} else {
|
||||
this.context.expanded.delete(this.id)
|
||||
}
|
||||
|
||||
const shouldPropagate = recursive ?? !shouldExpand
|
||||
|
||||
if (shouldPropagate) {
|
||||
this.rawChildren.forEach(child => {
|
||||
if (child.isGroup) {
|
||||
child.toggleExpand(shouldExpand, recursive)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toggleChildrenSelect(force?: boolean) {
|
||||
if (!this.context.allowMultiSelect) {
|
||||
throw new Error('allowMultiSelect must be enabled to use toggleChildrenSelect')
|
||||
}
|
||||
|
||||
const shouldSelect = force ?? !this.areChildrenFullySelected
|
||||
this.rawChildren.forEach(child => {
|
||||
child instanceof Group ? child.toggleChildrenSelect(shouldSelect) : child.toggleSelect(shouldSelect)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DefinitionBase } from '@core/composables/collection/definition-base'
|
||||
|
||||
export class LeafDefinition<T = any, const TDiscriminator = any> extends DefinitionBase<T, TDiscriminator> {}
|
||||
29
@xen-orchestra/web-core/lib/composables/collection/leaf.ts
Normal file
29
@xen-orchestra/web-core/lib/composables/collection/leaf.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Base } from '@core/composables/collection/base'
|
||||
|
||||
export class Leaf<T = any, const TDiscriminator = any> extends Base<T, TDiscriminator> {
|
||||
readonly isGroup = false
|
||||
|
||||
get passesFilterDownwards(): boolean {
|
||||
return this.passesFilter ?? false
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
if (this.passesFilterUpwards) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.passesFilter === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.parent?.isExpanded ?? true
|
||||
}
|
||||
|
||||
get labelClasses() {
|
||||
return {
|
||||
active: this.isActive,
|
||||
selected: this.isSelected,
|
||||
matches: this.passesFilter === true,
|
||||
}
|
||||
}
|
||||
}
|
||||
32
@xen-orchestra/web-core/lib/composables/collection/types.ts
Normal file
32
@xen-orchestra/web-core/lib/composables/collection/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Base } from '@core/composables/collection/base'
|
||||
import type { Group } from '@core/composables/collection/group'
|
||||
import type { GroupDefinition } from '@core/composables/collection/group-definition'
|
||||
import type { Leaf } from '@core/composables/collection/leaf'
|
||||
import type { LeafDefinition } from '@core/composables/collection/leaf-definition'
|
||||
|
||||
export type ItemOptions<T, TDiscriminator> = {
|
||||
discriminator?: TDiscriminator
|
||||
predicate?: (data: T) => boolean | undefined
|
||||
}
|
||||
|
||||
export type DefineCollectionOptions<T, TDiscriminator> = ItemOptions<T, TDiscriminator> & {
|
||||
idField?: keyof T
|
||||
}
|
||||
|
||||
export type Definition = LeafDefinition | GroupDefinition
|
||||
|
||||
export type CollectionContext = {
|
||||
allowMultiSelect: boolean
|
||||
selected: Map<string, Base>
|
||||
expanded: Map<string, Base>
|
||||
active: Base | undefined
|
||||
}
|
||||
|
||||
export type DefinitionToItem<TDefinition> =
|
||||
TDefinition extends GroupDefinition<infer T, infer TChildDefinition, infer TDiscriminator>
|
||||
? Group<T, DefinitionToItem<TChildDefinition>, TDiscriminator>
|
||||
: TDefinition extends LeafDefinition<infer T, infer TDiscriminator>
|
||||
? Leaf<T, TDiscriminator>
|
||||
: never
|
||||
|
||||
export type Item = Leaf | Group
|
||||
Reference in New Issue
Block a user