vue-material-adapter
Version:
Vue 3 wrapper arround Material Components for the Web
218 lines (183 loc) • 6.46 kB
JavaScript
import { closest } from '@material/dom/ponyfill.js';
import { MDCSnackbarFoundation } from '@material/snackbar/foundation.js';
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
toRefs,
watch,
} from 'vue';
const { strings, numbers } = MDCSnackbarFoundation;
export default {
name: 'mcw-snackbar',
props: {
modelValue: Boolean,
stacked: Boolean,
leading: Boolean,
message: String,
actionText: String,
timeoutMs: [String, Number],
closeOnEscape: { type: Boolean, default: true },
dismissAction: { type: [String, Boolean], default: true },
reason: String,
},
setup(props, { emit }) {
const uiState = reactive({
classes: {},
hidden: false,
actionHidden: false,
showMessage: true,
labelEl: undefined,
});
let foundation;
const rootClasses = computed(() => {
return {
'mdc-snackbar--leading': props.leading,
'mdc-snackbar--stacked': props.stacked,
...uiState.classes,
};
});
const showDismissAction = computed(() => {
return typeof props.dismissAction === 'string'
? props.dismissAction != 'false'
: props.dismissAction;
});
const handleKeydownEvent = event_ => foundation.handleKeyDown(event_);
const announce = (ariaElement, labelElement = ariaElement) => {
const priority = ariaElement.getAttribute('aria-live');
const text = props.message;
if (!text) {
return;
}
// Temporarily disable `aria-live` to prevent JAWS+Firefox from announcing the message twice.
ariaElement.setAttribute('aria-live', 'off');
// Temporarily clear `textContent` to force a DOM mutation event that will be detected by screen readers.
// `aria-live` elements are only announced when the element's `textContent` *changes*, so snackbars
// sent to the browser in the initial HTML response won't be read unless we clear the element's
// `textContent` first.
// Similarly, displaying the same snackbar message twice in a row doesn't trigger a DOM mutation event,
// so screen readers won't announce the second message unless we first clear `textContent`.
//
// We have to clear the label text two different ways to make it work in all browsers and screen readers:
//
// 1. `textContent = ''` is required for IE11 + JAWS
// 2. `innerHTML = ' '` is required for Chrome + JAWS and NVDA
//
// All other browser/screen reader combinations support both methods.
//
// The wrapper `<span>` visually hides the space character so that it doesn't cause jank when added/removed.
// N.B.: Setting `position: absolute`, `opacity: 0`, or `height: 0` prevents Chrome from detecting the DOM change.
//
// This technique has been tested in:
//
// * JAWS 2019:
// - Chrome 70
// - Firefox 60 (ESR)
// - IE 11
// * NVDA 2018:
// - Chrome 70
// - Firefox 60 (ESR)
// - IE 11
// * ChromeVox 53
uiState.showMessage = false;
// Prevent visual jank by temporarily displaying the label text in the ::before pseudo-element.
// CSS generated content is normally announced by screen readers
// (except in IE 11; see https://tink.uk/accessibility-support-for-css-generated-content/);
// however, `aria-live` is turned off, so this DOM update will be ignored by screen readers.
labelElement.setAttribute(
strings.ARIA_LIVE_LABEL_TEXT_ATTR,
props.message,
);
setTimeout(() => {
// Allow screen readers to announce changes to the DOM again.
ariaElement.setAttribute('aria-live', priority);
// Remove the message from the ::before pseudo-element.
labelElement.removeAttribute(strings.ARIA_LIVE_LABEL_TEXT_ATTR);
// Restore the original label text, which will be announced by screen readers.
uiState.showMessage = true;
}, numbers.ARIA_LIVE_DELAY_MS);
};
const adapter = {
addClass: className =>
(uiState.classes = { ...uiState.classes, [className]: true }),
announce: () => announce(uiState.labelEl),
notifyClosed: reason => {
emit(strings.CLOSED_EVENT.toLowerCase(), reason ? { reason } : {});
emit('update:modelValue', false);
emit('hide');
},
notifyClosing: reason => {
emit(strings.CLOSING_EVENT, reason ? { reason } : {});
emit('update:reason', reason);
},
notifyOpened: () => {
emit(strings.OPENED_EVENT.toLowerCase(), {});
emit('update:modelValue', true);
emit('show', {});
},
notifyOpening: () => emit(strings.OPENING_EVENT, {}),
removeClass: className => {
const { [className]: removed, ...rest } = uiState.classes;
uiState.classes = rest;
},
};
const surfaceClickHandler = event_ => {
if (isActionButton_(event_.target)) {
foundation.handleActionButtonClick(event_);
} else if (isActionIcon_(event_.target)) {
foundation.handleActionIconClick(event_);
}
};
watch(
() => props.modelValue,
nv => {
if (nv) {
foundation.open();
} else {
foundation.close(props.reason ?? '');
}
},
);
watch(
() => props.timeoutMs,
nv => {
if (nv !== void 0) {
foundation.setTimeoutMs(nv);
}
},
);
watch(
() => props.closeOnEscape,
nv => foundation.setCloseOnEscape(nv),
);
onMounted(() => {
window.addEventListener('keydown', handleKeydownEvent);
foundation = new MDCSnackbarFoundation(adapter);
foundation.init();
if (props.timeoutMs !== void 0) {
foundation.setTimeoutMs(props.timeoutMs);
}
foundation.setCloseOnEscape(props.closeOnEscape);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydownEvent);
foundation.destroy();
});
return {
...toRefs(uiState),
rootClasses,
showDismissAction,
surfaceClickHandler,
};
},
};
// ===
// Private functions
// ===
function isActionButton_(target) {
return Boolean(closest(target, strings.ACTION_SELECTOR));
}
function isActionIcon_(target) {
return Boolean(closest(target, strings.DISMISS_SELECTOR));
}