Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

203 changes: 202 additions & 1 deletion src/scripts/clipperUI/components/sectionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface SectionPickerState {
path: string;
section: OneNoteApi.Section;
};
keyboardNavigationHandler?: (e: KeyboardEvent) => void;
}

interface SectionPickerProp extends ClipperStateProp {
Expand Down Expand Up @@ -248,6 +249,206 @@ export class SectionPickerClass extends ComponentBase<SectionPickerState, Sectio
pickerLinkElement.insertBefore(srDiv, pickerLinkElement.firstChild);
}

// Keyboard navigation handler for the OneNotePicker tree
handlePickerKeyboardNavigation(element: HTMLElement, isFirstDraw: boolean) {
// Clean up existing handler if it exists
if (this.state.keyboardNavigationHandler) {
document.removeEventListener("keydown", this.state.keyboardNavigationHandler, true);
}

if (!isFirstDraw) {
return;
}

const keydownHandler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;

// Only handle keyboard events when focus is within the picker tree
const pickerContainer = document.getElementById(Constants.Ids.sectionLocationContainer);
if (!pickerContainer || !pickerContainer.contains(target)) {
return;
}

// Get all focusable tree items (notebooks and sections)
const treeItemsNodeList = document.querySelectorAll('[role="treeitem"]');
const treeItems: HTMLElement[] = [];
for (let i = 0; i < treeItemsNodeList.length; i++) {
treeItems.push(treeItemsNodeList[i] as HTMLElement);
}
if (treeItems.length === 0) {
return;
}

// Find currently focused item
let currentIndex = -1;
for (let i = 0; i < treeItems.length; i++) {
if (treeItems[i] === document.activeElement || treeItems[i].contains(document.activeElement)) {
currentIndex = i;
break;
}
}
if (currentIndex === -1) {
currentIndex = 0;
}

let handled = false;

switch (e.which) {
case Constants.KeyCodes.down:
// Move to next visible tree item
if (currentIndex < treeItems.length - 1) {
let nextIndex = currentIndex + 1;
while (nextIndex < treeItems.length) {
// Check if the item is visible
const nextItem = treeItems[nextIndex];
if (this.isTreeItemVisible(nextItem)) {
nextItem.focus();
break;
}
nextIndex++;
}
}
handled = true;
break;

case Constants.KeyCodes.up:
// Move to previous visible tree item
if (currentIndex > 0) {
let prevIndex = currentIndex - 1;
while (prevIndex >= 0) {
// Check if the item is visible
const prevItem = treeItems[prevIndex];
if (this.isTreeItemVisible(prevItem)) {
prevItem.focus();
break;
}
prevIndex--;
}
}
handled = true;
break;

case Constants.KeyCodes.right:
// Expand collapsed item or move to first child
const currentItem = treeItems[currentIndex];
const isExpanded = currentItem.getAttribute("aria-expanded") === "true";
if (currentItem.hasAttribute("aria-expanded") && !isExpanded) {
// Item can be expanded, click it to expand
currentItem.click();
} else if (isExpanded && currentIndex < treeItems.length - 1) {
// Already expanded, move to first child
let nextIndex = currentIndex + 1;
const nextItem = treeItems[nextIndex];
if (this.isTreeItemVisible(nextItem) && this.isChildOf(nextItem, currentItem)) {
nextItem.focus();
}
}
handled = true;
break;

case Constants.KeyCodes.left:
// Collapse expanded item or move to parent
const currentItemLeft = treeItems[currentIndex];
const isExpandedLeft = currentItemLeft.getAttribute("aria-expanded") === "true";
if (currentItemLeft.hasAttribute("aria-expanded") && isExpandedLeft) {
// Item is expanded, collapse it
currentItemLeft.click();
} else {
// Move to parent item
const parentItem = this.findParentTreeItem(currentItemLeft);
if (parentItem) {
parentItem.focus();
}
}
handled = true;
break;

case Constants.KeyCodes.home:
// Move to first tree item
if (treeItems.length > 0 && this.isTreeItemVisible(treeItems[0])) {
treeItems[0].focus();
}
handled = true;
break;

case Constants.KeyCodes.end:
// Move to last visible tree item
for (let i = treeItems.length - 1; i >= 0; i--) {
if (this.isTreeItemVisible(treeItems[i])) {
treeItems[i].focus();
break;
}
}
handled = true;
break;

default:
// No action needed for other keys
break;
}

if (handled) {
e.preventDefault();
e.stopPropagation();
}
};

// Store handler reference and add event listener
this.setState({ keyboardNavigationHandler: keydownHandler });
document.addEventListener("keydown", keydownHandler, true);
}

// Helper function to check if a tree item is visible
private isTreeItemVisible(item: HTMLElement): boolean {
if (!item) {
return false;
}
// Check if item or its parent containers are hidden
let current: HTMLElement = item;
while (current && current !== document.body) {
const style = window.getComputedStyle(current);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
// Check if parent is collapsed
const parent = current.parentElement;
if (parent && parent.classList.contains("Closed")) {
return false;
}
current = parent;
}
return true;
}

// Helper function to check if an item is a child of another
private isChildOf(child: HTMLElement, parent: HTMLElement): boolean {
let current = child.parentElement;
while (current && current !== document.body) {
if (current === parent) {
return true;
}
// Check if we've moved up to a sibling tree item
if (current.getAttribute("role") === "treeitem" && current !== parent) {
return false;
}
current = current.parentElement;
}
return false;
}

// Helper function to find parent tree item
private findParentTreeItem(item: HTMLElement): HTMLElement | undefined {
// Walk up the DOM to find the parent tree item
let current = item.parentElement;
while (current && current !== document.body) {
if (current.getAttribute("role") === "treeitem") {
return current;
}
current = current.parentElement;
}
return undefined;
}

render() {
if (this.dataSourceUninitialized()) {
// This logic gets executed on app launch (if already signed in) and whenever the user signs in or out ...
Expand Down Expand Up @@ -295,7 +496,7 @@ export class SectionPickerClass extends ComponentBase<SectionPickerState, Sectio
let locationString = Localization.getLocalizedString("WebClipper.Label.ClipLocation");

return (
<div id={Constants.Ids.locationPickerContainer} {...this.onElementFirstDraw(this.addSrOnlyLocationDiv)}>
<div id={Constants.Ids.locationPickerContainer} {...this.onElementDraw(this.handlePickerKeyboardNavigation)} {...this.onElementFirstDraw(this.addSrOnlyLocationDiv)}>
<div id={Constants.Ids.optionLabel} className="optionLabel">
<label htmlFor={Constants.Ids.sectionLocationContainer} aria-label={locationString} className="buttonLabelFont" style={Localization.getFontFamilyAsStyle(Localization.FontFamily.Regular)}>
<span aria-hidden="true">{locationString}</span>
Expand Down