Skip to content

Commit

Permalink
feat: implement typeahead
Browse files Browse the repository at this point in the history
  • Loading branch information
segunadebayo committed Jan 11, 2024
1 parent 47fc2d4 commit d4615db
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 25 deletions.
3 changes: 3 additions & 0 deletions .xstate/tree-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ const fetchMachine = createMachine({
},
"EXPANDED.SET": {
actions: ["setExpanded"]
},
TYPEAHEAD: {
actions: "focusMatchedItem"
}
}
}
Expand Down
45 changes: 34 additions & 11 deletions packages/machines/tree-view/src/tree-view.connect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getEventKey, type EventKeyMap } from "@zag-js/dom-event"
import { getEventKey, isModifiedEvent, type EventKeyMap } from "@zag-js/dom-event"
import { dataAttr } from "@zag-js/dom-query"
import type { NormalizeProps, PropTypes } from "@zag-js/types"
import { parts } from "./tree-view.anatomy"
Expand All @@ -7,14 +7,17 @@ import type { BranchProps, BranchState, ItemProps, ItemState, Send, State } from

export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>) {
const expandedIds = state.context.expandedIds
const selectedIds = state.context.selectedIds
const isTypingAhead = state.context.isTypingAhead
const focusedId = state.context.focusedId

function getItemState(props: ItemProps): ItemState {
const id = dom.getItemId(state.context, props.id)
return {
id,
isFocused: state.context.focusedId === id,
isDisabled: Boolean(props.disabled),
isSelected: state.context.selectedIds.has(id),
isFocused: focusedId === id,
isSelected: selectedIds.has(id),
}
}

Expand All @@ -23,9 +26,9 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
return {
id,
isDisabled: Boolean(props.disabled),
isFocused: state.context.focusedId === id,
isFocused: focusedId === id,
isExpanded: expandedIds.has(id),
isSelected: state.context.selectedIds.has(id),
isSelected: selectedIds.has(id),
}
}

Expand Down Expand Up @@ -119,8 +122,11 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
send({ type: "ITEM.END", id: itemState.id })
},
Space(event) {
event.preventDefault()
send({ type: "ITEM.CLICK", id: itemState.id })
if (isTypingAhead) {
send({ type: "TYPEAHEAD", key: event.key })
} else {
keyMap.Enter?.(event)
}
},
Enter(event) {
event.preventDefault()
Expand All @@ -129,8 +135,17 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
}

const key = getEventKey(event, state.context)
const handler = keyMap[key]
if (handler) handler(event)
const exec = keyMap[key]

if (exec) {
exec(event)
} else {
const isValidTypeahead = event.key.length === 1 && !isModifiedEvent(event)
if (!isValidTypeahead) return

send({ type: "TYPEAHEAD", key: event.key })
event.preventDefault()
}
},
onFocus(event) {
event.stopPropagation()
Expand Down Expand Up @@ -208,9 +223,17 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
}

const key = getEventKey(event, state.context)
const handler = keyMap[key]
const exec = keyMap[key]

if (exec) {
exec(event)
} else {
const isValidTypeahead = event.key.length === 1 && !isModifiedEvent(event)
if (!isValidTypeahead) return

if (handler) handler(event)
send({ type: "TYPEAHEAD", key: event.key })
event.preventDefault()
}
},
onFocus(event) {
event.stopPropagation()
Expand Down
29 changes: 26 additions & 3 deletions packages/machines/tree-view/src/tree-view.dom.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createScope, isHTMLElement, isHiddenElement } from "@zag-js/dom-query"
import { createScope, getByTypeahead, isHTMLElement, isHiddenElement } from "@zag-js/dom-query"
import type { MachineContext as Ctx } from "./tree-view.types"

