vue-material-adapter
Version:
Vue 3 wrapper arround Material Components for the Web
488 lines (409 loc) • 14.1 kB
JavaScript
import { closest, matches } from '@material/dom/ponyfill.js';
import {
cssClasses,
MDCListFoundation,
strings,
} from '@material/list/index.js';
import {
h,
onBeforeUnmount,
onMounted,
provide,
reactive,
ref,
watch,
} from 'vue';
import { emitCustomEvent } from '../base/index.js';
export default {
name: 'mcw-list',
props: {
multiSelectable: Boolean,
wrapFocus: Boolean,
textualList: Boolean,
modelValue: { type: [String, Number, Array] },
typeAhead: Boolean,
vertical: { typee: Boolean, default: () => true },
role: { type: String },
},
setup(props, { emit, slots, expose }) {
const uiState = reactive({
classes: { 'mdc-list': true },
listn: 0,
rootAttrs: {
'aria-orientation': props.vertical ? 'vertical' : 'horizontal',
},
});
const listRoot = ref();
if (props.multiSelectable) {
uiState.rootAttrs['aria-multiselectable'] = 'true';
}
let foundation;
let slotObserver;
const isInteractive = props.role === 'listbox' || props.role === 'menu';
// hash of child list items so we can set classes and attributes
const listItems = {};
// list of child elements that will have their item id in a data attribute
// used to find the listItem from events or by index. Importantly these are in DOM order.
const listElements = ref([]);
// called initially, and when the DOM tree changes
const updateListElements = rootElement => {
listElements.value = [
...rootElement.querySelectorAll(`.${cssClasses.LIST_ITEM_CLASS}`),
];
};
// expose data and methods to children (list items)
provide('mcwList', {
isInteractive,
registerListItem: item => (listItems[item.itemId] = item),
});
// find the list item by index.
// The list elements are in DOM order, so find it by index,
// then use its item id to lookup in the list item hash
const getListItemByIndex = index => {
const element = listElements.value[index];
if (element) {
const myItemId = element.dataset.myitemid;
return listItems[myItemId];
}
};
const layout = () => {
foundation.setVerticalOrientation(props.vertical);
// List items need to have at least tabindex=-1 to be focusable.
for (const itemElement of listRoot.value.querySelectorAll(
'.mdc-list-item:not([tabindex])',
)) {
const id = itemElement.dataset.myitemid;
const item = listItems[id];
item.setAttribute('tabindex', -1);
}
// Child button/a elements are not tabbable until the list item is focused.
for (const focusableChildElements of listRoot.value.querySelectorAll(
strings.FOCUSABLE_CHILD_ELEMENTS,
)) {
focusableChildElements.setAttribute('tabindex', -1);
}
foundation.setUseSelectedAttribute(true);
foundation.layout();
};
const initializeListType = () => {
if (isInteractive) {
const selection = [
...listRoot.value.querySelectorAll(strings.SELECTED_ITEM_SELECTOR),
].map(listItem => listElements.value.indexOf(listItem));
if (matches(listRoot.value, strings.ARIA_MULTI_SELECTABLE_SELECTOR)) {
foundation.setSelectedIndex(selection);
} else if (selection.length > 0) {
foundation.setSelectedIndex(selection[0]);
}
return;
}
const checkboxListItems = listRoot.value.querySelectorAll(
strings.ARIA_ROLE_CHECKBOX_SELECTOR,
);
const radioSelectedListItem = listRoot.value.querySelector(
strings.ARIA_CHECKED_RADIO_SELECTOR,
);
if (checkboxListItems.length > 0) {
const preselectedItems = listRoot.value.querySelectorAll(
strings.ARIA_CHECKED_CHECKBOX_SELECTOR,
);
foundation.setSelectedIndex(
Array.prototype.map.call(preselectedItems, listItem =>
listElements.value.indexOf(listItem),
),
);
} else if (radioSelectedListItem) {
foundation.setSelectedIndex(
listElements.value.indexOf(radioSelectedListItem),
);
}
};
const handleFocusInEvent = event_ => {
const index = getListItemIndex(event_, listElements.value);
foundation.handleFocusIn(event_, index);
};
const handleFocusOutEvent = event_ => {
const index = getListItemIndex(event_, listElements.value);
foundation.handleFocusOut(event_, index);
};
const handleKeydownEvent = event_ => {
const index = getListItemIndex(event_, listElements.value);
const target = event_.target;
foundation.handleKeydown(
event_,
target.classList.contains(cssClasses.LIST_ITEM_CLASS),
index,
);
};
const handleClickEvent = event_ => {
const index = getListItemIndex(event_, listElements.value);
const isCheckboxAlreadyUpdatedInAdapter = matches(
event_.target,
strings.CHECKBOX_RADIO_SELECTOR,
);
foundation.handleClick(index, isCheckboxAlreadyUpdatedInAdapter, event_);
};
const adapter = {
addClassForElementIndex: (index, className) =>
getListItemByIndex(index)?.addClass(className),
focusItemAtIndex: index => {
const element = listElements.value[index];
if (element) {
element.focus();
}
},
getAttributeForElementIndex: (index, attribute) =>
getListItemByIndex(index)?.getAttribute(attribute),
getFocusedElementIndex: () =>
listElements.value.indexOf(document.activeElement),
getListItemCount: () => listElements.value.length,
getPrimaryTextAtIndex: index =>
getListItemByIndex(index)?.getPrimaryText(),
hasCheckboxAtIndex: index => {
const listItem = listElements.value[index];
const returnValue =
listItem && !!listItem.querySelector(strings.CHECKBOX_SELECTOR);
return returnValue;
},
hasRadioAtIndex: index => {
const listItem = listElements.value[index];
return listItem && !!listItem.querySelector(strings.RADIO_SELECTOR);
},
isCheckboxCheckedAtIndex: index => {
const listItem = listElements.value[index];
const toggleElement = listItem.querySelector(strings.CHECKBOX_SELECTOR);
return toggleElement?.checked;
},
isFocusInsideList: () => {
const root = listRoot.value;
return (
root !== document.activeElement &&
root?.contains(document.activeElement)
);
},
isRootFocused: () => document.activeElement === listRoot.value,
listItemAtIndexHasClass: (index, className) =>
getListItemByIndex(index)?.hasClass(className),
notifyAction: index => {
emitCustomEvent(
listRoot.value,
strings.ACTION_EVENT,
{ index },
/** shouldBubble */ true,
);
if (Array.isArray(props.modelValue)) {
emit('update:modelValue', foundation.getSelectedIndex());
} else {
emit('update:modelValue', index);
}
},
notifySelectionChange: changedIndices => {
emit(
strings.SELECTION_CHANGE_EVENT.toLowerCase(),
{ changedIndices },
/** shouldBubble */ true,
);
},
removeClassForElementIndex: (index, className) =>
getListItemByIndex(index)?.removeClass(className),
setAttributeForElementIndex: (index, attribute, value) =>
getListItemByIndex(index)?.setAttribute(attribute, value),
setCheckedCheckboxOrRadioAtIndex: (index, isChecked) => {
const listItem = listElements.value[index];
const toggleElement = listItem.querySelector(
strings.CHECKBOX_RADIO_SELECTOR,
);
toggleElement && (toggleElement.checked = isChecked);
const event = new CustomEvent('update:modelValue', [true, false]);
toggleElement?.dispatchEvent(event);
},
setTabIndexForListItemChildren: (listItemIndex, tabIndexValue) => {
const element = listElements.value[listItemIndex];
const listItemChildren = Array.prototype.slice.call(
element.querySelectorAll(strings.CHILD_ELEMENTS_TO_TOGGLE_TABINDEX),
);
for (const element_ of listItemChildren) {
const listItem = listItems[element_.dataset.myitemid] ?? element_;
listItem.setAttribute('tabindex', tabIndexValue);
}
},
};
watch(
() => props.modelValue,
nv => {
if (Array.isArray(nv) || props.modelValue != nv) {
foundation.setSelectedIndex(nv);
}
},
);
watch(
() => props.wrapFocus,
nv => foundation.setWrapFocus(nv),
);
watch(
() => props.vertical,
nv => foundation.setVerticalOrientation(nv),
);
watch(
() => props.typeAhead,
nv => foundation.setHasTypeahead(nv),
);
const ensureFocusable = () => {
if (
isInteractive &&
!listRoot.value.querySelector(`.mdc-list-item[tabindex="0"]`)
) {
const index = getInitialFocusIndex(
foundation,
listRoot.value,
listElements.value,
);
if (index !== -1) {
listElements.value[index].tabIndex = 0;
}
}
};
onMounted(() => {
updateListElements(listRoot.value);
foundation = new MDCListFoundation(adapter);
foundation.init();
setSelectedIfSingleSelectionList(props, foundation, adapter);
const { wrapFocus, typeAhead, vertical } = props;
layout();
initializeListType();
ensureFocusable();
foundation.setWrapFocus(wrapFocus);
foundation.setVerticalOrientation(vertical);
if (typeAhead) {
foundation.setHasTypeahead(typeAhead);
}
// the list content could change outside of this component
// so use a mutation observer to trigger an update
slotObserver = new MutationObserver(() => {
updateListElements(listRoot.value);
});
slotObserver.observe(listRoot.value, {
childList: true,
// subtree: true,
});
});
onBeforeUnmount(() => {
slotObserver.disconnect();
foundation.destroy();
});
expose({
setSingleSelection: isSingleSelectionList =>
foundation.setSingleSelection(isSingleSelectionList),
setSelectedIndex: index => foundation.setSelectedIndex(index),
getSelectedIndex: () => foundation.getSelectedIndex(),
setEnabled: (itemIndex, isEnabled) =>
foundation.setEnabled(itemIndex, isEnabled),
typeaheadMatchItem: (nextChar, startingIndex) =>
foundation.typeaheadMatchItem(
nextChar,
startingIndex,
/** skipFocus */ true,
),
typeaheadInProgress: () => foundation.isTypeaheadInProgress(),
listElements,
getListItemByIndex,
getListElementByIndex: index => listElements.value[index],
getListElementIndex: element => {
return listElements.value.findIndex(element_ => element_ == element);
},
getListItemCount: () => listElements.value.length,
focus: () => {
listRoot.value.focus();
},
});
return () => {
return h(
'ul',
{
ref: listRoot,
class: uiState.classes,
onClick: handleClickEvent,
onKeydown: handleKeydownEvent,
onFocusin: handleFocusInEvent,
onFocusout: handleFocusOutEvent,
role: 'role',
...uiState.rootAttrs,
},
slots.default(),
);
};
},
};
// ===
// Private functions
// ===
// find the index of a list item from the event target
const getListItemIndex = (eventOrElement, listElements) => {
const { target } = eventOrElement;
if (target) {
const myItemId = target.dataset.myitemid;
// if clicked on a list item then just search
if (myItemId !== undefined) {
const listElementIndex = listElements.findIndex(
({ dataset: { myitemid } }) => myitemid === myItemId,
);
return listElementIndex;
}
}
// if the click wasnt on a list item
// or we were given an element then search up the DOM
const element = target ?? eventOrElement;
const nearestParent = closest(
element,
`.${cssClasses.LIST_ITEM_CLASS}, .${cssClasses.ROOT}`,
);
// Get the index of the element if it is a list item.
if (
nearestParent &&
matches(nearestParent, `.${cssClasses.LIST_ITEM_CLASS}`)
) {
return listElements.value.indexOf(nearestParent);
}
return -1;
};
const getInitialFocusIndex = (foundation, rootElement, listElements) => {
const selectedIndex = foundation.getSelectedIndex();
if (Array.isArray(selectedIndex) && selectedIndex.length > 0) {
return selectedIndex[0];
}
if (typeof selectedIndex === 'number' && selectedIndex !== -1) {
return selectedIndex;
}
const element = rootElement.querySelector(
`.mdc-list-item:not(.mdc-list-item--disabled)`,
);
if (element === null) {
return -1;
}
return getListItemIndex(element, listElements);
};
function setSelectedIfSingleSelectionList(props, foundation, adapter) {
const { modelValue, multiSelectable } = props;
// if a single selection list need to ensure the selected item has the selected or activated class
if (
multiSelectable != true &&
typeof modelValue === 'number' &&
!Number.isNaN(modelValue)
) {
const index = modelValue;
const hasSelectedClass = adapter.listItemAtIndexHasClass(
index,
cssClasses.LIST_ITEM_SELECTED_CLASS,
);
const hasActivatedClass = adapter.listItemAtIndexHasClass(
index,
cssClasses.LIST_ITEM_ACTIVATED_CLASS,
);
if (!(hasSelectedClass || hasActivatedClass)) {
adapter.addClassForElementIndex(modelValue, 'mdc-list-item--selected');
}
adapter.setAttributeForElementIndex(index, 'tabindex', 0);
foundation.setSingleSelection(true);
foundation.setSelectedIndex(index);
}
}