Skip to content

Commit

Permalink
Remove hover hook (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
kadiryazici authored Oct 18, 2022
1 parent 4e5d553 commit 677c730
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 81 deletions.
53 changes: 36 additions & 17 deletions playground/examples/nested/ItemRenderer.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { ref, shallowRef } from 'vue';
import type { Item, ItemGroup, CustomItem } from '../../../src';
import { Wowerlay } from 'wowerlay';
import useKey from '../../composables/useKey';
import { SelectableItems, type Context, filterSelectableItems } from '../../../src/';
import { SelectableItems, type Context } from '../../../src/';
import IconChevronRight from 'virtual:icons/carbon/chevron-right';
import ItemRenderer from './ItemRenderer.vue';
Expand All @@ -29,6 +29,26 @@ export const isFocusedOnBlackListedElement = () =>
(document.activeElement instanceof HTMLInputElement &&
!allowedInputTypes.includes(document.activeElement.type));
export const createFocusRestorer = (element: HTMLElement) => {
const elementScrollPositions = [] as { target: HTMLElement; x: number; y: number }[];
const rootScrollingElement = document.scrollingElement || document.documentElement;
let parent = element.parentNode as HTMLElement | null;
while (parent && parent !== rootScrollingElement) {
if (parent.offsetHeight < parent.scrollHeight || parent.offsetWidth < parent.scrollWidth) {
elementScrollPositions.push({ target: parent, x: parent.scrollLeft, y: parent.scrollTop });
}
parent = parent.parentNode as HTMLElement | null;
}
return () => {
for (const { target, x, y } of elementScrollPositions) {
target.scrollLeft = x;
target.scrollTop = y;
}
};
};
export default {
inheritAttrs: false,
};
Expand Down Expand Up @@ -57,13 +77,7 @@ const focusedElement = ref<null | HTMLElement>(null);
const expand = ref(false);
const focusedItem = shallowRef<Item<ItemMetaWithChildren> | undefined>();
const focusedItemHasChildren = computed(() => {
if (focusedItem.value == null) return false;
const meta = focusedItem.value.meta;
return meta && Array.isArray(meta.children) && meta.children.length > 0;
});
const focusedItemHasChildren = ref(false);
function setupHandler(ctx: Context) {
useKey('esc', () => emit('close'));
Expand Down Expand Up @@ -107,12 +121,22 @@ function setupHandler(ctx: Context) {
{ input: true },
);
ctx.onFocus((_meta, item, el) => {
ctx.onFocus((meta, item, el, byPointer) => {
focusedElement.value = el;
focusedItem.value = item;
focusedItemHasChildren.value = Array.isArray(meta?.children);
if (byPointer) {
expandIfHasChildren(meta);
}
if (!isInputing()) {
const restore = createFocusRestorer(el);
el.focus();
if (byPointer) {
restore();
}
} else {
el.scrollIntoView({
block: 'center',
Expand All @@ -121,18 +145,13 @@ function setupHandler(ctx: Context) {
}
});
const expandIfHasChildren = (meta: ItemMetaWithChildren) => {
if (meta && Array.isArray(meta.children) && filterSelectableItems(meta.children).length > 0) {
const expandIfHasChildren = (meta?: ItemMetaWithChildren) => {
if (Array.isArray(meta?.children)) {
expand.value = true;
}
};
ctx.onHover((meta) => {
expandIfHasChildren(meta);
});
ctx.onSelect((meta: ItemMetaWithChildren) => {
console.log(meta);
expandIfHasChildren(meta);
if (!props.preventCloseOnSelect && !meta.children) emit('close');
});
Expand Down
132 changes: 69 additions & 63 deletions src/Component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ import {
} from 'vue';
import { ClassNames, DEFAULT_ITEM_SLOT_NAME, HookType } from './constants';
import { filterSelectableItems, isComponent, isCustomItem, isItem, isItemGroup } from './functions';
import type { AllItems, Item, Hook } from './types';
import type {
AllItems,
Item,
Hook,
FocusHook,
SelectHook,
UnfocusHook,
DOMFocusHook,
} from './types';

const enum Direction {
Next,
Expand All @@ -31,9 +39,8 @@ export type Context = {
setFocusByKey(key?: string | null | undefined): void;
setFocusByIndex(index: number): void;
onSelect(fn: Hook): void;
onFocus(fn: Hook): void;
onFocus(fn: FocusHook): void;
onUnfocus(fn: Hook): void;
onHover(fn: Hook): void;
onDOMFocus(fn: Hook): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getItemMetaByKey<Meta = any>(key: string): Meta | undefined;
Expand All @@ -53,11 +60,10 @@ export interface Props {
setup(context: Context): void;

/* eslint-disable @typescript-eslint/no-explicit-any */
onSelect(meta: any, item: Item<any>, el: HTMLElement): void;
onItemFocus(meta: any, item: Item<any>, el: HTMLElement): void;
onItemUnfocus(meta: any, item: Item<any>, el: HTMLElement): void;
onItemDOMFocus(e: FocusEvent, meta: any, item: Item<any>): void;
onItemHover(meta: any, item: Item<any>, el: HTMLElement): void;
onSelect: SelectHook;
onItemFocus: FocusHook;
onItemUnfocus: UnfocusHook;
onItemDOMFocus: DOMFocusHook;
/* eslint-enable @typescript-eslint/no-explicit-any */
}

Expand Down Expand Up @@ -87,7 +93,6 @@ export default defineComponent({
itemFocus: null as unknown as Props['onItemFocus'],
itemUnfocus: null as unknown as Props['onItemUnfocus'],
itemDOMFocus: null as unknown as Props['onItemDOMFocus'],
itemHover: null as unknown as Props['onItemHover'],
},
setup(props, { emit, expose }) {
const selectableItems: ComputedRef<Item[]> = computed(() => filterSelectableItems(props.items));
Expand All @@ -100,16 +105,61 @@ export default defineComponent({
() => selectableItems.value[focusedItemIndex.value],
);

const setFocusByKey = (key?: string) => {
focusedItemKey.value = key || '';
const hooks = {
[HookType.Focus]: new Set<FocusHook>(),
[HookType.Select]: new Set<Hook>(),
[HookType.Unfocus]: new Set<Hook>(),
[HookType.DOMFocus]: new Set<Hook>(),
};

const onFocus = (fn: FocusHook) => hooks[HookType.Focus].add(fn);
const onSelect = (fn: Hook) => hooks[HookType.Select].add(fn);
const onUnfocus = (fn: Hook) => hooks[HookType.Unfocus].add(fn);
const onDOMFocus = (fn: Hook) => hooks[HookType.DOMFocus].add(fn);

const runHooks = (type: HookType, item: Item, byPointer = false) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const el = selectableItemsElements.get(item.key)!;
if (type === HookType.Focus) {
hooks[type].forEach((hook) => hook(item.meta, item, el, byPointer));
} else {
hooks[type].forEach((hook) => hook(item.meta, item, el));
}
};

function handleFocusedItemChange(to?: Item, from?: Item, byPointer = false) {
if (to?.key === from?.key) return;

if (isItem(from)) {
runHooks(HookType.Unfocus, from);
emit('itemUnfocus', from.meta, from, getItemElementByKey(from.key)!);
}

if (isItem(to)) {
runHooks(HookType.Focus, to, byPointer);
emit('itemFocus', to.meta, to, getItemElementByKey(to.key)!, byPointer);
}
}

const setFocusByKey = (key: string, byPointer = false) => {
const newKey = key || '';
const newItem = getItemByKey(selectableItems.value, newKey);

if (newItem?.disabled) return;

const oldItem = getItemByKey(selectableItems.value, focusedItemKey.value);

focusedItemKey.value = newKey;

handleFocusedItemChange(newItem, oldItem, byPointer);
};

watch(
() => selectableItems.value.map((item) => item.key),
(keys) => {
const exists = keys.some((key) => key === focusedItemKey.value);
if (!exists) {
setFocusByKey();
setFocusByKey('');
}
},
);
Expand All @@ -127,38 +177,6 @@ export default defineComponent({
const saveSelectableItemElement = (key: string, element: HTMLElement) =>
selectableItemsElements.set(key, element);

const hooks = new Map([
[HookType.Focus, new Set<Hook>()],
[HookType.Hover, new Set<Hook>()],
[HookType.Select, new Set<Hook>()],
[HookType.Unfocus, new Set<Hook>()],
[HookType.DOMFocus, new Set<Hook>()],
]);

const onFocus = (fn: Hook) => hooks.get(HookType.Focus)!.add(fn);
const onSelect = (fn: Hook) => hooks.get(HookType.Select)!.add(fn);
const onUnfocus = (fn: Hook) => hooks.get(HookType.Unfocus)!.add(fn);
const onHover = (fn: Hook) => hooks.get(HookType.Hover)!.add(fn);
const onDOMFocus = (fn: Hook) => hooks.get(HookType.DOMFocus)!.add(fn);

const runHooks = (type: HookType, item: Item) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const el = selectableItemsElements.get(item.key)!;
hooks.get(type)?.forEach((hook) => hook(item.meta, item, el));
};

watch(focusedItem, (to, from) => {
if (isItem(from)) {
runHooks(HookType.Unfocus, from);
emit('itemUnfocus', from.meta, from, getItemElementByKey(from.key)!);
}

if (isItem(to)) {
runHooks(HookType.Focus, to);
emit('itemFocus', to.meta, to, getItemElementByKey(to.key)!);
}
});

const scrollToFocusedItemElement = (options: ScrollIntoViewOptions = {}) => {
const el = selectableItemsElements.get(focusedItemKey.value);
if (el) el.scrollIntoView(options);
Expand All @@ -175,14 +193,7 @@ export default defineComponent({
};

const handleMouseEnter = (item: Item) => {
if (focusedItemKey.value === item.key || item.disabled) return;

setFocusByKey(item.key);
// It should be called after unFocus events
setTimeout(() => {
runHooks(HookType.Hover, item);
emit('itemHover', item.meta, item, getItemElementByKey(item.key)!);
});
setFocusByKey(item.key, true);
};

const handleClick = (item: Item) => {
Expand All @@ -200,7 +211,7 @@ export default defineComponent({
if (item.disabled) return;

runHooks(HookType.DOMFocus, item);
emit('itemDOMFocus', event, item.meta, item);
emit('itemDOMFocus', event, item.meta, item, getItemElementByKey(item.key)!);
};

const focusNextInDirection = (direction: Direction) => {
Expand Down Expand Up @@ -238,8 +249,8 @@ export default defineComponent({
return item?.disabled === true;
};

const selectItem = (queryKey?: string) => {
const item = getItemByKey(selectableItems.value, queryKey || focusedItemKey.value);
const selectItem = (key: string) => {
const item = getItemByKey(selectableItems.value, key);
if (!item || item.disabled) return;

const el = getItemElementByKey(item.key)!;
Expand All @@ -249,23 +260,18 @@ export default defineComponent({
emit('select', item.meta, item, el);
};

const clearFocus = () => {
focusedItemKey.value = '';
};

const context: Context = {
focusNext: () => focusNextInDirection(Direction.Next),
focusPrevious: () => focusNextInDirection(Direction.Previous),
clearFocus,
clearFocus: () => setFocusByKey(''),
setFocusByKey,
setFocusByIndex,
onSelect,
onFocus,
onUnfocus,
onHover,
onDOMFocus,
getSelectableItemCount: () => selectableItems.value.length,
selectFocusedElement: () => selectItem(),
selectFocusedElement: () => selectItem(focusedItemKey.value),
getItemElementByIndex,
getItemElementByKey,
scrollToFocusedItemElement,
Expand Down Expand Up @@ -369,6 +375,6 @@ export default defineComponent({

if (this.noWrapperElement) return h(Fragment, {}, rendered);

return h('div', { class: ClassNames.Wrapper }, rendered);
return h('div', mergeProps({ class: ClassNames.Wrapper }, this.$attrs), rendered);
},
});
1 change: 0 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ export enum HookType {
Focus,
Unfocus,
Select,
Hover,
DOMFocus,
}

Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export type {
Item,
ItemGroup,
Hook,
FocusHook,
DOMFocusHook,
SelectHook,
UnfocusHook,
CustomItemOptions,
ItemGroupOptions,
ItemOptions,
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export interface CustomItem<Meta = unknown> {

/* eslint-disable @typescript-eslint/no-explicit-any */
export type Hook = (meta: any, item: Item<any>, el: HTMLElement) => void;
export type SelectHook = Hook;
export type UnfocusHook = Hook;
export type FocusHook = (meta: any, item: Item<any>, el: HTMLElement, byPointer: boolean) => void;
export type DOMFocusHook = (event: FocusEvent, meta: any, item: Item<any>, el: HTMLElement) => void;
/* eslint-enable @typescript-eslint/no-explicit-any */

export type CustomItemOptions<Meta = unknown> = Omit<CustomItem<Meta>, 'type'>;
Expand Down

0 comments on commit 677c730

Please sign in to comment.