export const dom = createScope({
Expand All @@ -20,18 +20,23 @@ export const dom = createScope({
}
return null
},

getFocusedEl(ctx: Ctx) {
if (!ctx.focusedId) return null
return dom.getById(ctx, ctx.focusedId)
},

focusNode(node: Node | Element | null | undefined) {
if (isHTMLElement(node)) node.focus()
},
createWalker(ctx: Ctx) {

getTreeWalker(ctx: Ctx) {
const treeEl = dom.getTreeEl(ctx)
if (!treeEl) throw new Error("Tree view not found")

return dom.getDoc(ctx).createTreeWalker(treeEl, NodeFilter.SHOW_ELEMENT, {
const docEl = dom.getDoc(ctx)

return docEl.createTreeWalker(treeEl, NodeFilter.SHOW_ELEMENT, {
acceptNode(node: HTMLElement) {
if (isHiddenElement(node)) {
return NodeFilter.FILTER_REJECT
Expand All @@ -49,4 +54,22 @@ export const dom = createScope({
},
})
},

getMatchingEl(ctx: Ctx, key: string) {
const walker = dom.getTreeWalker(ctx)

const elements: HTMLElement[] = []
let node = walker.firstChild()

while (node) {
if (isHTMLElement(node)) elements.push(node)
node = walker.nextNode()
}

return getByTypeahead(elements, {
state: ctx.typeahead,
key,
activeId: ctx.focusedId,
})
},
})
24 changes: 16 additions & 8 deletions packages/machines/tree-view/src/tree-view.machine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createMachine, guards } from "@zag-js/core"
import { isHTMLElement } from "@zag-js/dom-query"
import { getByTypeahead, isHTMLElement } from "@zag-js/dom-query"
import { compact } from "@zag-js/utils"
import { dom } from "./tree-view.dom"
import type { MachineContext, MachineState, UserDefinedContext } from "./tree-view.types"
Expand All @@ -17,6 +17,7 @@ export function machine(userContext: UserDefinedContext) {
selectedIds: new Set(),
focusedId: null,
...ctx,
typeahead: getByTypeahead.defaultOptions,
},

entry: ["activateFirstTreeItemIfNeeded"],
Expand Down Expand Up @@ -75,6 +76,9 @@ export function machine(userContext: UserDefinedContext) {
"EXPANDED.SET": {
actions: ["setExpanded"],
},
TYPEAHEAD: {
actions: "focusMatchedItem",
},
},
},
},
Expand All @@ -87,7 +91,7 @@ export function machine(userContext: UserDefinedContext) {
actions: {
activateFirstTreeItemIfNeeded(ctx) {
if (ctx.focusedId) return
const walker = dom.createWalker(ctx)
const walker = dom.getTreeWalker(ctx)
const firstItem = walker.firstChild()
if (!isHTMLElement(firstItem)) return
set.focused(ctx, firstItem.id)
Expand Down Expand Up @@ -122,20 +126,20 @@ export function machine(userContext: UserDefinedContext) {
set.expanded(ctx, evt.value)
},
focusTreeFirstItem(ctx) {
const walker = dom.createWalker(ctx)
const walker = dom.getTreeWalker(ctx)
dom.focusNode(walker.firstChild())
},
focusTreeLastItem(ctx) {
const walker = dom.createWalker(ctx)
const walker = dom.getTreeWalker(ctx)
dom.focusNode(walker.lastChild())
},
focusBranchFirstItem(ctx) {
const walker = dom.createWalker(ctx)
const walker = dom.getTreeWalker(ctx)
walker.currentNode = document.activeElement as HTMLElement
dom.focusNode(walker.nextNode())
},
focusTreeNextItem(ctx) {
const walker = dom.createWalker(ctx)
const walker = dom.getTreeWalker(ctx)
const activeEl = dom.getActiveElement(ctx) as HTMLElement
if (ctx.focusedId) {
walker.currentNode = activeEl
Expand All @@ -146,7 +150,7 @@ export function machine(userContext: UserDefinedContext) {
}
},
focusTreePrevItem(ctx) {
const walker = dom.createWalker(ctx)
const walker = dom.getTreeWalker(ctx)
const activeEl = dom.getActiveElement(ctx) as HTMLElement
if (ctx.focusedId) {
walker.currentNode = activeEl
Expand All @@ -172,7 +176,7 @@ export function machine(userContext: UserDefinedContext) {
},
selectAllItems(ctx) {
const nextSet = new Set<string>()
const walker = dom.createWalker(ctx)
const walker = dom.getTreeWalker(ctx)
let node = walker.firstChild()
while (node) {
if (isHTMLElement(node)) {
Expand All @@ -182,6 +186,10 @@ export function machine(userContext: UserDefinedContext) {
}
set.selected(ctx, nextSet)
},
focusMatchedItem(ctx, evt) {
const node = dom.getMatchingEl(ctx, evt.key)
dom.focusNode(node)
},
},
},
)
Expand Down
17 changes: 15 additions & 2 deletions packages/machines/tree-view/src/tree-view.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StateMachine as S } from "@zag-js/core"
import type { TypeaheadState } from "@zag-js/dom-query"
import type { CommonProperties, Context, DirectionProperty, RequiredBy } from "@zag-js/types"

export interface OpenChangeDetails {
Expand Down Expand Up @@ -44,9 +45,21 @@ interface PublicContext extends DirectionProperty, CommonProperties {
onFocusChange?: (details: FocusChangeDetails) => void
}

type PrivateContext = Context<{}>
type PrivateContext = Context<{
/**
* @internal
* The typeahead state for faster keyboard navigation
*/
typeahead: TypeaheadState
}>

type ComputedContext = Readonly<{}>
type ComputedContext = Readonly<{
/**
* @computed
* Whether a typeahead search is ongoing
*/
isTypingAhead: boolean
}>

export type UserDefinedContext = RequiredBy<PublicContext, "id">

Expand Down
21 changes: 20 additions & 1 deletion packages/utilities/dom-query/src/get-by-text.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { indexOfId } from "./get-by-id"

const getValueText = <T extends HTMLElement>(item: T) => item.dataset.valuetext ?? item.textContent ?? ""
const sanitize = (str: string) =>
str
.split("")
.map((char) => {
const code = char.charCodeAt(0)

if (code > 0 && code < 128) {
return char
}

if (code >= 128 && code <= 255) {
return `/x${code.toString(16)}`.replace("/", "\\")
}

return ""
})
.join("")
.trim()

const getValueText = <T extends HTMLElement>(item: T) => sanitize(item.dataset.valuetext ?? item.textContent ?? "")

const match = (valueText: string, query: string) => valueText.trim().toLowerCase().startsWith(query.toLowerCase())

Expand Down

0 comments on commit d4615db

Please sign in to comment.